개발(IT)/C#(VisualStudio)

의약품 반품 보상 프로그램 만들기 — 단계별 강의 (2편)

isony 2026. 7. 6. 07:33
반응형

의약품 반품 보상 프로그램 만들기 — 단계별 강의 (2편)

1편에서 만든 "두뇌"(파서·대조·보상)는 바코드 문자열을 입력으로 받았다. 그런데 그 문자열은 어디서 오나? 이미지(사진) 속 GS1 DataMatrix를 디코드해서 얻는다. 2편은 바로 그 부분이다.

2편을 끝내면: 이미지 파일을 넣으면 → 그 안의 DataMatrix를 (여러 개라도) 읽어 문자열 + 위치 좌표로 돌려주고, 큰 사진 속 작은 코드사용자가 고른 영역도 처리하는 클래스를 갖게 된다. 여전히 콘솔에서 테스트한다. (화면 UI는 3편)

이번 편은 요청하신 대로 설명을 더 자세히 넣었다. 개념이 처음이면 📖과 💡 박스를 천천히 읽자.

 

들어가기 전에 — 큰 그림

 [사진/이미지 파일]
        │  (2편에서 만드는 부분)
        ▼
 ┌─────────────────────────┐
 │  BarcodeServiceCpp       │   ← ZXingCpp(디코드) + ImageSharp(이미지 처리)
 │  "이미지 → 코드 문자열 + 위치"  │
 └─────────────────────────┘
        │  "(01)08806443096813(17)280315(10)6248607(21)..."
        ▼
 ┌─────────────────────────┐
 │  Gs1Parser (1편)         │   ← 문자열 → GTIN/유통기한/로트
 └─────────────────────────┘
        ▼
 ┌─────────────────────────┐
 │ Repository + Compensation │  ← 대조 + 보상 (1편)
 └─────────────────────────┘

즉 2편의 산출물 BarcodeServiceCpp는 1편의 Gs1Parser 앞에 붙는 "눈" 역할이다.

 

Lesson 5 — 바코드 디코딩 기초

🎯 목표

NuGet 패키지를 설치하고, 이미지 파일 하나에서 DataMatrix를 읽어 문자열로 출력한다. 테스트용 코드 이미지도 직접 만든다.

📖 개념 1: NuGet 패키지란?

NuGet은 .NET의 "라이브러리 앱스토어"다. 남이 만든 기능 묶음(패키지)을 프로젝트에 한 줄로 추가한다. 우리가 쓸 패키지:

패키지 역할 비고

ZXingCpp 바코드 디코드(zxing-cpp의 .NET 래퍼) 네이티브 라이브러리 포함, 다중 판독이 기본
SixLabors.ImageSharp 이미지 로드·자르기·확대·흑백 처리 2.1.x = 무료(Apache-2.0)
ZXing.Net 바코드 생성(테스트 이미지 만들기용) 디코드는 ZXingCpp가 더 강함

💡 왜 디코드는 ZXingCpp, 생성은 ZXing.Net? 디코드(읽기)는 정확도가 생명이라 더 강한 zxing-cpp(C++ 엔진)를 쓴다. 생성(쓰기)은 테스트 이미지를 만드는 용도라 순수 .NET인 ZXing.Net이면 충분하다. 역할에 맞게 도구를 나눈 것이다.

📖 개념 2: DataMatrix가 뭔가

DataMatrix는 정사각형 안에 작은 흑백 점(모듈)으로 데이터를 담는 2D 바코드다.

  • 왼쪽·아래 변의 실선 L자(finder pattern)로 스캐너가 위치·방향을 찾는다.
  • 위·오른쪽 변의 점선(timing pattern)으로 가로·세로 칸 수를 센다.
  • 오류정정(Reed-Solomon) 덕분에 일부(최대 ~30%)가 손상돼도 복원된다. (단, 인쇄가 흐려 데이터 자체가 뭉개지면 한계가 있다 — 7편쯤 실제로 겪는다.)
  • 의약품은 보통 GS1 DataMatrix: 맨 앞에 FNC1 표시가 있고, GTIN·유통기한·로트·일련번호를 AI로 담는다(1편 파서가 처리하는 그것).
  DataMatrix 구조(개념도)
   ┌ ─ ─ ─ ─ ┐   ← timing(점선): 칸 수
   █ ░ █ ░ █ ░     데이터(흑/백 모듈)
   █ █ ░ █ ░ █
   █ ░ ░ █ █ ░
   █ █ █ █ █ █   ← finder(실선 L): 위치/방향
   └ 실 선 변 ┘

