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.main
parent
8ae1749dac
commit
0ef986e71a
|
|
@ -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;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
internal static class EmployeePhotoFaceProcessor
|
||||||
|
{
|
||||||
|
private const int DetectionMaxSide = 900;
|
||||||
|
|
||||||
|
/// <summary>Hikvision's recommended enrollment picture is 480x640.</summary>
|
||||||
|
private const int OutputTargetWidth = 480;
|
||||||
|
|
||||||
|
private const int OutputMinWidth = 240;
|
||||||
|
|
||||||
|
/// <summary>Upscale limit so a small source photo is not blown up into a blurry frame.</summary>
|
||||||
|
private const double MaxUpscale = 2.5;
|
||||||
|
|
||||||
|
private const int MaxJpegBytes = 200 * 1024;
|
||||||
|
|
||||||
|
/// <summary>Face box is widened to this multiple of its width; keeps chin/hair/shoulders in frame.</summary>
|
||||||
|
private const double CropWidthFactor = 2.0;
|
||||||
|
|
||||||
|
/// <summary>Portrait aspect (width:height) Hikvision enrollment photos are normalized to.</summary>
|
||||||
|
private const double CropAspect = 3.0 / 4.0;
|
||||||
|
|
||||||
|
/// <summary>Vertical placement of the face centre inside the crop (headroom above, shoulders below).</summary>
|
||||||
|
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<byte>();
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Returns the largest detected face in <paramref name="source"/> coordinates, or empty.</summary>
|
||||||
|
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<Rectangle>();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Percentile contrast stretch + gamma toward mid brightness + unsharp mask.</summary>
|
||||||
|
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<byte>();
|
||||||
|
|
||||||
|
var bytes = Array.Empty<byte>();
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -53,6 +53,10 @@
|
||||||
<PackageReference Include="MySql.Data">
|
<PackageReference Include="MySql.Data">
|
||||||
<Version>9.6.0</Version>
|
<Version>9.6.0</Version>
|
||||||
</PackageReference>
|
</PackageReference>
|
||||||
|
<!-- Managed Haar face detection for InitialDepartmentSync photo pre-processing -->
|
||||||
|
<PackageReference Include="Accord.Vision">
|
||||||
|
<Version>3.8.0</Version>
|
||||||
|
</PackageReference>
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
</Project>
|
</Project>
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue