살군의 보조기억 장치

Another memory device…

Archive for the ‘C++’ Category

첫번째 커밋 closed

with one comment

처음으로 한 커밋이 closed 됐다. 아쉽지만 merged 되진 않았고, 내가 닫았다. 내가 한 커밋을 할당받은 담당자가 그대로 놔두자라고 한 것이 제일 크고, 그 이유가 binary compatibility 때문이였다. binary compatibility이진 호환성 문제는 아무래도 major 버전 변환이 일어나기 전에 이제 정리되는 버전에서 중요하지도 않은 기능 때문에 호환성을 깨면서까지 바꾸기엔 의미가 없었던 듯 하다. 내가 봐도 맞는 이야기이고. 일단 전체 프로세스를 한번 거쳤다는 것에 의의를 둬야 되겠다.

그리고 나도 잘 몰랐던 binary compatibility 란 것에 대하 찾아봤다. 깊게 들어가면 플랫폼이나 os 에 따라 요구사항도 다르고 내가 지금 이걸 확인할 사항은 아니므로, 이 정도면 대충 감을 잡겠다 싶은 정도까지만 알아보자. 우선 정의를 보면,

Definition

A library is binary compatible, if a program linked dynamically to a former version of the library continues running with newer versions of the library without the need to recompile.
If a program needs to be recompiled to run with a new version of library but doesn’t require any further modifications, the library is source compatible.

Binary compatibility saves a lot of trouble. It makes it much easier to distribute software for a certain platform. Without ensuring binary compatibility between releases, people will be forced to provide statically linked binaries. Static binaries are bad because they

  • waste resources (especially memory)
  • don’t allow the program to benefit from bugfixes or extensions in the libraries

In the KDE project, we will provide binary compatibility within the life-span of a major release for the core libraries (kdelibs, kdepimlibs).

새로 컴파일 안하고 그대로 라이브러리를 그대로 교체해서 사용할 수 있는것이다. 조건이 대략 까다롭다. 외부 인터페이스는 전혀 건드리지 말아야 된다는 소리다. 인터페이스와 관련해서 binary compatibility 를 이야기할 때 빠지지 않는 ABI, API, interface 에 대한 내용을 살펴보면,

  • ABI – Application Binary Interface. The binary interface between systems. If a binary interface changes, both sides of the interface (the user and the implementation) must be recompiled.
  • API – Application Program Interface. The source interface between systems. If a source interface changes, code that uses that interface must be modified. API changes usually imply ABI changes.
  • Interface – A class where every method is pure virtual, and thus has no inherent implementation. An interface is merely a protocol for communication between objects.
  • Factory – Something that creates objects. In this article, we’ll use a single global function as our factory.
  • DLL Boundary – The line between code instantiated in a DLL and code in a calling process is called the DLL boundary. In some cases, code can be on both sides of the boundary: Consider an inline function in a header file that gets used in the DLL and the executable. The function is actually instantiated on both sides of the boundary. Therefore, if the inline function has a static variable, two variables will be created, one in the executable and one in the DLL, and which is used depends on whether the code in the DLL or the executable is calling the function.

물론 처음에 인용한 내용은 전반적인 binary compatibility 에 관한 이야기이고, 아래 인용한 내용은 이 가운데 windows 기반에서 binary compatibility 를 지키기 위해 고려해야 될 사항들을 정리한 것이다. 처음에 언급한 것 처럼, binary compatibility 는 플랫폼, os 등등에 따라 달라진다는 것만 생각한다면 쉽게 이해가 된다.

이 문제 때문에, 이번 커밋은 반영되지 못하고 그냥 닫혔다. 하나 배웠으니까… 다음에는 더 잘하겠지. 🙂

참고
Advertisements

Written by gomiski

2014/04/08 at 5:53 am

클래스 생성자 사용시 warning 문제

leave a comment »

아래글에서 언급한 것 처럼, 처음 커밋하고 build bot 에서 에러가 났을 때, 조금 당황했다. opencv 가 멀티플랫폼에서 돌아가기 때문에 linux 나 mac, android 까지 빌드를 하는데 에러가 난 부분이 linux 와 android 컴파일에서 났기 때문이다. 이거 linux 랑 android 설치를 해야되나… 잠깐 고민을 했다. 다행히 빌드로그가 잘 나와있고 문제점을 구글링 해서 처리할 수 있었다.

