Table of Contents

License plate recognition (MAUI) – camera + overlay toggle

Applies to SDK 2.2.x. The MAUI SDK supports Android and iOS only, using .NET MAUI 9 or 10. See requirements and licensing.

Goals:

  • build the recognition options via LPRRecognitionOptionHelper
  • use a RecognitionView in XAML
  • start/stop LPR recognition automatically when the camera opens
  • toggle overlays (bounding boxes for plates and vehicles)
  • display the cropped plate bitmap in a preview image
  • handle model download progress events

Prerequisites

  • Package(s): CapCognition.Maui and CapCognition.Maui.LPR
  • This example uses local models. Bundle the selected .ccml files as MauiAsset resources in Resources/Raw, preserving their logical filenames. See model placement and exact filenames. Missing assets cause an error; these local helpers do not fall back to downloads. Include every size/model selected by your UI (including the wide-text model for split-text mode).

Install the current modular packages and initialize their licensed modules once in MauiProgram.cs:

dotnet add package CapCognition.Maui
dotnet add package CapCognition.Maui.LPR
using CapCognition.Maui.Helpers;
using CapCognition.Maui.Common;
using CapCognition.Maui.LPR;
using SkiaSharp.Views.Maui.Controls.Hosting;

builder
    .UseMauiApp<App>()
    .UseSkiaSharp()
    .InitializeCapCognition(
    [
        Capture.Use(/* Add your capture license here */),
        Recognition.Use(/* Add your recognition license here */),
        LicensePlateDetection.Use(/* Add your LPR license here */),
    ]);

1) Create options (MainPage)

This method matches the setup from OnOpenLPRRecognition and uses LPRRecognitionOptionHelper.PrepareOptions to build the recognition options with default settings (320×320 plate model, 320n text model, country detection on, vehicle detection on, overlays on).

using CapCognition.Maui.Core.Shared.Common;

private void OnOpenLPRRecognition(object sender, EventArgs e)
{
    var recogOptions = LPRRecognitionOptionHelper.PrepareOptions(
        LPRResolution.R320,
        LPRTextResolution.R320n,
        isCountryMode: true,
        isVehicleDetection: true,
        hasOverlay: true);

    Navigation.PushAsync(new LPRRecognitionPage(recogOptions));
}

2) Option helper

The helper centralises option building and maps the enum values to concrete model types. It prepares and loads the bundled models synchronously before returning the options. Download-progress UI is relevant only if you adapt the example to use automatic model downloading.

using CapCognition.Maui.Core.Shared.Common;
using CapCognition.Maui.LPR;
using CapCognition.Maui.LPR.Shared.Extensions;

