diff --git a/src/HikvisionAttendanceManager.App/Services/FaceImageNormalizer.cs b/src/HikvisionAttendanceManager.App/Services/FaceImageNormalizer.cs new file mode 100644 index 0000000..b8395b3 --- /dev/null +++ b/src/HikvisionAttendanceManager.App/Services/FaceImageNormalizer.cs @@ -0,0 +1,109 @@ +using System.Drawing; +using System.Drawing.Drawing2D; +using System.Drawing.Imaging; +using System.IO; +using System.Runtime.Versioning; + +namespace HikvisionAttendanceManager.App.Services; + +[SupportedOSPlatform("windows")] +internal static class FaceImageNormalizer +{ + private const int TargetWidth = 480; + private const int TargetHeight = 640; + private const int MaxBytes = 200 * 1024; + private const int MaxQuality = 92; + private const int MinQuality = 55; + + public static bool TryNormalizeForEnrollment(byte[] input, out byte[] jpegBytes, out string note) + { + jpegBytes = []; + note = ""; + if (input.Length < 4 || input[0] != 0xFF || input[1] != 0xD8 || input[2] != 0xFF) + { + note = "Input is not a JPEG."; + return false; + } + + try + { + using var sourceStream = new MemoryStream(input); + using var source = Image.FromStream(sourceStream); + using var portrait = CropPortrait(source, TargetWidth, TargetHeight); + jpegBytes = CompressToTargetSize(portrait); + note = $"normalized={TargetWidth}x{TargetHeight} bytes={jpegBytes.Length}"; + return jpegBytes.Length > 0; + } + catch (Exception ex) + { + note = ex.Message; + return false; + } + } + + private static Bitmap CropPortrait(Image source, int targetWidth, int targetHeight) + { + var targetAspect = (double)targetWidth / targetHeight; + var sourceAspect = (double)source.Width / source.Height; + + int cropWidth; + int cropHeight; + if (sourceAspect > targetAspect) + { + cropHeight = source.Height; + cropWidth = (int)Math.Round(cropHeight * targetAspect); + } + else + { + cropWidth = source.Width; + cropHeight = (int)Math.Round(cropWidth / targetAspect); + } + + var cropX = Math.Max(0, (source.Width - cropWidth) / 2); + var cropY = Math.Max(0, (source.Height - cropHeight) / 2); + cropWidth = Math.Min(cropWidth, source.Width - cropX); + cropHeight = Math.Min(cropHeight, source.Height - cropY); + + var bitmap = new Bitmap(targetWidth, targetHeight); + using var graphics = Graphics.FromImage(bitmap); + graphics.CompositingQuality = CompositingQuality.HighQuality; + graphics.InterpolationMode = InterpolationMode.HighQualityBicubic; + graphics.SmoothingMode = SmoothingMode.HighQuality; + graphics.PixelOffsetMode = PixelOffsetMode.HighQuality; + graphics.DrawImage(source, + new Rectangle(0, 0, targetWidth, targetHeight), + new Rectangle(cropX, cropY, cropWidth, cropHeight), + GraphicsUnit.Pixel); + return bitmap; + } + + private static byte[] CompressToTargetSize(Bitmap bitmap) + { + byte[]? best = null; + for (var quality = MaxQuality; quality >= MinQuality; quality -= 3) + { + var candidate = SaveJpeg(bitmap, quality); + best = candidate; + if (candidate.Length <= MaxBytes) + return candidate; + } + + return best ?? []; + } + + private static byte[] SaveJpeg(Image image, int quality) + { + using var stream = new MemoryStream(); + using var encoderParameters = new EncoderParameters(1); + encoderParameters.Param[0] = new EncoderParameter(Encoder.Quality, quality); + var codec = ImageCodecInfo.GetImageEncoders().FirstOrDefault(info => info.FormatID == ImageFormat.Jpeg.Guid); + if (codec is null) + { + image.Save(stream, ImageFormat.Jpeg); + return stream.ToArray(); + } + + image.Save(stream, codec, encoderParameters); + return stream.ToArray(); + } +}