두 가지 문제가 있었는데, 그 가운데 하나가 바로 클래스 생성자 사용시 생길 수 있는 문제이다. 이게 왜 문제가 되는지는 아직 모르겠지만, 혹시 이러한 문제를 만나더라도 심각하게 고민하지는 않아도 된다. 단순한 룰만 지켜주면 쉽게 해결할 수 있기 때문이다.

class A
{
    int i, j, k;
    A(int a, int b, int c) : i(a), k(c), j(b) {}
}

위의 line:4 에서 warning 이 발생한다. 이유인즉,

주의할 것은 초기화 순서는 초기화 리스트의 순서가 아니라 멤버 선언 순서로 되는데, 경우에 따라 이들 차이로 인해 미묘한 문제가 발생할 수 있기 때문에 경고를 주는 것입니다.
멤버 선언 순서와 초기화 리스트의 순서를 일치시키는 것이 바람직한 스타일입니다.

라는 것이다. 따라서 생성자를 아래와 같이 바꿔주면 해결할 수 있다.

    A(int a, int b, int c) : i(a), j(b), k(c) {}

평소에는 신경도 안쓰던 것들이 멀티플랫폼의 대규모 프로젝트에서는 민감하게 처리되는 듯 하다.

참고

Written by gomiski

2014/04/03 at 10:36 am

Posted in C++, General, Lecture, opencv, study

Tagged with , , ,

Nested class 사용시 주의점

leave a comment »

클래스 안에 정의한 클래스를 정의한 것을 nested class 혹은 inner/outer class 라고 한다. 앞의글에서 언급했던 문제를 해결하기 위해 nested class 를 정의해서 사용하면 어떨까 생각하다가 찾아본 c++ 표준에 관한 이야기이다. 물론 nested class 로는 답이 아니라 적용하지는 않았다.

단순하게 생각해서 클래스 안에 클래스를 정의한 것이므로 inner class 에서 outer class 에 모든 원소를 접근할 수 있을 것 같지만, 실상은 그렇지 않다. 오묘하게 정의된 c++ 표준 문제 때문이다.

There is a difference between C++03 and C++11 in this regards. So the answer varies depending on which compiler you are using.

If you are using a C++03 compliant compiler then:
Nested class cannot access all members of the enclosing class.

If you are using C++11 compliant compiler then:
Nested class can access all members of the enclosing class. Nested class is treated as just another member of the class.

C++03 Standard 11.8 Nested classes:
The members of a nested class have no special access to members of an enclosing class, nor to classes or functions that have granted friendship to an enclosing class; the usual access rules shall be obeyed.

C++11 Standard 11.7 Nested Classes:
A nested class is a member and as such has the same access rights as any other member.

알고보면 단순하지만, c++ 표준에 따라 다르게 만들어야될 경우에는 민감한 문제가 될 수 있다. 참고로 현재 opencv 의 경우 c++98 을 준수하며, c++11 은 추천사항이 아니다.

참고

Written by gomiski

2014/04/01 at 8:31 am

Posted in C++, General, Lecture

Tagged with , , , , , , , , ,

Template 메소드의 virtual 사용 문제 (c++)

with one comment

한참 작업을 하다보니 상세 구현 부분에서 재미난 문제에 많이 부딪쳤다. 이 가운데 가장 핵심이 되는 문제가 바로 c++ 의 template function (혹은 method)에서 virtual 키워드를 사용할 수 있느냐에 대한 문제이다.

결론부터 먼저 이야기하면… 사용할 수 없다.

이 문제 때문에 어떻게 하면 이것을 피해갈 수 있을까에 대해 몇 일을 고민했다. 그냥 중복되는 코드를 몇줄 더 쓰면 되지만 그러기엔 코드가 지저분해지기 때문에 이를 피해갈 수 있는 방법에 대해 생각을 좀 했었다. 덕분에 잘 안쓰는 기능들에 대해 공부할 기회를 얻기도 했고. 예제코드를 한번 살펴보자.

class CV_EXPORTS Formatter
{
    // ...
    virtual void writeRows(std::ostream& out, const Mat& m) const { /*...*/ }
    virtual void writeCols(std::ostream& out, const void* data,
                       const int ncols, const int type) const { /*...*/ }