public static class LPRRecognitionOptionHelper
{
    public static List<RecognitionOptions> PrepareOptions(
        LPRResolution resolution,
        LPRTextResolution textResolution,
        bool isCountryMode,
        bool isVehicleDetection,
        bool hasOverlay)
    {
        var recognitionMode = isVehicleDetection
            ? isCountryMode
                ? LicensePlateDetectionRecognitionOptions.RecognitionModeType.CountryLicencePlateThenVehicle
                : LicensePlateDetectionRecognitionOptions.RecognitionModeType.NoCountryLicencePlateThenVehicle
            : isCountryMode
                ? LicensePlateDetectionRecognitionOptions.RecognitionModeType.CountryLicencePlateOnly
                : LicensePlateDetectionRecognitionOptions.RecognitionModeType.NoCountryLicencePlateOnly;

        var lpModelType = resolution switch
        {
            LPRResolution.R320  => LPModelType.LPModelType320n,
            LPRResolution.R640  => LPModelType.LPModelType640n,
            LPRResolution.R1280 => LPModelType.LPModelType1280n,
            _                   => LPModelType.LPModelType320n
        };

        var textModelType = textResolution switch
        {
            LPRTextResolution.R320n    => LPTextModelType.LPTextModelType320n,
            LPRTextResolution.R320s    => LPTextModelType.LPTextModelType320s,
            LPRTextResolution.R640n    => LPTextModelType.LPTextModelType640n,
            LPRTextResolution.RSplit512 => LPTextModelType.LPTextModelTypeSplit512n,
            _                          => LPTextModelType.LPTextModelType320n
        };

        var vehicleModelType = resolution switch
        {
            LPRResolution.R320  => VehicleModelType.VehicleModelType320n,
            LPRResolution.R640  => VehicleModelType.VehicleModelType640n,
            LPRResolution.R1280 => VehicleModelType.VehicleModelType1280n,
            _                   => VehicleModelType.VehicleModelType320n
        };

        var recognitionOptions = new RecognitionOptionBuilder()
            .AddLicensePlateDetectionRecognitionOption()
                .UseCroppedImageForRecognition()
                .DoAutomaticDetectionOptimization()
                .UseLocalModelsForRecognitionMode(recognitionMode)
                    .UseLocalLicensePlateModelFromType(lpModelType)
                    .UseLocalLicensePlateTextModelFromType(textModelType)
                    .UseLocalVehicleModelFromType(vehicleModelType)
                    .Done()
                .ConfigureOption(option =>
                {
                    option.ClearOldModelsFromCacheFolder();
                    option.CreateAndPrepareModelsAsync().GetAwaiter().GetResult();
                })
                .AddLicensePlateRecognitionOverlayDrawingOption()
                    .SetOverlays(hasOverlay)
                    .DisplayLicensePlateSurroundingBox()
                    .DisplayVehicleSurroundingBox()
                    .SetVehicleSurroundingRectColor(Color.FromRgb(0, 255, 0))
                    .SetVehicleSurroundingRectStrokeWidth(1)
                    .SetLicensePlateSurroundingRectColor(Color.FromRgb(255, 0, 0))
                    .SetLicensePlateSurroundingRectStrokeWidth(1)
                    .Done()
            .Done()
            .Build();

        return recognitionOptions;
    }
}

public enum LPRResolution  { R320, R640, R1280 }
public enum LPRTextResolution { R320n, R320s, R640n, RSplit512 }

3) UI (XAML)

The layout is a Grid that places the RecognitionView as a full-size background layer. Control buttons are overlaid in the top row and in lower rows. A small Image (LPPreview) shows the cropped plate bitmap from the last recognized result.

