From da5a963c587a9c2101bdb453e93d906b5bd2ffcc Mon Sep 17 00:00:00 2001 From: Ayrat Hudaygulov Date: Wed, 30 Sep 2026 10:24:36 +0100 Subject: [PATCH 1/2] Fix checked native barcode crop bounds --- src/CouponHubBot/CouponHubBot.fsproj | 1 + .../Services/CouponBarcodeReader.fs | 61 +++++++++++++++++++ src/CouponHubBot/Services/CouponOcrEngine.fs | 51 +--------------- tests/CouponHubBot.Ocr.Tests/OcrTests.fs | 24 ++++++++ 4 files changed, 87 insertions(+), 50 deletions(-) create mode 100644 src/CouponHubBot/Services/CouponBarcodeReader.fs diff --git a/src/CouponHubBot/CouponHubBot.fsproj b/src/CouponHubBot/CouponHubBot.fsproj index 4933c94f..4f2f9e1f 100644 --- a/src/CouponHubBot/CouponHubBot.fsproj +++ b/src/CouponHubBot/CouponHubBot.fsproj @@ -7,6 +7,7 @@ + diff --git a/src/CouponHubBot/Services/CouponBarcodeReader.fs b/src/CouponHubBot/Services/CouponBarcodeReader.fs new file mode 100644 index 00000000..2da78468 --- /dev/null +++ b/src/CouponHubBot/Services/CouponBarcodeReader.fs @@ -0,0 +1,61 @@ +namespace CouponHubBot.Services + +open System +open System.IO +open SixLabors.ImageSharp +open SixLabors.ImageSharp.PixelFormats + +module CouponBarcodeReader = + let tryDecode (imageBytes: ReadOnlyMemory) = + // Resolve the library before constructing objects with native finalizers. + let format = ZXingCpp.BarcodeFormat.Parse("EAN13") + use ms = new MemoryStream(imageBytes.ToArray()) + use image = Image.Load(ms) + // Checked crop views require a full trailing row stride, including unused pixels. + let pixels = Array.zeroCreate (image.Width * (image.Height + 1)) + image.CopyPixelDataTo(pixels) + use reader = new ZXingCpp.BarcodeReader() + reader.Formats <- ZXingCpp.BarcodeFormats(format) + reader.TryHarder <- true + reader.TryRotate <- true + reader.TryInvert <- false + reader.TryDownscale <- true + reader.MaxNumberOfSymbols <- 1 + reader.MinLineCount <- 2 + reader.ReturnErrors <- false + // ImageView borrows the pixel buffer throughout native decoding. + use _pin = Memory(pixels).Pin() + let decode x y width height = + let offset = y * image.Width + x + let length = height * image.Width + let region = ReadOnlySpan(pixels, offset, length) + let view = ZXingCpp.ImageView(region, width, height, ZXingCpp.ImageFormat.Lum, image.Width) + let results = + try reader.From(view) + finally GC.KeepAlive(view) + try + results + |> Array.tryPick (fun barcode -> + if barcode.IsValid && not (String.IsNullOrWhiteSpace barcode.Text) then + Some barcode.Text + else + None) + finally + for barcode in results do + barcode.Dispose() + + let result = + match decode 0 0 image.Width image.Height with + | Some text -> Some text + | None -> + let bands = [| 0.0, 0.50; 0.0, 0.60; 0.20, 0.70; 0.30, 0.80; 0.45, 1.0; 0.55, 1.0; 0.65, 1.0 |] + [| 0.0; 0.10; 0.20 |] + |> Array.tryPick (fun margin -> + bands + |> Array.tryPick (fun (top, bottom) -> + let x = int (Math.Round(float image.Width * margin)) + let y = min (image.Height - 1) (int (Math.Round(float image.Height * top))) + let width = image.Width - 2 * x + let height = max 1 (int (Math.Round(float image.Height * bottom)) - y) + decode x y width height)) + result diff --git a/src/CouponHubBot/Services/CouponOcrEngine.fs b/src/CouponHubBot/Services/CouponOcrEngine.fs index 71811de4..b2d86537 100644 --- a/src/CouponHubBot/Services/CouponOcrEngine.fs +++ b/src/CouponHubBot/Services/CouponOcrEngine.fs @@ -213,56 +213,7 @@ type CouponOcrEngine(azureTextOcr: IBotOcr, logger: ILogger, ti let tryDecodeNative (imageBytes: ReadOnlyMemory) = use activity = botActivity.StartActivity("couponOcr.barcode.native") try - // Resolve the library before constructing objects with native finalizers. - let format = ZXingCpp.BarcodeFormat.Parse("EAN13") - use ms = new MemoryStream(imageBytes.ToArray()) - use image = Image.Load(ms) - let pixels = Array.zeroCreate (image.Width * image.Height) - image.CopyPixelDataTo(pixels) - use reader = new ZXingCpp.BarcodeReader() - reader.Formats <- ZXingCpp.BarcodeFormats(format) - reader.TryHarder <- true - reader.TryRotate <- true - reader.TryInvert <- false - reader.TryDownscale <- true - reader.MaxNumberOfSymbols <- 1 - reader.MinLineCount <- 2 - reader.ReturnErrors <- false - // ImageView borrows the pixel buffer throughout native decoding. - use _pin = Memory(pixels).Pin() - let decode x y width height = - let offset = y * image.Width + x - let length = (height - 1) * image.Width + width - let region = ReadOnlySpan(pixels, offset, length) - let view = ZXingCpp.ImageView(region, width, height, ZXingCpp.ImageFormat.Lum, image.Width) - let results = - try reader.From(view) - finally GC.KeepAlive(view) - try - results - |> Array.tryPick (fun barcode -> - if barcode.IsValid && not (String.IsNullOrWhiteSpace barcode.Text) then - Some barcode.Text - else - None) - finally - for barcode in results do - barcode.Dispose() - - let result = - match decode 0 0 image.Width image.Height with - | Some text -> Some text - | None -> - let bands = [| 0.0, 0.50; 0.0, 0.60; 0.20, 0.70; 0.30, 0.80; 0.45, 1.0; 0.55, 1.0; 0.65, 1.0 |] - [| 0.0; 0.10; 0.20 |] - |> Array.tryPick (fun margin -> - bands - |> Array.tryPick (fun (top, bottom) -> - let x = int (Math.Round(float image.Width * margin)) - let y = min (image.Height - 1) (int (Math.Round(float image.Height * top))) - let width = image.Width - 2 * x - let height = max 1 (int (Math.Round(float image.Height * bottom)) - y) - decode x y width height)) + let result = CouponBarcodeReader.tryDecode imageBytes if not (isNull activity) then %activity.SetTag("barcode.found", result.IsSome) result diff --git a/tests/CouponHubBot.Ocr.Tests/OcrTests.fs b/tests/CouponHubBot.Ocr.Tests/OcrTests.fs index 0460b746..d54a001d 100644 --- a/tests/CouponHubBot.Ocr.Tests/OcrTests.fs +++ b/tests/CouponHubBot.Ocr.Tests/OcrTests.fs @@ -223,6 +223,30 @@ type OcrTests(output: ITestOutputHelper) = CouponOcrEngine(azure, XUnitLogging.XUnitLogger(output, logs), timeProvider) engine, http, logs + [] + [] + [] + [] + [] + member _.``Barcode search handles difficult coupon images without failing``(fileName: string) = + let bytes = File.ReadAllBytes(Path.Combine(AppContext.BaseDirectory, "Images", fileName)) + match CouponBarcodeReader.tryDecode(ReadOnlyMemory(bytes)) with + | Some barcode -> + let expected = Path.GetFileNameWithoutExtension(fileName).Split('_') |> Array.last + Assert.Equal(expected, barcode) + | None -> () + + [] + [] + [] + [] + [] + member _.``Barcode reader returns no result for a blank image``(width: int, height: int) = + use image = new Image(width, height, L8(255uy)) + use stream = new MemoryStream() + image.SaveAsPng(stream) + Assert.Equal(None, CouponBarcodeReader.tryDecode(ReadOnlyMemory(stream.ToArray()))) + [] [] [] From 507989af8c0f75a008262b098f05edbdcb52a7f4 Mon Sep 17 00:00:00 2001 From: Ayrat Hudaygulov Date: Wed, 30 Sep 2026 10:32:15 +0100 Subject: [PATCH 2/2] Run coupon CI on Noble ARM for native ZXing compatibility --- .github/workflows/_bot-build.yml | 6 +++++- .github/workflows/_bot-deploy.yml | 6 +++++- .github/workflows/coupon-build.yml | 1 + .github/workflows/coupon-deploy.yml | 1 + 4 files changed, 12 insertions(+), 2 deletions(-) diff --git a/.github/workflows/_bot-build.yml b/.github/workflows/_bot-build.yml index 5b1e718b..00a3255b 100644 --- a/.github/workflows/_bot-build.yml +++ b/.github/workflows/_bot-build.yml @@ -3,6 +3,10 @@ name: "Bot: Build" on: workflow_call: inputs: + runner: + type: string + default: ubuntu-22.04-arm + description: "Runner image for build and tests" test-command: required: true type: string @@ -24,7 +28,7 @@ on: jobs: build: - runs-on: ubuntu-22.04-arm + runs-on: ${{ inputs.runner }} steps: - uses: actions/checkout@v4 diff --git a/.github/workflows/_bot-deploy.yml b/.github/workflows/_bot-deploy.yml index 6d86b51d..c795b809 100644 --- a/.github/workflows/_bot-deploy.yml +++ b/.github/workflows/_bot-deploy.yml @@ -3,6 +3,10 @@ name: "Bot: Deploy" on: workflow_call: inputs: + runner: + type: string + default: ubuntu-22.04-arm + description: "Runner image for build and tests" test-command: required: true type: string @@ -72,7 +76,7 @@ env: jobs: deploy: - runs-on: ubuntu-22.04-arm + runs-on: ${{ inputs.runner }} # VPN peer is single-occupancy per bot (PR #369 / my-infra#111 gave every # bot its own dedicated WireGuard peer — WIREGUARD_CONFIG_VAHTER/_COUPON/ # _ALITA — so bots no longer share one peer). This group is scoped per diff --git a/.github/workflows/coupon-build.yml b/.github/workflows/coupon-build.yml index fd7ee25e..198e1352 100644 --- a/.github/workflows/coupon-build.yml +++ b/.github/workflows/coupon-build.yml @@ -9,6 +9,7 @@ jobs: build: uses: ./.github/workflows/_bot-build.yml with: + runner: ubuntu-24.04-arm test-command: | dotnet test tests/CouponHubBot.Tests/CouponHubBot.Tests.fsproj -c Release --logger "trx;LogFileName=test-results.trx" --logger "console;verbosity=detailed" dotnet test tests/CouponHubBot.Ocr.Tests/CouponHubBot.Ocr.Tests.fsproj -c Release --logger "trx;LogFileName=test-results.trx" --logger "console;verbosity=detailed" diff --git a/.github/workflows/coupon-deploy.yml b/.github/workflows/coupon-deploy.yml index c55a2b8c..e68fca0d 100644 --- a/.github/workflows/coupon-deploy.yml +++ b/.github/workflows/coupon-deploy.yml @@ -40,6 +40,7 @@ jobs: deploy: uses: ./.github/workflows/_bot-deploy.yml with: + runner: ubuntu-24.04-arm test-command: | dotnet test tests/CouponHubBot.Tests/CouponHubBot.Tests.fsproj -c Release --logger "trx;LogFileName=test-results.trx" dotnet test tests/CouponHubBot.Ocr.Tests/CouponHubBot.Ocr.Tests.fsproj -c Release --logger "trx;LogFileName=test-results.trx"