feat: convert NEDO XML templates to Hanvon face photos

Detect NEDO blobs, extract/prepare JPEG for Hanvon upload.
main
SYED MUSTUFA AHMED NAQVI 2026-09-04 12:32:13 +05:00
parent 7741523d25
commit b95fc02f74
3 changed files with 674 additions and 0 deletions

172
NedoPhotoPreprocessor.cs Normal file
View File

@ -0,0 +1,172 @@
using System;
using System.Configuration;
using System.Drawing;
using System.Drawing.Drawing2D;
using System.Drawing.Imaging;
using System.IO;
namespace HanvonF710XAttendanceService
{
internal static class NedoPhotoPreprocessor
{
public const int DefaultMinShortSide = 480;
public const int HanvonMaxSide = 1024;
public const int HanvonMaxBytes = 500 * 1024;
public static bool TryPrepareForHanvon(string photoBase64, out string preparedBase64, out int width, out int height, out string note)
{
preparedBase64 = null;
width = 0;
height = 0;
note = null;
if (string.IsNullOrWhiteSpace(photoBase64))
{
note = "empty";
return false;
}
byte[] sourceBytes;
try
{
sourceBytes = Convert.FromBase64String(photoBase64);
}
catch
{
note = "invalid_base64";
return false;
}
if (!IsCompleteJpeg(sourceBytes))
{
note = "incomplete_jpeg";
return false;
}
int minShortSide = GetIntSetting("NEDO_FACE_MIN_SHORT_SIDE", DefaultMinShortSide);
byte[] outputBytes;
using (var inputStream = new MemoryStream(sourceBytes))
using (var image = Image.FromStream(inputStream))
{
width = image.Width;
height = image.Height;
int shortSide = Math.Min(width, height);
if (shortSide >= minShortSide && sourceBytes.Length <= HanvonMaxBytes)
{
preparedBase64 = photoBase64;
note = "unchanged";
return true;
}
double scale = 1.0;
if (shortSide < minShortSide)
{
scale = (double)minShortSide / shortSide;
}
int targetWidth = (int)Math.Round(width * scale);
int targetHeight = (int)Math.Round(height * scale);
int longSide = Math.Max(targetWidth, targetHeight);
if (longSide > HanvonMaxSide)
{
double fit = (double)HanvonMaxSide / longSide;
targetWidth = (int)Math.Round(targetWidth * fit);
targetHeight = (int)Math.Round(targetHeight * fit);
}
using (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(image, 0, 0, targetWidth, targetHeight);
using (var outputStream = new MemoryStream())
{
var encoder = GetJpegEncoder();
if (encoder != null)
{
using (var parameters = new EncoderParameters(1))
{
parameters.Param[0] = new EncoderParameter(Encoder.Quality, 92L);
bitmap.Save(outputStream, encoder, parameters);
}
}
else
{
bitmap.Save(outputStream, ImageFormat.Jpeg);
}
outputBytes = outputStream.ToArray();
}
}
width = targetWidth;
height = targetHeight;
}
if (outputBytes.Length > HanvonMaxBytes)
{
note = "too_large_after_prepare";
return false;
}
preparedBase64 = Convert.ToBase64String(outputBytes);
note = "upscaled";
return true;
}
public static bool IsCompleteJpeg(byte[] bytes)
{
if (bytes == null || bytes.Length < 4)
{
return false;
}
if (bytes[0] != 0xFF || bytes[1] != 0xD8)
{
return false;
}
for (int i = bytes.Length - 2; i >= 0; i--)
{
if (bytes[i] == 0xFF && bytes[i + 1] == 0xD9)
{
return true;
}
}
return false;
}
private static ImageCodecInfo GetJpegEncoder()
{
ImageCodecInfo[] codecs = ImageCodecInfo.GetImageEncoders();
foreach (var codec in codecs)
{
if (string.Equals(codec.MimeType, "image/jpeg", StringComparison.OrdinalIgnoreCase))
{
return codec;
}
}
return null;
}
private static int GetIntSetting(string key, int defaultValue)
{
try
{
var raw = ConfigurationManager.AppSettings[key];
if (string.IsNullOrWhiteSpace(raw)) return defaultValue;
return int.TryParse(raw.Trim(), out var v) && v > 0 ? v : defaultValue;
}
catch
{
return defaultValue;
}
}
}
}

