Skip to content
Client v5: BLE, BLE Hosting, HTTP, Jobs - Linux, MacOS, & Blazor Support! Full AOT, RX on BLE only & MANY other features! Power up!

CameraView Frame Analyzers

The CameraView streams frames to a single analyzer, assigned to CameraView.Analyzer. It runs off the UI thread with back-pressure (a slow analyzer is simply skipped for a frame rather than backing up the camera). It always draws its bounding boxes, but only delivers a result while it is armed — a gated “scan trigger” model, so you get live boxes without an event firehose. The built-in CameraOverlayView draws the boxes. Only one analyzer runs at a time; to offer several detectors, build them once and swap the chosen one into Analyzer (see Offering several detectors).

Two channels per analyzer:

  • Presentation → the boxes returned from AnalyzeAsync, drawn every frame (never gated). A returned set persists until the analyzer returns a different set (replace) or null (clear).
  • Semantic result → delivered only while armed, on the UI thread, via the analyzer’s typed event (e.g. BarcodesDetected), bindable Command, and/or an OnDetected continuation that decides whether to keep scanning. Detection events that can hold several hits in one frame (barcode, face, motion) deliver an array.

Results are pulled, not pushed. Bind a button/Fab to CameraView.ScanCommand (or call CameraView.Scan()) to arm the analyzer for one scan. The next confirmed detection is delivered once, then the analyzer disarms (single-shot). To control continuation, set the analyzer’s OnDetected — a Func<TArgs, Task<bool>> run on the UI thread; return true to keep scanning (stay armed), false to stop until the next Scan(). It can be async (validate, confirm) before deciding. CameraView.StopScanning() disarms it.

<cam:CameraView x:Name="Camera">
<cam:BarcodeAnalyzer OnDetected="{Binding OnBarcode}" />
</cam:CameraView>
<shiny:Fab Icon="scan" Command="{Binding Source={x:Reference Camera}, Path=ScanCommand}" />
// return true => keep scanning, false => disarm.
// the event args carry every code in the frame — e.First is the first DetectedBarcode
public Func<BarcodesDetectedEventArgs, Task<bool>> OnBarcode => async e =>
{
if (Codes.Contains(e.First.Value)) return true; // dupe -> keep going
Codes.Add(e.First.Value);
return Codes.Count < 5; // stop after 5
};

The typed event and bound Command still fire alongside OnDetected, but only while armed (passive observers — they can’t influence continuation). While armed, the barcode event re-fires only when the set of codes in view changes, not on every frame.

var barcode = new BarcodeAnalyzer();
barcode.BarcodesDetected += (_, e) => status = $"{e.First.Format}: {e.First.Value}"; // fires while armed
camera.Analyzer = barcode;
camera.Scan(); // arm — next barcode set fires once

Swap the analyzer at any time — the running session picks up the change. Set camera.Analyzer = null for no analysis. Delivery is on the UI thread (the pipeline marshals it), so handlers can touch UI directly.

Declaring the analyzer in XAML (Commands & OnDetected)

Section titled “Declaring the analyzer in XAML (Commands & OnDetected)”

Analyzers are BindableObjects, so you can declare one inside <cam:CameraView> (it’s the content property, Analyzer) under the one cam: prefix. Only one child is allowed. Bind its OnDetected (Func<TArgs, Task<bool>>) to decide continuation, and/or its …Command for a passive observer. Both inherit the camera’s BindingContext and fire only while armed.

<cam:CameraView Facing="Back" Filter="Chrome">
<cam:BarcodeAnalyzer OnDetected="{Binding OnBarcode}" />
</cam:CameraView>

Each analyzer exposes the command matching its event (BarcodesDetectedCommand, MotionChangedCommand, FacesDetectedCommand, TextRecognizedCommand, DocumentDetectedCommand) — bind a Command<T> whose T is the event-args type. The command can’t say “keep scanning”; for that use OnDetected, which returns a Task<bool>.

Offering several detectors (the picker pattern)

Section titled “Offering several detectors (the picker pattern)”