    template<typename _Tp> void writeElems(std::ostream& out, const _Tp* data, const int nelems, const int cn) const
    {
        typedef typename DataType::work_type _WTp;
        for(int i = 0; i < nelems; i += cn)
        {
            out << (char)elemOpen;
            for(int j = 0; j < cn; j++)
                out << (_WTp)data[i + j] << (j+1 < cn ? (char)elemsep : ' ');
            out << (char)elemClose << (i+cn < nelems ? (char)colsep : ' ');
        }
    }
}

class MatlabFormatter : public Formatter
{
    void write(std::ostream&; out, const Mat& m, const int*, int) const
    {
        CV_Assert(m.dims <= 2);
        writeRows(out, m);
    }
}

위와 같은 상황일 때, 내가 하고싶은 것은 Formattertemplate void writeElems() 을 상속 받아서 MatlabFormatter 에서 사용하고자 하는 모습으로 오버라이딩overriding하고 싶은 것이다. 문제는 c++ 언어의 제약이다. c++ 표준에서 template function 은 클래스에서 dynamic binding 을 지원하지 않는다. 이유를 살펴보면,

Templates are all about the compiler generating code at compile-time. Virtual functions are all about the run-time system figuring out which function to call at run-time.

Once the run-time system figured out it would need to call a templatized virtual function, compilation is all done and the compiler cannot generate the appropriate instance anymore. Therefor, you cannot have virtual member function templates.

However, there are a few powerful and interesting techniques stemming from combining polymorphism and templates, notably so-called type erasure.

즉, 템플릿은 컴파일-타임에서 생성되는 코드인데, virtual 은 런-타임에서 탐지하는 것이기 때문에다. 위의 예와 같은 경우, 컴파일-타임에서 템플릿을 생성하려면 런-타임에서 알아야 할 것을 미리 알고 있어야 되기 때문에 사용이 불가능한 것이다. 게다가 출처가 정확히 기억이 나지 않는데… 누군가가 이렇게 글을 적어놓은 걸 본 적이 있다.

만일 virtual template function 을 사용하고자 한다면, 구조적으로 설계가 잘못된 것이다

완전 뜨끔하다. 근데 나는 저렇게 하고 싶다. 이걸 어떻게 해야될까? 약간의 트릭과 기본적인 클래스 상속에 대한 개념이 필요하다. 기본적으로 클래스를 상속받는다는 것은 상위 클래스의 코드를 재사용하기 위함이다. 단순히 다형성polymorphism을 위한 것이라면 abstract class 를 사용하는 것이 맞다. Java 는 interface라는 걸출한 키워드가 있지만, c++ 는 abstract class 와 pure virtual function 을 활용해야 한다. 한마디로 얘기하면 구조를 변경해야 된다는 것이다.

자! 정리해보자. 템플릿을 virtual 로 사용할 수 없다면 재사용 가능한 코드를 최대한 부모 클래스로 올리되, 템플릿을 자식 클래스에서 모두 공통으로 사용할 수 있도록 하는 것이다. 즉, 템플릿을 각 데이터의 출력만을 담당하고 나머지 Formatter 에서 필요로 하는 것들을 다 template 함수 밖으로 빼내서 자식 클래스에서는 템플릿 함수 자체를 그대로 사용할 수 있도록 만들어주면 된다.

class CV_EXPORTS Formatter
{
    // ...
    virtual void writeRow() { /*...*/ }
    virtual void writeCol() { /*...*/ }
    virtual void writeValue() { /*...*/ }

    template<typename _Tp> void writeElems(std::ostream& out, const Mat& m, const _Tp* data, const int row, const int col, const int cn) const
    {
        typedef typename DataType::work_type _WTp;
        out << (_WTp)data[row*m.cols*CV_MAT_CN(m.type()) + col*CV_MAT_CN(m.type()) + cn];
    }
}

class MatlabFormatter : public Formatter
{
    void write(std::ostream&; out, const Mat& m, const int*, int) const
    {
        CV_Assert(m.dims <= 2);
        writeRows(out, m);
    }

    void writeRows()
    {
        // 부모클래스의 템플릿 함수 호출
        Formatter::writeElems<const char>();
    }
}

나름 고민을 했었는데… 다 써놓고 보니 너무나 당연한 이야기다. 😉

참고

Written by gomiski

2014/04/01 at 6:06 am