📖 개념 3: 이미지를 디코더에 넘기는 흐름

ZXingCpp는 이미지를 ImageView 라는 형태로 받는다. 이건 "픽셀 바이트 배열 + 가로·세로 크기 + 픽셀 형식(RGB 등)"을 묶은 얇은 포장지다. 그래서 흐름은:

이미지 파일 ──(ImageSharp로 로드)──▶ Rgb24 이미지
            ──(픽셀을 byte[]로 복사)──▶ rgb 바이트 배열
            ──(ImageView로 포장)──▶ ZXingCpp에 전달 ──▶ 결과 목록

💡 왜 RGB 바이트 배열이 필요한가? 디코더는 "픽셀 색이 죽 늘어선 숫자 배열"만 이해한다. JPG/PNG는 압축된 파일이라 그대로는 못 읽는다. 그래서 ImageSharp가 압축을 풀어 Rgb24(픽셀당 R,G,B 3바이트) 이미지로 만들고, 그 픽셀들을 byte[]로 복사해 넘긴다.

💻 코드 (5-1) 패키지 설치 & 프로젝트 설정

1편의 콘솔 프로젝트에 패키지를 추가한다. VS2022: 도구 → NuGet 패키지 관리자 → 솔루션용 NuGet 관리 → 검색해 설치. 또는 .csproj를 직접 편집:

<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <OutputType>Exe</OutputType>
    <TargetFramework>net8.0</TargetFramework>
    <ImplicitUsings>enable</ImplicitUsings>
    <Nullable>enable</Nullable>
    <RootNamespace>PharmaReturn</RootNamespace>
    <!-- ZXingCpp 네이티브 라이브러리는 64비트다. 64비트로 고정. -->
    <Platforms>x64</Platforms>
    <PlatformTarget>x64</PlatformTarget>
  </PropertyGroup>

  <ItemGroup>
    <PackageReference Include="ZXingCpp" Version="0.5.1" />
    <PackageReference Include="SixLabors.ImageSharp" Version="2.1.9" />
    <PackageReference Include="ZXing.Net" Version="0.16.10" />
  </ItemGroup>

  <ItemGroup>
    <None Include="data\**\*" CopyToOutputDirectory="PreserveNewest" />
  </ItemGroup>
</Project>

💡 <PlatformTarget>x64</PlatformTarget> 가 왜 중요? ZXingCpp는 안에 C++로 만든 네이티브 .dll을 품고 있는데 64비트다. 프로젝트가 32비트(AnyCPU+Prefer32bit)면 그 dll을 못 불러 DllNotFoundException이 난다. 그래서 64비트로 못 박는다.

💻 코드 (5-2) 테스트용 코드 이미지 만들기

카메라가 없어도 테스트하려면, 우리가 코드 이미지를 만들어 그걸 다시 읽으면 된다(닫힌 루프). TestImageMaker.cs 추가:

using SixLabors.ImageSharp;
using SixLabors.ImageSharp.PixelFormats;
using ZXing;
using ZXing.Common;
using ZXing.Datamatrix;

namespace PharmaReturn;

