Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions BiStudent.Core/Contracts/Contracts.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<RecognizedFaceDto> Faces, int FrameWidth, int FrameHeight, string? Error = null);

public sealed record EnrollRequest(List<byte[]> Images);
public sealed record EnrollResultDto(bool Ok, string Message);
public sealed record EnrollRequest(List<byte[]> Images, bool AllowSimilar = false);
public sealed record EnrollResultDto(bool Ok, string Message, string? SimilarTo = null, double SimilarScore = 0, List<string>? Problems = null);

public sealed record ImportResultDto(int Added, int Skipped, List<string> Errors);
public sealed record ErrorDto(string Message);
87 changes: 81 additions & 6 deletions BiStudent.Core/FaceEngine.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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
{
Expand All @@ -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()
{
Expand Down Expand Up @@ -55,7 +64,9 @@ public IReadOnlyList<FaceMatch> 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);
Expand All @@ -71,11 +82,13 @@ public IReadOnlyList<FaceMatch> 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;
}
Expand Down Expand Up @@ -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);
Expand Down
50 changes: 50 additions & 0 deletions BiStudent.Core/ImageHeader.cs
Original file line number Diff line number Diff line change
@@ -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<byte> 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<byte> 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<byte> data) => (data[0] << 24) | (data[1] << 16) | (data[2] << 8) | data[3];
}
11 changes: 7 additions & 4 deletions BiStudent.Server/Endpoints/ApiEndpoints.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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));
Expand Down Expand Up @@ -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) =>
Expand Down Expand Up @@ -258,9 +261,9 @@ private static async Task<ReportDto> 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,
Expand Down
Loading
Loading