Posted in C++, General, Lecture

OpenCV Mat 클래스 분석#2 (예제코드)

with one comment

아마도 이 예제코드가 많이 도움이 될 것 같아서 바로 넣어둔다. 최초의 matrix 선언에서 type 을 바꿔가면서 실행해보면 각 함수, 멤버변수가 의미하는 바를 쉽게 확인할 수 있을 것이다. 아래글에서 언급한 내용을 확인하는데 도움이 될 것이다.


#include <stdio.h>
#include <iostream>
#include "opencv2/imgproc/imgproc.hpp"
#include "opencv2/highgui/highgui.hpp"
#include "opencv2/flann/miniflann.hpp"
#include <direct.h>

using namespace cv; // all the new API is put into "cv" namespace. Export its content
using namespace std;
using namespace cv::flann;


string depth2str(int depth) {
  string r;

  switch ( depth ) {
    case CV_8U:  r = "8U"; break;
    case CV_8S:  r = "8S"; break;
    case CV_16U: r = "16U"; break;
    case CV_16S: r = "16S"; break;
    case CV_32S: r = "32S"; break;
    case CV_32F: r = "32F"; break;
    case CV_64F: r = "64F"; break;
    default:     r = "User"; break;
  }

  return r;
}

string type2str(int type) {
  string r;

  uchar depth = type & CV_MAT_DEPTH_MASK;
  uchar chans = 1 + (type >> CV_CN_SHIFT);

  r = depth2str(depth);
  r += "C";
  r += (chans+'0');

  return r;
}

int main( int argc, char** argv )
{
    const int sz[] = {4,5,6};
    Mat M = Mat(3, sz, CV_16UC1);
    cv::randu(M, Scalar::all(0), Scalar::all(255));
    
    cout << "Mat::elemSize() = " << M.elemSize() << " Byte: same as sizeof(depth) * channels" << endl;
    cout << "Mat::elemSize1() = " << M.elemSize1() << " Byte: same as sizeof(depth)" << endl;
    cout << "Mat::depth() = " << depth2str(M.depth()) << endl;
    cout << "Mat::channels() = " << M.channels() << endl;
    cout << "Mat::type() = " << type2str(M.type()) << " : including both depth and channels, defined type number=" << M.type() << endl;
    cout << "Mat::dimension = " << M.dims << " --> rows = " << M.rows << ", cols = " << M.cols << endl;

    cout << endl << "\t\t|  Size[-1] = " << M.size[-1] << endl;
    for (int i=0; i<M.dims; i++) {
        cout << "Step[" << i << "] = " << M.step[i] << "\t|  Size[" << i << "] = " << M.size[i] << endl;
    }

    cout << endl;
    cout << "Mat::step1() = " << M.step1() << ": normalized step. matrix step divided by Mat::elemSize1()" << endl;
    cout << endl;

    return 0;
}

위 코드의 결과값은…

Mat::elemSize() = 2 Byte: same as sizeof(depth) * channels
Mat::elemSize1() = 2 Byte: same as sizeof(depth)
Mat::depth() = 16U
Mat::channels() = 1
Mat::type() = 16UC1 : including both depth and channels, defined type number=2
Mat::dimension = 3 –> rows = -1, cols = -1

                      | Size[-1] = 3
Step[0] = 60   | Size[0] = 4
Step[1] = 12   | Size[1] = 5
Step[2] = 2     | Size[2] = 6

Mat::step1() = 30: normalized step. matrix step divided by Mat::elemSize1()

참고

Written by gomiski

2014/03/13 at 5:23 am

OpenCV Mat 클래스 분석#1

with one comment

뭔가 시리즈물 글은 좋아하지 않느데… 이건 너무 거대한 거라 하나씩 끊고 가지 않으면 답이 없을 것 같아서 처음으로 시리즈를 시작한다. 물론 #2가 언제 나올지는 나도 모른다는게 함정. 지난번 글에서 이제는 코딩을 하면 될꺼라고 생각했는데 실수였다. N-dim matrix 내부를 볼려면 cv::Mat 클래스의 내용을 모르면 어찌 할 수가 없다. 당연히 내가 contribution 하려는 부분에도 문제가 생기는 거고. Matrix 출력을 matlab 처럼 하겠다는건데 내용물을 볼 수 없으면 시작도 못하는거니 말이다.