/// <summary>테스트용 GS1 DataMatrix PNG를 만든다(생성=ZXing.Net).</summary>
public static class TestImageMaker
{
    public static void Generate(string content, string outPath, int codePx = 240)
    {
        var hints = new Dictionary<EncodeHintType, object>
        {
            { EncodeHintType.GS1_FORMAT, true }, // 맨 앞에 FNC1(=GS1 표시) 추가
            { EncodeHintType.MARGIN, 1 },        // 코드 둘레 여백(quiet zone)
        };
        // 1) 내용 → 흑백 격자(BitMatrix)로 인코딩
        BitMatrix matrix = new DataMatrixWriter()
            .encode(content, BarcodeFormat.DATA_MATRIX, codePx, codePx, hints);

        // 2) 격자를 PNG로 그리기 (true=검정, false=흰색)
        using var img = new Image<L8>(matrix.Width, matrix.Height);
        for (int y = 0; y < matrix.Height; y++)
            for (int x = 0; x < matrix.Width; x++)
                img[x, y] = new L8(matrix[x, y] ? (byte)0 : (byte)255);
        img.Save(outPath);
    }
}

🔍 한 줄씩:

  • DataMatrixWriter().encode(...): 내용을 흑백 격자(BitMatrix)로 만든다. GS1_FORMAT=true면 GS1 규칙대로 FNC1을 앞에 붙인다.
  • Image<L8>: 8비트 회색(L=Luminance) 이미지. 0=검정, 255=흰색.
  • 격자의 각 칸을 픽셀로 칠해 PNG로 저장.

💻 코드 (5-3) 가장 단순한 디코더

BarcodeServiceCpp.cs 추가(이번 Lesson 버전 — 다음 Lesson에서 키운다):

using SixLabors.ImageSharp;
using SixLabors.ImageSharp.PixelFormats;
using ZXingCpp;
using SsImage = SixLabors.ImageSharp.Image;   // ← 이름 충돌 회피(아래 💡)

namespace PharmaReturn;

public static class BarcodeServiceCpp
{
    /// <summary>이미지 한 장에서 DataMatrix 문자열을 모두 읽는다.</summary>
    public static List<string> DecodeAll(string imagePath)
    {
        // 1) 이미지 로드 → RGB 픽셀 바이트로 복사
        using var image = SsImage.Load<Rgb24>(imagePath);
        var rgb = new byte[image.Width * image.Height * 3];
        image.CopyPixelDataTo(rgb);

        // 2) ZXingCpp가 이해하는 ImageView로 포장
        var view = new ImageView(rgb, image.Width, image.Height, ImageFormat.RGB);

        // 3) 디코더 설정: DataMatrix만, 반전/회전도 시도
        var reader = new BarcodeReader
        {
            Formats = BarcodeFormat.DataMatrix, // 포맷 한정 → 빠르고 정확
            TryInvert = true,                   // 흰 바탕 검은 코드의 반전도 시도
            TryRotate = true,                   // 돌아간 코드도 시도
        };

        // 4) From()은 찾은 모든 바코드 목록을 돌려준다(다중 판독이 기본)
        var results = new List<string>();
        foreach (var b in reader.From(view))
            if (b.IsValid && !string.IsNullOrEmpty(b.Text))
                results.Add(b.Text);
        return results;
    }
}

💡 using SsImage = SixLabors.ImageSharp.Image; 이게 뭐죠? ZXingCpp에도 Image라는 타입이 있고 SixLabors.ImageSharp에도 Image가 있다. 둘 다 using 하면 컴파일러가 "어느 Image?"라며 모호 참조 오류를 낸다(실제로 만들다 보면 만나는 흔한 에러다). 그래서 ImageSharp의 Image에 SsImage라는 별명을 붙여 명확히 가리킨다. Rgb24는 ImageSharp에만, ImageView·BarcodeReader는 ZXingCpp에만 있어 충돌이 없다.

✅ 직접 해보기

Program.cs를 잠깐 이렇게:

using PharmaReturn;

string path = Path.Combine(AppContext.BaseDirectory, "test.png");
// 1) 테스트 코드 만들기 (괄호 없는 원시형으로 생성)
TestImageMaker.Generate("010880644309681317280315106248607", path);
// 2) 다시 읽기
foreach (var code in BarcodeServiceCpp.DecodeAll(path))
    Console.WriteLine("읽음: " + code);

