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
1 change: 1 addition & 0 deletions Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@
<PackageVersion Include="SixLabors.ImageSharp" Version="3.1.12" />
<PackageVersion Include="ZXing.Net" Version="0.16.11" />
<PackageVersion Include="ZXing.Net.Bindings.ImageSharp.V3" Version="0.16.18" />
<PackageVersion Include="ZXingCpp" Version="0.5.3" />

<!-- Testing -->
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="18.0.1" />
Expand Down
1 change: 1 addition & 0 deletions src/CouponHubBot/CouponHubBot.fsproj
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
<PackageReference Include="SixLabors.ImageSharp" />
<PackageReference Include="ZXing.Net" />
<PackageReference Include="ZXing.Net.Bindings.ImageSharp.V3" />
<PackageReference Include="ZXingCpp" />
<!-- FakeTimeProvider for the test-only /test/clock/advance endpoint. Stable, ~30KB. -->
<PackageReference Include="Microsoft.Extensions.TimeProvider.Testing" />
</ItemGroup>
Expand Down
90 changes: 82 additions & 8 deletions src/CouponHubBot/Services/CouponOcrEngine.fs
Original file line number Diff line number Diff line change
Expand Up @@ -210,8 +210,71 @@ type CouponOcrEngine(azureTextOcr: IBotOcr, logger: ILogger<CouponOcrEngine>, ti
let someMoney (v: decimal) : Nullable<decimal> = Nullable(v)
let someDate (v: DateTime) : Nullable<DateTime> = Nullable(v)

let tryDecodeBarcode (imageBytes: ReadOnlyMemory<byte>) =
use activity = botActivity.StartActivity("couponOcr.barcode")
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))
if not (isNull activity) then
%activity.SetTag("barcode.found", result.IsSome)
result
with ex ->
if not (isNull activity) then
%activity.SetTag("error.type", ex.GetType().FullName)
%activity.SetStatus(ActivityStatusCode.Error)
logger.LogWarning(ex, "Native barcode decode failed; trying managed decoder")
None

