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
+