읽음: ...08806443096813...280315...6248607... 처럼 나오면 성공. (생성 옵션에 따라 GS 구분자가 보이지 않게 섞일 수 있는데, 1편 파서가 처리하니 걱정 말자.)

 

Lesson 6 — 위치 좌표 + 다중 판독

🎯 목표

바코드의 **문자열뿐 아니라 위치(네 꼭짓점)**도 함께 얻는다. 이건 나중에 화면에서 파란 박스를 그릴 재료다.

📖 개념 1: 왜 위치가 필요한가

3편 화면에서 "이 박스를 읽었어요"를 사람이 보려면, 코드가 이미지의 어디에 있는지 알아야 한다. 디코더는 각 코드의 네 꼭짓점 좌표(좌상·우상·우하·좌하)를 함께 알려준다.

   (TopLeft)───────(TopRight)
       │   DataMatrix   │
   (BottomLeft)────(BottomRight)

이 좌표는 이미지 픽셀 좌표다. 즉 "원본 이미지에서 왼쪽 위가 (0,0), 오른쪽으로 X, 아래로 Y"인 좌표계. (화면에 그릴 땐 확대/이동을 반영해 변환해야 하는데, 그건 3편에서.)

📖 개념 2: 결과를 담을 그릇 DecodedCode

문자열 + 꼭짓점 4개 + 유효 여부를 한 묶음으로 만든다. 1편에서 배운 record가 딱이다.

public sealed record DecodedCode(string Text, System.Drawing.Point[] Corners, bool Valid);

💡 System.Drawing.Point는 "정수 좌표 한 점(X,Y)"을 담는 표준 타입이다. 크로스플랫폼 콘솔(net8.0)에서도 쓸 수 있다(System.Drawing.Primitives에 포함). 매번 길게 쓰기 싫어서 파일 위에서 using DrawPoint = System.Drawing.Point; 별명을 준다.

📖 개념 3: "좌표 변환 함수"를 미리 끼워두는 설계

다음 Lesson(타일)에서, 잘라낸 조각 안의 좌표를 원본 이미지 좌표로 되돌려야 한다. 그래서 디코딩 핵심부를 만들 때, "꼭짓점을 어떻게 변환할지"를 함수로 받아두면 전체/조각 모두 같은 코드로 처리할 수 있다. 이게 Func<int,int,DrawPoint> transform다.

  • 전체 이미지일 때: (x, y) => 그대로 (x, y) — 변환 없음.
  • 조각(타일)일 때: (x, y) => (타일위치 + x/확대배율, ...) — 원본 좌표로 환산.

💡 처음 보면 "함수를 인자로 넘긴다"가 낯설 수 있다. 요리로 비유하면, 디코딩 함수는 "재료를 그릇에 담는 일"만 하고, **담기 직전에 적용할 양념(좌표 변환)**을 호출자가 골라 건네는 것이다. 덕분에 같은 디코딩 함수를 전체/조각에 재사용한다.

💻 코드

BarcodeServiceCpp.cs를 아래로 키운다(전체 이미지 다중 판독 + 위치).

using SixLabors.ImageSharp;
using SixLabors.ImageSharp.PixelFormats;
using ZXingCpp;
using SsImage = SixLabors.ImageSharp.Image;
using DrawPoint = System.Drawing.Point;

namespace PharmaReturn;

/// <summary>판독 결과 1건: 원문 + 4꼭짓점(원본 이미지 픽셀 좌표).</summary>
public sealed record DecodedCode(string Text, DrawPoint[] Corners, bool Valid);

public static class BarcodeServiceCpp
{
    /// <summary>전체 이미지에서 다중 판독(위치 포함).</summary>
    public static List<DecodedCode> DecodeDetailed(string imagePath)
    {
        using var image = SsImage.Load<Rgb24>(imagePath);
        // 전체 이미지: 좌표 변환 없음 → 그대로
        return DecodeView(image, (x, y) => new DrawPoint(x, y));
    }