Only one analyzer runs at a time, so to let the user choose a detector, build the instances once (keeping their OnDetected / event handlers wired) and assign the chosen one to Camera.Analyzer — e.g. from a picker. The sample app’s CameraPage does exactly this.

// built once, handlers stay wired
readonly BarcodeAnalyzer barcode = new() { /* OnDetected, Formats, ... */ };
readonly FaceAnalyzer faces = new();
readonly MotionAnalyzer motion = new();
void OnDetectorPicked(string choice) =>
Camera.Analyzer = choice switch
{
"Barcode" => barcode,
"Faces" => faces,
"Motion" => motion,
_ => null, // null = no analysis
};

Drop a CameraOverlayView over the CameraView in the same cell and point it at the camera — it auto-subscribes to the aggregated boxes and redraws. Each analyzer styles its own boxes (color / text); DefaultBoxColor / DefaultTextColor fill in anything unset.

<Grid>
<cam:CameraView x:Name="Camera" ScaleMode="AspectFill" />
<cam:CameraOverlayView Camera="{x:Reference Camera}" InputTransparent="True" />
</Grid>

Swap CameraView.Analyzer at runtime and the running pipeline picks it up live (seamless on Apple/Windows; Android rebinds its capture use-cases automatically). To turn the analyzer off without losing its bindings or internal state, set FrameAnalyzer.IsEnabled (bool, default true) instead of clearing it — it resumes instantly when re-enabled. While the analyzer is disabled or Analyzer is null, the camera behaves as if it had none (so, e.g., Android can record video again).

<!-- toggle live from a switch; the analyzer's binding + state are preserved while off -->
<cam:BarcodeAnalyzer OnDetected="{Binding OnBarcode}"
IsEnabled="{Binding IsToggled, Source={x:Reference ScanSwitch}}" />
  • IsEnabled (bool, default true) — run the analyzer or not (see above).
  • ShowBoundingBox (bool, default true) — set False to run an analyzer purely for its result and draw nothing. Honored by every built-in analyzer.
  • OverlayProvider (code-level Func<TArgs, IReadOnlyList<OverlayBox>?>) — return the exact boxes to draw for a detection, or null for none. When unset, the analyzer draws its own default styled box.
// args carry every code in the frame — box the ones that pass your check
barcode.OverlayProvider = e =>
e.Barcodes
.Where(b => b.Value.StartsWith("OK"))
.Select(b => new OverlayBox(b.BoundingBox, Colors.Lime, b.Value))
.ToList(); // empty/null => box nothing

CoordinateTransform (in Shiny.Controls.Camera) maps normalized boxes into view space accounting for aspect-fill crop, rotation and front-camera mirroring — the same helper the overlay uses.

Set FrameAnalyzer.ScanWindow (RectF?, normalized upright space 0..1, null = whole frame) to confine detection to a region — so it both prevents picking up other codes in one shot and speeds up scanning. Barcode honors it natively: Apple Vision’s regionOfInterest (iOS/macOS) and a Y-plane crop fed to MLKit (Android) mean the engine only decodes that band rather than the whole frame (a no-op on Windows / bare net10.0). It doubles as an aim guide: the built-in overlay dims everything outside the window and frames a viewfinder reticle. The active window is mirrored on the read-only CameraView.ScanWindow.

// a center band — handy for "line up the barcode here"
analyzer.ScanWindow = new RectF(0.1f, 0.4f, 0.8f, 0.2f); // x, y, width, height (0..1)

In XAML bind ScanWindow to a RectF? view-model property (it’s a struct — there’s no literal string form):

<cam:BarcodeAnalyzer OnDetected="{Binding OnBarcode}" ScanWindow="{Binding ScanBand}" />

CameraOverlayView styles the guide with ScanWindowColor (reticle outline, default white) and ScanWindowScrimColor (the dim outside, default ~43% black); set either to null to drop that part.

Capture & stop on detection (“scan then freeze”)

Section titled “Capture & stop on detection (“scan then freeze”)”

A common flow is “scan until you get a result, then freeze the preview and keep the photo.” Do it explicitly inside the analyzer’s OnDetected — arm with Scan(), then on the confirmed detection capture and/or stop, and return false to disarm:

