Skip to content
Document DB v12 - Improved Interceptors with Soft Delete Integration, AI protections, & Admin UI with Aspire Integration! How!?

MAUI Controls

Frameworks
.NET MAUI
Operating Systems
Android
iOS
macOS

Shiny.FaceIntelligence.Maui targets net10.0-android, net10.0-ios and net10.0-maccatalyst — the camera frame arrives as a native buffer, so this is the one package with per-platform code. Windows is not currently implemented.

Terminal window
dotnet add package Shiny.FaceIntelligence.Maui
// The analyzer holds per-camera state, so register it transient.
builder.Services.AddTransient<FaceRecognitionAnalyzer>();

Both controls resolve their services from Handler.MauiContext.Services, so there is nothing to inject and nothing to pass in.

PlatformRequired
iOS / Mac CatalystNSCameraUsageDescription in Info.plist
AndroidCAMERA permission in AndroidManifest.xml

The controls request the runtime permission themselves (CameraView.RequestPermissionAsync) and raise CameraFailed when it is refused — but a missing manifest entry is not a refusal. On iOS the app is killed outright the moment the camera is touched; on Android the request returns denied without ever prompting.

Recognition wants one fast, confident answer. Enrollment wants a diverse gallery, and needs steps, progress and instructions. A single control with a Mode flag would leave half its API dead in either mode.

xmlns:fi="clr-namespace:Shiny.FaceIntelligence.Maui;assembly=Shiny.FaceIntelligence.Maui"
<fi:FaceRecognitionView FaceRecognized="OnRecognized" CameraFailed="OnCameraFailed" />
<fi:FaceEnrollmentView PersonIdentifier="{Binding EmployeeId}"
Completed="OnEnrolled"
Rejected="OnRejected"
Progress="OnProgress" />

A drop-in recognition camera: preview, permission, start/stop lifecycle and the analyzer, behind one control.

MemberNotes
FaceRecognizedFires for every attempt, including no-match, so the UI can say “Unknown” instead of holding a stale name. Raised on the UI thread.
CameraFailedPermission refused or hardware error, with the reason.
FacingCameraFacing.Front by default. Bindable.
RecognitionEnabledBindable. Turn the expensive pipeline off while leaving the preview running.
HasFaceWhether the analyzer currently has a face.
AnalyzerThe underlying FaceRecognitionAnalyzer, for tuning (below).
EnrollAsync(id, allowDuplicate)One-off single-shot capture off the current frame.
void OnRecognized(object? sender, FaceRecognizedEventArgs e)
{
// e.Result (RecognitionResult), e.Bounds (normalized RectF), e.Confidence
this.Status = e.Result.IsMatch ? $"Hello {e.Result.PersonIdentifier}" : "Unknown";
}

Detection runs on every frame (it’s cheap) to drive the overlay box; the full embed-and-query pipeline runs only once the face has held steady, and is throttled after that.

PropertyDefaultWhat it does
StabilityFrames3Frames the box must stay put before recognizing.
StabilityTolerance0.05How much it may move (fraction of the frame) and still count as steady.
RecognitionInterval2 sMinimum gap between full recognitions.
MaxAnalysisWidth720Frames are downscaled to this width. Cost scales with it, not with sensor size.
MinConfidence0.7Detector confidence floor for the analyzer.
MatchColor / UnknownColor / UnknownTextgreen / red / "Unknown"Overlay appearance.

The camera pipeline runs analyzers max-one-in-flight and drops frames while busy, so the analyzer self-paces — it can take a whole frame interval without backing the camera up.

A guided, multi-shot wizard. Set PersonIdentifier, call BeginEnrollment(), and it walks the person through a sequence of steps, capturing one accepted shot per step.