    /// <summary>문자열만 필요할 때(하위 호환).</summary>
    public static List<string> DecodeAll(string imagePath)
        => DecodeDetailed(imagePath).Select(c => c.Text).ToList();

    /// <summary>
    /// 한 장의 (이미 준비된) 이미지를 디코드한다.
    /// transform: 디코더가 준 조각-내부 좌표(cx,cy)를 원본 좌표로 바꾸는 함수.
    /// </summary>
    private static List<DecodedCode> DecodeView(Image<Rgb24> img, Func<int, int, DrawPoint> transform)
    {
        var rgb = new byte[img.Width * img.Height * 3];
        img.CopyPixelDataTo(rgb);
        var view = new ImageView(rgb, img.Width, img.Height, ImageFormat.RGB);

        var reader = new BarcodeReader
        {
            Formats = BarcodeFormat.DataMatrix,
            TryInvert = true,
            TryRotate = true,
        };

        var list = new List<DecodedCode>();
        foreach (var b in reader.From(view))
        {
            if (!b.IsValid || string.IsNullOrEmpty(b.Text)) continue;

            // Position: 네 꼭짓점(PointI: X, Y).
            // ※ 설치된 ZXingCpp 버전에 따라 멤버명이 다를 수 있다(아래 ⚠️).
            var p = b.Position;
            var corners = new[]
            {
                transform(p.TopLeft.X,     p.TopLeft.Y),
                transform(p.TopRight.X,    p.TopRight.Y),
                transform(p.BottomRight.X, p.BottomRight.Y),
                transform(p.BottomLeft.X,  p.BottomLeft.Y),
            };
            list.Add(new DecodedCode(b.Text, corners, true));
        }
        return list;
    }
}

🔍 핵심:

  • DecodeDetailed는 DecodeView에 "변환 안 함" 함수를 넘긴다(전체 이미지니까).
  • b.Position에서 네 꼭짓점을 꺼내 transform을 거쳐 Corners에 담는다.
  • 결과는 DecodedCode 목록.

⚠️ 버전 주의(중요): b.Position.TopLeft.X 부분은 ZXingCpp 버전에 따라 멤버 이름이 다를 수 있다(예: Position이 배열/인덱서이거나 TopLeft가 소문자거나). 빌드에서 이 줄에 에러가 나면, 설치된 버전의 Barcode/Position 정의를 보고 그 이름에 맞춰 바꾸면 된다. 나머지 구조는 그대로 둔다.

✅ 직접 해보기

using PharmaReturn;
string path = Path.Combine(AppContext.BaseDirectory, "test.png");
TestImageMaker.Generate("010880644309681317280315106248607", path);

foreach (var c in BarcodeServiceCpp.DecodeDetailed(path))
{
    Console.WriteLine($"문자열: {c.Text}");
    Console.WriteLine($"좌상 꼭짓점: ({c.Corners[0].X},{c.Corners[0].Y})");
}

문자열과 좌상 꼭짓점 좌표가 같이 나오면 성공. (좌표는 코드가 그려진 위치 픽셀이다.)

 

Lesson 7 — 타일 판독(대량·작은 코드) + 영역 판독

🎯 목표

큰 사진 한 장에 작은 코드가 여러 개여도 최대한 다 읽는다. 또 사용자가 고른 특정 영역만 다시 읽는다.

📖 개념 1: 왜 전체 디코드로는 부족한가

스마트폰 사진은 크다(예: 1152×2560). 그 안에 코드가 작게 여러 개면, 디코더가 전체에서 한두 개만 찾고 만다. 코드가 화면 대비 너무 작아 "안 보이는" 셈이다.

해결책 = 타일 분할. 이미지를 작은 사각형(타일)으로 쪼개 각 타일을 디코드하면, 그 타일 안에선 코드가 상대적으로 커져 잘 읽힌다. 거기에 타일을 확대(upscale) 까지 하면 더 좋아진다.

 큰 사진을 격자로 쪼갬(겹치게)        각 타일을 확대해서 디코드
 ┌────┬────┬────┐                  ┌──────────┐
 │ ▥  │  ▥ │ ▥  │   ──▶ 타일1 ──▶  │   ▥▥▥    │ ──▶ 읽힘!
 ├────┼────┼────┤                  └──────────┘
 │  ▥ │ ▥  │  ▥ │
 └────┴────┴────┘