375
NedoTemplateConverter.cs Normal file
View File

@ -0,0 +1,375 @@
using System;
using System.Text;
using System.Text.RegularExpressions;
namespace HanvonF710XAttendanceService
{
internal enum DbToDeviceTemplateFormat
{
Empty,
NedoXml,
HanvonJpegFace,
HanvonRecord
}
internal enum NedoPhotoExtractResult
{
Found,
Missing,
Invalid
}
internal sealed class DbToDeviceUploadPlan
{
public DbToDeviceTemplateFormat Format { get; set; } = DbToDeviceTemplateFormat.Empty;
public string PushMode { get; set; }
public string Payload { get; set; }
public bool UseProfileVerification { get; set; }
public NedoPhotoExtractResult? NedoPhotoStatus { get; set; }
public int PhotoByteLength { get; set; }
public int PhotoBase64Length { get; set; }
public int SourceBlobLength { get; set; }
public string PrepareNote { get; set; }
public int PreparedWidth { get; set; }
public int PreparedHeight { get; set; }
public string PhotoSourceLabel { get; set; }
public string PhotoSourceUrl { get; set; }
/// <param name="hrmsEmployeeId">employees.id used for portal photo URL. Must NOT be the device serial.</param>
public bool TryResolveEmployeePhoto(string hrmsEmployeeId, out string error)
{
error = null;
bool hadNedoPayload = HasUploadPayload;
if (Format != DbToDeviceTemplateFormat.NedoXml || !EmployeePhotoSourceSettings.IsEnabled())
{
if (Format == DbToDeviceTemplateFormat.NedoXml && HasUploadPayload)
{
PhotoSourceLabel = "NEDO_XML";
}
return HasUploadPayload;
}
if (string.IsNullOrWhiteSpace(hrmsEmployeeId))
{
error = "hrms_employee_id_missing";
if (hadNedoPayload)
{
PhotoSourceLabel = "NEDO_XML";
return true;
}
return false;
}
if (HrmsEmployeePhotoClient.TryDownloadEmployeePhoto(hrmsEmployeeId, out HrmsEmployeePhotoDownloadResult portalPhoto))
{
Payload = Convert.ToBase64String(portalPhoto.ImageBytes);
PhotoByteLength = portalPhoto.ImageBytes.Length;
PhotoBase64Length = portalPhoto.Base64Length;
PhotoSourceLabel = "HRMS_PORTAL";
PhotoSourceUrl = portalPhoto.Url;
PreparedWidth = portalPhoto.Width;
PreparedHeight = portalPhoto.Height;
return true;
}
error = portalPhoto?.Error;
if (hadNedoPayload)
{
PhotoSourceLabel = "NEDO_XML";
return true;
}
return false;
}
public bool TryPrepareFacePayload(out string error)
{
error = null;
if (Format != DbToDeviceTemplateFormat.NedoXml && Format != DbToDeviceTemplateFormat.HanvonJpegFace)
{
return true;
}
if (string.IsNullOrWhiteSpace(Payload))
{
error = "empty";
return false;
}
if (!NedoPhotoPreprocessor.TryPrepareForHanvon(Payload, out string prepared, out int width, out int height, out string note))
{
error = note ?? "prepare_failed";
return false;
}
Payload = prepared;
PrepareNote = note;
PreparedWidth = width;
PreparedHeight = height;
PhotoBase64Length = prepared?.Length ?? 0;
PhotoByteLength = NedoTemplateConverter.GetDecodedJpegByteLength(prepared);
return true;
}
public string FormatLabel
{
get
{
switch (Format)
{
case DbToDeviceTemplateFormat.NedoXml: return "NEDO_XML";
case DbToDeviceTemplateFormat.HanvonJpegFace: return "HANVON_JPEG";
case DbToDeviceTemplateFormat.HanvonRecord: return "HANVON_RECORD";
default: return "EMPTY";
}
}
}
public bool HasUploadPayload => !string.IsNullOrWhiteSpace(Payload);
public static DbToDeviceUploadPlan FromBlob(byte[] blob)
{
var plan = new DbToDeviceUploadPlan();
plan.SourceBlobLength = blob?.Length ?? 0;
if (blob == null || blob.Length == 0)
{
plan.Format = DbToDeviceTemplateFormat.Empty;
return plan;
}
if (blob.Length >= 3 && blob[0] == 0xFF && blob[1] == 0xD8 && blob[2] == 0xFF)
{
plan.Format = DbToDeviceTemplateFormat.HanvonJpegFace;
plan.PushMode = "face";
plan.Payload = Convert.ToBase64String(blob);
plan.PhotoByteLength = blob.Length;
plan.UseProfileVerification = true;
return plan;
}
string text = Encoding.UTF8.GetString(blob).Trim();
if (string.IsNullOrWhiteSpace(text))
{
plan.Format = DbToDeviceTemplateFormat.Empty;
return plan;
}
if (NedoTemplateConverter.IsNedoXml(text))
{
plan.Format = DbToDeviceTemplateFormat.NedoXml;
plan.PushMode = "face";
plan.UseProfileVerification = true;
string photo;
NedoPhotoExtractResult photoResult = NedoTemplateConverter.TryExtractPhoto(text, out photo);
plan.NedoPhotoStatus = photoResult;
if (photoResult == NedoPhotoExtractResult.Found)
{
plan.Payload = photo;
plan.PhotoBase64Length = photo?.Length ?? 0;
plan.PhotoByteLength = NedoTemplateConverter.GetDecodedJpegByteLength(photo);
}
return plan;
}
string normalized = HanvonHttpApiClient.NormalizeFaceTemplatePayload(blob, out string pushMode);
plan.Payload = normalized;
plan.PushMode = pushMode;
plan.Format = string.Equals(pushMode, "face", StringComparison.OrdinalIgnoreCase)
? DbToDeviceTemplateFormat.HanvonJpegFace
: DbToDeviceTemplateFormat.HanvonRecord;
plan.UseProfileVerification = plan.Format == DbToDeviceTemplateFormat.HanvonJpegFace;
if (plan.Format == DbToDeviceTemplateFormat.HanvonJpegFace && !string.IsNullOrWhiteSpace(normalized))
{
plan.PhotoByteLength = NedoTemplateConverter.GetDecodedJpegByteLength(normalized);
}
return plan;
}
}
internal static class NedoTemplateConverter
{
private static readonly Regex PhotoAttributeRegex = new Regex(
@"photo\s*=\s*""([^""]*)""",
RegexOptions.IgnoreCase | RegexOptions.CultureInvariant);
public static bool IsNedoXml(string text)
{
if (string.IsNullOrWhiteSpace(text))
{
return false;
}
bool hasPhoto = text.IndexOf("photo=\"", StringComparison.OrdinalIgnoreCase) >= 0
|| text.IndexOf("photo =", StringComparison.OrdinalIgnoreCase) >= 0;
if (!hasPhoto)
{
return false;
}
bool hasFaceData = text.IndexOf("face_data=\"", StringComparison.OrdinalIgnoreCase) >= 0;
bool hasCheckTypeFace = text.IndexOf("check_type=\"face\"", StringComparison.OrdinalIgnoreCase) >= 0;
bool hasIdAttribute = Regex.IsMatch(text, @"\bid\s*=\s*""", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant);
return hasFaceData || hasCheckTypeFace || hasIdAttribute;
}
public static NedoPhotoExtractResult TryExtractPhoto(string nedoText, out string photoBase64)
{
photoBase64 = null;
if (string.IsNullOrWhiteSpace(nedoText))
{
return NedoPhotoExtractResult.Missing;
}
int photoAttrIndex = FindPhotoAttributeIndex(nedoText, out int valueStart);
if (photoAttrIndex < 0)
{
return NedoPhotoExtractResult.Missing;
}
if (valueStart >= nedoText.Length)
{
return NedoPhotoExtractResult.Missing;
}
int faceDataIndex = nedoText.IndexOf("\"face_data", valueStart, StringComparison.OrdinalIgnoreCase);
if (faceDataIndex < 0)
{
faceDataIndex = nedoText.IndexOf("\" face_data", valueStart, StringComparison.OrdinalIgnoreCase);
}
int valueEnd;
if (faceDataIndex > valueStart)
{
valueEnd = faceDataIndex;
}
else
{
valueEnd = nedoText.IndexOf('"', valueStart);
}
if (valueEnd <= valueStart)
{
Match match = PhotoAttributeRegex.Match(nedoText);
if (!match.Success || match.Groups.Count < 2)
{
return NedoPhotoExtractResult.Missing;
}
return ValidateAndNormalizePhoto(match.Groups[1].Value, out photoBase64);
}
string raw = nedoText.Substring(valueStart, valueEnd - valueStart);
return ValidateAndNormalizePhoto(raw, out photoBase64);
}
private static int FindPhotoAttributeIndex(string text, out int valueStart)
{
valueStart = -1;
Match spaced = Regex.Match(text, @"photo\s*=\s*""", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant);
if (!spaced.Success)
{
return -1;
}
valueStart = spaced.Index + spaced.Length;
return spaced.Index;
}
private static NedoPhotoExtractResult ValidateAndNormalizePhoto(string raw, out string photoBase64)
{
photoBase64 = null;
if (string.IsNullOrWhiteSpace(raw))
{
return NedoPhotoExtractResult.Missing;
}
raw = raw.Trim();
if (raw.StartsWith("data:", StringComparison.OrdinalIgnoreCase))
{
int comma = raw.IndexOf(',');
if (comma >= 0)
{
raw = raw.Substring(comma + 1);
}
}
raw = raw.Replace("\r", "").Replace("\n", "").Trim();
if (!IsValidJpegBase64(raw))
{
return NedoPhotoExtractResult.Invalid;
}
try
{
byte[] decoded = Convert.FromBase64String(raw);
if (!NedoPhotoPreprocessor.IsCompleteJpeg(decoded))
{
return NedoPhotoExtractResult.Invalid;
}
}
catch
{
return NedoPhotoExtractResult.Invalid;
}
photoBase64 = raw;
return NedoPhotoExtractResult.Found;
}
public static bool IsValidJpegBase64(string base64)
{
if (string.IsNullOrWhiteSpace(base64))
{
return false;
}
try
{
byte[] decoded = Convert.FromBase64String(base64);
return decoded.Length >= 3
&& decoded[0] == 0xFF
&& decoded[1] == 0xD8
&& decoded[2] == 0xFF;
}
catch
{
return false;
}
}
public static int GetDecodedJpegByteLength(string base64)
{
if (string.IsNullOrWhiteSpace(base64))
{
return 0;
}
try
{
return Convert.FromBase64String(base64).Length;
}
catch
{
return 0;
}
}
public static bool UsesFaceField(DbToDeviceUploadPlan plan)
{
return plan != null
&& string.Equals(plan.PushMode, "face", StringComparison.OrdinalIgnoreCase);
}
public static bool UsesRecordField(DbToDeviceUploadPlan plan)
{
return plan != null
&& string.Equals(plan.PushMode, "record", StringComparison.OrdinalIgnoreCase);
}
}
}