<?xml version="1.0" encoding="utf-8" ?>
<ContentPage xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
             xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
             xmlns:views="clr-namespace:CapCognition.Maui.Views;assembly=CapCognition.Maui"
             x:Class="YourApp.LPRRecognitionPage"
             Title="LPR Recognition">

    <Grid>
        <Grid.RowDefinitions>
            <RowDefinition Height="80"/>
            <RowDefinition Height="*"/>
            <RowDefinition Height="60"/>
            <RowDefinition Height="60"/>
        </Grid.RowDefinitions>
        <Grid.ColumnDefinitions>
            <ColumnDefinition Width="*"/>
            <ColumnDefinition Width="*"/>
            <ColumnDefinition Width="*"/>
            <ColumnDefinition Width="*"/>
            <ColumnDefinition Width="*"/>
        </Grid.ColumnDefinitions>

        <!-- Full-screen camera preview -->
        <views:RecognitionView
            x:Name="RecognitionView"
            Grid.Row="0" Grid.RowSpan="4"
            Grid.Column="0" Grid.ColumnSpan="5"
            RequestedPreviewResolutionWidth="1920"
            RequestedCaptureResolutionWidth="1080"
            CaptureIntervalInMs="0"
            UseFrontCamera="False"
            FillPreviewToView="False"
            DisplayWatermark="False" />

        <!-- Top row: toggle buttons -->
        <Button Grid.Row="0" Grid.Column="0" BackgroundColor="LightGray"
                LineBreakMode="WordWrap"
                Text="{Binding ResolutionText}"   Clicked="OnToggleResolution" />
        <Button Grid.Row="0" Grid.Column="1" BackgroundColor="LightGray"
                LineBreakMode="WordWrap"
                Text="{Binding TextResolutionText}" Clicked="OnToggleTextResolution" />
        <Button Grid.Row="0" Grid.Column="2" BackgroundColor="LightGray"
                LineBreakMode="WordWrap"
                Text="{Binding CountryText}"      Clicked="OnToggleHasCountryDetection" />
        <Button Grid.Row="0" Grid.Column="3" BackgroundColor="LightGray"
                LineBreakMode="WordWrap"
                Text="{Binding VehicleText}"      Clicked="OnToggleHasVehicleDetection" />
        <Button Grid.Row="0" Grid.Column="4" BackgroundColor="LightGray"
                LineBreakMode="WordWrap"
                Text="{Binding OverlayText}"      Clicked="OnToggleOverlay" />

        <!-- Cropped plate preview (bottom-right) -->
        <Image x:Name="LPPreview"
               Grid.Row="1" Grid.Column="3"
               Aspect="AspectFit" />

        <!-- Camera controls -->
        <Button Grid.Row="2" Grid.Column="0" BackgroundColor="LightGray"
                Text="{Binding TorchText}"        Clicked="OnToggleTorch" />
        <Button Grid.Row="2" Grid.Column="1" BackgroundColor="LightGray"
                Text="Close Camera"               Clicked="OnCameraClose" />
        <Button Grid.Row="2" Grid.Column="2" BackgroundColor="LightGray"
                Text="Open Camera"                Clicked="OnCameraOpen" />

        <!-- Recognition controls -->
        <Button Grid.Row="3" Grid.Column="0" BackgroundColor="LightGray"
                Text="Single Recogn."             Clicked="OnRecognizeSingleImage" />
        <Button Grid.Row="3" Grid.Column="1" BackgroundColor="LightGray"
                Text="{Binding ContinuousCaptureText}" Clicked="OnRecognizeContinuousImages" />

    </Grid>
</ContentPage>

4) Code-behind (simplified)

Key points:

  • Options are injected and registered on the RecognitionView via RecognitionView.AddOption(...).
  • The camera is opened in OnLoaded / OnAppearing.
  • Continuous recognition starts automatically inside OnCameraOpened.
  • Model download progress is reported via three events on LicensePlateDetectionRecognitionOptions.
  • Changing resolution/mode at runtime replaces options and reopens the camera.
using CapCognition.Maui.Core.Shared.Common;
using CapCognition.Maui.LPR;
using SkiaSharp;
using SkiaSharp.Views.Maui.Controls;

namespace YourApp;

public partial class LPRRecognitionPage : ContentPage
{
    public LPRRecognitionPage(List<RecognitionOptions> options)
    {
        _options = options;
        InitializeComponent();
        BindingContext = this;

        DefineTorchText();
        DefineContRecognitionText();

        foreach (var option in options)
        {
            RecognitionView.AddOption(option);
        }

        _licensePlateOptions = RecognitionView.GetOption<LicensePlateDetectionRecognitionOptions>();

        Loaded += OnLoaded;
    }

    // --- Bindable properties ---

    public string ResolutionText      { get; set; } = "Res: 320x320";
    public string TextResolutionText  { get; set; } = "Mod n: 320x320";
    public string CountryText         { get; set; } = "C: On";
    public string VehicleText         { get; set; } = "V: On";
    public string OverlayText         { get; set; } = "O: On";
    public string TorchText           { get; set; } = "Torch On";
    public string ContinuousCaptureText { get; set; } = "Start Cont.";

    // --- Lifecycle ---

    protected override void OnAppearing()
    {
        base.OnAppearing();

        OpenCamera();
    }

    private async void OnLoaded(object? sender, EventArgs e)
    {
        var result = await RecognitionView.RequestAllPermissionAsync();
        if (!result)
        {
            await Navigation.PopAsync();
        }

        OpenCamera();
    }

    protected override void OnDisappearing()
    {
        RecognitionView.CloseCamera();
        base.OnDisappearing();
    }

    // --- Camera ---