<cam:CameraView x:Name="Camera">
<cam:PassportAnalyzer OnDetected="{Binding OnPassport}" />
</cam:CameraView>
public Func<DocumentDetectedEventArgs<Passport>, Task<bool>> OnPassport => async e =>
{
Passport p = e.Document; // the merged document
var photo = await Camera.CaptureAndStopAsync(); // full-res still, then stop the session
ShowThumbnail(photo);
return false; // single-shot: disarm
};

The detection handed to OnDetected is the confirmed one — for documents the merged record (after accumulation), for MotionAnalyzer motion starting. CaptureAndStopAsync() grabs the full-res still and stops the session atomically, returning the CameraPhoto (which also raises MediaCaptured); use CapturePhotoAsync() to capture without stopping. To resume after a stop, call Camera.StartAsync() then Camera.Scan() to re-arm.

Each analyzer ships as its own package so apps pull only what they need. The strategy is hybrid: native ML where available, managed fallback elsewhere.

  • NuGet downloads for Shiny.Maui.Controls.Camera.Barcode
using Shiny.Maui.Controls.Camera.Barcode;
var barcode = new BarcodeAnalyzer();
barcode.BarcodesDetected += (_, e) =>
{
foreach (var code in e.Barcodes) // DetectedBarcode { Value, Format, BoundingBox }
Console.WriteLine($"{code.Format}: {code.Value}"); // BoundingBox is normalized upright
};
camera.Analyzer = barcode;

Decodes 1D/2D barcodes and QR codes with the native scanner — Apple Vision (VNDetectBarcodesRequest) on iOS / Mac Catalyst / macOS and Android MLKit (BarcodeScanning). A no-op on Windows and bare net10.0 (no native barcode scanner). BarcodesDetectedEventArgs carries Barcodes (an IReadOnlyList<DetectedBarcode>, every code in the frame) plus a convenience First; each DetectedBarcode is a record with Value (string), Format (Shiny.Controls.Camera.BarcodeFormat), and BoundingBox (RectF). While armed it re-fires only when the set of codes in view changes (not per-frame).

Restrict the scanned symbologies with BarcodeAnalyzer.Formats (IList<BarcodeFormat>?, null = all) — the filter is applied natively (Vision Symbologies / MLKit SetBarcodeFormats). In XAML it’s settable inline as a case-insensitive, comma-separated list:

<cam:BarcodeAnalyzer BarcodesDetectedCommand="{Binding ScanCommand}"
Formats="QrCode,Ean13,Code128" />

Set Formats before scanning starts (constructor / XAML); on Android the scanner client is built on the first frame, so changing it mid-session takes effect only after the analyzer is re-created.

  • NuGet downloads for Shiny.Maui.Controls.Camera.Face
using Shiny.Maui.Controls.Camera.Face;
var faces = new FaceAnalyzer();
faces.FacesDetected += (_, e) => Console.WriteLine($"{e.Faces.Count} face(s)");
camera.Analyzer = faces;

Apple Vision (iOS / Mac Catalyst / macOS), Android MLKit, and Windows.Media.FaceAnalysis. FacesDetectedEventArgs.Faces is a list of DetectedFace (bounds, confidence, optional landmarks).

  • NuGet downloads for Shiny.Maui.Controls.Camera.Motion
using Shiny.Maui.Controls.Camera.Motion;
var motion = new MotionAnalyzer
{
PixelThreshold = 25, // per-pixel luma delta to count as changed
AreaThreshold = 0.04, // fraction of pixels that must change to trigger
GridColumns = 16, // grid resolution used to split motion into regions
CellThreshold = 0.10, // fraction of a cell that must change to box it
EnterFrames = 3, // consecutive frames above threshold before reporting "started"
ExitFrames = 5 // consecutive frames below threshold before reporting "stopped"
};
// e.Regions = a box per distinct moving area; e.Region = their union; e.Intensity = 0..1
motion.MotionChanged += (_, e) => { /* e.InMotion, e.Regions, e.Region, e.Intensity */ };
camera.Analyzer = motion;