View File

@ -0,0 +1,127 @@
using System;
using System.Text;
namespace HanvonF710XAttendanceService
{
internal static class NedoTemplateConverterTests
{
private const string SampleJpegBase64 =
"/9j/4AAQSkZJRgABAQEAOABkAAD/2wBDAA0JCgsKCA0LCgsODg0PEyAVExISEyccHhcgLikxMC4pLSwzOko+MzZGNywtQFdBRkxOUlNSMj5aYVpQYEpRUk//2wBDAQ4ODhMREyYVFSZPNS01T09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT0//wAARCACgAHgDASIAAhEBAxEB/8QAFQABAQAAAAAAAAAAAAAAAAAAAAb/xAAUEAEAAAAAAAAAAAAAAAAAAAAA/9oADAMBAAIQAxAAAAGfAP/Z";
public static int RunAll()
{
int failed = 0;
failed += Run("1 detect NEDO XML", TestDetectNedoXml);
failed += Run("2 extract valid NEDO photo", TestExtractValidNedoPhoto);
failed += Run("3 missing NEDO photo", TestMissingNedoPhoto);
failed += Run("4 invalid NEDO photo", TestInvalidNedoPhoto);
failed += Run("5 NEDO plan uses face not record", TestNedoPlanUsesFace);
failed += Run("6 Hanvon-native template uses record", TestHanvonNativeUsesRecord);
failed += Run("7 NEDO plan uses profile verification", TestNedoUsesProfileVerification);
failed += Run("8 employee 5 style NEDO blob", TestEmployee5StyleBlob);
failed += Run("9 prepare upscales small NEDO photo", TestPrepareUpscalesSmallPhoto);
return failed;
}
private static int Run(string name, Func<bool> test)
{
try
{
if (!test())
{
Console.WriteLine("FAIL: " + name);
return 1;
}
Console.WriteLine("PASS: " + name);
return 0;
}
catch (Exception ex)
{
Console.WriteLine("FAIL: " + name + " err=" + ex.Message);
return 1;
}
}
private static bool TestDetectNedoXml()
{
string xml = BuildNedoXml("5", SampleJpegBase64);
return NedoTemplateConverter.IsNedoXml(xml);
}
private static bool TestExtractValidNedoPhoto()
{
string xml = BuildNedoXml("5", SampleJpegBase64);
NedoPhotoExtractResult result = NedoTemplateConverter.TryExtractPhoto(xml, out string photo);
return result == NedoPhotoExtractResult.Found
&& photo == SampleJpegBase64
&& NedoTemplateConverter.GetDecodedJpegByteLength(photo) > 0;
}
private static bool TestMissingNedoPhoto()
{
string xml = "id=\"5\" name=\"\" check_type=\"face\" face_data=\"abc\"";
NedoPhotoExtractResult result = NedoTemplateConverter.TryExtractPhoto(xml, out string photo);
return result == NedoPhotoExtractResult.Missing && photo == null;
}
private static bool TestInvalidNedoPhoto()
{
string xml = BuildNedoXml("5", "not-valid-base64!!!");
NedoPhotoExtractResult result = NedoTemplateConverter.TryExtractPhoto(xml, out string photo);
return result == NedoPhotoExtractResult.Invalid && photo == null;
}
private static bool TestNedoPlanUsesFace()
{
string xml = BuildNedoXml("5", SampleJpegBase64);
var plan = DbToDeviceUploadPlan.FromBlob(Encoding.UTF8.GetBytes(xml));
return plan.Format == DbToDeviceTemplateFormat.NedoXml
&& NedoTemplateConverter.UsesFaceField(plan)
&& !NedoTemplateConverter.UsesRecordField(plan);
}
private static bool TestHanvonNativeUsesRecord()
{
string record = Convert.ToBase64String(new byte[] { 0x01, 0x02, 0x03, 0x04, 0x05, 0x06 });
var plan = DbToDeviceUploadPlan.FromBlob(Encoding.UTF8.GetBytes(record));
return plan.Format == DbToDeviceTemplateFormat.HanvonRecord
&& NedoTemplateConverter.UsesRecordField(plan)
&& !NedoTemplateConverter.UsesFaceField(plan);
}
private static bool TestNedoUsesProfileVerification()
{
string xml = BuildNedoXml("5", SampleJpegBase64);
var plan = DbToDeviceUploadPlan.FromBlob(Encoding.UTF8.GetBytes(xml));
return plan.UseProfileVerification;
}
private static bool TestEmployee5StyleBlob()
{
string xml = "id=\"5\" name=\"\" authority=\"0X11\" card_num=\"0Xffffffff\" calid=\"0\" check_type=\"face\" opendoor_type=\"face\" password=\"\" alg_edition=\"3.1\" sn=\"6753718120000147\"photo=\"" + SampleJpegBase64 + "\"face_data=\"abc\"";
var plan = DbToDeviceUploadPlan.FromBlob(Encoding.UTF8.GetBytes(xml));
return plan.Format == DbToDeviceTemplateFormat.NedoXml
&& plan.HasUploadPayload
&& plan.NedoPhotoStatus == NedoPhotoExtractResult.Found;
}
private static bool TestPrepareUpscalesSmallPhoto()
{
var plan = DbToDeviceUploadPlan.FromBlob(Encoding.UTF8.GetBytes(BuildNedoXml("5", SampleJpegBase64)));
if (!plan.TryPrepareFacePayload(out string error))
{
return false;
}
return string.Equals(plan.PrepareNote, "upscaled", StringComparison.OrdinalIgnoreCase)
&& plan.PreparedWidth >= NedoPhotoPreprocessor.DefaultMinShortSide
&& plan.PreparedHeight >= NedoPhotoPreprocessor.DefaultMinShortSide;
}
private static string BuildNedoXml(string id, string photo)
{
return "id=\"" + id + "\" name=\"\" check_type=\"face\" photo=\"" + photo + "\" face_data=\"biometric-not-used\"";
}
}
}