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"
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())))
+
[]
[]
[]