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: 6 additions & 0 deletions src/CouponHubBot/CouponHubBot.fsproj
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@
<Compile Include="Services/MembershipService.fs" />
<Compile Include="Services/NotificationService.fs" />
<Compile Include="Services/CouponBarcodeReader.fs" />
<Compile Include="Services/BarcodeLuminanceSource.fs" />
<Compile Include="Services/BarcodeBinarizer.fs" />
<Compile Include="Services/CouponOcrEngine.fs" />
<Compile Include="Services/GitHubService.fs" />
<Compile Include="Services/ReminderService.fs" />
Expand All @@ -33,6 +35,10 @@
<PackageReference Include="Microsoft.Extensions.TimeProvider.Testing" />
</ItemGroup>

<ItemGroup>
<Content Include="ThirdPartyNotices.txt" CopyToOutputDirectory="PreserveNewest" />
</ItemGroup>

<ItemGroup>
<ProjectReference Include="..\BotInfra\BotInfra.fsproj" />
</ItemGroup>
Expand Down
107 changes: 107 additions & 0 deletions src/CouponHubBot/Services/BarcodeBinarizer.fs
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
namespace CouponHubBot.Services

open System
open System.Runtime.InteropServices
open System.Runtime.Intrinsics
open ZXing
open ZXing.Common

type internal BarcodeBinarizer(source: LuminanceSource) =
inherit GlobalHistogramBinarizer(source)

let mutable luminances = Array.empty<byte>
let buckets = Array.zeroCreate<int> 32

// Histogram and valley selection follow ZXing.Net; see ThirdPartyNotices.txt.
let estimateThreshold () =
let mutable firstPeak = 0
let mutable firstPeakSize = 0
let mutable maxBucketCount = 0
for x = 0 to buckets.Length - 1 do
if buckets[x] > firstPeakSize then
firstPeak <- x
firstPeakSize <- buckets[x]
maxBucketCount <- max maxBucketCount buckets[x]

let mutable secondPeak = 0
let mutable secondPeakScore = 0
for x = 0 to buckets.Length - 1 do
let distance = x - firstPeak
let score = buckets[x] * distance * distance
if score > secondPeakScore then
secondPeak <- x
secondPeakScore <- score

let lowPeak = min firstPeak secondPeak
let highPeak = max firstPeak secondPeak
if highPeak - lowPeak <= (buckets.Length >>> 4) then
None
else
let mutable valley = highPeak - 1
let mutable valleyScore = -1
for x = highPeak - 1 downto lowPeak + 1 do
let distance = x - lowPeak
let score = distance * distance * (highPeak - x) * (maxBucketCount - buckets[x])
if score > valleyScore then
valley <- x
valleyScore <- score
Some (valley <<< 3)

override _.createBinarizer(nextSource) = BarcodeBinarizer(nextSource)

override _.getBlackRow(y, row) =
let width = source.Width
let result =
if isNull row || row.Size < width then
BitArray(width)
else
row.clear()
row
if luminances.Length < width then
luminances <- Array.zeroCreate width
let pixels = source.getRow(y, luminances)
Array.Clear buckets
for x = 0 to width - 1 do
let bucket = int pixels[x] >>> 3
buckets[bucket] <- buckets[bucket] + 1

match estimateThreshold () with
| None -> null
| Some threshold ->
match width with
| 1 | 2 ->
for x = 0 to width - 1 do
if int pixels[x] < threshold then
result[x] <- true
| _ ->
let mutable x = 1
if Vector128.IsHardwareAccelerated then
// Comparing before division preserves truncation toward zero at threshold zero.
let bound = Vector128.Create(int16 (if threshold = 0 then -1 else threshold * 2))
while x + 16 <= width - 1 do
let left = MemoryMarshal.Read<Vector128<byte>>(ReadOnlySpan<byte>(pixels, x - 1, 16))
let center = MemoryMarshal.Read<Vector128<byte>>(ReadOnlySpan<byte>(pixels, x, 16))
let right = MemoryMarshal.Read<Vector128<byte>>(ReadOnlySpan<byte>(pixels, x + 1, 16))
let low =
Vector128.ShiftLeft(Vector128.WidenLower(center).AsInt16(), 2)
- Vector128.WidenLower(left).AsInt16() - Vector128.WidenLower(right).AsInt16()
let high =
Vector128.ShiftLeft(Vector128.WidenUpper(center).AsInt16(), 2)
- Vector128.WidenUpper(left).AsInt16() - Vector128.WidenUpper(right).AsInt16()
let bits =
Vector128.Narrow(
Vector128.LessThan(low, bound).AsUInt16(),
Vector128.LessThan(high, bound).AsUInt16())
let mask = Vector128.ExtractMostSignificantBits bits
let word = x >>> 5
let shift = x &&& 31
result.Array[word] <- result.Array[word] ||| int (mask <<< shift)
if shift > 16 then
result.Array[word + 1] <- result.Array[word + 1] ||| int (mask >>> (32 - shift))
x <- x + 16
while x < width - 1 do
let sharpened = (4 * int pixels[x] - int pixels[x - 1] - int pixels[x + 1]) / 2
if sharpened < threshold then
result[x] <- true
x <- x + 1
result
44 changes: 44 additions & 0 deletions src/CouponHubBot/Services/BarcodeLuminanceSource.fs
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
namespace CouponHubBot.Services

open System
open ZXing

type internal BarcodeLuminanceSource private
(pixels: byte[], width: int, height: int, offset: int, xStride: int, yStride: int) =
inherit LuminanceSource(width, height)

new(source: LuminanceSource) =
BarcodeLuminanceSource(source.Matrix, source.Width, source.Height, 0, 1, source.Width)