📖 개념 2: 겹침(overlap)이 왜 필요한가

코드가 타일 경계에 걸치면 잘려서 못 읽는다. 그래서 타일을 50% 정도 겹치게 이동시키면, 경계에 걸린 코드도 옆 타일에선 온전히 들어온다.

 겹침 없음: 경계의 코드가 반토막 ✗
   |‾‾‾‾|‾‾‾‾|
   |  ▥▮|▮▥  |     ← 코드가 두 타일에 쪼개짐
 겹침 50%: 다음 타일이 절반 겹쳐 온전히 포함 ✓
   |‾‾‾‾|
      |‾‾‾‾|        ← 이 타일 안에 코드 전체가 들어옴
  • step = tile × (1 - overlap) 만큼만 이동한다. overlap=0.5면 타일 크기의 절반씩 이동.

📖 개념 3: 좌표 되돌리기(이게 transform의 진짜 쓸모)

타일을 잘라 3배 확대해서 디코드하면, 디코더가 주는 꼭짓점은 "확대된 조각 안" 좌표다. 이걸 원본 좌표로 되돌려야 화면에 제 위치에 그린다.

  • 조각은 원본의 (ox, oy)에서 잘렸고, up배 확대됐다.
  • 그러니 조각 내부 좌표 (cx, cy) → 원본 좌표 = (ox + cx/up, oy + cy/up).

이게 (cx, cy) => new DrawPoint(ox + cx / up, oy + cy / up) 한 줄의 의미다. Lesson 6에서 "변환 함수를 받아두는" 설계를 한 이유가 여기서 빛난다.

📖 개념 4: 중복 제거(dedup)

타일이 겹치니 같은 코드가 여러 타일에서 잡힌다. 그래서 문자열을 키로 딕셔너리에 담아(TryAdd) 같은 내용은 한 번만 남긴다. (의약품은 일련번호(21)가 낱개마다 달라 문자열이 전부 달라서, 서로 다른 박스가 합쳐질 걱정은 없다.)

💻 코드 (최종형)

BarcodeServiceCpp.cs를 최종 버전으로 키운다. (Lesson 6 버전에 타일/영역/확대 처리를 더한 것)

using SixLabors.ImageSharp;
using SixLabors.ImageSharp.PixelFormats;
using SixLabors.ImageSharp.Processing;   // Crop, Resize, Grayscale, Contrast
using ZXingCpp;
using SsImage = SixLabors.ImageSharp.Image;
using DrawPoint = System.Drawing.Point;
using DrawRect = System.Drawing.Rectangle;

namespace PharmaReturn;

public sealed record DecodedCode(string Text, DrawPoint[] Corners, bool Valid);

public static class BarcodeServiceCpp
{
    /// <summary>전체 + 타일 다중 패스로 가능한 모든 코드를 위치와 함께 회수.</summary>
    public static List<DecodedCode> DecodeDetailed(string imagePath)
    {
        using var image = SsImage.Load<Rgb24>(imagePath);
        var byText = new Dictionary<string, DecodedCode>();

        // 1) 전체 이미지 한 번
        foreach (var c in DecodeView(image, (x, y) => new DrawPoint(x, y)))
            byText.TryAdd(c.Text, c);

        // 2) 타일 패스: (타일 크기, 확대배율) 두 조합으로 작은 코드 회수
        foreach (var (tile, up) in new[] { (512, 2), (320, 3) })
            TilePass(image, tile, overlap: 0.5, upscale: up, byText);

        return byText.Values.ToList();
    }