Pure-managed luminance frame-differencing — cross-platform, no native dependency. Raises MotionChanged when motion starts and stops, and clusters motion into separate regions so movement in two spots draws two boxes rather than one box spanning both. MotionEventArgs.Regions carries one normalized RectF per moving area (with Region as their union for back-compat); tune how finely motion is split with GridColumns / CellThreshold.

The event is debounced so it isn’t twitchy: MotionChanged reports InMotion=true only after EnterFrames consecutive frames above threshold (default 3) and InMotion=false only after ExitFrames below it (default 5) — a single noisy frame or a brief pause mid-movement won’t fire it. Raise EnterFrames if it’s still too sensitive. The overlay boxes are not debounced, so they keep tracking each frame.

  • NuGet downloads for Shiny.Maui.Controls.Camera.Ocr
using Shiny.Maui.Controls.Camera.Ocr;
var ocr = new OcrAnalyzer();
ocr.TextRecognized += (_, e) =>
{
foreach (var block in e.Blocks) // RecognizedText { Text, BoundingBox, Confidence }
Console.WriteLine(block.Text);
};
camera.Analyzer = ocr;

Recognizes text with Apple Vision / Android MLKit / Windows.Media.Ocr. The reusable text recognizer is shared with the document analyzers below.

Documents — invoices, receipts, IDs, cards, passports

Section titled “Documents — invoices, receipts, IDs, cards, passports”
  • NuGet downloads for Shiny.Maui.Controls.Camera.Documents

Structured extraction, where each document type is its own analyzer with its own strongly-typed event (DocumentDetected, typed to the document). Every payload is a strong record with nullable fields — only what was found is populated. Ships analyzers for invoices (Invoice, with order lines), receipts (Receipt, with line items + per-tax breakdown + totals), business cards (BusinessCard, with emails + typed phones + name/title/company/website/address), driver’s licenses (DriversLicense), health cards (HealthCard), credit cards (CreditCard), and passports (Passport).

using Shiny.Maui.Controls.Camera.Documents;
var invoice = new InvoiceAnalyzer();
invoice.DocumentDetected += (_, e) =>
{
Invoice doc = e.Document;
Console.WriteLine($"#{doc.Number} total {doc.Total}{doc.Lines.Count} line(s)");
foreach (var line in doc.Lines) // InvoiceLine { Description, Quantity, UnitPrice, Amount }
Console.WriteLine($" {line.Quantity} x {line.Description} = {line.Amount}");
};
var license = new DriversLicenseAnalyzer(); // deterministic — no ML
license.DocumentDetected += (_, e) =>
Console.WriteLine($"{e.Document.FirstName} {e.Document.LastName}{e.Document.Number}");
// one analyzer runs at a time — assign the one you want (swap to switch document types)
camera.Analyzer = invoice;
  • DriversLicenseAnalyzer reads the PDF417 barcode on the back of US/Canadian licenses with the native scanner (Apple Vision / Android MLKit; a no-op on Windows and bare net10.0) and parses the AAMVA record into a strongly-typed DriversLicense (number, names, DOB, expiry, address, Jurisdiction) — the AAMVA parse is deterministic. It covers US states and the Canadian provinces that emit an AAMVA PDF417 (BC, AB, SK, MB, NS, NB, PEI, NL) and auto-detects Canadian CCYYMMDD date order from the country element or the province code (so dates parse correctly even when the country field is absent). Ontario and Quebec licences do not carry a PDF417 barcode and therefore cannot be scanned this way — back an OCR-based DocumentAnalyzer with a custom parser for those.
  • PassportAnalyzer locates and parses the passport MRZ (the two <<< lines, ICAO TD3) into a Passport (number, surname, given names, nationality, issuing country, DOB, expiry, sex) — the MRZ parse is deterministic, only locating the lines depends on OCR.
  • CreditCardAnalyzer reads the front of a payment card into a CreditCard. The brand (CreditCardType — Visa, Mastercard, Amex, …) and number validity are derived deterministically from the number’s IIN prefix + Luhn; name / expiry / company are best-effort OCR. (Cvv is on the back signature panel and PCI-sensitive — it is almost always null from a front scan.)
  • ReceiptAnalyzer reads a point-of-sale receipt into a Receipt — merchant header, purchased line items (Receipt.Lines), a per-tax breakdown (Receipt.Taxes, each with an optional rate), plus Subtotal / Tax (the sum) / Tip / Discount / Total and best-effort PaymentMethod / CardLast4 / Currency / Date / Time. OCR + best-effort rules.
  • HealthCardAnalyzer is OCR + best-effort rules tuned for Canadian health cards: it detects the issuing province from on-card keywords and applies that province’s member-number format — Quebec/RAMQ (4 letters + 8 digits), Ontario/OHIP (10 digits + a 2-letter version code), BC Personal Health Number (10 digits), Alberta/AHCIP (9 digits), and the rest of the provinces — surfacing HealthCard.Province plus a Plan field. Unknown layouts fall back to the longest plausible digit run.
  • BusinessCardAnalyzer reads a business card into a BusinessCard — the cardholder Name + JobTitle, the Company, every contact channel (Emails and typed Phones, each tagged Mobile/Office/Fax… from its line label), Website, and a best-effort Address. Email / phone / URL are matched deterministically; name / title / company are heuristic (cards have no fixed layout). BusinessCard.Email / .Phone return the first of each for convenience. OCR + best-effort rules.
  • InvoiceAnalyzer / ReceiptAnalyzer / BusinessCardAnalyzer / HealthCardAnalyzer are OCR + best-effort rules. Swap the rules on any OCR-backed analyzer by supplying a custom IDocumentParser<T> to the constructor (new InvoiceAnalyzer(new MyInvoiceParser())), where TryParse returns the typed payload plus the boxes to draw — back it with rules, a cloud Document AI, or a vision LLM.
