From 0ef986e71a73119c4b999d1af3d3b114cf9b8c7a Mon Sep 17 00:00:00 2001 From: "mustafa.ahmed" Date: Sat, 15 Aug 2026 15:57:51 +0500 Subject: [PATCH] feat: add HRMS employee photo face preprocessing engine Add EmployeePhotoFaceProcessor: a managed pipeline that detects the largest face in an HRMS portal photo, crops it with margin in a 3:4 frame, normalizes to Hikvision's 480x640 enrollment size (capped upscale), applies percentile contrast/gamma correction plus an unsharp mask, and encodes JPEG within a size budget. Face detection uses Accord.Vision's managed Haar cascade (added as a package reference) so it works on the existing net48/x86 target with no native binaries. Returns an explicit NoFace status so callers can skip enrollment for photos without a detectable face. --- EmployeePhotoFaceProcessor.cs | 416 ++++++++++++++++++++++++++++++ HikvisionAttendanceService.csproj | 4 + 2 files changed, 420 insertions(+) create mode 100644 EmployeePhotoFaceProcessor.cs diff --git a/EmployeePhotoFaceProcessor.cs b/EmployeePhotoFaceProcessor.cs new file mode 100644 index 0000000..e586bca --- /dev/null +++ b/EmployeePhotoFaceProcessor.cs @@ -0,0 +1,416 @@ +using System; +using System.Drawing; +using System.Drawing.Drawing2D; +using System.Drawing.Imaging; +using System.IO; +using System.Linq; +using Accord.Vision.Detection; +using Accord.Vision.Detection.Cascades; + +namespace HikvisionAttendanceService; + +/// +/// Prepares HRMS portal photos for Hikvision FaceDataRecord: detect face → crop with margin → +/// normalize size → brightness/contrast/sharpness → JPEG. +/// Used by the initial department sync only; DB↔device template flows never call this. +/// +internal static class EmployeePhotoFaceProcessor +{ + private const int DetectionMaxSide = 900; + + /// Hikvision's recommended enrollment picture is 480x640. + private const int OutputTargetWidth = 480; + + private const int OutputMinWidth = 240; + + /// Upscale limit so a small source photo is not blown up into a blurry frame. + private const double MaxUpscale = 2.5; + + private const int MaxJpegBytes = 200 * 1024; + + /// Face box is widened to this multiple of its width; keeps chin/hair/shoulders in frame. + private const double CropWidthFactor = 2.0; + + /// Portrait aspect (width:height) Hikvision enrollment photos are normalized to. + private const double CropAspect = 3.0 / 4.0; + + /// Vertical placement of the face centre inside the crop (headroom above, shoulders below). + private const double FaceCentreOffset = 0.45; + + private const double SharpenAmount = 0.6; + + internal enum PhotoStatus + { + Ok, + NoFace, + Error + } + + internal sealed class PhotoResult + { + public PhotoStatus Status { get; set; } = PhotoStatus.Error; + public byte[] JpegBytes { get; set; } = Array.Empty(); + public Size Original { get; set; } + public Size Face { get; set; } + public Size Processed { get; set; } + public int JpegQuality { get; set; } + public string Enhancement { get; set; } = ""; + public string Error { get; set; } = ""; + + public string Describe() => + "original=" + Original.Width + "x" + Original.Height + + " face=" + (Face.Width > 0 ? Face.Width + "x" + Face.Height : "none") + + " processed=" + (Processed.Width > 0 ? Processed.Width + "x" + Processed.Height : "none") + + " jpegBytes=" + JpegBytes.Length; + } + + private static readonly object DetectorLock = new object(); + private static HaarObjectDetector? _detector; + private static ImageCodecInfo? _jpegCodec; + + public static PhotoResult Process(byte[] sourceBytes) + { + var result = new PhotoResult(); + if (sourceBytes == null || sourceBytes.Length == 0) + { + result.Error = "empty_photo"; + return result; + } + + try + { + using var source = LoadAsRgb24(sourceBytes); + result.Original = source.Size; + + var face = DetectLargestFace(source); + if (face.Width <= 0 || face.Height <= 0) + { + result.Status = PhotoStatus.NoFace; + return result; + } + + result.Face = new Size(face.Width, face.Height); + + var crop = BuildCropRect(face, source.Size); + using var processed = CropAndResize(source, crop); + result.Enhancement = Enhance(processed); + result.Processed = processed.Size; + + result.JpegBytes = EncodeJpegWithinLimit(processed, out var quality); + result.JpegQuality = quality; + if (result.JpegBytes.Length == 0) + { + result.Error = "jpeg_encode_failed"; + return result; + } + + result.Status = PhotoStatus.Ok; + return result; + } + catch (Exception ex) + { + result.Status = PhotoStatus.Error; + result.Error = ex.Message; + return result; + } + } + + private static Bitmap LoadAsRgb24(byte[] bytes) + { + using var ms = new MemoryStream(bytes, writable: false); + using var decoded = Image.FromStream(ms, useEmbeddedColorManagement: false, validateImageData: false); + var rgb = new Bitmap(decoded.Width, decoded.Height, PixelFormat.Format24bppRgb); + rgb.SetResolution(96f, 96f); + using (var g = Graphics.FromImage(rgb)) + { + g.CompositingMode = CompositingMode.SourceCopy; + g.InterpolationMode = InterpolationMode.HighQualityBicubic; + g.PixelOffsetMode = PixelOffsetMode.HighQuality; + g.DrawImage(decoded, new Rectangle(0, 0, rgb.Width, rgb.Height)); + } + + return rgb; + } + + /// Returns the largest detected face in coordinates, or empty. + private static Rectangle DetectLargestFace(Bitmap source) + { + var scale = 1.0; + var longest = Math.Max(source.Width, source.Height); + Bitmap? scaled = null; + try + { + var frame = source; + if (longest > DetectionMaxSide) + { + scale = (double)DetectionMaxSide / longest; + var w = Math.Max(1, (int)Math.Round(source.Width * scale)); + var h = Math.Max(1, (int)Math.Round(source.Height * scale)); + scaled = new Bitmap(w, h, PixelFormat.Format24bppRgb); + using (var g = Graphics.FromImage(scaled)) + { + g.InterpolationMode = InterpolationMode.HighQualityBicubic; + g.PixelOffsetMode = PixelOffsetMode.HighQuality; + g.DrawImage(source, new Rectangle(0, 0, w, h)); + } + + frame = scaled; + } + + var minSide = Math.Min(frame.Width, frame.Height); + var best = Rectangle.Empty; + + // First pass favours large, well-framed portraits; second pass is a lenient fallback. + foreach (var pass in new[] + { + new { Min = Math.Max(48, minSide / 8), Factor = 1.2f }, + new { Min = 24, Factor = 1.1f } + }) + { + var found = RunDetector(frame, pass.Min, pass.Factor); + if (found.Length == 0) + continue; + + best = found.OrderByDescending(r => (long)r.Width * r.Height).First(); + break; + } + + if (best.Width <= 0) + return Rectangle.Empty; + + if (scale >= 1.0) + return best; + + return new Rectangle( + (int)Math.Round(best.X / scale), + (int)Math.Round(best.Y / scale), + (int)Math.Round(best.Width / scale), + (int)Math.Round(best.Height / scale)); + } + finally + { + scaled?.Dispose(); + } + } + + private static Rectangle[] RunDetector(Bitmap frame, int minSize, float scaleFactor) + { + lock (DetectorLock) + { + _detector ??= new HaarObjectDetector(new FaceHaarCascade()); + _detector.SearchMode = ObjectDetectorSearchMode.NoOverlap; + _detector.ScalingMode = ObjectDetectorScalingMode.GreaterToSmaller; + _detector.ScalingFactor = scaleFactor; + _detector.MinSize = new Size(minSize, minSize); + _detector.MaxSize = new Size(frame.Width, frame.Height); + return _detector.ProcessFrame(frame) ?? Array.Empty(); + } + } + + private static Rectangle BuildCropRect(Rectangle face, Size image) + { + var centreX = face.X + face.Width / 2.0; + var centreY = face.Y + face.Height / 2.0; + + var cropW = face.Width * CropWidthFactor; + var cropH = cropW / CropAspect; + + var fit = Math.Min(1.0, Math.Min(image.Width / cropW, image.Height / cropH)); + cropW *= fit; + cropH *= fit; + + var left = centreX - cropW / 2.0; + var top = centreY - cropH * FaceCentreOffset; + + left = Math.Max(0, Math.Min(left, image.Width - cropW)); + top = Math.Max(0, Math.Min(top, image.Height - cropH)); + + var rect = new Rectangle( + (int)Math.Round(left), + (int)Math.Round(top), + Math.Max(1, (int)Math.Round(cropW)), + Math.Max(1, (int)Math.Round(cropH))); + + rect.Width = Math.Min(rect.Width, image.Width - rect.X); + rect.Height = Math.Min(rect.Height, image.Height - rect.Y); + return rect; + } + + private static Bitmap CropAndResize(Bitmap source, Rectangle crop) + { + var upscaleCap = (int)Math.Round(crop.Width * MaxUpscale); + var outW = Math.Min(OutputTargetWidth, Math.Max(OutputMinWidth, upscaleCap)); + outW -= outW % 4; + var outH = (int)Math.Round(outW / CropAspect); + outH -= outH % 4; + + var target = new Bitmap(outW, outH, PixelFormat.Format24bppRgb); + target.SetResolution(96f, 96f); + using (var g = Graphics.FromImage(target)) + { + g.CompositingMode = CompositingMode.SourceCopy; + g.InterpolationMode = InterpolationMode.HighQualityBicubic; + g.PixelOffsetMode = PixelOffsetMode.HighQuality; + g.SmoothingMode = SmoothingMode.HighQuality; + g.DrawImage(source, new Rectangle(0, 0, outW, outH), crop, GraphicsUnit.Pixel); + } + + return target; + } + + /// Percentile contrast stretch + gamma toward mid brightness + unsharp mask. + private static string Enhance(Bitmap bmp) + { + var rect = new Rectangle(0, 0, bmp.Width, bmp.Height); + var data = bmp.LockBits(rect, ImageLockMode.ReadWrite, PixelFormat.Format24bppRgb); + try + { + var stride = data.Stride; + var buffer = new byte[stride * bmp.Height]; + System.Runtime.InteropServices.Marshal.Copy(data.Scan0, buffer, 0, buffer.Length); + + var note = ApplyToneCurve(buffer, stride, bmp.Width, bmp.Height); + ApplyUnsharpMask(buffer, stride, bmp.Width, bmp.Height); + + System.Runtime.InteropServices.Marshal.Copy(buffer, 0, data.Scan0, buffer.Length); + return note + " sharpen=" + SharpenAmount.ToString("0.00"); + } + finally + { + bmp.UnlockBits(data); + } + } + + private static string ApplyToneCurve(byte[] buffer, int stride, int width, int height) + { + var hist = new int[256]; + for (int y = 0; y < height; y++) + { + var row = y * stride; + for (int x = 0; x < width; x++) + { + var i = row + x * 3; + var luma = (buffer[i + 2] * 77 + buffer[i + 1] * 151 + buffer[i] * 28) >> 8; + hist[luma]++; + } + } + + long total = (long)width * height; + var lo = Percentile(hist, total, 0.02); + var hi = Percentile(hist, total, 0.98); + + double gain = 1.0; + if (hi - lo >= 8 && hi - lo < 230) + gain = Math.Min(2.2, 245.0 / (hi - lo)); + + var stretch = new byte[256]; + for (int v = 0; v < 256; v++) + stretch[v] = ClampByte((v - lo) * gain + 5.0); + + double sum = 0; + for (int v = 0; v < 256; v++) + sum += (double)hist[v] * stretch[v]; + var mean = total > 0 ? sum / total : 128.0; + + double gamma = 1.0; + if (mean > 4 && mean < 250) + { + gamma = Math.Log(128.0 / 255.0) / Math.Log(mean / 255.0); + gamma = Math.Max(0.65, Math.Min(1.4, gamma)); + } + + var lut = new byte[256]; + for (int v = 0; v < 256; v++) + { + var stretched = stretch[v] / 255.0; + lut[v] = ClampByte(Math.Pow(stretched, gamma) * 255.0); + } + + for (int y = 0; y < height; y++) + { + var row = y * stride; + for (int x = 0; x < width; x++) + { + var i = row + x * 3; + buffer[i] = lut[buffer[i]]; + buffer[i + 1] = lut[buffer[i + 1]]; + buffer[i + 2] = lut[buffer[i + 2]]; + } + } + + return "lo=" + lo + " hi=" + hi + " gain=" + gain.ToString("0.00") + + " mean=" + mean.ToString("0") + " gamma=" + gamma.ToString("0.00"); + } + + private static void ApplyUnsharpMask(byte[] buffer, int stride, int width, int height) + { + if (width < 3 || height < 3) + return; + + var source = (byte[])buffer.Clone(); + for (int y = 1; y < height - 1; y++) + { + var row = y * stride; + var prev = row - stride; + var next = row + stride; + for (int x = 1; x < width - 1; x++) + { + var col = x * 3; + for (int c = 0; c < 3; c++) + { + var i = row + col + c; + var blur = + source[prev + col - 3 + c] + 2 * source[prev + col + c] + source[prev + col + 3 + c] + + 2 * source[row + col - 3 + c] + 4 * source[i] + 2 * source[row + col + 3 + c] + + source[next + col - 3 + c] + 2 * source[next + col + c] + source[next + col + 3 + c]; + var blurred = blur / 16.0; + buffer[i] = ClampByte(source[i] + SharpenAmount * (source[i] - blurred)); + } + } + } + } + + private static int Percentile(int[] hist, long total, double fraction) + { + if (total <= 0) + return 0; + + var threshold = (long)(total * fraction); + long running = 0; + for (int v = 0; v < hist.Length; v++) + { + running += hist[v]; + if (running >= threshold) + return v; + } + + return 255; + } + + private static byte ClampByte(double value) => + value <= 0 ? (byte)0 : value >= 255 ? (byte)255 : (byte)(value + 0.5); + + private static byte[] EncodeJpegWithinLimit(Bitmap bmp, out int quality) + { + quality = 0; + var codec = _jpegCodec ??= ImageCodecInfo.GetImageEncoders() + .FirstOrDefault(c => string.Equals(c.MimeType, "image/jpeg", StringComparison.OrdinalIgnoreCase)); + if (codec == null) + return Array.Empty(); + + var bytes = Array.Empty(); + foreach (var q in new[] { 92, 85, 75, 65, 55 }) + { + using var parameters = new EncoderParameters(1); + parameters.Param[0] = new EncoderParameter(Encoder.Quality, (long)q); + using var ms = new MemoryStream(); + bmp.Save(ms, codec, parameters); + bytes = ms.ToArray(); + quality = q; + if (bytes.Length <= MaxJpegBytes) + break; + } + + return bytes; + } +} diff --git a/HikvisionAttendanceService.csproj b/HikvisionAttendanceService.csproj index 1d11aa3..66c567a 100644 --- a/HikvisionAttendanceService.csproj +++ b/HikvisionAttendanceService.csproj @@ -53,6 +53,10 @@ 9.6.0 + + + 3.8.0 +