    private void OpenCamera()
    {
        if (!RecognitionView.Initialized || RecognitionView.CameraIsOpen)
        {
            return;
        }

        // Subscribe to model download events if models still need preparation
        if (_licensePlateOptions != null && _licensePlateOptions.CheckIfModelsNeedsPreparation())
        {
            _licensePlateOptions.ModelsDownloadStartedEvt  += OnModelsDownloadStarted;
            _licensePlateOptions.PreparationProgressEvt    += OnModelLoadingProgress;
            _licensePlateOptions.ModelsDownloadedEvt       += OnModelsDownloaded;
        }

        RecognitionView.CameraOpenedEvt -= OnCameraOpened;
        RecognitionView.CameraOpenedEvt += OnCameraOpened;
        RecognitionView.OpenCamera();
    }

    private void OnCameraOpened(bool success)
    {
        RecognitionView.CameraOpenedEvt -= OnCameraOpened;
        if (!success)
        {
            Console.WriteLine("Camera could not be opened!");
            return;
        }

        // Automatically start continuous recognition after camera opens
        DoStartContinuousRecognition();
        DefineTorchText();
        DefineContRecognitionText();
    }

    private void CloseCamera()
    {
        if (!RecognitionView.CameraIsOpen) 
        {
            return;
        }
        RecognitionView.CloseCamera();
    }

    // --- Recognition ---

    private void OnRecognizeSingleImage(object? sender, EventArgs e)
    {
        if (!RecognitionView.CameraIsOpen) 
        {
            return;
        }
        RecognitionView.SingleRecognition();
    }

    private void OnRecognizeContinuousImages(object? sender, EventArgs e)
    {
        if (!RecognitionView.CameraIsOpen) 
        {
            return;
        }

        if (RecognitionView.CapturePending)
        {
            DoStopContinuousRecognition();
        }
        else
        {
            DoStartContinuousRecognition();
        }

        DefineContRecognitionText();
    }

    private void DoStartContinuousRecognition()
    {
        var success = RecognitionView.StartContinuousRecognition();
        if (success)
        {
            RecognitionView.RecognitionResultEvt += OnImageRecognized;
        }
    }

    private void DoStopContinuousRecognition()
    {
        var success = RecognitionView.StopContinuousRecognition();
        if (success)
        {
            RecognitionView.RecognitionResultEvt -= OnImageRecognized;
        }
    }

    private void OnImageRecognized(RecognitionResult result, SKBitmap bitmap)
    {
        var lpResult = result.GetResult<RecognitionProcessorLicensePlateDetectionResult>();
        if (lpResult == null)
        {
            Console.WriteLine($"No LP result: {result}");
            return;
        }

        Console.WriteLine(
            $"Plate: {lpResult.PlateNumberValidated} ({lpResult.PlateConfidence}), " +
            $"Country: {lpResult.PlateCountryCode}, " +
            $"Vehicle: {lpResult.VehicleType} ({lpResult.VehicleConfidence})");

        // Show cropped plate in the preview image (must marshal to main thread)
        var bitmapClone = lpResult.PlateBitmap?.Copy();
        MainThread.BeginInvokeOnMainThread(() =>
        {
            LPPreview.Source = (SKBitmapImageSource)bitmapClone;
        });
    }

    // --- Toggle controls ---

    private void OnToggleTorch(object? sender, EventArgs e)
    {
        RecognitionView.SetTorch(!RecognitionView.TorchIsOn);
        DefineTorchText();
    }

    private void OnToggleOverlay(object? sender, EventArgs e)
    {
        _hasOverlay = !_hasOverlay;
        OverlayText = _hasOverlay ? "O: On" : "O: Off";
        if (_licensePlateOptions != null)
        {
            _licensePlateOptions.DrawingOptions.EnableOverlays = _hasOverlay;
        }
    }

    private void OnToggleResolution(object? sender, EventArgs e)
    {
        _currentResolution = _currentResolution switch
        {
            LPRResolution.R320  => LPRResolution.R640,
            LPRResolution.R640  => LPRResolution.R1280,
            _                   => LPRResolution.R320
        };
        ResolutionText = _currentResolution switch
        {
            LPRResolution.R640  => "Res: 640x640",
            LPRResolution.R1280 => "Res: 1280x1280",
            _                   => "Res: 320x320"
        };
        ChangeToNewRecognitionType();
    }