let tryDecodeManaged (imageBytes: ReadOnlyMemory<byte>) =
use activity = botActivity.StartActivity("couponOcr.barcode.managed")
let mutable attempts = 0
try
use original =
Expand All @@ -224,12 +287,12 @@ type CouponOcrEngine(azureTextOcr: IBotOcr, logger: ILogger<CouponOcrEngine>, ti
let opts = DecodingOptions()
opts.TryHarder <- true
opts.PossibleFormats <- [| BarcodeFormat.EAN_13 |]
opts.TryInverted <- true
opts.TryInverted <- false
let reader = BarcodeReader<Rgba32>()
reader.Options <- opts
reader.AutoRotate <- true

let decode (label: string) (img: Image<Rgba32>) =
let decode (label: string) (img: LuminanceSource) =
use attempt = botActivity.StartActivity("couponOcr.barcode.decode")
attempts <- attempts + 1
if not (isNull attempt) then
Expand All @@ -248,12 +311,12 @@ type CouponOcrEngine(azureTextOcr: IBotOcr, logger: ILogger<CouponOcrEngine>, ti

let clamp (v: int) (minv: int) (maxv: int) = max minv (min maxv v)

let cropTry (baseLabel: string) (img: Image<Rgba32>) (rect: Rectangle) =
let cropTry (baseLabel: string) (img: LuminanceSource) (rect: Rectangle) =
let x = clamp rect.X 0 (img.Width - 1)
let y = clamp rect.Y 0 (img.Height - 1)
let w = clamp rect.Width 1 (img.Width - x)
let h = clamp rect.Height 1 (img.Height - y)
use cropped = img.Clone(fun ctx -> ctx.Crop(Rectangle(x, y, w, h)) |> ignore)
let cropped = img.crop(x, y, w, h)
decode baseLabel cropped

let tryOnImage (labelPrefix: string) (img: Image<Rgba32>) =
Expand All @@ -263,7 +326,8 @@ type CouponOcrEngine(azureTextOcr: IBotOcr, logger: ILogger<CouponOcrEngine>, ti
// - Middle band crops (covers centre region)
// - Bottom crops (useful for app screenshots)
// - Center-narrowed variants of the above (avoid noise on sides)
match decode (labelPrefix + ":full") img with
let luminance = ImageSharpLuminanceSource<Rgba32>(img)
match decode (labelPrefix + ":full") luminance with
| Some t -> Some t
| None ->
let w = img.Width
Expand Down Expand Up @@ -312,7 +376,7 @@ type CouponOcrEngine(azureTextOcr: IBotOcr, logger: ILogger<CouponOcrEngine>, ti

allRects
|> Array.mapi (fun i r -> (i, r))
|> Array.tryPick (fun (i, r) -> cropTry $"{labelPrefix}:crop{i}" img r)
|> Array.tryPick (fun (i, r) -> cropTry $"{labelPrefix}:crop{i}" luminance r)

let tryAll () =
match tryOnImage "orig" original with
Expand Down Expand Up @@ -369,6 +433,16 @@ type CouponOcrEngine(azureTextOcr: IBotOcr, logger: ILogger<CouponOcrEngine>, ti
logger.LogWarning(ex, "Barcode decode failed")
noneBarcode

let tryDecodeBarcode (imageBytes: ReadOnlyMemory<byte>) =
use activity = botActivity.StartActivity("couponOcr.barcode")
let result =
match tryDecodeNative imageBytes with
| Some text -> text
| None -> tryDecodeManaged imageBytes
if not (isNull activity) then
%activity.SetTag("barcode.found", not (String.IsNullOrWhiteSpace result))
result

let parseFromText (nowUtc: DateTime) (ocrText: string) =
let amounts = CouponOcrParsing.findEuroAmounts ocrText
let dates = CouponOcrParsing.findDates nowUtc ocrText
Expand Down
57 changes: 30 additions & 27 deletions src/CouponHubBot/docs/OCR.md
Original file line number Diff line number Diff line change
@@ -1,27 +1,30 @@
# OCR Pipeline

## Overview

`CouponOcrEngine` combines barcode scanning (ZXing) with Azure Computer Vision text OCR to extract coupon data from photos.

## Components

- **ZXing.Net** + ImageSharp bindings — barcode/QR code scanning
- **AzureOcrService** — calls Azure Computer Vision `imageanalysis:analyze` endpoint
- **CouponOcrEngine** — orchestrates both, returns `CouponOCR` record

## OCR Result

`CouponOCR` contains optional fields: `value`, `min_check`, `expires_at`, `barcode_text`. Any combination may be present. The result is **always a pre-fill suggestion** — the user confirms final values in the wizard.

## Caching (for tests)

`tests/CouponHubBot.Ocr.Tests/AzureCache/` stores Azure OCR responses as `*.azure.json` files. These are git-tracked so tests can run without Azure API keys (cache-only mode).

## Configuration

Env vars:
- `OCR_ENABLED` — enable/disable OCR (default: false)
- `OCR_MAX_FILE_SIZE_BYTES` — max photo size for OCR processing
- `AZURE_OCR_ENDPOINT` — Azure Computer Vision endpoint URL
- `AZURE_OCR_KEY` — Azure API key
# OCR Pipeline

## Overview

`CouponOcrEngine` combines barcode scanning (ZXing) with Azure Computer Vision text OCR to extract coupon data from photos.

## Components

- **ZXingCpp** + ImageSharp — EAN-13 scanning on grayscale images, followed by horizontal crops
- **ZXing.Net** + ImageSharp bindings — fallback with rotation, contrast and resizing; luminance is shared across crops
- **AzureOcrService** — calls Azure Computer Vision `imageanalysis:analyze` endpoint
- **CouponOcrEngine** — orchestrates both, returns `CouponOCR` record

Native library load or decode failures fall back to the managed decoder. The Linux native binaries require glibc 2.38 and libstdc++ with GLIBCXX_3.4.31; the Noble runtime image provides both.

## OCR Result

`CouponOCR` contains optional fields: `value`, `min_check`, `expires_at`, `barcode_text`. Any combination may be present. The result is **always a pre-fill suggestion** — the user confirms final values in the wizard.

## Caching (for tests)

`tests/CouponHubBot.Ocr.Tests/AzureCache/` stores Azure OCR responses as `*.azure.json` files. These are git-tracked so tests can run without Azure API keys (cache-only mode).

## Configuration

Env vars:
- `OCR_ENABLED` — enable/disable OCR (default: false)
- `OCR_MAX_FILE_SIZE_BYTES` — max photo size for OCR processing
- `AZURE_OCR_ENDPOINT` — Azure Computer Vision endpoint URL
- `AZURE_OCR_KEY` — Azure API key
43 changes: 26 additions & 17 deletions tests/BotInfra.Tests/EventStoreSnapshotTests.fs
Original file line number Diff line number Diff line change
Expand Up @@ -59,16 +59,16 @@ let private hasEvent (name: string) (a: Activity) = a.Events |> Seq.exists (fun

/// A fold that parks its first call until released — holds a load between its query and its cache put.
let private parkedFold () =
let entered = new SemaphoreSlim(0)
let entered = TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously)
let release = new SemaphoreSlim(0)
let mutable first = true
let f s e =
if first then
first <- false
%entered.Release()
release.Wait()
entered.SetResult()
Assert.True(release.Wait(TimeSpan.FromSeconds 30.0), "The concurrent operation did not release the parked fold")
fold s e
f, entered, release
f, entered.Task, release

type SnapshotRow = { schema_version: int; stream_version: int; state: string }

Expand Down Expand Up @@ -448,10 +448,13 @@ type EventStoreSnapshotTests(db: PostgresFixture) =
do! appendRaw sid (added 2)
use _scope = store.BeginRequestScope()
let parked, entered, release = parkedFold ()
let slowLoad = store.LoadState(parked, Tally.Zero, policy 100, sid)
do! entered.WaitAsync()
let! _ = increment (policy 100) sid 10
%release.Release()
let slowLoad = Task.Run<Tally * int>(fun () -> store.LoadState(parked, Tally.Zero, policy 100, sid))
try
do! entered.WaitAsync(TimeSpan.FromSeconds 30.0)
let! _ = increment (policy 100) sid 10
()
finally
%release.Release()
let! (_, slowVersion) = slowLoad
Assert.Equal(2, slowVersion)
let! (state, version) = load (policy 100) sid
Expand All @@ -465,10 +468,13 @@ type EventStoreSnapshotTests(db: PostgresFixture) =
do! appendRaw sid (added 2)
use _scope = store.BeginRequestScope()
let parked, entered, release = parkedFold ()
let slowLoad = store.LoadState(parked, Tally.Zero, policy 100, sid)
do! entered.WaitAsync()
let! _ = store.Transact(fold, Tally.Zero, (fun _ -> [ Added {| amount = 10 |} ]), sid)
%release.Release()
let slowLoad = Task.Run<Tally * int>(fun () -> store.LoadState(parked, Tally.Zero, policy 100, sid))
try
do! entered.WaitAsync(TimeSpan.FromSeconds 30.0)
let! _ = store.Transact(fold, Tally.Zero, (fun _ -> [ Added {| amount = 10 |} ]), sid)
()
finally
%release.Release()
let! _ = slowLoad
let! (state, version) = load (policy 100) sid
let! (_, rawVersion) = store.GetRawEventsForStream sid
Expand Down Expand Up @@ -504,11 +510,14 @@ type EventStoreSnapshotTests(db: PostgresFixture) =
let otherPod = EventStore(db.ConnectionString, "event", jsonOpts, "event_snapshot")
use spans = new LoadSpans()
let parked, entered, release = parkedFold ()
let slowLoad = store.LoadState(parked, Tally.Zero, policy 1, sid)
do! entered.WaitAsync()
do! appendRaw sid (added 3)
let! _ = otherPod.LoadState(fold, Tally.Zero, policy 1, sid)
%release.Release()
let slowLoad = Task.Run<Tally * int>(fun () -> store.LoadState(parked, Tally.Zero, policy 1, sid))
try
do! entered.WaitAsync(TimeSpan.FromSeconds 30.0)
do! appendRaw sid (added 3)
let! _ = otherPod.LoadState(fold, Tally.Zero, policy 1, sid)
()
finally
%release.Release()
let! (_, slowVersion) = slowLoad
Assert.Equal(2, slowVersion)

Expand Down
38 changes: 38 additions & 0 deletions tests/CouponHubBot.Ocr.Tests/OcrTests.fs
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,14 @@ open System.Net.Http
open System.Text
open Microsoft.Extensions.Logging
open Microsoft.Extensions.Options
open SixLabors.ImageSharp
open SixLabors.ImageSharp.PixelFormats
open SixLabors.ImageSharp.Processing
open Xunit

open CouponHubBot
open CouponHubBot.Services
open CouponHubBot.Utils
open BotInfra

module private XUnitLogging =
Expand Down Expand Up @@ -179,6 +183,12 @@ module private Parsing =

type OcrTests(output: ITestOutputHelper) =

let buildCpuOnlyEngine () =
let azure =
{ new IBotOcr with
member _.AnalyzeImageBytes _ = task { return null } }
CouponOcrEngine(azure, Microsoft.Extensions.Logging.Abstractions.NullLogger<CouponOcrEngine>.Instance, TimeProvider.System)

let buildEngine (imageFileName: string) =
let logs = ResizeArray<string>()
let endpoint = Environment.GetEnvironmentVariable("AZURE_OCR_ENDPOINT")
Expand Down Expand Up @@ -213,6 +223,34 @@ type OcrTests(output: ITestOutputHelper) =
CouponOcrEngine(azure, XUnitLogging.XUnitLogger<CouponOcrEngine>(output, logs), timeProvider)
engine, http, logs

[<Theory>]
[<InlineData(0)>]
[<InlineData(90)>]
[<InlineData(180)>]
[<InlineData(270)>]
member _.``Coupon barcode is recognized without OCR at any right angle``(angle: int) =
task {
let path = Path.Combine(AppContext.BaseDirectory, "Images", "10_50_01-04_01-13_2706602781191.jpg")
use image = Image.Load<Rgba32>(path)
image.Mutate(fun ctx -> %ctx.Rotate(float32 angle))
use stream = new MemoryStream()
image.SaveAsPng(stream)
let! result = (buildCpuOnlyEngine ()).Recognize(ReadOnlyMemory<byte>(stream.ToArray()))
Assert.Equal("2706602781191", result.barcode)
}

[<Theory>]
[<InlineData(1, 1)>]
[<InlineData(120, 80)>]
member _.``Blank image does not invent a barcode``(width: int, height: int) =
task {
use image = new Image<L8>(width, height, L8(255uy))
use stream = new MemoryStream()
image.SaveAsPng(stream)
let! result = (buildCpuOnlyEngine ()).Recognize(ReadOnlyMemory<byte>(stream.ToArray()))
Assert.Null(result.barcode)
}

[<Theory>]
[<InlineData("10_50_01-04_01-13_2706602781191.jpg")>]
[<InlineData("10_50_01-06_01-15_2706643333717.jpg")>]
Expand Down
Loading