ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • [UE5] Bitmap 파일을 작성해서 이미지 파일 생성
    언리얼엔진/노노그램 2024. 1. 5. 17:53

     

    전에 언리얼 엔진의 OpenCV 플러그인으로 런타임에서 이미지 파일을 생성하고 불러오는 작업을 했다.

    https://mstone8370.tistory.com/14

     

    [UE5] 퍼즐 진행 상황 기록 기능

    난이도가 높은 퍼즐은 푸는데 시간이 오래걸려서 일단 상황을 기록해두고 나중에 다시 이어서 하는 경우가 있다. 그래서 퍼즐을 푸는중에 나가면 퍼즐의 마지막 상황을 기록하게 했다. 그렇다

    mstone8370.tistory.com

    원래 노노그램 프로젝트는 모바일에서 작동하게 하는걸 목표로 했는데 이 플러그인을 사용한 뒤로 apk파일이 빌드되지 않는 문제가 생겼다.

    여러 정보를 찾아보면서 컴파일까지는 완료되게 했지만 이후에 Gradle에서 링크 에러가 발생해서 결국엔 실패하게 됐다.

    원인을 계속 찾아보니 버전이 호환되지 않는 문제인듯 했다.

    관련 내용은 아래에 더보기를 누르면 나온다.

    더보기
    더보기

     

    언리얼 엔진의 플러그인으로 제공되는 OpenCV의 버전은 4.5.5 이고,

    언리얼 엔진 5.2 버전 기준으로 사용되는 안드로이드 NDK 버전은 r25b 다.

     

    언리얼 엔진 플러그인으로 제공되는 OpenCV 버전

     

    언리얼 엔진 5.2 버전 기준 안드로이드 NDK 버전

    그런데 이 둘은 서로 호환되지 않는것으로 보인다.

     

    OpenCV 4.5.5 버전은 NDK 22 까지만 지원하는듯하다.

    OpenCV와 NDK 버전 호환성에 대해 정리된 자료를 찾기 어려워서 이런식으로 추측해봤다.

     

    OpcnCV 4.7.0

    OpenCV 4.7.0 버전에는 NDK r25버전용 config 파일이 추가된걸로 봐선 NDK r25는 config 파일이 따로 필요한듯하다.

    그래서 이 config파일을 그대로 넣어서 사용해보려고 했지만 Gradle 버전과 맞지않는 문제가 발생했다.

     

    config 파일에는 Gradle 버전이 명시되어있는데 언리얼 엔진에서 사용되는 Gradle은 로그 파일을 확인해보니 6.1.1로 추정된다.

    그래서 언리얼 엔진의 Gradle 버전을 바꾸려고 알아보니 언리얼 엔진을 커스텀 빌드해서 사용해야한다는듯하다.

    커스텀 빌드는 이전에 시도해봤는데 시간이 지나치게 오래걸렸던 기억이 있다.

     

    이렇게 해서 Gradle 버전을 바꿔도 OpenCV 문제가 해결된다는 보장이 없어서 이 문제는 일단은 미뤄뒀었다.

     


     

    그래서 일단은 미뤄뒀는데 최근에 이미지 파일을 직접 작성하면 되지 않을까 싶어서 이미지 파일의 구조를 알아보게 됐다.

    OpenCV는 이미지 파일을 저장하는 기능만 사용하는데다가 저장할 이미지 크기가 작고 단순해서 가능성이 있어보였다.

     

     

     


     

     

     

    이전에 PNG를 사용하고 있었으므로 PNG를 알아봤는데, PNG는 픽셀 정보를 압축해서 약간 복잡했다.

    반면에 Bitmap은 압축 하지 않으므로 Bitmap으로 진행하기로 결정했다.

     


     

    언리얼 엔진에 적용하기 전에 간단하게 테스트 코드를 작성해봤다.

     

    Bitmap 파일의 구조에 대한 정보는 잘 정리된 글이 많아서 링크를 달아둔다.

    https://velog.io/@cksgh1224/%EB%B9%84%ED%8A%B8%EB%A7%B5-%EB%B6%84%EC%84%9D-4-%EA%B5%AC%EC%A1%B0-1

     

    비트맵 구조 (비트맵 분석 #4)

    1. 픽셀 표현 방법 비트맵 파일은 픽셀 하나를 몇 비트로 저장하느냐에 따라 구조가 달라진다. 픽셀당 1, 4, 8 비트 각각의 주소 공간을 하나하나 기준 세워서 각각의 주소에 해당 색상을 하나하나

    velog.io

    https://ramrider.tistory.com/4

     

    비트맵 파일 BMP 포맷 알아보기

    비트맵 파일의 구조 : 비트맵 파일 헤더는 파일 식별정보, 크기, 데이터 위치 등의 정보를 담고 있습니다. 비트맵 정보헤더는 가로, 세로의 크기, 해상도, 픽셀의 비트수 등 그림의 정보를 담고

    ramrider.tistory.com

     

     

    파일에 작성할 정보를 먼저 배열에 넣어두는 방식으로 진행한다.

    배열의 타입은 1 바이트인 unsigned char 타입으로 한다.

    Bitmap은 리틀 엔디안 방식으로 저장되어있고 헤더에 저장되는 정보들은 2바이트와 4바이트이므로 해당 정보를 작성하는 함수를 만들었다.

    #define BYTE unsigned char
    #define WORD unsigned short
    #define DWORD unsigned long
    
    void WriteWORD(WORD val, BYTE* file, int offset)
    {
        file[offset] = BYTE(val);
        file[offset + 1] = BYTE(val >> 8);
    }
    
    void WriteDWORD(DWORD val, BYTE* file, int offset)
    {
        WriteWORD(WORD(val), file, offset);
        WriteWORD(WORD(val >> 16), file, offset + 2);
    }

    WriteWORD 함수는 2바이트 정보를 1바이트씩 나눠서 리틀 엔디안 방식으로 저장하고, WriteDWORD는 4바이트 정보를 2바이트씩 나눠서 WriteWORD 함수로 저장한다.

     

    사실 노노그램에 사용될 이미지는 크기가 작아서 정보의 크기가 1바이트를 넘어갈 일이 없다.

    그래서 리틀 엔디안을 고려하지 않고 1바이트씩 값을 지정해줘도 문제가 없긴하다.

     


     

    헤더

    헤더에는 Bitmap 파일 헤더와 DIB 헤더가 있다.

     

    Bitmap 파일 헤더에 필요한 정보는 파일의 크기와 픽셀 데이터의 시작 위치다.

    경험상 파일의 크기는 작성하지 않아도 문제가 없었다.

     

    DIB 헤더에 필요한 정보는 DIB 헤더의 크기, 이미지의 width와 height, 색상 비트다.

    그 외에 다른 정보가 더 필요하긴 하지만 기본값만 사용해도 상관없는 정보다.

     

    DWORD Width = 10;
    DWORD Height = 10;
    // Bitmap의 픽셀 정보에서 가로 줄의 바이트는 4의 배수로 만들어야 함.
    DWORD HorizontalLength = DWORD(ceilf(Width / 4.f)) * 4;
    
    // Bitmap file header size + DIB header size + Color table size = 14 + 40 + 256 * 4 = 1078
    DWORD PixelStorageOffset = 14 + 40 + 256 * 4;
    DWORD FileSize = PixelStorageOffset + HorizontalLength * Height;
    
    BYTE* FileData = new BYTE[FileSize];
    memset(FileData, 0, sizeof(BYTE) * FileSize);
    
    // Bitmap file header
    FileData[0] = 'B';
    FileData[1] = 'M';
    WriteDWORD(FileSize, FileData, 2);             // File size
    WriteDWORD(PixelStorageOffset, FileData, 10);  // Pixel storage offset
    
    // DIB header
    WriteDWORD(40, FileData, 14);                  // DIB header size
    WriteDWORD(Width, FileData, 18);               // Width
    WriteDWORD(Height, FileData, 22);              // Hegith
    WriteWORD(1, FileData, 26);                    // 1 color plane
    WriteWORD(8, FileData, 28);                    // 8 bit color

    테스트를 위해서 가로 10픽셀, 세로 10픽셀의 이미지를 만들기로 했다.

     

    노노그램에 사용될 이미지는 2개의 색만 사용되므로 색상은 1비트나 4비트를 지정해도 되지만, 언리얼 엔진에서는 색상이 1비트와 4비트인 이미지는 지원하지 않아서 색상에 8비트를 사용했다.

     

    Bitmap 파일 헤더에는 파일의 전체 크기와 픽셀 데이터가 시작하는 오프셋을 지정해줘야하는데 이 값은 먼저 계산했다.

    Bitmap 파일 헤더와 DIB 헤더의 크기는 14 + 40으로 54 바이트고, 컬러 테이블에는 256개의 색을 지정하기로 해서 컬러 테이블까지의 크기인 PixelStorageOffset은 저렇게 계산할 수 있다.

     

    컬러 테이블 바로 다음에는 픽셀 정보가 나오는데 이때 이미지의 가로 줄의 크기는 4바이트의 배수로 지정해줘야해서 12바이트가 사용되므로 픽셀 데이터의 크기는 12 * 10 = 120 바이트만큼 차지하게 된다.

    이 값에 PixelStorageOffset을 더하면 전체 파일 크기인 FileSize를 구할 수 있다.

     

     

    컬러 테이블

    컬러 테이블에는 256개의 색을 넣었다.

    노노그램 이미지에는 2개의 색만 사용되어서 컬러 테이블에 두개의 색만 넣어봤지만 이런 경우에는 이미지가 제대로 나오지 않아서 256개의 색을 다 넣는 방식을 사용했다.

    이때 R G B 값을 각각 지정해 줄 수 있지만 노노그램 이미지는 무채색 이미지가 사용될 예정이므로 셋 다 같은 값을 지정했다.

    그래서 일반적인 이미지 파일을 만들 때에는 여기에 나온 방식을 그대로 쓰면 안되고 다른 컬러 테이블을 사용해야한다.

    // Color table
    // 00 00 00 00 ~ FF FF FF 00
    for (int Color = 0; Color < 256; Color++)
    {
        // 54 = Color table offset
        FileData[54 + Color * 4 + 0] = Color;  // B
        FileData[54 + Color * 4 + 1] = Color;  // G
        FileData[54 + Color * 4 + 2] = Color;  // R
    }

    컬러 테이블의 한 컬러는 4 바이트를 사용하고, 순서대로 B G R 색이다.

    마지막 바이트는 예악된 값으로 값을 설정하지 않아도 된다.

    여기에서는 R G B 모두 같은 값을 지정했지만 서로 다른 값을 넣어도 된다.

     

     

    Pixel storage

    픽셀 데이터에는 각 픽셀에 맞는 값을 지정하면 된다.

    여기에서는 노노그램에 사용될 이미지이므로 이 프로젝트 방식에 따라 문자열로 인코딩 된 데이터를 디코딩하면서 픽셀 값을 지정하게 했다.

    이때 픽셀 데이터는 위 아래가 뒤집힌 상태로 값을 넣어줘야한다.

     

    픽셀 데이터에 지정되는 값은 컬러 테이블의 색상 번호다.

    여기에서는 컬러 테이블의 색상 번호와 픽셀의 R G B 값이 동일하게 지정되어있다.

    int CharToNum(char Char)
    {
        int ret = Char - 'A';
        if (Char >= 'a')
        {
            ret -= ('a' - 'Z' - 1);
        }
        return ret;
    }
    
    
    // ...
    
    
    string Data = "0E1C0E|0D1E0D|0C1G0C|0B1C0B1C0E|1K|1K|0B1C0B1C0E|0C1B0B1C0E|0E1C0E|0E1C0E";
    
    // Pixel storage
    int x = 0;
    int y = 0;
    
    BYTE TargetVal = 255;
    for (const char& c : Data)
    {
        if (c == '|')
        {
            x = 0;
            y++;
            continue;
        }
    
        if ('0' <= c && c < '4')
        {
            TargetVal = (c == '1') ? 127 : 255;
        }
        else
        {
            const int length = CharToNum(c);
            for (int i = 0; i < length; i++)
            {
                int Offset = FileSize - ((y + 1) * HorizontalLength) + x;
                FileData[Offset] = TargetVal;
                x++;
            }
        }
    }

     

     

    파일 저장

    마지막으로 파일을 저장하고 끝낸다.

    ofstream file(FileDir, ios::binary);
    if (file)
    {
        file.write((char*)FileData, sizeof(BYTE) * FileSize);
    }
    
    delete[] FileData;

     

    저장한 결과는 아래의 작은 이미지다.

     

    아래는 전체 코드다.

    // 테스트 코드
    
    #include <fstream>
    #include <string>
    #include <math.h>
    
    #define BYTE unsigned char
    #define WORD unsigned short
    #define DWORD unsigned long
    
    using namespace std;
    
    int CharToNum(char Char)
    {
        int ret = Char - 'A';
        if (Char >= 'a')
        {
            ret -= ('a' - 'Z' - 1);
        }
        return ret;
    }
    
    void WriteWORD(WORD val, BYTE* file, int offset)
    {
        file[offset] = BYTE(val);
        file[offset + 1] = BYTE(val >> 8);
    }
    
    void WriteDWORD(DWORD val, BYTE* file, int offset)
    {
        WriteWORD(WORD(val), file, offset);
        WriteWORD(WORD(val >> 16), file, offset + 2);
    }
    
    int main()
    {
        string Data = "0E1C0E|0D1E0D|0C1G0C|0B1C0B1C0E|1K|1K|0B1C0B1C0E|0C1B0B1C0E|0E1C0E|0E1C0E";
    
        string FileDir = "test.bmp";
    
        DWORD Width = 10;
        DWORD Height = 10;
        // Bitmap의 픽셀 정보에서 가로 줄의 바이트는 4의 배수로 만들어야 함.
        DWORD HorizontalLength = DWORD(ceilf(Width / 4.f)) * 4;
    
        // Bitmap file header size + DIB header size + Color table size = 14 + 40 + 256 * 4 = 1078
        DWORD PixelStorageOffset = 14 + 40 + 256 * 4;
        DWORD FileSize = PixelStorageOffset + HorizontalLength * Height;
    
        BYTE* FileData = new BYTE[FileSize];
        memset(FileData, 0, sizeof(BYTE) * FileSize);
    
        // Bitmap file header
        FileData[0] = 'B';
        FileData[1] = 'M';
        WriteDWORD(FileSize, FileData, 2);             // File size
        WriteDWORD(PixelStorageOffset, FileData, 10);  // Pixel storage offset
    
        // DIB header
        WriteDWORD(40, FileData, 14);                  // DIB header size
        WriteDWORD(Width, FileData, 18);               // Width
        WriteDWORD(Height, FileData, 22);              // Hegith
        WriteWORD(1, FileData, 26);                    // 1 color plane
        WriteWORD(8, FileData, 28);                    // 8 bit color
    
        // Color table
        // 00 00 00 00 ~ FF FF FF 00
        for (int Color = 0; Color < 256; Color++)
        {
            // 54 = Color table offset
            FileData[54 + Color * 4 + 0] = Color;  // B
            FileData[54 + Color * 4 + 1] = Color;  // G
            FileData[54 + Color * 4 + 2] = Color;  // R
        }
    
        // Pixel storage
        int x = 0;
        int y = 0;
    
        BYTE TargetVal = 255;
        for (const char& c : Data)
        {
            if (c == '|')
            {
                x = 0;
                y++;
                continue;
            }
    
            if ('0' <= c && c < '4')
            {
                TargetVal = (c == '1') ? 127 : 255;
            }
            else
            {
                const int length = CharToNum(c);
                for (int i = 0; i < length; i++)
                {
                    int Offset = FileSize - ((y + 1) * HorizontalLength) + x;
                    FileData[Offset] = TargetVal;
                    x++;
                }
            }
        }
        
        ofstream file(FileDir, ios::binary);
        if (file)
        {
            file.write((char*)FileData, sizeof(BYTE) * FileSize);
        }
    
        delete[] FileData;
    }

     


     

    최종 적용

    다음으로 언리얼 엔진에 적용하기 위해 수정했다.

    언리얼 엔진은 다양한 플랫폼에서 사용 가능한 파일 매니저가 있어서 파일을 작성하려면 이걸 쓰면 된다.

    IPlatformFile& FileManager = FPlatformFileManager::Get().GetPlatformFile();

     

    변수의 타입도 언리얼 엔진의 방식대로 변경한다.

    1 바이트인 BYTE는 uint8

    2 바이트인 WORD는 uint16

    4 바이트인 DWORD는 uint32로 변경한다.

     

    그리고 코드도 다듬어서 다음과 같이 작성했다.

    // ImageMaker.h
    
    // 마지막으로 작성한 오프셋의 다음 위치 리턴
    template <typename T>
    uint32 UImageMaker::WriteBytes(T Value, uint8* File, uint32 Offset, uint32 ByteCount) const
    {
        for (uint32 i = 0; i < ByteCount; ++i)
        {
            File[Offset + i] = static_cast<uint8>((Value >> (8 * i)) & 0xFF);
        }
        return Offset + ByteCount;
    }
    
    
    
    // ImageMaker.cpp
    
    const uint32 UImageMaker::FILE_HEADER_SIZE = 14;
    const uint32 UImageMaker::DIB_HEADER_SIZE  = 40;
    const uint8  UImageMaker::FILE_SIZE_OFFSET = 2;
    const uint8  UImageMaker::OFF_BITS_OFFSET  = 10;
    const uint32 UImageMaker::COLOR_TABLE_SIZE = 256 * 4;
    const uint16 UImageMaker::COLOR_PLANE      = 1;
    const uint16 UImageMaker::COLOR_DEPTH      = 8;
    const uint8  UImageMaker::EMPTY_COLOR      = 255;
    
    uint32 UImageMaker::Write2Bytes(uint16 Value, uint8* File, uint32 Offset) const
    {
        return WriteBytes(Value, File, Offset, 2);
    }
    
    uint32 UImageMaker::Write4Bytes(uint32 Value, uint8* File, uint32 Offset) const
    {
        return WriteBytes(Value, File, Offset, 4);
    }
    
    void UImageMaker::DecodeAndFillPixelData(const FString& Data, uint8* FileData, uint8 Color, uint32 FileSize, uint32 RowBytes) const
    {
        uint8 CurrentColor = EMPTY_COLOR;
    
        int32 x = 0;
        int32 y = 0;
        for (const TCHAR& c : Data)
        {
            if (c == '|')
            {
                x = 0;
                ++y;
                continue;
            }
    
            if ('0' <= c && c < '4')
            {
                CurrentColor = (c == '1') ? Color : EMPTY_COLOR;
            }
            else
            {
                const int32 Length = UNonogramStatics::CharToNum(c);
                for (int32 i = 0; i < Length; i++)
                {
                    // 아래에서부터 위로 픽셀 저장
                    int32 Offset = FileSize - ((y + 1) * RowBytes) + x;
                    FileData[Offset] = CurrentColor;
                    ++x;
                }
            }
        }
    }
    
    bool UImageMaker::SaveInProgressImage(const FString& Data, const FString& FolderName, const FString& BoardName, uint32 RowSize, uint32 ColSize, uint8 Color) const
    {
        // 파일 시스템 접근을 위한 파일 매니저
        IPlatformFile& FileManager = FPlatformFileManager::Get().GetPlatformFile();
    
        // 파일 경로
        const FString FolderDir = FPaths::Combine(InProgressDir, FolderName);
        const FString FileName = BoardName + ".bmp";
        const FString FileDir = FPaths::Combine(FolderDir, BoardName + ".bmp");
        
        // 폴더 생성
        FileManager.CreateDirectory(*FolderDir);
    
        // 이미지 크기 및 파일 크기 계산
        const uint32 Width = ColSize;
        const uint32 Height = RowSize;
        const uint32 RowBytes = FMath::CeilToInt32(Width / 4.f) * 4; // 4의 배수로
        const uint32 PixelStorageOffset = FILE_HEADER_SIZE + DIB_HEADER_SIZE + COLOR_TABLE_SIZE;
        const uint32 FileSize = PixelStorageOffset + (RowBytes * Height);
    
        // 파일 데이터 메모리 할당
        TUniquePtr<uint8[]> FileData = MakeUnique<uint8[]>(FileSize);
        FMemory::Memzero(FileData.Get(), FileSize);
    
        // Bitmap file header
        FileData[0] = 'B';
        FileData[1] = 'M';
        Write4Bytes(FileSize, FileData.Get(), FILE_SIZE_OFFSET);           // File size
        Write4Bytes(PixelStorageOffset, FileData.Get(), OFF_BITS_OFFSET);  // Pixel storage offset
    
        // DIB header
        {
            uint32 CurrentOffset = FILE_HEADER_SIZE;
            CurrentOffset = Write4Bytes(DIB_HEADER_SIZE, FileData.Get(), CurrentOffset);    // DIB header size
            CurrentOffset = Write4Bytes(Width, FileData.Get(), CurrentOffset);              // Width
            CurrentOffset = Write4Bytes(Height, FileData.Get(), CurrentOffset);             // Hegith
            CurrentOffset = Write2Bytes(COLOR_PLANE, FileData.Get(), CurrentOffset);        // 1 color plane
            CurrentOffset = Write2Bytes(COLOR_DEPTH, FileData.Get(), CurrentOffset);        // 8 bit color
        }
    
        // Color table
        // 00 00 00 00 ~ FF FF FF 00 (Gray Scale)
        for (int32 ColorIdx = 0; ColorIdx < 256; ColorIdx++)
        {
            const SIZE_T ColorTableOffset = FILE_HEADER_SIZE + DIB_HEADER_SIZE + (ColorIdx * 4);
            FileData[ColorTableOffset + 0] = ColorIdx;  // Blue
            FileData[ColorTableOffset + 1] = ColorIdx;  // Green
            FileData[ColorTableOffset + 2] = ColorIdx;  // Red
        }
    
        // Pixel storage
        DecodeAndFillPixelData(Data, FileData.Get(), Color, FileSize, RowBytes);
    
        // Save image file
        bool bSuccess = false;
        IFileHandle* FileHandle = FileManager.OpenWrite(*FileDir, false, true);
        if (FileHandle)
        {
            bSuccess = FileHandle->Write(FileData.Get(), sizeof(uint8) * FileSize);
            delete FileHandle;  // 파일을 닫기 위해 IFileHandle을 delete
        }
    
        if (!bSuccess)
        {
            UE_LOG(LogTemp, Error, TEXT("Failed to save image: %s"), *FileDir);
        }
        return bSuccess;
    }

     


     

    적용한 결과는 아래와 같다.

     

    결과만 보면 이전과 큰 차이가 없지만 이제 안드로이드에서도 작동이 가능하게 됐다.

     

    안드로이드 폰에서 작동한 결과

     

     

     

Designed by Tistory.