    private void OnToggleTextResolution(object? sender, EventArgs e)
    {
        _currentTextResolution = _currentTextResolution switch
        {
            LPRTextResolution.R320n    => LPRTextResolution.R320s,
            LPRTextResolution.R320s    => LPRTextResolution.R640n,
            LPRTextResolution.R640n    => LPRTextResolution.RSplit512,
            _                          => LPRTextResolution.R320n
        };
        TextResolutionText = _currentTextResolution switch
        {
            LPRTextResolution.R320s    => "Mod s: 320x320",
            LPRTextResolution.R640n    => "Mod n: 640x640",
            LPRTextResolution.RSplit512 => "Split: 160x512",
            _                          => "Mod n: 320x320"
        };
        ChangeToNewRecognitionType();
    }

    private void OnToggleHasCountryDetection(object? sender, EventArgs e)
    {
        _hasCountryDetection = !_hasCountryDetection;
        CountryText = _hasCountryDetection ? "C: On" : "C: Off";
        ChangeToNewRecognitionType();
    }

    private void OnToggleHasVehicleDetection(object? sender, EventArgs e)
    {
        _hasVehicleDetection = !_hasVehicleDetection;
        VehicleText = _hasVehicleDetection ? "V: On" : "V: Off";
        ChangeToNewRecognitionType();
    }

    // --- Runtime option swap ---

    private void ChangeToNewRecognitionType()
    {
        var recognitionOptions = LPRRecognitionOptionHelper.PrepareOptions(
            _currentResolution, _currentTextResolution,
            _hasCountryDetection, _hasVehicleDetection, _hasOverlay);

        RecognitionView.StopContinuousRecognition();
        RecognitionView.CloseCamera();

        foreach (var option in _options)
        {
            RecognitionView.RemoveOption(option);
        }

        _options = recognitionOptions;

        foreach (var option in recognitionOptions)
        {
            RecognitionView.AddOption(option);
        }

        RecognitionView.OpenCamera();
        RecognitionView.StartContinuousRecognition();
    }

    // --- Model download events ---

    private void OnModelsDownloadStarted(int noOfModels)
    {
        Console.WriteLine($"Start downloading {noOfModels} license plate models");
    }

    private void OnModelLoadingProgress(long readBytes, int indexOfModel, int noOfModels)
    {
        Console.WriteLine($"Model ({indexOfModel}/{noOfModels}) downloading – {readBytes} bytes read");
    }

    private void OnModelsDownloaded(bool success, int noOfModels)
    {
        Console.WriteLine($"Finished downloading {noOfModels} models – success={success}");
        _licensePlateOptions!.ModelsDownloadStartedEvt  -= OnModelsDownloadStarted;
        _licensePlateOptions.PreparationProgressEvt     -= OnModelLoadingProgress;
        _licensePlateOptions.ModelsDownloadedEvt        -= OnModelsDownloaded;
    }

    // --- Helpers ---

    private void DefineTorchText()         => TorchText             = RecognitionView.TorchIsOn ? "Torch Off" : "Torch On";
    private void DefineContRecognitionText() => ContinuousCaptureText = RecognitionView.IsRecognizing ? "Stop Cont." : "Start Cont.";

    // --- Fields ---

    private LPRResolution      _currentResolution      = LPRResolution.R320;
    private LPRTextResolution  _currentTextResolution  = LPRTextResolution.R320n;
    private bool               _hasVehicleDetection    = true;
    private bool               _hasCountryDetection    = true;
    private bool               _hasOverlay             = true;

    private List<RecognitionOptions>                    _options;
    private LicensePlateDetectionRecognitionOptions?    _licensePlateOptions;
}