override _.getRow(y, row) =
if y < 0 || y >= height then
invalidArg (nameof y) "Row must be within the image."
let result = if isNull row || row.Length < width then Array.zeroCreate width else row
let start = offset + y * yStride
match xStride with
| 1 -> pixels.AsSpan(start, width).CopyTo(result.AsSpan())
| _ ->
for x = 0 to width - 1 do
result[x] <- pixels[start + x * xStride]
result

override this.Matrix =
let result = Array.zeroCreate<byte> (width * height)
let row = Array.zeroCreate<byte> width
for y = 0 to height - 1 do
this.getRow(y, row).AsSpan(0, width).CopyTo(result.AsSpan(y * width, width))
result

override _.CropSupported = true
override _.RotateSupported = true
override _.InversionSupported = true
override this.invert() = InvertedLuminanceSource(this)

override _.crop(left, top, cropWidth, cropHeight) =
if left < 0 || top < 0 || cropWidth <= 0 || cropHeight <= 0
|| left > width - cropWidth || top > height - cropHeight then
invalidArg (nameof cropWidth) "Crop must fit within the image."
BarcodeLuminanceSource(pixels, cropWidth, cropHeight, offset + left * xStride + top * yStride, xStride, yStride)

override _.rotateCounterClockwise() =
BarcodeLuminanceSource(pixels, height, width, offset + (width - 1) * xStride, yStride, -xStride)
58 changes: 40 additions & 18 deletions src/CouponHubBot/Services/CouponOcrEngine.fs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ open System.Diagnostics
open System.Globalization
open System.IO
open System.Text.RegularExpressions
open System.Threading
open System.Threading.Tasks
open Microsoft.Extensions.Logging
open CouponHubBot
Expand Down Expand Up @@ -235,22 +236,27 @@ type CouponOcrEngine(azureTextOcr: IBotOcr, logger: ILogger<CouponOcrEngine>, ti
if not (isNull activity) then
%activity.SetTag("image.width", original.Width)
%activity.SetTag("image.height", original.Height)
let opts = DecodingOptions()
opts.TryHarder <- true
opts.PossibleFormats <- [| BarcodeFormat.EAN_13 |]
opts.TryInverted <- false
let reader = BarcodeReader<Rgba32>()
reader.Options <- opts
reader.AutoRotate <- true

let decode (label: string) (img: LuminanceSource) =
let createReader () =
let opts = DecodingOptions()
opts.TryHarder <- true
opts.PossibleFormats <- [| BarcodeFormat.EAN_13 |]
opts.TryInverted <- false
let reader = BarcodeReaderGeneric(null, (fun source -> BarcodeBinarizer(source) :> Binarizer), null)
reader.Options <- opts
reader.AutoRotate <- true
reader

let reader = createReader ()
let parallelOptions = ParallelOptions(MaxDegreeOfParallelism = Environment.ProcessorCount)

let decode (decoder: BarcodeReaderGeneric) (label: string) (img: LuminanceSource) =
use attempt = botActivity.StartActivity("couponOcr.barcode.decode")
attempts <- attempts + 1
%Interlocked.Increment(&attempts)
if not (isNull attempt) then
%attempt.SetTag("barcode.strategy", label)
%attempt.SetTag("image.width", img.Width)
%attempt.SetTag("image.height", img.Height)
let res = reader.Decode(img)
let res = decoder.Decode(img)
if not (isNull attempt) then
%attempt.SetTag("barcode.found", not (isNull res) && not (String.IsNullOrWhiteSpace res.Text))
if isNull res || String.IsNullOrWhiteSpace res.Text then
Expand All @@ -262,13 +268,13 @@ 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: LuminanceSource) (rect: Rectangle) =
let cropTry (decoder: BarcodeReaderGeneric) (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)
let cropped = img.crop(x, y, w, h)
decode baseLabel cropped
decode decoder baseLabel cropped

let tryOnImage (labelPrefix: string) (img: Image<Rgba32>) =
// Strategy:
Expand All @@ -277,8 +283,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)
let luminance = ImageSharpLuminanceSource<Rgba32>(img)
match decode (labelPrefix + ":full") luminance with
let luminance = BarcodeLuminanceSource(ImageSharpLuminanceSource<Rgba32>(img))
match decode reader (labelPrefix + ":full") luminance with
| Some t -> Some t
| None ->
let w = img.Width
Expand Down Expand Up @@ -325,9 +331,25 @@ type CouponOcrEngine(azureTextOcr: IBotOcr, logger: ILogger<CouponOcrEngine>, ti
let allRects =
Array.append fullWidthCrops centerCrops

allRects
|> Array.mapi (fun i r -> (i, r))
|> Array.tryPick (fun (i, r) -> cropTry $"{labelPrefix}:crop{i}" luminance r)
match parallelOptions.MaxDegreeOfParallelism with
| 1 ->
allRects
|> Array.mapi (fun i r -> (i, r))
|> Array.tryPick (fun (i, r) -> cropTry reader $"{labelPrefix}:crop{i}" luminance r)
| _ ->
let results = Array.create allRects.Length None
%Parallel.For<BarcodeReaderGeneric>(
0, allRects.Length, parallelOptions,
Func<BarcodeReaderGeneric>(createReader),
Func<int, ParallelLoopState, BarcodeReaderGeneric, BarcodeReaderGeneric>(fun i state decoder ->
let result = cropTry decoder $"{labelPrefix}:crop{i}" luminance allRects[i]
results[i] <- result
if result.IsSome then
state.Break()
decoder),
Action<BarcodeReaderGeneric>(fun _ -> ()))
// Break completes lower indices so crop priority is independent of scheduling.
results |> Array.tryPick id

let tryAll () =
match tryOnImage "orig" original with
Expand Down
Loading
Loading