feat(services): add portrait JPEG normalizer for face enrollment

Adds image normalization (480×640 portrait, JPEG ≤200 KB) required by the ISAPI face upload path. Commit this first so the project builds against the already-committed HikvisionIsapiClient.
main
SYED MUSTUFA AHMED NAQVI 2026-08-27 09:59:09 +05:00
parent f047129c14
commit e7714452da
1 changed files with 109 additions and 0 deletions

View File

@ -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();
}
}