Step-by-step explanation

  1. Choose a recognition mode

    • The mode is derived from two boolean flags: isCountryMode (enables country-code extraction) and isVehicleDetection (enables vehicle-type classification). The four combinations map directly to LicensePlateDetectionRecognitionOptions.RecognitionModeType.
  2. Select model sizes

    • Three independent model groups are configured: the plate-detection model (LPModelType), the text-reading model (LPTextModelType), and the vehicle-detection model (VehicleModelType). Larger sizes (640 / 1280) improve accuracy at the cost of speed.
  3. Prepare models

    • ClearOldModelsFromCacheFolder() removes stale cached versions.
    • CreateAndPrepareModelsAsync() downloads and loads the models. Subscribe to ModelsDownloadStartedEvt, PreparationProgressEvt, and ModelsDownloadedEvt to track download progress.
  4. Configure overlay drawing

    • AddLicensePlateRecognitionOverlayDrawingOption() enables bounding-box overlays for both the plate and the vehicle with configurable colours and stroke widths.
  5. Register options in the RecognitionView

    • Each RecognitionOptions object is registered via RecognitionView.AddOption(...) in the page constructor.
  6. Open camera and start recognition

    • OnCameraOpened is called once the camera is ready. Continuous recognition is started there automatically so the page immediately begins scanning.
  7. Display results

    • OnImageRecognized extracts RecognitionProcessorLicensePlateDetectionResult from the result. It logs plate number, confidence, country code, and vehicle type, and copies the cropped plate bitmap to an Image control on the main thread.
  8. Change options at runtime

    • ChangeToNewRecognitionType demonstrates how to swap the active recognition options: stop recognition, close the camera, remove all old options, add new ones, then reopen the camera.

Optional: stabilize results with LicensePlateTemporalTracker

The tracker is included in the LPR package. Keep a tracker for the page lifetime and feed it every recognition callback, including callbacks with no detections. Use its stable result event for UI output instead of displaying each raw OCR result.

using CapCognition.Maui.LPR.CapCognitionLicensePlateTracking;

// Set false to keep displaying raw per-frame recognition results.
private const bool UseTemporalTracker = true;
private LicensePlateTemporalTracker? _tracker;

private void InitializeTracking()
{
    if (!UseTemporalTracker) return;

    _tracker = new LicensePlateTemporalTracker(
        LicensePlateTrackerOptions.Create(LicensePlateTrackingMode.CarDetection));
    _tracker.ResultChanged += OnStableResultChanged;
    RecognitionView.RecognitionExecutedEvt += OnRecognitionExecuted;
}

private void OnRecognitionExecuted(RecognitionResult? result, SKBitmap bitmap)
{
    try
    {
        _tracker?.Process(bitmap, result ?? new RecognitionResult());
    }
    finally
    {
        // The tracker copies retained images; release the callback's plate crops.
        if (result != null)
        {
            foreach (var plate in result.Results.OfType<RecognitionProcessorLicensePlateDetectionResult>())
                plate.PlateBitmap?.Dispose();
        }
    }
}

private void OnStableResultChanged(
    LicensePlateTemporalTracker tracker,
    StableLicensePlateResult result)
{
    MainThread.BeginInvokeOnMainThread(() =>
    {
        using (result)
        {
            StablePlateLabel.Text = result.State == StableLicensePlateState.Removed
                ? "—"
                : result.PlateNumber ?? "—";
        }
    });
}

protected override void OnDisappearing()
{
    if (_tracker != null)
    {
        RecognitionView.RecognitionExecutedEvt -= OnRecognitionExecuted;
        _tracker.ResultChanged -= OnStableResultChanged;
        _tracker.Dispose();
        _tracker = null;
    }

    RecognitionView.CloseCamera();
    base.OnDisappearing();
}

Call InitializeTracking() once after InitializeComponent() and replace the sample's raw-result handler with this callback while tracking is enabled. Add a Label named StablePlateLabel to display the stabilized plate. Choose ParkingOfficer, Barrier, or CarDetection to match the vehicle lifecycle; LicensePlateTrackerOptions.Create(mode) supplies starting thresholds.