diff --git a/src/CouponHubBot/CouponHubBot.fsproj b/src/CouponHubBot/CouponHubBot.fsproj index 4f2f9e1f..ed38af0f 100644 --- a/src/CouponHubBot/CouponHubBot.fsproj +++ b/src/CouponHubBot/CouponHubBot.fsproj @@ -8,6 +8,8 @@ + + @@ -33,6 +35,10 @@ + + + + diff --git a/src/CouponHubBot/Services/BarcodeBinarizer.fs b/src/CouponHubBot/Services/BarcodeBinarizer.fs new file mode 100644 index 00000000..b2a5d2e3 --- /dev/null +++ b/src/CouponHubBot/Services/BarcodeBinarizer.fs @@ -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 + let buckets = Array.zeroCreate 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>(ReadOnlySpan(pixels, x - 1, 16)) + let center = MemoryMarshal.Read>(ReadOnlySpan(pixels, x, 16)) + let right = MemoryMarshal.Read>(ReadOnlySpan(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 diff --git a/src/CouponHubBot/Services/BarcodeLuminanceSource.fs b/src/CouponHubBot/Services/BarcodeLuminanceSource.fs new file mode 100644 index 00000000..cf726b74 --- /dev/null +++ b/src/CouponHubBot/Services/BarcodeLuminanceSource.fs @@ -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 (width * height) + let row = Array.zeroCreate 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) diff --git a/src/CouponHubBot/Services/CouponOcrEngine.fs b/src/CouponHubBot/Services/CouponOcrEngine.fs index b2d86537..c807998c 100644 --- a/src/CouponHubBot/Services/CouponOcrEngine.fs +++ b/src/CouponHubBot/Services/CouponOcrEngine.fs @@ -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 @@ -235,22 +236,27 @@ type CouponOcrEngine(azureTextOcr: IBotOcr, logger: ILogger, 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() - 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 @@ -262,13 +268,13 @@ type CouponOcrEngine(azureTextOcr: IBotOcr, logger: ILogger, 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) = // Strategy: @@ -277,8 +283,8 @@ type CouponOcrEngine(azureTextOcr: IBotOcr, logger: ILogger, 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(img) - match decode (labelPrefix + ":full") luminance with + let luminance = BarcodeLuminanceSource(ImageSharpLuminanceSource(img)) + match decode reader (labelPrefix + ":full") luminance with | Some t -> Some t | None -> let w = img.Width @@ -325,9 +331,25 @@ type CouponOcrEngine(azureTextOcr: IBotOcr, logger: ILogger, 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( + 0, allRects.Length, parallelOptions, + Func(createReader), + Func(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(fun _ -> ())) + // Break completes lower indices so crop priority is independent of scheduling. + results |> Array.tryPick id let tryAll () = match tryOnImage "orig" original with diff --git a/src/CouponHubBot/ThirdPartyNotices.txt b/src/CouponHubBot/ThirdPartyNotices.txt new file mode 100644 index 00000000..8bce16d3 --- /dev/null +++ b/src/CouponHubBot/ThirdPartyNotices.txt @@ -0,0 +1,206 @@ +BarcodeBinarizer.fs adapts the row binarization and threshold selection from +ZXing.Net GlobalHistogramBinarizer, with F# translation and SIMD row processing. +Copyright 2009 ZXing authors. Licensed under the Apache License, Version 2.0. +Source: https://github.com/micjahn/ZXing.Net/blob/master/Source/lib/common/GlobalHistogramBinarizer.cs + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/tests/CouponHubBot.Ocr.Tests/OcrTests.fs b/tests/CouponHubBot.Ocr.Tests/OcrTests.fs index d54a001d..2fbb3957 100644 --- a/tests/CouponHubBot.Ocr.Tests/OcrTests.fs +++ b/tests/CouponHubBot.Ocr.Tests/OcrTests.fs @@ -6,6 +6,7 @@ open System.IO open System.Net open System.Net.Http open System.Text +open System.Threading.Tasks open Microsoft.Extensions.Logging open Microsoft.Extensions.Options open SixLabors.ImageSharp @@ -46,7 +47,7 @@ module private XUnitLogging = else "\n" + ex.ToString() let line = $"[{logLevel}] {category}: {msg}{exText}" - sink.Add(line) + lock sink (fun () -> sink.Add(line)) output.WriteLine(line) with _ -> // Never let logging break tests @@ -263,6 +264,63 @@ type OcrTests(output: ITestOutputHelper) = Assert.Equal("2706602781191", result.barcode) } + [] + [] + [] + member _.``Low contrast coupon is recognized without OCR upright or inverted``(angle: int) = + task { + let path = Path.Combine(AppContext.BaseDirectory, "Images", "5_25_2026-02-08_2026-02-14_2706726228947.jpg") + use image = Image.Load(path) + image.Mutate(fun ctx -> %ctx.Rotate(float32 angle)) + use stream = new MemoryStream() + image.SaveAsPng(stream) + let! result = (buildCpuOnlyEngine ()).Recognize(ReadOnlyMemory(stream.ToArray())) + Assert.Equal("2706726228947", result.barcode) + } + + [] + member _.``Concurrent recognition keeps coupon barcodes separate``() = + task { + let files = + [| "5_25_2026-02-08_2026-02-14_2706726228947.jpg" + "10_50_01-04_01-13_2706602781191.jpg" |] + let engine = buildCpuOnlyEngine () + let inputs = + files + |> Array.map (fun file -> + let bytes = File.ReadAllBytes(Path.Combine(AppContext.BaseDirectory, "Images", file)) + let expected = Path.GetFileNameWithoutExtension(file).Split('_') |> Array.last + bytes, expected) + let jobs = + Array.init 8 (fun i -> + Task.Run(Func(fun () -> + task { + let bytes, expected = inputs[i % inputs.Length] + let! result = engine.Recognize(ReadOnlyMemory(bytes)) + Assert.Equal(expected, result.barcode) + } :> Task))) + do! Task.WhenAll jobs + } + + [] + [] + [] + [] + member _.``Patterned images do not invent a barcode``(width: int) = + task { + use image = new Image(width, 49) + image.ProcessPixelRows(fun pixels -> + for y = 0 to pixels.Height - 1 do + let row = pixels.GetRowSpan y + for x = 0 to row.Length - 1 do + let value = if (x / 3 + y / 4) % 2 = 0 then 32uy else 224uy + row[x] <- Rgba32(value, value, value)) + use stream = new MemoryStream() + image.SaveAsPng(stream) + let! result = (buildCpuOnlyEngine ()).Recognize(ReadOnlyMemory(stream.ToArray())) + Assert.Null(result.barcode) + } + [] [] []