opencv 에서 cv::Mat 는 제일 기초가 되는 자료구조이다. 메뉴얼도 잘 되있어 opencv doc 페이지를 참조하면 쉽게 사용할 수 있다. 게다가 2-dimension + multi-channel 부분은 손댈 여지가 없을 정도로 잘 구현이 되어있다. 그러나 이전글에서 봤듯이, 3-dimension 이상은 matrix 의 내용을 쉽게 볼 수가 없다.

이 문제를 해결하기 위해, 우선 cv::Mat 내부를 살펴보자.

class CV_EXPORTS Mat
{
	// constructors & destructors
	// tons of overloading functions
	// ...

    enum { MAGIC_VAL=0x42FF0000, AUTO_STEP=0, CONTINUOUS_FLAG=CV_MAT_CONT_FLAG, SUBMATRIX_FLAG=CV_SUBMAT_FLAG };
	
    bool isContinuous() const;         //! returns true iff the matrix data is continuous (i.e. when there are no gaps between successive rows). similar to CV_IS_MAT_CONT(cvmat->type)
    bool isSubmatrix() const;          //! returns true if the matrix is a submatrix of another matrix
    
    size_t elemSize() const;           //! returns element size in bytes, similar to CV_ELEM_SIZE(cvmat->type)
    size_t elemSize1() const;          //! returns the size of element channel in bytes.
    size_t step1(int i=0) const;       //! returns step/elemSize1()
	
    int type() const;                  //! returns element type, similar to CV_MAT_TYPE(cvmat->type)
    int depth() const;                 //! returns element type, similar to CV_MAT_DEPTH(cvmat->type)
    int channels() const;              //! returns element type, similar to CV_MAT_CN(cvmat->type)
    bool empty() const;                //! returns true if matrix data is NULL
    
    size_t total() const;              //! returns the total number of matrix elements
    
    int flags;                    //! includes several bit-fields: - the magic signature, - continuity flag, - depth, - number of channels

    int dims;                     //! the matrix dimensionality, >= 2   
    int rows, cols;               //! the number of rows and columns or (-1, -1) when the matrix has more than 2 dimensions
    uchar* data;                  //! pointer to the data

    int* refcount;                //! pointer to the reference counter; when matrix points to user-allocated data, the pointer is NULL
    uchar* datastart;             //! helper fields used in locateROI and adjustROI
    uchar* dataend;
    uchar* datalimit;

    MatAllocator* allocator;      //! custom allocator

    struct CV_EXPORTS MSize
    {
        //...
        int* p;
    };

    struct CV_EXPORTS MStep
    {
        //...
        size_t* p;
        size_t buf[2];
    };

    MSize size;
    MStep step;

    //...
};

이렇게 길게 소스코드를 늘어놓고 보니, 필수요소는 생각보다 적다. 우선, line:16~18 은 matrix 의 type, depth, channel 을 얻는것이다. 예를들어 matrix 선언시에 CV_16UC3 이라고 정의를 했다면, channel=3, depth=16U 가 되고 type 은 channel과 depth 정보를 모두 반환한다. 자세한 내용은 여기를 참조하자.

Line:25~26 은 matrix 의 행rows과 열cols 그리고 dimension 정보를 저장하고 있다.
Line:27 은 실제 matrix 의 데이터가 저장되는 곳을 지시하는 포인터이다.

마지막으로 line:49~50 은 matrix 의 부가정보를 기록하는 곳이다. matrix 는 초기화시에 size, step 에 대해 정의를 한다. 예를들어, CV_8UC1 에 3 x 4 인 2D matrix + 1-channel 을 정의했을때, step 은 행row과 열col에서 사용하는 Byte 수를 저장하고, size 는 행과 열의 수를 저장한다. 자세한 예제는 다른글을 참고하자.

여기서 언급하고 싶은 부분은 negative index 를 가지는 포인터 사용에 대한 것이다. size 와 step 을 초기화하는 아래 코드를 보면

