
Face Detection and Face Verification in ASP.NET Web API with pgvector and Blazor WASM
Author - Abdul Rahman (Bhai)
Blazor
35 Articles
Table of Contents
What we gonna do?
Your phone unlocks the moment it sees your face. But how do you build the same thing — reliable, production-grade face recognition — entirely in .NET, without wrapping a Python service or calling a paid cloud API? In this article, we build a complete face mesh attendance system from scratch: a Blazor WASM frontend that captures 478 facial landmarks directly in the browser, a pgvector-backed ASP.NET Core API that stores and searches those vectors in sub-milliseconds, and a liveness detection layer that stops photos and screens from tricking the system.
Face Mesh is a lightweight neural network (originally developed by Google MediaPipe) that locates 478 three-dimensional landmarks on a detected face in real time. Each landmark has an (x, y) coordinate normalised to the inter-ocular distance (IOD), producing a 956-element float vector per frame (478 landmarks × x, y). The Z-coordinate — which encodes facial depth — is used separately by the liveness detection layer and is intentionally excluded from the recognition vector. That 956-element vector is the facial biometric template we store, search, and match.
By the end of this article you will understand how to build the enrollment and check-in pipeline end-to-end, configure an HNSW nearest-neighbour index with pgvector for fast vector similarity search, integrate a JS-interop face mesh component into a Blazor WASM app, and add multi-mode liveness detection to prevent spoofing attacks.
Why we gonna do?
Every office with a sign-in sheet has the same problem: nobody trusts it. Manual attendance records are forgeable in seconds, badge swipes are shareable, and the five-minute window at the start of a class or shift sees people signing in for absent colleagues — a practice known as buddy punching.
Buddy punching costs US employers an estimated $373 million per year according to the American Payroll Association. Beyond the financial loss, incorrect attendance data flows downstream into payroll, compliance reports, and emergency muster lists — where a single wrong entry can have serious consequences. Traditional biometric solutions (fingerprint scanners, iris readers, proprietary access hardware) solve this but introduce procurement costs, vendor lock-in, and hardware maintenance.
Face mesh attendance removes all of that. The camera is already built into every laptop and phone. The inference runs entirely in the browser via WebAssembly — no images or video ever leave the device; only a compact 956-element float array is sent to the API. The pgvector extension turns a standard PostgreSQL database into a vector search engine with a single HNSW index, delivering sub-millisecond L2 nearest-neighbour lookups regardless of how many enrolled persons are in the database. And the entire backend and frontend are written in C# — no Python, no ML framework installed on the server.
Why pgvector Instead of a Dedicated Vector Database?
Dedicated vector databases like Pinecone and Weaviate are powerful tools, but they add a new operational dependency to your stack. If you already run PostgreSQL — and most .NET shops do — the pgvector extension gives you vector storage and approximate nearest-neighbour search without a new database engine to provision, monitor, or back up. Entity Framework Core talks to it through the Pgvector.EntityFrameworkCore NuGet package using the familiar DbContext API you already know.
Why Liveness Detection Matters
Without liveness detection, a printed photo or a phone screen showing someone's face can fool a face recognition system. The demo supports four configurable modes that trade friction for security: passive Z-depth analysis (detects the flat geometry of a photo or screen without requiring the user to do anything), active blink challenge (requires a deliberate blink, defeating still images), both combined, or none for low-stakes scenarios. This is production-ready anti-spoofing at zero additional hardware cost.
How we gonna do?
System Architecture at a Glance
The system has three layers. The browser runs a FaceMesh JavaScript component (built on MediaPipe) that streams landmark coordinates to a Blazor WASM page via a .NET callback. The Blazor page calls the ASP.NET Core minimal API with the captured vector. The API persists the vector to PostgreSQL + pgvector on enrollment, and runs an L2 nearest-neighbour query at check-in time.
┌──────────────────────────────────────────────────┐
│ Browser │
│ ┌──────────────────────────────────────────┐ │
│ │ MediaPipe FaceMesh (WASM) │ │
│ │ → 478 landmarks → 956-element float[] │ │
│ └────────────────────┬─────────────────────┘ │
│ ┌──────────────────── ▼ ───────────────────┐ │
│ │ Blazor WASM (C#) │ │
│ │ Enroll.razor / Attendance.razor │ │
│ └────────────────────┬─────────────────────┘ │
└───────────────────────│──────────────────────────┘
│ HTTP (JSON)
▼
┌─────────────────────────────-------┐
│ ASP.NET Core Minimal API │
│ POST /api/mesh/persons/enroll. │
│ POST /api/mesh/attendance/checkin │
│ GET /api/mesh/persons │
│ GET /api/mesh/attendance │
└──────────────┬──────────────-------┘
│ EF Core
▼
┌──────────────────────────────┐
│ PostgreSQL + pgvector │
│ mesh_persons (vector(956)) │
│ HNSW index (L2 distance) │
│ mesh_attendance_records │
└──────────────────────────────┘
Step 1: Set Up the API Project
Create an ASP.NET Core minimal API project targeting net10.0 and add the required NuGet packages.
dotnet new webapi -n FaceMeshAttendanceApi
cd FaceMeshAttendanceApi
dotnet add package Npgsql.EntityFrameworkCore.PostgreSQL --version 10.0.2
dotnet add package Pgvector.EntityFrameworkCore --version 0.3.0
Step 2: Define the EF Core Entities and DbContext
Two tables drive the system. mesh_persons holds the enrolled biometric vectors, and mesh_attendance_records records every check-in event. The person's vector column is typed as vector(956) — the native pgvector type that enables index-accelerated distance queries.
using Microsoft.EntityFrameworkCore;
using Pgvector;
using Pgvector.EntityFrameworkCore;
// ── EF Core entities ──────────────────────────────────────────────────────
public class MeshPersonEntity
{
public Guid Id { get; set; }
public string Name { get; set; } = "";
public Vector Descriptor { get; set; } = null!; // vector(956) in Postgres
public DateTime EnrolledAt { get; set; }
public ICollection<MeshAttendanceRecordEntity> AttendanceRecords { get; set; } = [];
}
public class MeshAttendanceRecordEntity
{
public Guid Id { get; set; }
public Guid PersonId { get; set; }
public MeshPersonEntity Person { get; set; } = null!;
public string PersonName { get; set; } = "";
public DateTime CheckedInAt { get; set; }
}
// ── DbContext ─────────────────────────────────────────────────────────────
public class FaceMeshDbContext(DbContextOptions<FaceMeshDbContext> options)
: DbContext(options)
{
public DbSet<MeshPersonEntity> Persons => Set<MeshPersonEntity>();
public DbSet<MeshAttendanceRecordEntity> AttendanceRecords => Set<MeshAttendanceRecordEntity>();
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.HasPostgresExtension("vector");
modelBuilder.Entity<MeshPersonEntity>(e =>
{
e.ToTable("mesh_persons");
e.Property(p => p.Descriptor).HasColumnType("vector(956)");
// HNSW index for sub-millisecond nearest-neighbour search
e.HasIndex(p => p.Descriptor)
.HasMethod("hnsw")
.HasOperators("vector_l2_ops");
});
modelBuilder.Entity<MeshAttendanceRecordEntity>(e =>
{
e.ToTable("mesh_attendance_records");
e.HasOne(r => r.Person)
.WithMany(p => p.AttendanceRecords)
.HasForeignKey(r => r.PersonId);
});
}
}
The HNSW (Hierarchical Navigable Small World) index is the key to performance. Unlike a brute-force table scan that computes L2 distance to every enrolled person, HNSW builds a multi-layer graph structure that narrows the search logarithmically. With thousands of enrolled persons, HNSW still returns the nearest neighbour in under a millisecond.
Step 3: Configure the API in Program.cs
Wire up the DbContext using the connection string provided by your environment (Aspire injects this as ConnectionStrings__facemesh-db), and enable CORS so the Blazor WASM app can reach the API from the browser.
var builder = WebApplication.CreateBuilder(args);
var connectionString = builder.Configuration.GetConnectionString("facemesh-db")
?? throw new InvalidOperationException(
"Connection string 'facemesh-db' not found.");
builder.Services.AddDbContext<FaceMeshDbContext>(opts =>
opts.UseNpgsql(connectionString, npgsql => npgsql.UseVector()));
builder.Services.AddCors(options =>
options.AddDefaultPolicy(p =>
p.AllowAnyOrigin().AllowAnyMethod().AllowAnyHeader()));
var app = builder.Build();
app.UseCors();
// Auto-create schema on first run (use migrations in production)
using (var scope = app.Services.CreateScope())
{
var db = scope.ServiceProvider.GetRequiredService<FaceMeshDbContext>();
await db.Database.EnsureCreatedAsync();
}
// Map your endpoints here (Steps 4 and 5)
app.Run();
Step 4: Build the Enrollment Endpoint
Enrollment stores a person's name alongside their 956-element landmark vector. The vector is the averaged result of multiple captured frames — more samples means a more stable reference descriptor and fewer false negatives at check-in time.
// POST /api/mesh/persons/enroll
app.MapPost("/api/mesh/persons/enroll",
async (MeshEnrollRequest req, FaceMeshDbContext db) =>
{
if (string.IsNullOrWhiteSpace(req.Name) || req.Vector is not { Length: 956 })
return Results.BadRequest(
"Name and a 956-element landmark vector are required.");
var person = new MeshPersonEntity
{
Id = Guid.NewGuid(),
Name = req.Name.Trim(),
Descriptor = new Vector(req.Vector),
EnrolledAt = DateTime.UtcNow
};
db.Persons.Add(person);
await db.SaveChangesAsync();
return Results.Created(
$"/api/mesh/persons/{person.Id}",
new { person.Id, person.Name });
});
record MeshEnrollRequest(string Name, float[] Vector);
Step 5: Build the Check-In Endpoint
Check-in is where the vector magic happens. The incoming 956-element vector is compared against every enrolled descriptor using L2 distance (Euclidean distance in 956-dimensional space), and the closest match is returned. If that distance exceeds the recognition threshold, the face is unknown. The query is pushed entirely into PostgreSQL via EF Core, so the HNSW index is used — no vectors are loaded into .NET memory for comparison.
// Recognition threshold: L2 distance in IOD-normalised landmark space.
// Within-person variation across frames: ~0.3–0.8
// Between-person variation: ~1.5–4.0
const double RecognitionThreshold = 1.2;
// POST /api/mesh/attendance/checkin
app.MapPost("/api/mesh/attendance/checkin",
async (MeshCheckInRequest req, FaceMeshDbContext db) =>
{
if (req.Vector is not { Length: 956 })
return Results.BadRequest("A 956-element landmark vector is required.");
var vec = new Vector(req.Vector);
// pgvector L2 nearest-neighbour — runs inside Postgres via HNSW index
var best = await db.Persons
.Select(p => new { Person = p, Distance = p.Descriptor.L2Distance(vec) })
.OrderBy(x => x.Distance)
.FirstOrDefaultAsync();
if (best == null || best.Distance > RecognitionThreshold)
return Results.Ok(new MeshCheckInResponse(
false, null, null, (float)(best?.Distance ?? double.MaxValue)));
// Idempotent: one check-in per person per UTC day
var dayStart = DateTime.UtcNow.Date;
var dayEnd = dayStart.AddDays(1);
bool alreadyIn = await db.AttendanceRecords
.AnyAsync(r => r.PersonId == best.Person.Id
&& r.CheckedInAt >= dayStart
&& r.CheckedInAt < dayEnd);
if (!alreadyIn)
{
db.AttendanceRecords.Add(new MeshAttendanceRecordEntity
{
Id = Guid.NewGuid(),
PersonId = best.Person.Id,
PersonName = best.Person.Name,
CheckedInAt = DateTime.UtcNow
});
await db.SaveChangesAsync();
}
return Results.Ok(new MeshCheckInResponse(
true, best.Person.Id, best.Person.Name, (float)best.Distance));
});
record MeshCheckInRequest(float[] Vector);
record MeshCheckInResponse(
bool Recognized,
Guid? PersonId,
string? PersonName,
float Distance);
Notice the idempotency guard: if someone walks through the camera field twice in the same day, the second pass is recognised but no duplicate record is created. This keeps the attendance log clean without any client-side state management.
Step 6: Set Up the Blazor WASM Frontend
The frontend registers one default HttpClient scoped to the app's own base address and one named HttpClient specifically for the attendance API — so that base URLs are configured in one place and every page can simply request the named client by key.
// Program.cs (Blazor WASM)
var apiBaseUrl = builder.Configuration["FaceMeshAttendanceApi:BaseUrl"]
?? "http://localhost:5236";
builder.Services.AddHttpClient("facemesh",
client => client.BaseAddress = new Uri(apiBaseUrl));
Step 7: Build the Enrollment Page
The FaceMesh Razor component wraps the MediaPipe JavaScript library via JS interop. It exposes three key parameters: Mode (enroll or checkin), LivenessMode (the anti-spoofing strategy), and an OnFeatureVectorCaptured event callback. The callback delivers a FeatureVectorResult that carries the averaged 956-element vector, a sample count, and a liveness confirmation flag.
The enrollment page hosts a FaceMesh Razor component set to Mode="enroll". When the component fires OnFeatureVectorCaptured, the page stores the averaged vector and shows a name input field. The user types their name and clicks Register — the vector and name travel to the API as a single JSON payload.
@page "/"
@page "/enroll"
@inject IHttpClientFactory HttpFactory
<FaceMesh @ref="_face"
Mode="enroll"
LivenessMode="@_livenessMode"
OnFeatureVectorCaptured="HandleCapture" />
@if (_capturedVector != null)
{
<div class="card mt-3">
<div class="card-body">
<h5>Face captured — @_capturedSamples sample(s) averaged</h5>
<input placeholder="Full name" @bind="_personName" />
<button @onclick="RegisterPerson"
disabled="@(string.IsNullOrWhiteSpace(_personName) || _busy)">
@(_busy ? "Registering…" : "Register")
</button>
</div>
</div>
}
@code {
private FaceMesh? _face;
private string _livenessMode = "blink";
private float[]? _capturedVector;
private int _capturedSamples;
private string _personName = "";
private bool _busy;
private void HandleCapture(FeatureVectorResult result)
{
if (result.Detected && result.Vector != null)
{
_capturedVector = result.Vector;
_capturedSamples = result.SampleCount;
}
}
private async Task RegisterPerson()
{
if (_capturedVector is null || string.IsNullOrWhiteSpace(_personName)) return;
_busy = true;
StateHasChanged();
var http = HttpFactory.CreateClient("facemesh");
var payload = new { name = _personName.Trim(), vector = _capturedVector };
await http.PostAsJsonAsync("/api/mesh/persons/enroll", payload);
_busy = false;
_personName = "";
_capturedVector = null;
StateHasChanged();
}
}
The FaceMesh component averages multiple frames before firing the event. More frames mean a more stable descriptor — the SampleCount property tells you how many frames were averaged, which you can surface in the UI so the user knows the capture quality.
Step 8: Build the Attendance Check-In Page
The attendance page works in a continuous loop: every 1.5 seconds the FaceMesh component fires OnFeatureVectorCaptured, and if the liveness check is confirmed, the page posts the vector to the check-in endpoint. A recognised face triggers a success alert and refreshes the today's log table. An unrecognised face shows a distance-annotated warning so you know how far off the match was.
@page "/attendance"
@inject IHttpClientFactory HttpFactory
<FaceMesh @ref="_face"
Mode="checkin"
LivenessMode="@_livenessMode"
OnFeatureVectorCaptured="HandleCheckin" />
@code {
private FaceMesh? _face;
private string _livenessMode = "blink";
private bool _processing;
private async Task HandleCheckin(FeatureVectorResult result)
{
if (!result.Detected || result.Vector is null || _processing) return;
// Block until liveness is confirmed when a mode is active
if (_livenessMode != "none" && !(_face?.LivenessConfirmed ?? false)) return;
_processing = true;
try
{
var http = HttpFactory.CreateClient("facemesh");
var payload = new { vector = result.Vector };
var response = await http.PostAsJsonAsync(
"/api/mesh/attendance/checkin", payload);
if (response.IsSuccessStatusCode)
{
var data = await response.Content
.ReadFromJsonAsync<CheckInResponseDto>();
if (data?.Recognized == true)
{
_face?.ResetLiveness();
// Show success alert and refresh log table
}
}
}
finally { _processing = false; }
}
private record CheckInResponseDto(
bool Recognized,
Guid? PersonId,
string? PersonName,
float Distance);
}
Step 9: Understand the Liveness Detection Modes
The FaceMesh component exposes four configurable liveness modes via a dropdown select. Each mode trades user friction against spoofing resistance.
Mode | Mechanism | Defeats
────────────┼────────────────────────────────────┼──────────────────────────
none | No check — live video assumed | Nothing
blink | Active challenge: blink required | Still photos
depth | Passive: Z-depth geometry check | Flat photos, screens
both | Z-depth first, then blink | Photos + screens + video
The depth mode analyses the Z-coordinates of the 478 landmarks. A real face has a characteristic depth distribution — a printed photo or a phone screen has a near-zero Z variance. The blink mode detects the landmark-level eye closure ratio drop and requires it to cross a threshold, which static images and looped video clips cannot satisfy.
After a successful check-in, ResetLiveness() is called on the component so the next person must pass the liveness check independently. Without this reset, a single confirmed liveness event would allow an unlimited number of subsequent check-ins without re-verification.
Step 10: Tune the Recognition Threshold
The threshold of 1.2 is the maximum L2 distance between a probe vector and the closest stored descriptor for recognition to succeed. The choice of 1.2 is empirical and reflects the IOD-normalised landmark space:
Scenario | Typical L2 Distance
──────────────────────────────────────┼────────────────────
Same person, different frames | 0.3 – 0.8
Same person, different lighting | 0.5 – 1.0
Same person, glasses on/off | 0.8 – 1.1
Different people | 1.5 – 4.0
────────────────────────────────────────────────────────────
Recognition threshold | 1.2
If you see false negatives (recognised people being rejected), raise the threshold towards 1.3 – 1.4 to make the system more permissive. If you see false positives (wrong people being accepted), lower it towards 1.0 – 1.1 to make the system stricter.
The distance value is surfaced in both the success and failure responses so you can calibrate against real data from your deployment environment.
Putting It All Together: The Full Flow
ENROLL FLOW
───────────
Browser → FaceMesh JS captures 478 landmarks × N frames
FaceMesh WASM → Averages N frames → 956-element float[]
Enroll.razor → POST /api/mesh/persons/enroll { name, vector }
API → INSERT into mesh_persons (name, vector(956))
→ PostgreSQL creates HNSW index entry
→ Returns 201 Created { id, name }
CHECK-IN FLOW
─────────────
Browser → FaceMesh JS captures landmarks continuously
FaceMesh WASM → Checks liveness → fires OnFeatureVectorCaptured
Attendance.razor→ POST /api/mesh/attendance/checkin { vector }
API → SELECT … ORDER BY descriptor <-> vec LIMIT 1 (HNSW)
→ If distance ≤ 1.2 AND not already checked in today
→ INSERT into mesh_attendance_records
→ Returns { recognized: true, personName, distance }
Summary
In this article we built a complete face mesh attendance system in pure .NET — no Python, no cloud AI APIs, no proprietary hardware. Here are the key takeaways:
- The browser's MediaPipe FaceMesh model produces a 956-element float vector (478 landmarks × x, y, IOD-normalised) that acts as a compact facial biometric template — only this vector is sent to the API, never raw video or images.
- pgvector with an HNSW index turns standard PostgreSQL into a sub-millisecond vector search engine — no dedicated vector database required, no new infrastructure to operate.
- The recognition threshold (L2 distance 1.2) sits between within-person variation (0.3–0.8) and between-person variation (1.5–4.0). Tune it up or down by reading the distance values in real deployment data.
- The idempotent check-in guard (one record per person per UTC day) keeps the attendance log clean without any client-side state.
- Four configurable liveness modes (none, blink, depth, both) let you balance user friction against spoofing resistance for your security context.
- The entire stack runs on net10.0 using only Npgsql.EntityFrameworkCore.PostgreSQL and Pgvector.EntityFrameworkCore on the server side.
If this sparked your interest in ML-powered .NET applications, read AI Powered Image Recognition in .NET with ML.NET and ONNX Runtime to see how to run a ResNet50 model entirely inside a .NET process — another pattern where keeping inference in-process eliminates a network hop and a service boundary.