var card = new CreditCardAnalyzer();
card.DocumentDetected += (_, e) =>
Console.WriteLine($"{e.Document.Type} {e.Document.Number} exp {e.Document.Expiry:MM/yy}");
var passport = new PassportAnalyzer(); // deterministic MRZ parse
passport.DocumentDetected += (_, e) =>
Console.WriteLine($"{e.Document.GivenNames} {e.Document.Surname} ({e.Document.Nationality})");

The reusable building blocks (RecognizedText, DocumentField, DocumentLineItem, DocumentDetectedEventArgs<T>, IDocumentParser<T>) live in Shiny.Controls.Camera.

Every document analyzer hands back a strongly-typed record. Every data property is nullable (or a Unknown/Unspecified enum) — only what was found is populated, so check before use.

record Invoice(string? Number, DateOnly? Date, decimal? Total,
IReadOnlyList<InvoiceLine> Lines, IReadOnlyList<DocumentField> Fields);
record InvoiceLine(string? Description, decimal? Quantity, decimal? UnitPrice, decimal? Amount, RectF? Bounds);
record Receipt(string? Merchant, string? MerchantPhone, string? ReceiptNumber,
DateOnly? Date, TimeOnly? Time, IReadOnlyList<ReceiptLine> Lines,
decimal? Subtotal, IReadOnlyList<ReceiptTax> Taxes, decimal? Tax,
decimal? Tip, decimal? Discount, decimal? Total,
string? Currency, string? PaymentMethod, string? CardLast4,
IReadOnlyList<DocumentField> Fields);
record ReceiptLine(string? Description, decimal? Quantity, decimal? UnitPrice, decimal? Amount, RectF? Bounds);
record ReceiptTax(string? Label, decimal? Rate, decimal? Amount, RectF? Bounds);
record BusinessCard(string? Name, string? JobTitle, string? Company,
IReadOnlyList<string> Emails, IReadOnlyList<BusinessCardPhone> Phones,
string? Website, string? Address, IReadOnlyList<DocumentField> Fields); // .Email / .Phone = first of each
record BusinessCardPhone(string Number, string? Type, RectF? Bounds); // Type = Mobile / Office / Fax / … from the line label
record DriversLicense(string? Number, string? FirstName, string? LastName,
DateOnly? DateOfBirth, DateOnly? Expiry, string? Address,
string? Jurisdiction, IReadOnlyList<DocumentField> Fields);
record HealthCard(string? Number, string? Name, DateOnly? Expiry, string? Issuer,
string? Province, IReadOnlyList<DocumentField> Fields);
record CreditCard(CreditCardType Type, string? Number, DateOnly? Expiry,
string? FirstName, string? LastName, string? CompanyName, string? Cvv,
IReadOnlyList<DocumentField> Fields);
enum CreditCardType { Unknown, Visa, Mastercard, Amex, Discover, DinersClub, JCB, UnionPay, Maestro }
record Passport(string? Number, string? Surname, string? GivenNames,
string? Nationality, string? IssuingCountry,
DateOnly? DateOfBirth, DateOnly? Expiry, PassportSex Sex,
IReadOnlyList<DocumentField> Fields);
enum PassportSex { Unspecified, Male, Female }
// shared building block carried in each record's Fields bag
record DocumentField(string Label, string? Value, RectF? Bounds, float Confidence);

