Introduction
In my previous article, Step-by-Step Guide: Build a CRUD Blazor App with Entity Framework and PostgreSQL, we focused on data basics and CRUD flow.
In this article, we move to a practical AI patrol scenario: connect a Blazor web app to a device camera, send image frames to backend APIs, and display match alerts in real time. We will also explain how Blazor interacts with JavaScript, the face recognition logic behind the current demo, and the SQL DbStrategy design.
The project is now renamed to AIPatrolCamera.
This demo is built with .NET 10 and can be developed with VS Code.
Prerequisites
Before we begin, make sure you have:
- .NET 10 SDK installed
- VS Code + C# Dev Kit extension
- A modern browser (Chrome/Edge recommended)
- PostgreSQL (or use SQLite fallback from configuration)
- Basic understanding of Blazor components and dependency injection
Step 1: Open and run AIPatrolCamera in VS Code
Open terminal in VS Code:
cd AIPatrolCamera
dotnet restore
dotnet run
If startup succeeds, open the URL shown in the terminal and navigate to /patrol.
Step 2: Understand the camera page in Blazor
The page is implemented in Components/Pages/Patrol.razor.
Key UI elements:
-
<video id="video">for live camera preview -
<canvas id="canvas" class="d-none">for snapshot capture - A start button that triggers patrol mode
- An alert box shown when backend detects a match
Patrol page uses:
@rendermode InteractiveServer-
IJSRuntimefor JS interop -
[JSInvokable]methodShowAlert(...)so JavaScript can call back into .NET
This is the core bridge between Blazor UI and browser camera APIs.
Detailed coding (Components/Pages/Patrol.razor)
@page "/patrol"
@rendermode InteractiveServer
@implements IAsyncDisposable
<PageTitle>Patrol</PageTitle>
<h1>AI Patrol Mode</h1>
<video id="video" autoplay playsinline class="patrol-video"></video>
<canvas id="canvas" class="d-none"></canvas>
@if (alertVisible)
{
<div class="alert-box" role="alert" aria-live="assertive">
<h2>⚠ MATCH FOUND</h2>
<p><strong>Name:</strong> @alertName</p>
<p><strong>Risk:</strong> @alertRisk</p>
<p><strong>Action:</strong> @alertInstruction</p>
</div>
}
<button class="btn btn-danger mt-3" @onclick="StartPatrolAsync">Start Patrol</button>
@code {
private bool alertVisible;
private string alertName = string.Empty;
private string alertRisk = string.Empty;
private string alertInstruction = string.Empty;
private DotNetObjectReference<Patrol>? dotNetReference;
private bool isInteractive;
[Inject]
private IJSRuntime JS { get; set; } = default!;
protected override void OnAfterRender(bool firstRender)
{
if (firstRender)
{
isInteractive = true;
}
}
private async Task StartPatrolAsync()
{
dotNetReference ??= DotNetObjectReference.Create(this);
await JS.InvokeVoidAsync("patrolCamera.start", dotNetReference);
}
[JSInvokable]
public Task ShowAlert(string name, string riskLevel, string instruction)
{
alertName = name;
alertRisk = riskLevel;
alertInstruction = instruction;
alertVisible = true;
StateHasChanged();
return Task.CompletedTask;
}
public async ValueTask DisposeAsync()
{
if (!isInteractive)
{
dotNetReference?.Dispose();
return;
}
try
{
await JS.InvokeVoidAsync("patrolCamera.stop");
}
catch (JSDisconnectedException)
{
}
dotNetReference?.Dispose();
}
}
Step 3: Understand Blazor ↔ JavaScript interop flow
In Patrol.razor, when user clicks Start Patrol, Blazor executes:
await JS.InvokeVoidAsync("patrolCamera.start", dotNetReference);
This calls JavaScript function window.patrolCamera.start(...) in wwwroot/js/patrolCamera.js.
Inside JS:
- Request camera stream from browser:
navigator.mediaDevices.getUserMedia({ video: true })
- Bind stream to
<video> - Start timer (
setInterval) every 3 seconds - Draw current frame to canvas
- Convert frame to Base64 image with
canvas.toDataURL("image/jpeg") - POST to backend API
/api/detect - If match found, call C# method:
dotNetHelper.invokeMethodAsync("ShowAlert", ...)
In short:
- Blazor calls JS to access browser-only capability (camera)
- JS calls Blazor to update component state (alert rendering)
This two-way interop is the most important pattern when working with hardware features in web apps.
Detailed coding (wwwroot/js/patrolCamera.js)
window.patrolCamera = (() => {
let dotNetHelper = null;
let stream = null;
let captureTimer = null;
async function start(helper) {
dotNetHelper = helper;
const video = document.getElementById("video");
if (!video) {
return;
}
if (captureTimer) {
clearInterval(captureTimer);
captureTimer = null;
}
stream = await navigator.mediaDevices.getUserMedia({ video: true });
video.srcObject = stream;
captureTimer = setInterval(captureFrame, 3000);
}
async function captureFrame() {
const video = document.getElementById("video");
const canvas = document.getElementById("canvas");
if (!video || !canvas || !dotNetHelper || video.videoWidth === 0 || video.videoHeight === 0) {
return;
}
const context = canvas.getContext("2d");
if (!context) {
return;
}
canvas.width = video.videoWidth;
canvas.height = video.videoHeight;
context.drawImage(video, 0, 0);
const image = canvas.toDataURL("image/jpeg");
const response = await fetch("/api/detect", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ image })
});
if (!response.ok) {
return;
}
const result = await response.json();
if (result?.match) {
await dotNetHelper.invokeMethodAsync(
"ShowAlert",
result.name ?? "Unknown",
result.riskLevel ?? "Unknown",
result.instruction ?? "Observe and Report");
}
}
function stop() {
if (captureTimer) {
clearInterval(captureTimer);
captureTimer = null;
}
if (stream) {
for (const track of stream.getTracks()) {
track.stop();
}
stream = null;
}
dotNetHelper = null;
}
return { start, stop };
})();
Also ensure JS file is loaded in Components/App.razor:
<script src="@Assets["js/patrolCamera.js"]"></script>
<script src="@Assets["_framework/blazor.web.js"]"></script>
Step 4: Explain camera permission and authority best practices
When your page calls getUserMedia, browser asks user permission to use camera.
Please remind readers:
- Always request camera access only after explicit user action (for example clicking Start Patrol)
- Explain clearly why camera is needed
- Provide a visible stop action and actually stop tracks (
track.stop()) - Use HTTPS in non-local environments (camera APIs are restricted in insecure contexts)
- Avoid capturing or sending frames when patrol is not active
- Do not store sensitive data unless users have agreed
Good authority handling improves trust and keeps your app aligned with privacy expectations.
Step 5: Backend detection endpoint and current demo logic
Backend API is Controllers/DetectController.cs:
- Accepts
ImageRequest - Calls
FaceRecognitionService.DetectAsync(...) - Returns
DetectResponse
Current FaceRecognitionService is a demo implementation:
- Decodes incoming Base64 image
- Reads first person from database
- Returns a simulated match response
So right now, it is a pipeline demo (camera → API → UI alert), not real AI face embedding comparison yet.
This is still useful because architecture and data flow are already valid.
Detailed coding (Controllers/DetectController.cs)
[ApiController]
[Route("api/[controller]")]
public class DetectController(FaceRecognitionService faceRecognitionService) : ControllerBase
{
[HttpPost]
public async Task<IActionResult> Detect([FromBody] ImageRequest request, CancellationToken cancellationToken)
{
if (request is null)
{
return BadRequest();
}
var result = await faceRecognitionService.DetectAsync(request.Image, cancellationToken);
return Ok(result);
}
}
Detailed coding (Services/FaceRecognitionService.cs) - current demo behavior
public class FaceRecognitionService(AppDbContext dbContext)
{
public async Task<DetectResponse> DetectAsync(string imageData, CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(imageData))
{
return new DetectResponse { Match = false };
}
var imageBytes = TryDecodeBase64(imageData);
if (imageBytes is null || imageBytes.Length == 0)
{
return new DetectResponse { Match = false };
}
var firstKnownPerson = await dbContext.People
.AsNoTracking()
.OrderBy(x => x.Id)
.FirstOrDefaultAsync(cancellationToken);
if (firstKnownPerson is null)
{
return new DetectResponse
{
Match = true,
Name = "Test Person",
RiskLevel = "Medium",
Instruction = "Observe and Report"
};
}
return new DetectResponse
{
Match = true,
Name = firstKnownPerson.Name,
RiskLevel = firstKnownPerson.RiskLevel,
Instruction = "Observe and Report"
};
}
}
Step 6: SQL DbStrategy design explained
AIPatrolCamera uses strategy pattern for DB provider selection:
IDbProviderStrategyPostgresDbProviderStrategySqliteDbProviderStrategyDbProviderStrategyResolver
Program.cs registers both strategies and resolves one by config key:
"Database": {
"Provider": "Postgres"
}
Why this DbStrategy is good:
- No hardcoded provider in startup logic
- Easy switching between PostgreSQL and SQLite
- Good for local demo vs deployment environment
- Keeps EF Core configuration clean and extensible
In this project, AppDbContext currently contains a People table with fields like:
NameRiskLevel-
FaceEmbedding(byte[] placeholder for model output)
This aligns with patrol use case and future AI model integration.
Detailed coding (Data/DbStrategy/*)
public interface IDbProviderStrategy
{
string ProviderName { get; }
void Configure(DbContextOptionsBuilder optionsBuilder, IConfiguration configuration);
}
public class PostgresDbProviderStrategy : IDbProviderStrategy
{
public string ProviderName => "Postgres";
public void Configure(DbContextOptionsBuilder optionsBuilder, IConfiguration configuration)
{
var connectionString = configuration.GetConnectionString("DefaultConnection")
?? throw new InvalidOperationException("ConnectionStrings:DefaultConnection is required for Postgres provider.");
optionsBuilder.UseNpgsql(connectionString);
}
}
public class SqliteDbProviderStrategy : IDbProviderStrategy
{
public string ProviderName => "Sqlite";
public void Configure(DbContextOptionsBuilder optionsBuilder, IConfiguration configuration)
{
var connectionString = configuration.GetConnectionString("SqliteConnection")
?? "Data Source=AIPatrolCamera.db";
optionsBuilder.UseSqlite(connectionString);
}
}
public class DbProviderStrategyResolver(IEnumerable<IDbProviderStrategy> strategies, IConfiguration configuration)
{
public IDbProviderStrategy Resolve()
{
var configuredProvider = configuration["Database:Provider"] ?? "Postgres";
var strategy = strategies.FirstOrDefault(s =>
string.Equals(s.ProviderName, configuredProvider, StringComparison.OrdinalIgnoreCase));
return strategy ?? throw new InvalidOperationException($"Unsupported Database:Provider '{configuredProvider}'.");
}
}
And in Program.cs:
builder.Services.AddSingleton<IDbProviderStrategy, SqliteDbProviderStrategy>();
builder.Services.AddSingleton<IDbProviderStrategy, PostgresDbProviderStrategy>();
builder.Services.AddSingleton<DbProviderStrategyResolver>();
builder.Services.AddDbContext<AppDbContext>((serviceProvider, options) =>
{
var strategyResolver = serviceProvider.GetRequiredService<DbProviderStrategyResolver>();
var strategy = strategyResolver.Resolve();
strategy.Configure(options, builder.Configuration);
});
Step 7: Running the App
Run the app:
dotnet run
What to do next - real image detection with ONNX in ML.NET
The next milestone is replacing demo logic in FaceRecognitionService with real model inference.
Target direction:
- Add ONNX face detection/recognition model to project
- Load model through ML.NET pipeline
- Preprocess camera frame (resize/normalize/tensor conversion)
- Run inference to get embedding or prediction scores
- Compare with stored embeddings in database
- Apply threshold for match decision
- Return confidence + risk-based instruction
The project already references Microsoft.ML.OnnxRuntime, so you have a good starting point.
When this part is done, patrol mode becomes true AI detection instead of static demo output.
Detailed coding skeleton for next step (ONNX + ML.NET direction)
Use this as a reference implementation structure for your next article:
public class FaceRecognitionService(AppDbContext dbContext)
{
public async Task<DetectResponse> DetectAsync(string imageData, CancellationToken cancellationToken)
{
var imageBytes = DecodeImage(imageData);
if (imageBytes.Length == 0)
{
return new DetectResponse { Match = false };
}
// 1) preprocess image -> tensor
// 2) run ONNX inference -> embedding
// 3) compare embedding with dbContext.People FaceEmbedding
// 4) pick best score above threshold
var threshold = 0.80f;
var bestMatch = await dbContext.People.AsNoTracking().FirstOrDefaultAsync(cancellationToken);
if (bestMatch is null)
{
return new DetectResponse { Match = false };
}
return new DetectResponse
{
Match = true,
Name = bestMatch.Name,
RiskLevel = bestMatch.RiskLevel,
Instruction = "Observe and Report"
};
}
}
This keeps your readers focused on architecture first, then model details in the next deep-dive post.
Suggested mini roadmap for readers
If your readers want an implementation order, you can suggest:
- Stabilize current camera capture interval and error handling
- Add logging for detection latency and API response time
- Implement ONNX inference in
FaceRecognitionService - Add confidence threshold configuration in
appsettings.json - Improve data model for multiple embeddings per person
- Add audit table for detection events
This keeps learning progressive and practical.
Conclusion
In this article, we focused on three core ideas:
- How Blazor connects to a device camera through JavaScript interop
- How the current face recognition flow works end-to-end in AIPatrolCamera
- How SQL DbStrategy keeps database provider selection clean and flexible
And most importantly, we prepared a clear next step: move from demo service logic to real ONNX-based image detection with ML.NET.
If you already finished the previous CRUD tutorial, this is a strong bridge from data-driven Blazor apps to AI-enabled real-world scenarios.


Top comments (0)