this.Enrollment.BeginEnrollment();
void OnEnrolled(object? sender, FaceEnrollmentResult e)
{
// e.People — one Person document per accepted shot
// e.MinPairwiseDistance — how tightly clustered the gallery is
// e.SkippedSteps — steps that timed out; non-zero is normal
}
EventPayload
ProgressFaceEnrollmentProgress(StepIndex, StepCount, Instruction, CapturedCount)
RejectedFaceEnrollmentRejected(Reason, Hint) — a ready-to-show hint per rejected frame
CompletedFaceEnrollmentResult
CameraFailedReason string

FaceEnrollmentRejection covers NoFace, TooFar, TooClose, NotSteady, TooBlurry, TooDark, TooBright and TooSimilar.

GateVerifiable?Notes
Fitting the target oval (FaceGuide)yesPosition and size — the primary gate
Face size vs frame (MinFaceFraction / MaxFaceFraction)yesCoarser distance gate
Steadiness (RequiredStableFrames, default 3)yesReuses the analyzer’s stability counter
Sharpness + brightness (MinSharpness 8, MinBrightness 0.18, MaxBrightness 0.92)yesVariance-of-Laplacian at a fixed working size, so the threshold is scale-independent
Novelty (MinNoveltyDistance, default 0.18)yesEmbeds the candidate and rejects it if it’s within that distance of a shot already captured
Head anglenoInstructed only

Each step can carry a FaceGuide — a target oval drawn over the preview. Everything outside it is dimmed, the outline is amber-dashed when off-target and solid green the moment the face fits, and the live detection is drawn faintly so the person can see which way to move. FaceGuide.Correction(...) turns a miss into a directional hint (“Move left into the outline”, “Move closer — fill the outline”).

this.Enrollment.Steps =
[
new FaceEnrollmentStep("Fit your face in the outline", Guide: new FaceGuide(0.50f, 0.50f, 0.55f)),
new FaceEnrollmentStep("Now move into the outline on the left", Guide: new FaceGuide(0.30f, 0.48f, 0.52f)),
new FaceEnrollmentStep("And the outline on the right", Guide: new FaceGuide(0.70f, 0.48f, 0.52f))
];

Moving the target around the frame is how the wizard gets genuine pose variation: the person physically moves, so the camera sees them from a different angle — verifiable, unlike an instruction to turn their head. That’s why FaceEnrollmentStep.Default is a sequence of outline positions (centre, left, right, top, big, small) rather than angle prompts.

  • StepCountdown (3 s) shows “Get ready… 3 / 2 / 1” then “Hold still…”, and captures are only accepted after it. Without it the wizard fires before the instruction can be read and just takes N shots of whatever was already in frame.
  • StepTimeout (12 s) skips a step that can’t be satisfied — “move back” is unreachable with a phone at arm’s length — and reports it in FaceEnrollmentResult.SkippedSteps.
  • Storage is incremental. Each accepted shot is enrolled immediately rather than batched to the end, so one impossible step doesn’t discard the five shots already captured. Consequently CancelEnrollment() does not undo anything — use Forget(id) for that.

Per-frame cost stays off the UI thread and single-flight: the cheap geometric gates run inline, and only once they pass does the control decode, measure and embed on a background thread.

Use FaceRecognitionAnalyzer with CameraView.Analyzer directly when you need a camera configured in ways the wrapper doesn’t expose. It exposes FaceDetected, FaceLost and FaceRecognized events plus LastFace, the AnalyzedFace record carrying the JPEG bytes, the pixel FaceBox, and ImageWidth/ImageHeight (hence Aspect) so an overlay can reproduce the AspectFill transform.

The per-platform frame converter produces an upright, mirror-corrected bitmap, and everything — the detector’s pixel FaceBox, the embed crop, and the normalized overlay rect — is expressed against that single bitmap. Rotation and mirroring are applied once, up front. This is what removes the classic normalized-versus-pixel and front-camera-mirroring bugs.

  • Apple — the frame is already a managed BGRA copy, so it’s a straight memory copy into a bitmap. No CGImage round-trip.
  • Android — CameraX YUV_420_888 is converted in managed code (BT.601, honouring row/pixel strides), subsampling straight to MaxAnalysisWidth rather than converting full-res and then resizing.