    /// <summary>지정 영역만 확대해 재판독(꼭짓점은 원본 좌표로 보정).</summary>
    public static List<DecodedCode> DecodeRegion(string imagePath, DrawRect region)
    {
        using var image = SsImage.Load<Rgb24>(imagePath);
        // 영역이 이미지 밖으로 나가지 않게 교집합으로 자른다
        var r = DrawRect.Intersect(region, new DrawRect(0, 0, image.Width, image.Height));
        if (r.Width <= 1 || r.Height <= 1) return new();

        const int up = 2;
        using var crop = image.Clone(c => c.Crop(new Rectangle(r.X, r.Y, r.Width, r.Height))
                                           .Resize(r.Width * up, r.Height * up).Grayscale());
        var byText = new Dictionary<string, DecodedCode>();
        foreach (var c in DecodeView(crop, (cx, cy) => new DrawPoint(r.X + cx / up, r.Y + cy / up)))
            byText.TryAdd(c.Text, c);
        return byText.Values.ToList();
    }

    public static List<string> DecodeAll(string imagePath)
        => DecodeDetailed(imagePath).Select(c => c.Text).ToList();

    // ── 내부 ───────────────────────────────────────────────
    private static void TilePass(Image<Rgb24> image, int tile, double overlap, int upscale,
                                 Dictionary<string, DecodedCode> byText)
    {
        int step = Math.Max(1, (int)(tile * (1 - overlap))); // 겹침만큼 덜 이동
        for (int y = 0; y < image.Height; y += step)
            for (int x = 0; x < image.Width; x += step)
            {
                int w = Math.Min(tile, image.Width - x);   // 가장자리에서 잘림 방지
                int h = Math.Min(tile, image.Height - y);
                if (w < 40 || h < 40) continue;             // 너무 작은 조각은 스킵

                int ox = x, oy = y, up = upscale;
                // 조각을 잘라 → 확대 → 흑백 → 대비 강화
                using var crop = image.Clone(c => c.Crop(new Rectangle(ox, oy, w, h))
                                                   .Resize(w * up, h * up).Grayscale().Contrast(1.2f));
                // 조각 내부 좌표 → 원본 좌표로 되돌리는 변환을 넘긴다
                foreach (var c in DecodeView(crop, (cx, cy) => new DrawPoint(ox + cx / up, oy + cy / up)))
                    byText.TryAdd(c.Text, c);  // 중복(같은 문자열)은 자동 무시
            }
    }

    private static List<DecodedCode> DecodeView(Image<Rgb24> img, Func<int, int, DrawPoint> transform)
    {
        var rgb = new byte[img.Width * img.Height * 3];
        img.CopyPixelDataTo(rgb);
        var view = new ImageView(rgb, img.Width, img.Height, ImageFormat.RGB);

        var reader = new BarcodeReader
        {
            Formats = BarcodeFormat.DataMatrix,
            TryInvert = true,
            TryRotate = true,
        };

        var list = new List<DecodedCode>();
        foreach (var b in reader.From(view))
        {
            if (!b.IsValid || string.IsNullOrEmpty(b.Text)) continue;
            var p = b.Position;
            var corners = new[]
            {
                transform(p.TopLeft.X,     p.TopLeft.Y),
                transform(p.TopRight.X,    p.TopRight.Y),
                transform(p.BottomRight.X, p.BottomRight.Y),
                transform(p.BottomLeft.X,  p.BottomLeft.Y),
            };
            list.Add(new DecodedCode(b.Text, corners, true));
        }
        return list;
    }
}

🔍 새로 들어온 것 설명:

  • image.Clone(c => c.Crop(...).Resize(...).Grayscale().Contrast(...)): ImageSharp의 처리 체인. 원본을 건드리지 않고 복제본에 자르기→확대→흑백→대비 강화를 순서대로 적용한다. (using System.Drawing.Processing; 필요)
    • 흑백·대비를 왜? 디코더는 명암으로 모듈을 구분한다. 색 정보를 빼고 대비를 키우면 인쇄가 옅은 코드도 잘 잡힌다.
  • DecodeRegion: 사용자가 고른 사각형(region)만 잘라 2배 확대해 디코드. 3편에서 "영역 판독" 버튼에 연결된다.
  • DrawRect.Intersect: 사용자가 이미지 경계 밖까지 드래그해도 안전하게 이미지 안으로 잘라준다.

