From a0a72586968a7022a503b390ed9bd60acb3e973e Mon Sep 17 00:00:00 2001 From: Ligreus Date: Sun, 11 Oct 2026 14:27:28 +0500 Subject: [PATCH] Tune recognition from the client and check enrollment quality Recognition threshold, margin and frames in a row are stored on the server and edited in a settings dialog; calibration mode shows the best candidate, similarity and margin. Enrollment rejects blurry, dark, overexposed, distant or near-identical shots and asks before saving a face similar to another student. Enrollments and settings updates are serialized; oversized images are rejected from their header. Fixes #7 Fixes #8 --- BiStudent.Core/Contracts/Contracts.cs | 7 +- BiStudent.Core/FaceEngine.cs | 87 +++++++- BiStudent.Core/ImageHeader.cs | 50 +++++ BiStudent.Server/Endpoints/ApiEndpoints.cs | 11 +- .../Services/RecognitionService.cs | 188 +++++++++++++--- BiStudent.Tests/ImageQualityTests.cs | 55 +++++ BiStudent.Tests/RecognitionSettingsTests.cs | 104 +++++++++ BiStudent/Api/ApiClient.cs | 7 +- BiStudent/Pages/CameraPage.cs | 37 +++- BiStudent/Pages/EnrollmentDialog.cs | 200 ++++++++---------- BiStudent/Pages/RecognitionSettingsDialog.cs | 136 ++++++++++++ 11 files changed, 729 insertions(+), 153 deletions(-) create mode 100644 BiStudent.Core/ImageHeader.cs create mode 100644 BiStudent.Tests/ImageQualityTests.cs create mode 100644 BiStudent.Tests/RecognitionSettingsTests.cs create mode 100644 BiStudent/Pages/RecognitionSettingsDialog.cs diff --git a/BiStudent.Core/Contracts/Contracts.cs b/BiStudent.Core/Contracts/Contracts.cs index 04d230b..c1261cd 100644 --- a/BiStudent.Core/Contracts/Contracts.cs +++ b/BiStudent.Core/Contracts/Contracts.cs @@ -48,14 +48,15 @@ public sealed record ReportDto( int SecondsPerManualMark, int MinutesPerManualReport); public sealed record AutomationSettingsDto(int SecondsPerManualMark, int MinutesPerManualReport, int LateAfterMinutes, int FramesInRow); +public sealed record RecognitionSettingsDto(double MatchThreshold, double MinimumMargin, int FramesInRow); public sealed record RecognizeRequest(int SlotId, DateOnly Date, byte[] Image); public sealed record RecognizedFaceDto(int X, int Y, int W, int H, int? StudentId, string? Name, double Similarity, - bool Marked, bool AlreadyMarked, bool OtherGroup); + bool Marked, bool AlreadyMarked, bool OtherGroup, string? CandidateName = null, double Margin = 0); public sealed record RecognitionResultDto(List Faces, int FrameWidth, int FrameHeight, string? Error = null); -public sealed record EnrollRequest(List Images); -public sealed record EnrollResultDto(bool Ok, string Message); +public sealed record EnrollRequest(List Images, bool AllowSimilar = false); +public sealed record EnrollResultDto(bool Ok, string Message, string? SimilarTo = null, double SimilarScore = 0, List? Problems = null); public sealed record ImportResultDto(int Added, int Skipped, List Errors); public sealed record ErrorDto(string Message); diff --git a/BiStudent.Core/FaceEngine.cs b/BiStudent.Core/FaceEngine.cs index 946535c..9c6a99b 100644 --- a/BiStudent.Core/FaceEngine.cs +++ b/BiStudent.Core/FaceEngine.cs @@ -3,7 +3,10 @@ namespace BiStudent; -public sealed record FaceMatch(OpenCvSharp.Rect Bounds, int? StudentId, double Similarity); +public sealed record FaceMatch(OpenCvSharp.Rect Bounds, int? StudentId, double Similarity, int? CandidateId = null, double Margin = 0); + +public sealed record SampleQuality(bool Ok, string? Problem, int FaceSize, double Sharpness, double Brightness); +public sealed record CapturedSample(byte[] Image, float[] Embedding); public sealed class FaceEngine : IDisposable { @@ -21,8 +24,14 @@ private static readonly (double X, double Y)[] AlignedLandmarks = // OpenCV Zoo uses 0.363 for cosine matching. A slightly higher threshold // and a margin between different students reduce accidental attendance marks. - public const double MatchThreshold = 0.43; - public const double MinimumMargin = 0.06; + public const double DefaultMatchThreshold = 0.43; + public const double DefaultMinimumMargin = 0.06; + public const int MinimumSampleFace = 100; + public const double MinimumSharpness = 40; + public const double MinimumBrightness = 50; + public const double MaximumBrightness = 210; + public double MatchThreshold { get; set; } = DefaultMatchThreshold; + public double MinimumMargin { get; set; } = DefaultMinimumMargin; public FaceEngine() { @@ -55,7 +64,9 @@ public IReadOnlyList Scan(Mat frame) var bounds = Bounds(faces, row, frame.Width, frame.Height); if (bounds.Width < 65 || bounds.Height < 65) continue; int? id = null; + int? candidateId = null; double best = 0; + double margin = 0; if (enrolled.Count > 0) { using var aligned = Align(frame, faces, row); @@ -71,11 +82,13 @@ public IReadOnlyList Scan(Mat frame) if (ranked.Length > 0) { best = ranked[0].Value; - if (best >= MatchThreshold && (ranked.Length == 1 || best - ranked[1].Value >= MinimumMargin)) - id = ranked[0].Key; + candidateId = ranked[0].Key; + margin = ranked.Length == 1 ? best : best - ranked[1].Value; + if (best >= MatchThreshold && margin >= MinimumMargin) + id = candidateId; } } - matches.Add(new FaceMatch(bounds, id, best)); + matches.Add(new FaceMatch(bounds, id, best, candidateId, margin)); } return matches; } @@ -103,6 +116,68 @@ public byte[] CaptureSample(Mat frame, int faceIndex = 0, bool requireSingle = f return bytes; } + public SampleQuality AssessFrame(Mat frame) + { + using var faces = FindFaces(frame); + if (faces.Rows == 0) + return new SampleQuality(false, "В кадре нет лица", 0, 0, 0); + if (faces.Rows != 1) + return new SampleQuality(false, "В кадре несколько лиц — оставьте одного человека", 0, 0, 0); + + var bounds = Bounds(faces, 0, frame.Width, frame.Height); + var faceSize = Math.Min(bounds.Width, bounds.Height); + if (faceSize < MinimumSampleFace) + return new SampleQuality(false, "Лицо слишком далеко — подойдите ближе к камере", faceSize, 0, 0); + + using var aligned = Align(frame, faces, 0); + using var gray = new Mat(); + Cv2.CvtColor(aligned, gray, ColorConversionCodes.BGR2GRAY); + using var laplacian = new Mat(); + Cv2.Laplacian(gray, laplacian, MatType.CV_64F); + Cv2.MeanStdDev(laplacian, out _, out var deviation); + var sharpness = deviation.Val0 * deviation.Val0; + var brightness = Cv2.Mean(gray).Val0; + string? problem = null; + if (sharpness < MinimumSharpness) + problem = "Кадр размыт — не двигайтесь"; + else if (brightness < MinimumBrightness) + problem = "Слишком темно — добавьте света"; + else if (brightness > MaximumBrightness) + problem = "Лицо засвечено — уберите яркий свет за спиной"; + return new SampleQuality(problem is null, problem, faceSize, sharpness, brightness); + } + + public CapturedSample CaptureChecked(Mat frame) + { + var quality = AssessFrame(frame); + if (!quality.Ok) throw new InvalidOperationException(quality.Problem); + var image = CaptureSample(frame, requireSingle: true); + return new CapturedSample(image, Embedding(image)); + } + + public float[] Embedding(byte[] alignedImage) + { + using var aligned = Cv2.ImDecode(alignedImage, ImreadModes.Color); + if (aligned.Empty() || aligned.Width != 112 || aligned.Height != 112) + throw new InvalidOperationException("Образец лица должен иметь размер 112×112."); + return Feature(aligned); + } + + public static double Similarity(float[] a, float[] b) => Dot(a, b); + + public (int StudentId, double Similarity)? MostSimilar(float[] vector, int excludeStudentId) + { + (int StudentId, double Similarity)? best = null; + foreach (var sample in enrolled) + { + if (sample.StudentId == excludeStudentId) continue; + var similarity = Similarity(vector, sample.Vector); + if (best is null || similarity > best.Value.Similarity) + best = (sample.StudentId, similarity); + } + return best; + } + private Mat FindFaces(Mat frame) { var size = new OpenCvSharp.Size(frame.Width, frame.Height); diff --git a/BiStudent.Core/ImageHeader.cs b/BiStudent.Core/ImageHeader.cs new file mode 100644 index 0000000..cfc050a --- /dev/null +++ b/BiStudent.Core/ImageHeader.cs @@ -0,0 +1,50 @@ +namespace BiStudent; + +/// Читает размеры JPEG/PNG из заголовка без декодирования, чтобы отклонять огромные кадры до выделения памяти. +public static class ImageHeader +{ + public const int MaximumSide = 4096; + public const long MaximumPixels = 12_000_000; + + public static bool TryReadSize(ReadOnlySpan data, out int width, out int height) + { + width = height = 0; + if (data.Length >= 24 && data[0] == 0x89 && data[1] == 0x50 && data[2] == 0x4E && data[3] == 0x47) + { + width = ReadInt32BigEndian(data[16..]); + height = ReadInt32BigEndian(data[20..]); + return width > 0 && height > 0; + } + if (data.Length < 4 || data[0] != 0xFF || data[1] != 0xD8) return false; + var position = 2; + while (position + 9 < data.Length) + { + if (data[position] != 0xFF) { position++; continue; } + var marker = data[position + 1]; + if (marker == 0xFF) { position++; continue; } + if (marker is 0xD8 or 0x01 or (>= 0xD0 and <= 0xD7)) { position += 2; continue; } + var length = (data[position + 2] << 8) | data[position + 3]; + if (length < 2) return false; + var isFrame = marker is >= 0xC0 and <= 0xCF && marker is not (0xC4 or 0xC8 or 0xCC); + if (isFrame) + { + height = (data[position + 5] << 8) | data[position + 6]; + width = (data[position + 7] << 8) | data[position + 8]; + return width > 0 && height > 0; + } + position += 2 + length; + } + return false; + } + + /// null — кадр допустим; иначе текст причины отказа. + public static string? Validate(ReadOnlySpan data) + { + if (!TryReadSize(data, out var width, out var height)) return "Кадр должен быть изображением JPEG или PNG."; + if (width > MaximumSide || height > MaximumSide || (long)width * height > MaximumPixels) + return $"Кадр слишком большой ({width}×{height}); максимум {MaximumSide} пикселей по стороне."; + return null; + } + + private static int ReadInt32BigEndian(ReadOnlySpan data) => (data[0] << 24) | (data[1] << 16) | (data[2] << 8) | data[3]; +} diff --git a/BiStudent.Server/Endpoints/ApiEndpoints.cs b/BiStudent.Server/Endpoints/ApiEndpoints.cs index 73b7a16..dd5c88b 100644 --- a/BiStudent.Server/Endpoints/ApiEndpoints.cs +++ b/BiStudent.Server/Endpoints/ApiEndpoints.cs @@ -23,7 +23,10 @@ public static void MapApi(this WebApplication app) var teacher = await db.Teachers.AsNoTracking().SingleAsync(x => x.Id == TeacherId(user)); return new TeacherDto(teacher.Id, teacher.FullName, teacher.Login); }); - api.MapGet("/settings/automation", (IConfiguration configuration) => Automation(configuration)); + api.MapGet("/settings/automation", async (IConfiguration configuration, RecognitionService recognition) => + Automation(configuration, (await recognition.GetSettingsAsync()).FramesInRow)); + api.MapGet("/settings/recognition", (RecognitionService recognition) => recognition.GetSettingsAsync()); + api.MapPut("/settings/recognition", (RecognitionSettingsDto settings, RecognitionService recognition) => recognition.SaveSettingsAsync(settings)); api.MapGet("/dashboard", async (ClaimsPrincipal user, StatsService stats, ScheduleService schedule) => await DashboardAsync(TeacherId(user), stats, schedule, DateTime.Now)); @@ -114,7 +117,7 @@ public static void MapApi(this WebApplication app) return new ImportResultDto(added, skipped, []); }); students.MapPost("/{id:int}/enroll", async (int id, EnrollRequest request, RecognitionService recognition) => - await recognition.EnrollAsync(id, request.Images)).WithMetadata(new RequestSizeLimitAttribute(8 * 1024 * 1024)); + await recognition.EnrollAsync(id, request.Images, request.AllowSimilar)).WithMetadata(new RequestSizeLimitAttribute(8 * 1024 * 1024)); var lessons = api.MapGroup("/lessons/{slotId:int}/{date}"); lessons.MapGet("/attendance", async (int slotId, DateOnly date, ClaimsPrincipal user, AppDbContext db, AttendanceService attendance) => @@ -258,9 +261,9 @@ private static async Task ReportAsync(ClaimsPrincipal user, StatsServ automation.SecondsPerManualMark, automation.MinutesPerManualReport); } - private static AutomationSettingsDto Automation(IConfiguration configuration) => new( + private static AutomationSettingsDto Automation(IConfiguration configuration, int? framesInRow = null) => new( configuration.GetValue("Automation:SecondsPerManualMark", 6), configuration.GetValue("Automation:MinutesPerManualReport", 20), - configuration.GetValue("Attendance:LateAfterMinutes", 10), configuration.GetValue("Recognition:FramesInRow", 3)); + configuration.GetValue("Attendance:LateAfterMinutes", 10), framesInRow ?? configuration.GetValue("Recognition:FramesInRow", 3)); private static SlotDto Map(ScheduleSlot slot, string groupName) => new( slot.Id, slot.GroupId, groupName, slot.Subject, slot.Room, slot.DayOfWeek, slot.Start, slot.End, diff --git a/BiStudent.Server/Services/RecognitionService.cs b/BiStudent.Server/Services/RecognitionService.cs index 72071b8..1626f83 100644 --- a/BiStudent.Server/Services/RecognitionService.cs +++ b/BiStudent.Server/Services/RecognitionService.cs @@ -1,3 +1,4 @@ +using System.Globalization; using BiStudent.Contracts; using BiStudent.Server.Data; using Microsoft.AspNetCore.DataProtection; @@ -9,9 +10,13 @@ namespace BiStudent.Server.Services; public sealed class RecognitionService(IServiceScopeFactory scopes, IDataProtectionProvider protection, IConfiguration configuration) : IDisposable { + private const int MaximumEnrollImages = 6; private readonly object gate = new(); + private readonly SemaphoreSlim settingsGate = new(1, 1); + private readonly SemaphoreSlim enrollGate = new(1, 1); private readonly Dictionary<(int TeacherId, int SlotId, DateOnly Date), Dictionary> streaks = []; private FaceEngine? engine; + private RecognitionSettingsDto? settingsCache; private bool initialized; private string? error; private int revision; @@ -37,10 +42,81 @@ public void Invalidate() lock (gate) { revision++; streaks.Clear(); } } + private static bool ValidThreshold(double value) => double.IsFinite(value) && value is >= 0.30 and <= 0.70; + private static bool ValidMargin(double value) => double.IsFinite(value) && value is >= 0.00 and <= 0.20; + private static bool ValidFrames(int value) => value is >= 1 and <= 10; + + public async Task GetSettingsAsync() + { + lock (gate) { if (settingsCache is not null) return settingsCache; } + await settingsGate.WaitAsync(); + try { return await LoadSettingsAsync(); } + finally { settingsGate.Release(); } + } + + private async Task LoadSettingsAsync() + { + lock (gate) { if (settingsCache is not null) return settingsCache; } + using var scope = scopes.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var values = await db.AppSettings.AsNoTracking().ToDictionaryAsync(x => x.Key, x => x.Value); + var threshold = values.TryGetValue("recognition.threshold", out var thresholdText) + && double.TryParse(thresholdText, NumberStyles.Float, CultureInfo.InvariantCulture, out var parsedThreshold) + && ValidThreshold(parsedThreshold) ? parsedThreshold : FaceEngine.DefaultMatchThreshold; + var margin = values.TryGetValue("recognition.margin", out var marginText) + && double.TryParse(marginText, NumberStyles.Float, CultureInfo.InvariantCulture, out var parsedMargin) + && ValidMargin(parsedMargin) ? parsedMargin : FaceEngine.DefaultMinimumMargin; + var frames = values.TryGetValue("recognition.frames", out var framesText) + && int.TryParse(framesText, NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsedFrames) + && ValidFrames(parsedFrames) ? parsedFrames : configuration.GetValue("Recognition:FramesInRow", 3); + if (!ValidFrames(frames)) frames = 3; + var loaded = new RecognitionSettingsDto(threshold, margin, frames); + lock (gate) return settingsCache ??= loaded; + } + + public async Task SaveSettingsAsync(RecognitionSettingsDto settings) + { + if (!ValidThreshold(settings.MatchThreshold)) + throw new InvalidOperationException("Порог сходства должен быть в диапазоне 0.30–0.70."); + if (!ValidMargin(settings.MinimumMargin)) + throw new InvalidOperationException("Отрыв от второго кандидата должен быть в диапазоне 0.00–0.20."); + if (!ValidFrames(settings.FramesInRow)) + throw new InvalidOperationException("Количество кадров подряд должно быть в диапазоне 1–10."); + await settingsGate.WaitAsync(); + try + { + using var scope = scopes.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var values = new Dictionary + { + ["recognition.threshold"] = settings.MatchThreshold.ToString("R", CultureInfo.InvariantCulture), + ["recognition.margin"] = settings.MinimumMargin.ToString("R", CultureInfo.InvariantCulture), + ["recognition.frames"] = settings.FramesInRow.ToString(CultureInfo.InvariantCulture) + }; + foreach (var pair in values) + { + var stored = await db.AppSettings.FindAsync(pair.Key); + if (stored is null) db.AppSettings.Add(new AppSetting { Key = pair.Key, Value = pair.Value }); + else stored.Value = pair.Value; + } + await db.SaveChangesAsync(); + lock (gate) { settingsCache = settings; streaks.Clear(); } + return settings; + } + finally { settingsGate.Release(); } + } + private async Task TrainIfNeededAsync() + { + for (var attempt = 0; attempt < 5; attempt++) + if (await TrainOnceAsync()) return; + throw new InvalidOperationException("Образцы лиц меняются слишком часто — повторите попытку."); + } + + private async Task TrainOnceAsync() { int version; - lock (gate) { if (trainedRevision == revision) return; version = revision; } + lock (gate) { if (trainedRevision == revision) return true; version = revision; } using var scope = scopes.CreateScope(); var db = scope.ServiceProvider.GetRequiredService(); var stored = await db.FaceSamples.AsNoTracking().Select(x => new { x.StudentId, x.ProtectedImage }).ToListAsync(); @@ -52,9 +128,11 @@ private async Task TrainIfNeededAsync() } lock (gate) { - if (version != revision || engine is null) return; + if (engine is null) return true; + if (version != revision) return false; engine.Train(samples); trainedRevision = version; + return true; } } @@ -69,27 +147,40 @@ public async Task RecognizeAsync(int teacherId, int slotId var students = await db.Students.AsNoTracking().Select(x => new { x.Id, x.FullName, x.GroupId }).ToDictionaryAsync(x => x.Id); var marked = await db.AttendanceMarks.AsNoTracking().Where(x => x.SlotId == slotId && x.Date == date) .Select(x => x.StudentId).ToListAsync(); - try { await TrainIfNeededAsync(); } - catch (Exception ex) { return new RecognitionResultDto([], 0, 0, ex.Message); } + if (ImageHeader.Validate(jpeg) is { } invalid) return new RecognitionResultDto([], 0, 0, invalid); + var settings = await GetSettingsAsync(); using var frame = Cv2.ImDecode(jpeg, ImreadModes.Color); if (frame.Empty()) return new RecognitionResultDto([], 0, 0, "Не удалось прочитать кадр JPEG."); - IReadOnlyList matches; - try { lock (gate) matches = engine!.Scan(frame); } - catch (Exception ex) { return new RecognitionResultDto([], frame.Width, frame.Height, ex.Message); } - var eligible = matches.Where(x => x.StudentId is int id && students.TryGetValue(id, out var s) && s.GroupId == slot.GroupId) - .Select(x => x.StudentId!.Value).ToHashSet(); - var ready = new HashSet(); - lock (gate) + IReadOnlyList? matches = null; + HashSet ready = []; + try { - var key = (teacherId, slotId, date); - if (!streaks.TryGetValue(key, out var counts)) streaks[key] = counts = []; - foreach (var id in counts.Keys.Except(eligible).ToArray()) counts.Remove(id); - foreach (var id in eligible) + for (var attempt = 0; attempt < 3 && matches is null; attempt++) { - counts[id] = counts.GetValueOrDefault(id) + 1; - if (counts[id] == Math.Max(1, configuration.GetValue("Recognition:FramesInRow", 3))) ready.Add(id); + await TrainIfNeededAsync(); + lock (gate) + { + if (trainedRevision != revision) continue; + var current = settingsCache ?? settings; + engine!.MatchThreshold = current.MatchThreshold; + engine.MinimumMargin = current.MinimumMargin; + matches = engine.Scan(frame); + var eligible = matches.Where(x => x.StudentId is int id && students.TryGetValue(id, out var s) && s.GroupId == slot.GroupId) + .Select(x => x.StudentId!.Value).ToHashSet(); + ready.Clear(); + var key = (teacherId, slotId, date); + if (!streaks.TryGetValue(key, out var counts)) streaks[key] = counts = []; + foreach (var id in counts.Keys.Except(eligible).ToArray()) counts.Remove(id); + foreach (var id in eligible) + { + counts[id] = counts.GetValueOrDefault(id) + 1; + if (counts[id] >= current.FramesInRow && !marked.Contains(id)) ready.Add(id); + } + } } } + catch (Exception ex) { return new RecognitionResultDto([], frame.Width, frame.Height, ex.Message); } + if (matches is null) return new RecognitionResultDto([], frame.Width, frame.Height, "Образцы лиц обновляются — повторите через секунду."); var changed = new HashSet(); var attendance = scope.ServiceProvider.GetRequiredService(); foreach (var id in ready) @@ -97,41 +188,88 @@ public async Task RecognizeAsync(int teacherId, int slotId var faces = matches.Select(match => { var id = match.StudentId; var known = id is int value && students.TryGetValue(value, out var student) ? student : null; + var candidate = match.CandidateId is int candidateId && students.TryGetValue(candidateId, out var possible) + ? possible.FullName : null; var other = known is not null && known.GroupId != slot.GroupId; return new RecognizedFaceDto(match.Bounds.X, match.Bounds.Y, match.Bounds.Width, match.Bounds.Height, id, known?.FullName, match.Similarity, id is int studentId && changed.Contains(studentId), - id is int existing && marked.Contains(existing), other); + id is int existing && marked.Contains(existing), other, candidate, match.Margin); }).ToList(); return new RecognitionResultDto(faces, frame.Width, frame.Height); } - public async Task EnrollAsync(int studentId, IReadOnlyList jpegs) + public async Task EnrollAsync(int studentId, IReadOnlyList jpegs, bool allowSimilar) { if (jpegs.Count < 3) return new EnrollResultDto(false, "Нужно не меньше трёх кадров."); + if (jpegs.Count > MaximumEnrollImages) return new EnrollResultDto(false, $"Нужно не больше {MaximumEnrollImages} кадров."); + await enrollGate.WaitAsync(); + try { return await EnrollCoreAsync(studentId, jpegs, allowSimilar); } + finally { enrollGate.Release(); } + } + + private async Task EnrollCoreAsync(int studentId, IReadOnlyList jpegs, bool allowSimilar) + { EnsureEngine(); if (!IsAvailable) return new EnrollResultDto(false, Error ?? "Распознавание недоступно."); - var images = new List(); + var samples = new List(); + var problems = new List(); for (var i = 0; i < jpegs.Count; i++) { try { + if (ImageHeader.Validate(jpegs[i]) is { } invalid) throw new InvalidOperationException(invalid); using var frame = Cv2.ImDecode(jpegs[i], ImreadModes.Color); if (frame.Empty()) throw new InvalidOperationException("Не удалось прочитать JPEG."); - lock (gate) images.Add(engine!.CaptureSample(frame, requireSingle: true)); + lock (gate) samples.Add(engine!.CaptureChecked(frame)); } - catch (Exception ex) { return new EnrollResultDto(false, $"Кадр {i + 1}: {ex.Message}"); } + catch (Exception ex) { problems.Add($"Снимок {i + 1}: {ex.Message}"); } } + if (problems.Count > 0) return new EnrollResultDto(false, "Некоторые снимки не подходят — переснимите их", Problems: problems); + for (var i = 0; i < samples.Count; i++) + for (var j = i + 1; j < samples.Count; j++) + { + double similarity; + lock (gate) similarity = FaceEngine.Similarity(samples[i].Embedding, samples[j].Embedding); + if (similarity > 0.95) problems.Add($"Снимки {i + 1} и {j + 1} почти одинаковые — немного поверните голову"); + } + if (problems.Count > 0) return new EnrollResultDto(false, "Некоторые снимки не подходят — переснимите их", Problems: problems); using var scope = scopes.CreateScope(); var db = scope.ServiceProvider.GetRequiredService(); if (!await db.Students.AnyAsync(x => x.Id == studentId)) return new EnrollResultDto(false, "Студент не найден."); + try { await TrainIfNeededAsync(); } + catch (Exception ex) { return new EnrollResultDto(false, ex.Message); } + var settings = await GetSettingsAsync(); + (int StudentId, double Similarity)? similar = null; + foreach (var sample in samples) + { + (int StudentId, double Similarity)? candidate; + lock (gate) candidate = engine!.MostSimilar(sample.Embedding, studentId); + if (candidate is { } match && (similar is null || match.Similarity > similar.Value.Similarity)) + similar = match; + } + string? similarTo = null; + double similarScore = 0; + if (similar is { } closest && closest.Similarity >= settings.MatchThreshold) + { + similarTo = await db.Students.AsNoTracking().Where(x => x.Id == closest.StudentId) + .Select(x => x.FullName).SingleOrDefaultAsync(); + if (similarTo is not null) + { + similarScore = closest.Similarity; + var warning = $"Лицо похоже на {similarTo} ({similarScore:P0}). Если это другой человек — подтвердите сохранение: автоотметка для этих студентов будет менее надёжной."; + if (!allowSimilar) return new EnrollResultDto(false, warning, similarTo, similarScore); + } + } var old = await db.FaceSamples.Where(x => x.StudentId == studentId).ToListAsync(); db.FaceSamples.RemoveRange(old); - db.FaceSamples.AddRange(images.Select(image => new FaceSample { - StudentId = studentId, ProtectedImage = Protector.Protect(image), CreatedAt = DateTime.Now + db.FaceSamples.AddRange(samples.Select(sample => new FaceSample { + StudentId = studentId, ProtectedImage = Protector.Protect(sample.Image), CreatedAt = DateTime.Now })); await db.SaveChangesAsync(); Invalidate(); - return new EnrollResultDto(true, "Образцы лица сохранены."); + return new EnrollResultDto(true, similarTo is null ? "Образцы лица сохранены." + : $"Образцы лица сохранены. Лицо похоже на {similarTo} ({similarScore:P0}); автоотметка для этих студентов будет менее надёжной.", + similarTo, similarScore); } public void Dispose() diff --git a/BiStudent.Tests/ImageQualityTests.cs b/BiStudent.Tests/ImageQualityTests.cs new file mode 100644 index 0000000..750df85 --- /dev/null +++ b/BiStudent.Tests/ImageQualityTests.cs @@ -0,0 +1,55 @@ +using Xunit; +using OpenCvSharp; + +namespace BiStudent.Tests; + +public sealed class ImageQualityTests +{ + private static byte[] Encode(string extension, int width, int height) + { + using var image = new Mat(new OpenCvSharp.Size(width, height), MatType.CV_8UC3, Scalar.Gray); + Cv2.ImEncode(extension, image, out var bytes); + return bytes; + } + + [Theory] + [InlineData(".jpg")] + [InlineData(".png")] + public void ReadsSizeFromHeader(string extension) + { + Assert.True(ImageHeader.TryReadSize(Encode(extension, 640, 480), out var width, out var height)); + Assert.Equal((640, 480), (width, height)); + Assert.Null(ImageHeader.Validate(Encode(extension, 640, 480))); + } + + [Fact] + public void RejectsOversizedImageBeforeDecoding() + { + var png = Encode(".png", 16, 16); + png[16] = 0; png[17] = 0; png[18] = 0x3E; png[19] = 0x80; + png[20] = 0; png[21] = 0; png[22] = 0x3E; png[23] = 0x80; + Assert.Contains("слишком большой", ImageHeader.Validate(png)); + } + + [Fact] + public void RejectsUnknownFormat() => + Assert.NotNull(ImageHeader.Validate("not an image"u8.ToArray())); + + [Fact] + public void AssessFrameReportsMissingFace() + { + using var engine = new FaceEngine(); + using var frame = new Mat(new OpenCvSharp.Size(640, 480), MatType.CV_8UC3, Scalar.Black); + var quality = engine.AssessFrame(frame); + Assert.False(quality.Ok); + Assert.Equal("В кадре нет лица", quality.Problem); + Assert.Throws(() => engine.CaptureChecked(frame)); + } + + [Fact] + public void SimilarityOfVectorWithItselfIsOne() + { + var vector = new[] { 0.6f, 0.8f }; + Assert.Equal(1, FaceEngine.Similarity(vector, vector), 5); + } +} diff --git a/BiStudent.Tests/RecognitionSettingsTests.cs b/BiStudent.Tests/RecognitionSettingsTests.cs new file mode 100644 index 0000000..a2443a6 --- /dev/null +++ b/BiStudent.Tests/RecognitionSettingsTests.cs @@ -0,0 +1,104 @@ +using System.Net; +using System.Net.Http.Headers; +using System.Net.Http.Json; +using BiStudent.Contracts; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Mvc.Testing; +using Xunit; + +namespace BiStudent.Tests; + +public sealed class RecognitionSettingsTests : IDisposable +{ + private readonly string temporaryDirectory = Path.Combine(Path.GetTempPath(), $"BiStudent.Tests.{Guid.NewGuid():N}"); + private readonly WebApplicationFactory factory; + private readonly HttpClient client; + + public RecognitionSettingsTests() + { + Directory.CreateDirectory(temporaryDirectory); + factory = new ApiFactory(temporaryDirectory); + client = factory.CreateClient(); + } + + [Fact] + public async Task GetSettings_ReturnsDefaults() + { + await AuthenticateAsync(); + var settings = await client.GetFromJsonAsync("/api/settings/recognition"); + Assert.NotNull(settings); + Assert.Equal(0.43, settings.MatchThreshold); + Assert.Equal(0.06, settings.MinimumMargin); + Assert.Equal(3, settings.FramesInRow); + } + + [Fact] + public async Task SaveSettings_PersistsValues() + { + await AuthenticateAsync(); + var expected = new RecognitionSettingsDto(0.55, 0.08, 5); + var response = await client.PutAsJsonAsync("/api/settings/recognition", expected); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var saved = await response.Content.ReadFromJsonAsync(); + Assert.Equal(expected, saved); + var loaded = await client.GetFromJsonAsync("/api/settings/recognition"); + Assert.Equal(expected, loaded); + } + + [Fact] + public async Task SaveSettings_WithInvalidThresholdReturnsBadRequest() + { + await AuthenticateAsync(); + var response = await client.PutAsJsonAsync("/api/settings/recognition", new RecognitionSettingsDto(0.9, 0.06, 3)); + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + var error = await response.Content.ReadFromJsonAsync(); + Assert.NotNull(error); + Assert.Contains("0.30–0.70", error.Message); + } + + [Fact] + public async Task Automation_UsesSavedFramesInRow() + { + await AuthenticateAsync(); + var response = await client.PutAsJsonAsync("/api/settings/recognition", new RecognitionSettingsDto(0.43, 0.06, 5)); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var automation = await client.GetFromJsonAsync("/api/settings/automation"); + Assert.NotNull(automation); + Assert.Equal(5, automation.FramesInRow); + } + + private async Task AuthenticateAsync() + { + var response = await client.PostAsJsonAsync("/api/auth/login", new LoginRequest("teacher", "teacher123")); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var token = await response.Content.ReadFromJsonAsync(); + Assert.NotNull(token); + client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token.AccessToken); + } + + public void Dispose() + { + client.Dispose(); + factory.Dispose(); + Directory.Delete(temporaryDirectory, recursive: true); + } + + private sealed class ApiFactory(string directory) : WebApplicationFactory + { + protected override void ConfigureWebHost(IWebHostBuilder builder) + { + builder.UseContentRoot(ServerRoot()); + builder.UseSetting("ConnectionStrings:Default", $"Data Source={Path.Combine(directory, "test.db")};Pooling=False"); + } + + private static string ServerRoot() + { + for (var current = new DirectoryInfo(AppContext.BaseDirectory); current is not null; current = current.Parent) + { + var candidate = Path.Combine(current.FullName, "BiStudent.Server"); + if (File.Exists(Path.Combine(candidate, "seed", "students.demo.csv"))) return candidate; + } + throw new DirectoryNotFoundException("BiStudent.Server/seed/students.demo.csv"); + } + } +} diff --git a/BiStudent/Api/ApiClient.cs b/BiStudent/Api/ApiClient.cs index d3e0481..3e40af7 100644 --- a/BiStudent/Api/ApiClient.cs +++ b/BiStudent/Api/ApiClient.cs @@ -63,6 +63,9 @@ public async Task LoadMeAsync() public Task AutomationAsync() => GetAsync("api/settings/automation"); public Task RecognitionStatusAsync() => GetAsync("api/recognition/status"); + public Task RecognitionSettingsAsync() => GetAsync("api/settings/recognition"); + public Task SaveRecognitionSettingsAsync(RecognitionSettingsDto settings) => + RequestAsync(HttpMethod.Put, "api/settings/recognition", Json(settings)); public Task DashboardAsync(CancellationToken cancellationToken = default) => RequestAsync(HttpMethod.Get, "api/dashboard", cancellationToken: cancellationToken); public Task ReportAsync() => GetAsync("api/report"); @@ -90,8 +93,8 @@ public Task SaveStudentAsync(int? id, SaveStudentRequest request) => public Task DeleteStudentAsync(int id) => RequestAsync(HttpMethod.Delete, $"api/students/{id}"); public Task ImportStudentsAsync(string csv) => RequestAsync(HttpMethod.Post, "api/students/import", new StringContent(csv, Encoding.UTF8, "text/csv")); - public Task EnrollAsync(int studentId, List images) => - RequestAsync(HttpMethod.Post, $"api/students/{studentId}/enroll", Json(new EnrollRequest(images)), timeout: LongTimeout); + public Task EnrollAsync(int studentId, List images, bool allowSimilar = false) => + RequestAsync(HttpMethod.Post, $"api/students/{studentId}/enroll", Json(new EnrollRequest(images, allowSimilar)), timeout: LongTimeout); public Task> AttendanceAsync(int slotId, DateOnly date, CancellationToken cancellationToken = default) => RequestAsync>(HttpMethod.Get, $"api/lessons/{slotId}/{QueryDate(date)}/attendance", cancellationToken: cancellationToken); diff --git a/BiStudent/Pages/CameraPage.cs b/BiStudent/Pages/CameraPage.cs index 83b657a..95175b4 100644 --- a/BiStudent/Pages/CameraPage.cs +++ b/BiStudent/Pages/CameraPage.cs @@ -12,8 +12,12 @@ private sealed record LessonChoice(LessonDto Lesson) private readonly ModernComboBox lessons = new() { Dock = DockStyle.Fill }; private readonly ModernComboBox cameras = new() { Dock = DockStyle.Fill }; - private readonly ModernButton start = new() { Text = "▶ Запустить", Primary = true, Width = 165 }; - private readonly ModernButton stop = new() { Text = "■ Остановить", Width = 165 }; + private readonly ModernButton start = new() { Text = "▶ Запустить", Primary = true, Width = 140 }; + private readonly ModernButton stop = new() { Text = "■ Остановить", Width = 140 }; + private readonly ModernButton settingsButton = new() { Text = "⚙ Настройки", Width = 136 }; + private readonly CheckBox calibration = new() { Text = "Калибровка", AutoSize = true, ForeColor = Theme.Text, BackColor = Theme.Surface, Font = Theme.Font(9.5f), Margin = new Padding(6, 12, 0, 0), Cursor = Cursors.Hand }; + private volatile bool calibrate; + private double matchThreshold = FaceEngine.DefaultMatchThreshold; private readonly ModernButton finish = new() { Text = "Завершить пару", Danger = true, Width = 180 }; private readonly PictureBox preview = new() { Dock = DockStyle.Fill, SizeMode = PictureBoxSizeMode.Zoom, BackColor = Theme.Sidebar }; private readonly Label placeholder = Ui.Label("Камера выключена. Выберите пару и нажмите «Запустить».\nОтметка ставится после нескольких кадров подряд.", 10, Theme.Muted); @@ -57,7 +61,10 @@ public CameraPage(ApiClient api) : base(api, "Автоотметка", "Каме layout.Controls.Add(Field("Пара", lessons), 0, 0); layout.Controls.Add(Field("Камера", cameras), 0, 1); var buttons = Ui.Row(); - buttons.Controls.Add(start); buttons.Controls.Add(stop); + buttons.Controls.Add(start); buttons.Controls.Add(stop); buttons.Controls.Add(settingsButton); buttons.Controls.Add(calibration); + new ToolTip().SetToolTip(calibration, "Показывать лучшего кандидата и сходство даже ниже порога — чтобы подобрать порог под условия аудитории"); + calibration.CheckedChanged += (_, _) => calibrate = calibration.Checked; + settingsButton.Click += async (_, _) => await OpenSettingsAsync(); layout.Controls.Add(buttons, 0, 2); banner.TextAlign = ContentAlignment.MiddleLeft; banner.AutoEllipsis = true; @@ -142,9 +149,11 @@ public override async Task LoadAsync() var dayTask = Api.DayAsync(date); var automationTask = Api.AutomationAsync(); var statusTask = Api.RecognitionStatusAsync(); - await Task.WhenAll(dayTask, automationTask, statusTask); + var settingsTask = Api.RecognitionSettingsAsync(); + await Task.WhenAll(dayTask, automationTask, statusTask, settingsTask); if (IsDisposed) return; automation = await automationTask; + matchThreshold = (await settingsTask).MatchThreshold; var status = await statusTask; recognitionOk = status.Ok; SetBanner(status.Ok ? "" : status.Message); @@ -434,13 +443,16 @@ private void DrawFaces(Bitmap bitmap) using var graphics = Graphics.FromImage(bitmap); foreach (var face in result.Faces) { - var color = face.StudentId is null ? Theme.Red : face.OtherGroup ? Theme.Muted + var candidate = calibrate && face.StudentId is null && face.CandidateName is not null; + var color = candidate ? Theme.Yellow : face.StudentId is null ? Theme.Red : face.OtherGroup ? Theme.Muted : face.Marked || face.AlreadyMarked ? Theme.Accent : Theme.Green; var bounds = new Rectangle((int)(face.X * scale), (int)(face.Y * scale), Math.Max(1, (int)(face.W * scale)), Math.Max(1, (int)(face.H * scale))); using var pen = new Pen(color, 3); graphics.DrawRectangle(pen, bounds); - var text = face.StudentId is null ? "Неизвестный" + var text = candidate ? $"? {face.CandidateName} · {face.Similarity * 100:0}% (нужно ≥ {matchThreshold * 100:0}%)" + : face.StudentId is null ? "Неизвестный" + : calibrate ? $"{face.Name ?? "Студент"} · {face.Similarity * 100:0}% · отрыв {face.Margin * 100:0}%{(face.OtherGroup ? " · другая группа" : "")}" : $"{face.Name ?? "Студент"} · {face.Similarity * 100:0}%{(face.OtherGroup ? " · другая группа" : "")}"; var size = TextRenderer.MeasureText(text, Theme.Font(10, FontStyle.Bold)); var label = new Rectangle(Math.Clamp(bounds.Left, 0, Math.Max(0, bitmap.Width - size.Width - 12)), @@ -490,12 +502,25 @@ private void SetBanner(string message) banner.Text = string.IsNullOrWhiteSpace(message) ? "" : $"⚠ {message}"; } + private async Task OpenSettingsAsync() + { + using var dialog = new RecognitionSettingsDialog(Api); + if (dialog.ShowDialog(this) != DialogResult.OK || dialog.SavedSettings is not { } settings || IsDisposed) return; + matchThreshold = settings.MatchThreshold; + if (automation is not null) automation = automation with { FramesInRow = settings.FramesInRow }; + if (selectedLesson is not null && !running) + placeholder.Text = $"Камера выключена. Выберите пару и нажмите «Запустить».\nОтметка ставится после {settings.FramesInRow} кадров подряд."; + Ui.Toast(this, "Настройки распознавания сохранены"); + await Task.CompletedTask; + } + private void SetControls() { lessons.Enabled = !running && !finishing && !manualBusy && lessons.Items.Count > 0; cameras.Enabled = !running && cameras.Items.Count > 0; start.Enabled = !running && !finishing && !manualBusy && recognitionOk && selectedLesson is not null && cameras.SelectedItem is CameraDevice; stop.Enabled = running; + settingsButton.Enabled = !running && !finishing && !loading; finish.Enabled = !finishing && !manualBusy && selectedLesson is not null; attendance.Enabled = !manualBusy && !finishing; } diff --git a/BiStudent/Pages/EnrollmentDialog.cs b/BiStudent/Pages/EnrollmentDialog.cs index 83ab624..1b404d9 100644 --- a/BiStudent/Pages/EnrollmentDialog.cs +++ b/BiStudent/Pages/EnrollmentDialog.cs @@ -1,5 +1,3 @@ -using System.Collections; -using System.Reflection; using BiStudent.Contracts; using OpenCvSharp; @@ -18,14 +16,16 @@ internal sealed class EnrollmentDialog : DarkForm private readonly ModernButton take = new() { Text = "Снять", Primary = true, Width = 125 }; private readonly ModernButton retake = new() { Text = "Переснять последний", Width = 190 }; private readonly ModernButton save = new() { Text = "Сохранить образцы", Width = 200 }; - private readonly List shots = []; + private readonly byte[]?[] shots = new byte[]?[3]; private readonly object frameLock = new(); + private readonly object engineLock = new(); + private int? retakeSlot; private CancellationTokenSource? cameraCts; private Task? cameraTask; private Mat? lastFrame; private Bitmap? queuedBitmap; - private object? engine; - private MethodInfo? detect; + private FaceEngine? engine; + private SampleQuality? quality; private int faceCount; private int pendingFrame; private DateTime lastFrameAt; @@ -57,6 +57,7 @@ public EnrollmentDialog(ApiClient api, StudentDto student) steps[i] = Ui.Label("", 10, Theme.Muted); layout.Controls.Add(steps[i], 0, i + 1); } + var tips = new ToolTip(); var previews = new TableLayoutPanel { Dock = DockStyle.Fill, ColumnCount = 3, BackColor = Color.Transparent }; for (var i = 0; i < 3; i++) { @@ -66,6 +67,9 @@ public EnrollmentDialog(ApiClient api, StudentDto student) Dock = DockStyle.Fill, SizeMode = PictureBoxSizeMode.Zoom, BackColor = Theme.SurfaceHover, Margin = new Padding(0, 5, 8, 5) }; + var index = i; + thumbs[i].Click += (_, _) => RetakeAt(index); + tips.SetToolTip(thumbs[i], "Нажмите, чтобы переснять этот снимок"); previews.Controls.Add(thumbs[i], i, 0); } layout.Controls.Add(previews, 0, 4); @@ -106,46 +110,8 @@ public EnrollmentDialog(ApiClient api, StudentDto student) private void CreateDetector() { - try - { - var type = typeof(EnrollResultDto).Assembly.GetType("BiStudent.FaceEngine"); - if (type is null) return; - var models = Directory.EnumerateFiles(AppContext.BaseDirectory, "*.onnx", SearchOption.AllDirectories).ToArray(); - var yunet = models.FirstOrDefault(p => p.Contains("yunet", StringComparison.OrdinalIgnoreCase) || - p.Contains("detection", StringComparison.OrdinalIgnoreCase)); - var sface = models.FirstOrDefault(p => p.Contains("sface", StringComparison.OrdinalIgnoreCase) || - p.Contains("recognition", StringComparison.OrdinalIgnoreCase)); - foreach (var constructor in type.GetConstructors(BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic) - .OrderBy(c => c.GetParameters().Length)) - { - var parameters = constructor.GetParameters(); - var arguments = new object?[parameters.Length]; - var valid = true; - for (var i = 0; i < parameters.Length; i++) - { - var parameter = parameters[i]; - if (parameter.ParameterType == typeof(string)) - { - var recognition = parameter.Name?.Contains("recogn", StringComparison.OrdinalIgnoreCase) == true || - parameter.Name?.Contains("sface", StringComparison.OrdinalIgnoreCase) == true || i > 0; - arguments[i] = recognition ? sface : yunet; - if (arguments[i] is null) valid = false; - } - else if (parameter.HasDefaultValue) arguments[i] = parameter.DefaultValue; - else valid = false; - } - if (!valid) continue; - try { engine = constructor.Invoke(arguments); break; } - catch { } - } - if (engine is null) return; - detect = type.GetMethods(BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic) - .FirstOrDefault(m => m.Name == "Detect" && m.GetParameters() is { Length: > 0 } p && - p[0].ParameterType.IsAssignableFrom(typeof(Mat)) && - p.Skip(1).All(x => x.HasDefaultValue)); - if (detect is null) { (engine as IDisposable)?.Dispose(); engine = null; } - } - catch { (engine as IDisposable)?.Dispose(); engine = null; detect = null; } + try { engine = new FaceEngine(); } + catch { engine = null; } } private async Task CameraLoopAsync(CancellationToken token) @@ -199,7 +165,7 @@ private async Task CameraLoopAsync(CancellationToken token) { noFrameSince = DateTime.UtcNow; var faces = DetectFaces(frame); - StoreFrame(frame, faces.Count); + StoreFrame(frame, faces.Count, Assess(frame, faces.Count)); if (Interlocked.CompareExchange(ref pendingFrame, 1, 0) == 0) { Bitmap? bitmap = null; @@ -247,8 +213,11 @@ private async Task CameraLoopAsync(CancellationToken token) { camera?.Dispose(); ClearLatestFrame(); - (engine as IDisposable)?.Dispose(); - engine = null; detect = null; + lock (engineLock) + { + engine?.Dispose(); + engine = null; + } } } @@ -268,7 +237,7 @@ private void PostStatus(string message, CancellationToken token) }); } - private void StoreFrame(Mat frame, int count) + private void StoreFrame(Mat frame, int count, SampleQuality? check) { lock (frameLock) { @@ -276,6 +245,7 @@ private void StoreFrame(Mat frame, int count) lastFrame = frame.Clone(); lastFrameAt = DateTime.UtcNow; faceCount = count; + quality = check; } } @@ -287,6 +257,7 @@ private void ClearLatestFrame() lastFrame = null; lastFrameAt = default; faceCount = 0; + quality = null; } } @@ -305,93 +276,91 @@ private void FrameReady(CancellationToken token, bool available) private List DetectFaces(Mat frame) { - var result = new List(); - if (engine is null || detect is null) return result; - try + lock (engineLock) { - var parameters = detect.GetParameters(); - var arguments = new object?[parameters.Length]; - arguments[0] = frame; - for (var i = 1; i < parameters.Length; i++) arguments[i] = parameters[i].DefaultValue; - if (detect.Invoke(engine, arguments) is IEnumerable faces) + if (engine is null) return []; + try { return engine.Detect(frame).Select(r => new Rectangle(r.X, r.Y, r.Width, r.Height)).ToList(); } + catch { - foreach (var face in faces) - { - if (face is null) continue; - result.Add(FaceBounds(face) ?? Rectangle.Empty); - } + engine.Dispose(); + engine = null; + return []; } } - catch - { - (engine as IDisposable)?.Dispose(); - engine = null; - detect = null; - result.Clear(); - } - return result; } - private static Rectangle? FaceBounds(object face) + private SampleQuality? Assess(Mat frame, int faces) { - if (face is Rectangle rectangle) return rectangle; - if (face is Rect rect) return new Rectangle(rect.X, rect.Y, rect.Width, rect.Height); - var nested = Member(face, "Rect") ?? Member(face, "Bounds") ?? Member(face, "Box"); - if (nested is not null && !ReferenceEquals(nested, face)) return FaceBounds(nested); - var x = Member(face, "X"); var y = Member(face, "Y"); - var w = Member(face, "Width") ?? Member(face, "W"); - var h = Member(face, "Height") ?? Member(face, "H"); - return x is null || y is null || w is null || h is null ? null - : new Rectangle(Convert.ToInt32(x), Convert.ToInt32(y), Convert.ToInt32(w), Convert.ToInt32(h)); + lock (engineLock) + { + if (engine is null) return null; + try { return engine.AssessFrame(frame); } + catch { return null; } + } } - private static object? Member(object value, string name) => - value.GetType().GetProperty(name, BindingFlags.Instance | BindingFlags.Public)?.GetValue(value) ?? - value.GetType().GetField(name, BindingFlags.Instance | BindingFlags.Public)?.GetValue(value); + private int Filled => shots.Count(s => s is not null); + private int NextSlot => retakeSlot is int slot && shots[slot] is null ? slot : Array.IndexOf(shots, null); private void UpdateState() { bool fresh; int count; + SampleQuality? check; lock (frameLock) { fresh = lastFrame is not null && (DateTime.UtcNow - lastFrameAt).TotalMilliseconds < 500; count = faceCount; + check = quality; } - take.Enabled = !saving && !stopping && shots.Count < 3 && fresh && detectorReady && count == 1; - retake.Enabled = !saving && !stopping && shots.Count > 0; - save.Enabled = !saving && !stopping && shots.Count == 3; + var next = NextSlot; + take.Enabled = !saving && !stopping && next >= 0 && fresh && detectorReady && count == 1 && check is { Ok: true }; + retake.Enabled = !saving && !stopping && Filled > 0; + save.Enabled = !saving && !stopping && Filled == 3; for (var i = 0; i < 3; i++) { - steps[i].Text = i < shots.Count ? $"✓ {i + 1}/3 {Prompts[i]}" - : $"{i + 1}/3 {Prompts[i]}"; - steps[i].ForeColor = i < shots.Count ? Theme.Green : i == shots.Count ? Theme.AccentHover : Theme.Muted; + steps[i].Text = shots[i] is not null ? $"✓ {i + 1}/3 {Prompts[i]}" : $"{i + 1}/3 {Prompts[i]}"; + steps[i].ForeColor = shots[i] is not null ? Theme.Green : i == next ? Theme.AccentHover : Theme.Muted; + thumbs[i].Cursor = shots[i] is not null && !saving ? Cursors.Hand : Cursors.Default; } if (saving) { hint.Text = "Сохранение образцов…"; return; } if (!detectorReady) { hint.Text = "Проверка лица недоступна: не удалось загрузить модель."; return; } if (!fresh) return; - hint.Text = shots.Count == 3 ? "Три снимка готовы. Сохраните образцы." - : count == 0 ? "Наведите камеру так, чтобы в кадре было ровно одно лицо." - : count > 1 ? "В кадре несколько лиц. Оставьте одного студента." - : $"Готово: {Prompts[shots.Count].ToLowerInvariant()}. Нажмите «Снять»."; + hint.ForeColor = Theme.Muted; + if (next < 0) { hint.Text = "Три снимка готовы. Сохраните образцы или нажмите на снимок, чтобы переснять его."; return; } + if (check is { Ok: false, Problem: { } problem }) + { + hint.ForeColor = Theme.Yellow; + hint.Text = $"{problem}."; + return; + } + if (count == 0) { hint.Text = "Наведите камеру так, чтобы в кадре было ровно одно лицо."; return; } + if (count > 1) { hint.Text = "В кадре несколько лиц. Оставьте одного студента."; return; } + if (check is null) { hint.ForeColor = Theme.Yellow; hint.Text = "Не удалось оценить кадр — немного измените положение головы."; return; } + hint.Text = $"Кадр хороший: {Prompts[next].ToLowerInvariant()}. Нажмите «Снять»."; } private void TakeShot() { if (!take.Enabled) return; + var slot = NextSlot; + if (slot < 0) return; Mat frame; lock (frameLock) { - if (lastFrame is null || faceCount != 1 || (DateTime.UtcNow - lastFrameAt).TotalMilliseconds >= 500) return; + if (lastFrame is null || faceCount != 1 || quality is not { Ok: true } || (DateTime.UtcNow - lastFrameAt).TotalMilliseconds >= 500) return; frame = lastFrame.Clone(); } using (frame) { Cv2.ImEncode(".jpg", frame, out var jpeg, new ImageEncodingParam(ImwriteFlags.JpegQuality, 92)); - shots.Add(jpeg); + shots[slot] = jpeg; + if (retakeSlot == slot) retakeSlot = null; using var stream = new MemoryStream(jpeg); using var decoded = new Bitmap(stream); - thumbs[shots.Count - 1].Image = new Bitmap(decoded); + var old = thumbs[slot].Image; + thumbs[slot].Image = new Bitmap(decoded); + old?.Dispose(); } banner.Visible = false; banner.Text = ""; @@ -400,18 +369,25 @@ private void TakeShot() private void Retake() { - if (saving || shots.Count == 0) return; - shots.RemoveAt(shots.Count - 1); - var thumb = thumbs[shots.Count]; - var image = thumb.Image; - thumb.Image = null; + var last = Array.FindLastIndex(shots, s => s is not null); + if (last >= 0) RetakeAt(last); + } + + private void RetakeAt(int index) + { + if (saving || shots[index] is null) return; + shots[index] = null; + retakeSlot = index; + var image = thumbs[index].Image; + thumbs[index].Image = null; image?.Dispose(); UpdateState(); } private void ResetShots() { - shots.Clear(); + Array.Clear(shots); + retakeSlot = null; foreach (var thumb in thumbs) { var image = thumb.Image; @@ -423,19 +399,29 @@ private void ResetShots() private async Task SaveAsync() { - if (saving || shots.Count != 3) return; + if (saving || Filled != 3) return; saving = true; take.Enabled = false; retake.Enabled = false; save.Enabled = false; UseWaitCursor = true; UpdateState(); try { - var result = await api.EnrollAsync(student.Id, shots.ToList()); + var images = shots.Select(s => s!).ToList(); + var result = await api.EnrollAsync(student.Id, images); if (IsDisposed) return; - if (result.Ok) { DialogResult = DialogResult.OK; Close(); } - else { ResetShots(); ShowError(result.Message); } + if (!result.Ok && result.SimilarTo is not null && + Ui.Confirm(this, "Похожее лицо", result.Message, "Сохранить всё равно")) + result = await api.EnrollAsync(student.Id, images, allowSimilar: true); + if (IsDisposed) return; + if (result.Ok) { DialogResult = DialogResult.OK; Close(); return; } + if (result.Problems is { Count: > 0 } problems) + { + ShowError($"{result.Message} — подробности в окне со списком."); + Ui.Message(this, "Какие снимки переснять", string.Join(Environment.NewLine, problems)); + } + else if (result.SimilarTo is null) { ResetShots(); ShowError(result.Message); } } - catch (ApiException ex) { if (!IsDisposed) { ResetShots(); ShowError(ex.Message); } } + catch (ApiException ex) { if (!IsDisposed) ShowError(ex.Message); } finally { saving = false; diff --git a/BiStudent/Pages/RecognitionSettingsDialog.cs b/BiStudent/Pages/RecognitionSettingsDialog.cs new file mode 100644 index 0000000..d1ca5c7 --- /dev/null +++ b/BiStudent/Pages/RecognitionSettingsDialog.cs @@ -0,0 +1,136 @@ +using BiStudent.Contracts; + +namespace BiStudent; + +internal sealed class RecognitionSettingsDialog : DarkForm +{ + private readonly ApiClient api; + private readonly NumericUpDown threshold = Number(30, 70); + private readonly NumericUpDown margin = Number(0, 20); + private readonly NumericUpDown frames = Number(1, 10); + private readonly Label error = Ui.Label("", 9.5f, Theme.Red); + private readonly ModernButton save = new() { Text = "Сохранить", Primary = true, Width = 140 }; + + public RecognitionSettingsDto? SavedSettings { get; private set; } + private bool busy = true; + private bool loaded; + private ModernButton? cancelButton; + private ModernButton? defaultsButton; + + public RecognitionSettingsDialog(ApiClient api) + { + this.api = api; + Text = "Настройки распознавания"; + ClientSize = new Size(620, 470); + FormBorderStyle = FormBorderStyle.FixedDialog; + StartPosition = FormStartPosition.CenterParent; + MaximizeBox = false; MinimizeBox = false; ShowInTaskbar = false; + + var heading = Ui.Label(Text, 15, Theme.Text, FontStyle.Bold); + heading.Dock = DockStyle.Top; + heading.Height = 56; + heading.Padding = new Padding(24, 10, 0, 0); + + var content = new Panel { Dock = DockStyle.Fill, BackColor = Theme.Background, Padding = new Padding(24, 8, 24, 8) }; + var layout = new TableLayoutPanel { Dock = DockStyle.Fill, RowCount = 4, BackColor = Theme.Background }; + for (var i = 0; i < 3; i++) layout.RowStyles.Add(new RowStyle(SizeType.Absolute, 96)); + layout.RowStyles.Add(new RowStyle(SizeType.Percent, 100)); + layout.Controls.Add(Field("Порог сходства", "Выше — меньше ложных отметок, но чаще «Неизвестный»", threshold, true), 0, 0); + layout.Controls.Add(Field("Отрыв от второго кандидата", "Защищает от путаницы похожих студентов", margin, true), 0, 1); + layout.Controls.Add(Field("Кадров подряд для отметки", "Больше — надёжнее, но отметка ставится дольше", frames, false), 0, 2); + error.TextAlign = ContentAlignment.TopLeft; + layout.Controls.Add(error, 0, 3); + content.Controls.Add(layout); + + var buttons = new FlowLayoutPanel + { + Dock = DockStyle.Bottom, Height = 58, FlowDirection = FlowDirection.RightToLeft, + BackColor = Theme.Background, Padding = new Padding(16, 8, 6, 8) + }; + var cancel = new ModernButton { Text = "Отмена", Width = 120 }; + var defaults = new ModernButton { Text = "По умолчанию", Width = 165 }; + save.Enabled = false; + cancelButton = cancel; defaultsButton = defaults; + SetEditing(false); + save.Click += async (_, _) => await SaveAsync(); + cancel.Click += (_, _) => { DialogResult = DialogResult.Cancel; Close(); }; + defaults.Click += (_, _) => { SetValues(new RecognitionSettingsDto(FaceEngine.DefaultMatchThreshold, FaceEngine.DefaultMinimumMargin, 3)); loaded = true; SetEditing(true); }; + buttons.Controls.Add(save); buttons.Controls.Add(cancel); buttons.Controls.Add(defaults); + AcceptButton = save; + CancelButton = cancel; + Controls.Add(content); Controls.Add(buttons); Controls.Add(heading); + Shown += async (_, _) => await LoadSettingsAsync(); + } + + private static NumericUpDown Number(int minimum, int maximum) => new() + { + Minimum = minimum, Maximum = maximum, Value = minimum, DecimalPlaces = 0, + Increment = 1, BackColor = Theme.SurfaceHover, ForeColor = Theme.Text, + Font = Theme.Font(11), Width = 112, TextAlign = HorizontalAlignment.Right + }; + + private static Control Field(string caption, string explanation, NumericUpDown input, bool percent) + { + var row = new TableLayoutPanel { Dock = DockStyle.Fill, RowCount = 3, BackColor = Theme.Background }; + row.RowStyles.Add(new RowStyle(SizeType.Absolute, 28)); + row.RowStyles.Add(new RowStyle(SizeType.Absolute, 38)); + row.RowStyles.Add(new RowStyle(SizeType.Absolute, 26)); + row.Controls.Add(Ui.Label(caption, 10, Theme.Text, FontStyle.Bold), 0, 0); + var value = Ui.Row(); + value.Controls.Add(input); + if (percent) + { + var unit = Ui.Label("%", 10, Theme.Text); + unit.AutoSize = true; unit.Dock = DockStyle.None; unit.Padding = new Padding(8, 7, 0, 0); + value.Controls.Add(unit); + } + row.Controls.Add(value, 0, 1); + row.Controls.Add(Ui.Label(explanation, 9, Theme.Muted), 0, 2); + return row; + } + + private void SetValues(RecognitionSettingsDto settings) + { + threshold.Value = Math.Clamp((decimal)Math.Round(settings.MatchThreshold * 100), threshold.Minimum, threshold.Maximum); + margin.Value = Math.Clamp((decimal)Math.Round(settings.MinimumMargin * 100), margin.Minimum, margin.Maximum); + frames.Value = Math.Clamp(settings.FramesInRow, frames.Minimum, frames.Maximum); + error.Text = ""; + } + + private async Task LoadSettingsAsync() + { + try { SetValues(await api.RecognitionSettingsAsync()); loaded = true; } + catch (ApiException ex) { if (!IsDisposed) error.Text = $"{ex.Message}. Можно задать значения «По умолчанию»."; } + finally { if (!IsDisposed) SetEditing(true); } + } + + private async Task SaveAsync() + { + SetEditing(false); + error.Text = ""; + try + { + var settings = new RecognitionSettingsDto((double)(threshold.Value / 100m), (double)(margin.Value / 100m), (int)frames.Value); + SavedSettings = await api.SaveRecognitionSettingsAsync(settings); + if (IsDisposed) return; + DialogResult = DialogResult.OK; + Close(); + } + catch (ApiException ex) { if (!IsDisposed) error.Text = ex.Message; } + finally { if (!IsDisposed && DialogResult != DialogResult.OK) SetEditing(true); } + } + + private void SetEditing(bool enabled) + { + busy = !enabled; + threshold.Enabled = margin.Enabled = frames.Enabled = save.Enabled = enabled && loaded; + if (cancelButton is not null) cancelButton.Enabled = enabled; + if (defaultsButton is not null) defaultsButton.Enabled = enabled; + } + + protected override void OnFormClosing(FormClosingEventArgs e) + { + if (busy && SavedSettings is null && e.CloseReason == CloseReason.UserClosing && DialogResult != DialogResult.OK) e.Cancel = true; + base.OnFormClosing(e); + } +}