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; } }