📖 개념 5(중요): 그래도 안 되는 코드가 있다 — 한계와 운영

타일·확대·대비까지 다 해도, 인쇄 자체가 흐려 데이터가 손상된 코드는 못 읽는다(오류정정 한도 밖). 이건 알고리즘이 아니라 물리적 한계다. 운영에서는:

  1. 더 정면·근접 촬영 또는 줄(트레이) 단위로 나눠 촬영 → 정상 인쇄분은 거의 다 잡힘.
  2. 그래도 안 되면 그 박스의 인쇄문자(사람이 읽는 글자)를 OCR로 보완 → 3편에서 만든다.
  3. 인쇄가 흐린 로트는 입고 검수 단계에서 재라벨/반려.

✅ 직접 해보기

  1. 여러 코드를 한 장에 만들어 다중 판독 확인:
using PharmaReturn;
string dir = AppContext.BaseDirectory;

// 코드 3장을 각각 만든 뒤, 직접 한 장에 합치긴 번거로우니
// 여기서는 '한 장에 1개' 이미지를 여러 개 만들어 각각 디코드해 본다.
string[] codes =
{
    "010880644309681317280315106248607",
    "010880644309632517280414106240606",
    "010880644304152317290420101190605",
};
for (int i = 0; i < codes.Length; i++)
{
    string p = Path.Combine(dir, $"t{i}.png");
    TestImageMaker.Generate(codes[i], p);
    var found = BarcodeServiceCpp.DecodeDetailed(p);
    Console.WriteLine($"t{i}.png → {found.Count}개: {string.Join(" / ", found.Select(c => c.Text))}");
}

각 파일에서 1개씩 읽히면 OK.

  1. (선택) 실제 스마트폰으로 박스 사진을 찍어 프로젝트에 넣고 DecodeDetailed("내사진.jpg")로 몇 개 잡히는지 보자. 정면·또렷할수록 많이 잡힌다. 전체 1패스보다 타일 패스 덕분에 더 많이 잡히는 걸 체감할 수 있다.

 

2편 마무리

지금까지:

  • TestImageMaker.cs — 테스트용 코드 이미지 생성(ZXing.Net)
  • BarcodeServiceCpp.cs — 이미지 → 코드 문자열 + 위치, 다중·타일·영역 판독(ZXingCpp + ImageSharp)

이제 1편 + 2편을 합치면 "이미지 → 코드 문자열 → 파싱 → 대조 → 보상"의 끝에서 끝까지가 콘솔에서 돈다. 한번 직접 이어 붙여 실행해 보자:

using PharmaReturn;
var repo = DeliveryRepository.LoadCsv(Path.Combine(AppContext.BaseDirectory, "data", "deliveries.csv"));
var svc = new CompensationService(repo, today: new DateOnly(2026, 6, 16));

foreach (var c in BarcodeServiceCpp.DecodeDetailed("내사진.jpg"))   // ① 이미지 → 코드
{
    var f = Gs1Parser.Parse(c.Text);                                // ② 파싱
    var r = svc.Evaluate(new ReturnRequest(f.Gtin!, f.Lot, f.Expiry, 1, ReturnReason.Expiry)); // ③ 보상
    Console.WriteLine($"{f.Gtin} {f.Lot} → [{r.Status}] {r.Message}");
}

핵심 복습

  • 디코더는 이미지 픽셀 배열(ImageView) 을 먹는다 → ImageSharp로 변환해 넘긴다.
  • 다중 판독은 reader.From()이 목록을 주는 것으로 기본 지원.
  • 타일 분할 + 확대 + 겹침으로 큰 사진의 작은 코드 회수율을 끌어올린다. 조각 좌표는 transform으로 원본 좌표로 되돌린다.
  • 물리적으로 흐린 코드는 한계 → 촬영 개선 또는 OCR 보완(3편).

 

 

반응형