Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion .github/workflows/_bot-build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -24,7 +28,7 @@ on:

jobs:
build:
runs-on: ubuntu-22.04-arm
runs-on: ${{ inputs.runner }}

steps:
- uses: actions/checkout@v4
Expand Down
6 changes: 5 additions & 1 deletion .github/workflows/_bot-deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions .github/workflows/coupon-build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
1 change: 1 addition & 0 deletions .github/workflows/coupon-deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
1 change: 1 addition & 0 deletions src/CouponHubBot/CouponHubBot.fsproj
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
<Compile Include="Services/DbService.fs" />
<Compile Include="Services/MembershipService.fs" />
<Compile Include="Services/NotificationService.fs" />
<Compile Include="Services/CouponBarcodeReader.fs" />
<Compile Include="Services/CouponOcrEngine.fs" />
<Compile Include="Services/GitHubService.fs" />
<Compile Include="Services/ReminderService.fs" />
Expand Down
61 changes: 61 additions & 0 deletions src/CouponHubBot/Services/CouponBarcodeReader.fs
Original file line number Diff line number Diff line change
@@ -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<byte>) =
// 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<L8>(ms)
// Checked crop views require a full trailing row stride, including unused pixels.
let pixels = Array.zeroCreate<byte> (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<byte>(pixels).Pin()
let decode x y width height =
let offset = y * image.Width + x
let length = height * image.Width
let region = ReadOnlySpan<byte>(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
51 changes: 1 addition & 50 deletions src/CouponHubBot/Services/CouponOcrEngine.fs
Original file line number Diff line number Diff line change
Expand Up @@ -213,56 +213,7 @@ type CouponOcrEngine(azureTextOcr: IBotOcr, logger: ILogger<CouponOcrEngine>, ti
let tryDecodeNative (imageBytes: ReadOnlyMemory<byte>) =
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<L8>(ms)
let pixels = Array.zeroCreate<byte> (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<byte>(pixels).Pin()
let decode x y width height =
let offset = y * image.Width + x
let length = (height - 1) * image.Width + width
let region = ReadOnlySpan<byte>(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
Expand Down
24 changes: 24 additions & 0 deletions tests/CouponHubBot.Ocr.Tests/OcrTests.fs
Original file line number Diff line number Diff line change
Expand Up @@ -223,6 +223,30 @@ type OcrTests(output: ITestOutputHelper) =
CouponOcrEngine(azure, XUnitLogging.XUnitLogger<CouponOcrEngine>(output, logs), timeProvider)
engine, http, logs

[<Theory>]
[<InlineData("5_25_2026-01-11_2026-01-17_2706653336241.jpg")>]
[<InlineData("5_25_2026-01-20_2026-01-26_2706680353051.jpg")>]
[<InlineData("5_25_2026-02-08_2026-02-14_2706726228947.jpg")>]
[<InlineData("5_25_2026-04-05_2026-04-15_2706873139950.jpg")>]
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<byte>(bytes)) with
| Some barcode ->
let expected = Path.GetFileNameWithoutExtension(fileName).Split('_') |> Array.last
Assert.Equal(expected, barcode)
| None -> ()

[<Theory>]
[<InlineData(120, 80)>]
[<InlineData(960, 1280)>]
[<InlineData(1, 1)>]
[<InlineData(3, 5)>]
member _.``Barcode reader returns no result for a blank image``(width: int, height: int) =
use image = new Image<L8>(width, height, L8(255uy))
use stream = new MemoryStream()
image.SaveAsPng(stream)
Assert.Equal(None, CouponBarcodeReader.tryDecode(ReadOnlyMemory<byte>(stream.ToArray())))

[<Theory>]
[<InlineData(0)>]
[<InlineData(90)>]
Expand Down
Loading