static inline void setSize( Mat& m, int _dims, const int* _sz,
                            const size_t* _steps, bool autoSteps=false )
{
    CV_Assert( 0 <= _dims && _dims <= CV_MAX_DIM );
    if( m.dims != _dims )
    {
        if( m.step.p != m.step.buf )
        {
            fastFree(m.step.p);
            m.step.p = m.step.buf;
            m.size.p = &m.rows;
        }
        if( _dims > 2 )
        {
            m.step.p = (size_t*)fastMalloc(_dims*sizeof(m.step.p[0]) + (_dims+1)*sizeof(m.size.p[0]));
            m.size.p = (int*)(m.step.p + _dims) + 1;
            m.size.p[-1] = _dims;
            m.rows = m.cols = -1;
        }
    }
    //...
}

line:17 에서 어마어마한 사용법을 볼 수 있다. 세상에나! 인덱스가 음수다. 이게 가능한가? 첨 보면 아주아주 당황스럽다. 기본적으로 배열의 인덱스는 0 부터 시작하고 음수가 될 수 없다라고 알고 있을 것이다. 그러나 실상은 그렇지 않다. 즉, 음수가 가능하다. 단, 미리 정의된 배열의 범위안에서 사용이 가능한 것이다.

That is correct. From C99 §6.5.2.1/2:

The definition of the subscript operator [] is that E1[E2] is identical to (*((E1)+(E2))).

There’s no magic. It’s a 1-1 equivalence. As always when dereferencing a pointer (*), you need to be sure it’s pointing to a valid address.

하나하나 정리를 하고 싶은데 시간이 없네. 위의 코드에서 step 과 size 의 핵심은 메모리 영역을 공유하면서 포인터로 지시하는 위치만 다르다는 것이다.. 할당된 메모리의 시작위치는 step.p 가 가르키고 있고, dim + 1 만큼 이동한 위치를 size.p 가 지시한다. 예를들어, 2D matrix 를 선언했다고 할 때, size 와 step 이 사용하는 메모리는 2 * sizeof(m.step.p[0]) + (2 + 1) * sizeof(m.size.p[0])만큼 할당이 된다. m.step.p[0] 과 m.size.p[0] 은 unsigned int 이므로 sizeof(m.step.p[0]) 은 4 가 된다. 그러므로 총 메모리 할당량은 20 Byte 가 된다.

아래 메모리 모형은 1 칸이 4 Byte 라고 볼 때,

| a | b | c | d | e | …

m.step.p = addr(a)
m.size.p = addr(d)
m.size.p – 1 = addr(c) —> m.size.p[-1] = value(c)

가 되므로 유효한 표현식이 되는 것이다. 이 내용을 확인하고 싶다면 여기 참조.

나머지 내용은 다음 기회에.

참고

Written by gomiski

2014/03/13 at 5:04 am

OpenCV Data Type 정리

leave a comment »

Opencv 의 기본 데이터 타입이 자꾸 햇갈려서 정리한다. 기본적으로는 아래와 같다.

  • CV_8U : 8-bit unsigned integer: uchar ( 0..255 )
  • CV_8S : 8-bit signed integer: schar ( -128..127 )
  • CV_16U : 16-bit unsigned integer: ushort ( 0..65535 )
  • CV_16S : 16-bit signed integer: short ( -32768..32767 )
  • CV_32S : 32-bit signed integer: int ( -2147483648..2147483647 )
  • CV_32F : 32-bit floating-point number: float ( -FLT_MAX..FLT_MAX, INF, NAN )
  • CV_64F : 64-bit floating-point number: double ( -DBL_MAX..DBL_MAX, INF, NAN )

그치만 실제로 사용하는 것은 멀티채널의 배열을 정의하는 경우가 많으므로, 아래와 같이 사용하는 경우가 많다.

Multi-channel (n-channel) types can be specified using the following options:

  • CV_8UC1 … CV_64FC4 constants (for a number of channels from 1 to 4)
  • CV_8UC(n) … CV_64FC(n) or CV_MAKETYPE(CV_8U, n) … CV_MAKETYPE(CV_64F, n) macros when the number of channels is more than 4 or unknown at the compilation time.
Note: CV_32FC1 == CV_32F, CV_32FC2 == CV_32FC(2) == CV_MAKETYPE(CV_32F, 2), and CV_MAKETYPE(depth, n) == ((x&7)<<3) + (n-1). This means that the constant type is formed from the depth, taking the lowest 3 bits, and the number of channels minus 1, taking the next log2(CV_CN_MAX) bits.
참고

Written by gomiski

2014/03/11 at 8:37 am

Posted in C++, General, Lecture, opencv

Tagged with , , , ,