Beyond the first-class properties, every payload also exposes a Fields list of DocumentField (label/value/box/confidence) carrying anything extra the parser recognized — including, for a custom IDocumentParser<T>, fields you don’t model as typed properties.

Derive from FrameAnalyzer (it implements IFrameAnalyzer, makes the analyzer a BindableObject, marshals events/commands to the UI thread, and adds IsEnabled + ShowBoundingBox + OverlayProvider). Use frame.GetLuminance() for a cached grayscale plane (ideal for managed CV), or down-cast CameraFrame to the platform frame (AppleCameraFrame, AndroidCameraFrame, WindowsCameraFrame) to feed a native ML SDK with zero copies.

public class GreenScreenAnalyzer : FrameAnalyzer
{
public override string Id => "myapp.greenscreen";
public event EventHandler<GreenScreenEventArgs>? Detected;
// expose your own typed continuation so consumers can keep/stop scanning
public Func<GreenScreenEventArgs, Task<bool>>? OnDetected { get; set; }
public override ValueTask<IReadOnlyList<OverlayBox>?> AnalyzeAsync(CameraFrame frame, CancellationToken ct)
{
var luma = frame.GetLuminance(); // ReadOnlySpan<byte>, Width x Height
// ... your detection logic ...
if (/* nothing found */ true)
return default; // null -> clear this analyzer's boxes
var args = new GreenScreenEventArgs(/* ... */);
this.Deliver(args, () => this.Detected?.Invoke(this, args), /* command */ null, this.OnDetected);
return new ValueTask<IReadOnlyList<OverlayBox>?>(
this.ResolveOverlay(args, /* OverlayProvider */ null,
() => new[] { new OverlayBox(box) }));
}
}

Deliver(args, raiseEvent, command, onDetected) is gated by arming — it does nothing while disarmed, raises your event + bound Command (and awaits onDetected) only while armed, consumes the arm so a lingering detection won’t re-fire, and re-arms when onDetected returns true. Boxes via ResolveOverlay(args, provider, defaultBoxes) draw every frame regardless (honoring ShowBoundingBox / OverlayProvider). Keep analyzers allocation-light and don’t retain the frame past the returned task — the pipeline disposes it (releasing the pooled native buffer) once the analyzer finishes.

For an OCR-backed document you don’t derive from FrameAnalyzer directly — derive from DocumentAnalyzer<TDocument> and supply an IDocumentParser<TDocument>. The base class does the heavy lifting: it runs the shared OCR recognizer each frame, hands the recognized text to your parser, delivers the typed DocumentDetected event (the bound DocumentDetectedCommand, and OnDetected) while armed, draws the parser’s boxes, and honors IsEnabled / ShowBoundingBox / OverlayProvider. You only write what counts as your document and how to pull its fields out of text.

A parser is the single seam — it turns the OCR’d lines into a typed payload plus the boxes to draw:

public interface IDocumentParser<TDocument>
{
// true + populated outputs on success; false when the text isn't this document (analyzer clears its overlay)
bool TryParse(IReadOnlyList<RecognizedText> text,
out TDocument document,
out IReadOnlyList<OverlayBox> boxes);
}

RecognizedText (Text, BoundingBox, Confidence) is already in normalized upright image space, so boxes you build from .BoundingBox line up with the overlay automatically.

Option A — swap the parser on an existing analyzer

Section titled “Option A — swap the parser on an existing analyzer”

The quickest customization: keep the built-in analyzer (and its payload type) but replace the rules — pass your own IDocumentParser<T> to its constructor. Back it with stricter rules, a cloud Document AI, or a vision LLM without touching the rest of the pipeline.

camera.Analyzer = new InvoiceAnalyzer(new MyInvoiceParser()); // IDocumentParser<Invoice>

Three pieces: a payload record, a parser, and a one-line analyzer that wires them. (Business cards already ship as BusinessCardAnalyzer — here’s a loyalty/membership-card scanner to show a brand-new type end to end:)

using System.Text.RegularExpressions;
using Microsoft.Maui.Graphics;
using Shiny.Controls.Camera; // RecognizedText, DocumentField, IDocumentParser<T>, OverlayBox
using Shiny.Maui.Controls.Camera.Documents; // DocumentAnalyzer<T>
// 1) payload — nullable fields (only what was found is set) + a Fields bag for extras
public record LoyaltyCard(
string? Program, string? MemberName, string? MemberNumber,
IReadOnlyList<DocumentField> Fields);
// 2) parser — pull fields out of the OCR'd lines
public sealed partial class LoyaltyCardParser : IDocumentParser<LoyaltyCard>
{
[GeneratedRegex(@"\b\d[\d ]{8,}\d\b")] private static partial Regex Number();
[GeneratedRegex(@"member|rewards|loyalty", RegexOptions.IgnoreCase)] private static partial Regex Signal();
public bool TryParse(
IReadOnlyList<RecognizedText> text,
out LoyaltyCard document,
out IReadOnlyList<OverlayBox> boxes)
{
document = null!;
boxes = [];
// a cheap "is this my document?" signal first — a long member number. Bail fast otherwise so the
// analyzer doesn't misfire on every frame of unrelated text.
var numberLine = text.FirstOrDefault(t => Number().IsMatch(t.Text));
if (numberLine is null)
return false;
var program = text.FirstOrDefault(t => Signal().IsMatch(t.Text))?.Text
?? text.FirstOrDefault()?.Text; // the program/brand line
var number = Number().Match(numberLine.Text).Value.Replace(" ", "");
var fields = new List<DocumentField>
{
new("Program", program, text.FirstOrDefault()?.BoundingBox),
new("Member #", number, numberLine.BoundingBox),
};
document = new LoyaltyCard(program, null, number, fields);
// box each field we actually located
boxes = fields
.Where(f => f.Bounds is not null)
.Select(f => new OverlayBox(f.Bounds!.Value, Colors.Lime, f.Label))
.ToList();
return true;
}
}
// 3) analyzer — the base does OCR + threading + events + overlay
public sealed class LoyaltyCardAnalyzer : DocumentAnalyzer<LoyaltyCard>
{
public LoyaltyCardAnalyzer() : base(new LoyaltyCardParser()) { }
public LoyaltyCardAnalyzer(IDocumentParser<LoyaltyCard> parser) : base(parser) { } // allow swap-in too
public override string Id => "myapp.camera.loyaltycard";
}

Use it like any other analyzer — code or XAML (it inherits DocumentDetectedCommand, IsEnabled, ShowBoundingBox, and OverlayProvider from the base):

var cards = new LoyaltyCardAnalyzer();
cards.DocumentDetected += (_, e) => status = $"{e.Document.Program}{e.Document.MemberNumber}";
camera.Analyzer = cards;
<my:LoyaltyCardAnalyzer DocumentDetectedCommand="{Binding CardCommand}"
IsEnabled="{Binding ScanCards}" />