first commit
This commit is contained in:
@@ -0,0 +1,137 @@
|
||||
/**
|
||||
* Client-side normalisation for face photos headed to a Hikvision face library.
|
||||
*
|
||||
* The terminal is strict in ways a raw camera roll photo never satisfies, so the browser
|
||||
* does the work before upload:
|
||||
* - JPEG only. Phone photos are usually HEIC, which the terminal rejects outright.
|
||||
* - Small. The ~3MB figure in the Hikvision docs is the ISAPI transport ceiling; the
|
||||
* face *library* caps far lower, so an oversized photo uploads happily and then fails
|
||||
* enrolment -- the worst failure mode, because it looks like it worked.
|
||||
* - Portrait. Terminals expect a 3:4 face frame; a square crop tends to cut the chin.
|
||||
*
|
||||
* Canvas does all of this, so there is no image dependency in this project.
|
||||
*/
|
||||
|
||||
/** Target frame. 3:4 portrait is what the face library expects. */
|
||||
export const FACE_WIDTH = 480
|
||||
export const FACE_HEIGHT = 640
|
||||
|
||||
/** Face-library budget, well under the ISAPI transport limit. */
|
||||
export const FACE_TARGET_BYTES = 150 * 1024
|
||||
export const FACE_MAX_BYTES = 200 * 1024
|
||||
|
||||
/** Guard on the *input* file, applied before decoding so a huge file can't exhaust memory. */
|
||||
export const INPUT_MAX_BYTES = 3 * 1024 * 1024
|
||||
|
||||
/** Quality ladder: start high, step down only while over budget. */
|
||||
const QUALITY_STEPS = [0.85, 0.75, 0.65]
|
||||
|
||||
export interface NormalizedFacePhoto {
|
||||
/** Bare base64 JPEG, no data: prefix — what the API expects. */
|
||||
base64: string
|
||||
/** data: URL for previewing in an <img>. */
|
||||
previewUrl: string
|
||||
bytes: number
|
||||
width: number
|
||||
height: number
|
||||
}
|
||||
|
||||
export class FacePhotoError extends Error {}
|
||||
|
||||
/**
|
||||
* Decodes any browser-readable image, cover-crops it to 3:4, resizes to 480x640 and
|
||||
* encodes JPEG under the face-library budget.
|
||||
*/
|
||||
export async function normalizeFacePhoto(
|
||||
source: Blob | HTMLVideoElement
|
||||
): Promise<NormalizedFacePhoto> {
|
||||
if (source instanceof Blob) {
|
||||
if (source.size > INPUT_MAX_BYTES) {
|
||||
throw new FacePhotoError(
|
||||
`That image is ${Math.round(source.size / 1024 / 1024)} MB. Choose one under 3 MB.`
|
||||
)
|
||||
}
|
||||
if (source.size === 0) {
|
||||
throw new FacePhotoError("That file is empty.")
|
||||
}
|
||||
}
|
||||
|
||||
const bitmap = await toBitmap(source)
|
||||
|
||||
try {
|
||||
const canvas = document.createElement("canvas")
|
||||
canvas.width = FACE_WIDTH
|
||||
canvas.height = FACE_HEIGHT
|
||||
|
||||
const ctx = canvas.getContext("2d")
|
||||
if (!ctx) throw new FacePhotoError("This browser cannot process images.")
|
||||
|
||||
// Cover-crop: fill the 3:4 frame from the centre rather than squashing the face.
|
||||
const scale = Math.max(FACE_WIDTH / bitmap.width, FACE_HEIGHT / bitmap.height)
|
||||
const drawWidth = bitmap.width * scale
|
||||
const drawHeight = bitmap.height * scale
|
||||
|
||||
ctx.imageSmoothingQuality = "high"
|
||||
// A face library needs an opaque background; a transparent PNG would otherwise
|
||||
// flatten to black and wreck the exposure.
|
||||
ctx.fillStyle = "#ffffff"
|
||||
ctx.fillRect(0, 0, FACE_WIDTH, FACE_HEIGHT)
|
||||
ctx.drawImage(
|
||||
bitmap,
|
||||
(FACE_WIDTH - drawWidth) / 2,
|
||||
(FACE_HEIGHT - drawHeight) / 2,
|
||||
drawWidth,
|
||||
drawHeight
|
||||
)
|
||||
|
||||
let blob: Blob | null = null
|
||||
for (const quality of QUALITY_STEPS) {
|
||||
blob = await canvasToBlob(canvas, quality)
|
||||
if (blob && blob.size <= FACE_TARGET_BYTES) break
|
||||
}
|
||||
|
||||
if (!blob) throw new FacePhotoError("Could not process that image. Try another photo.")
|
||||
|
||||
if (blob.size > FACE_MAX_BYTES) {
|
||||
throw new FacePhotoError(
|
||||
"Could not compress that photo enough for the access terminal. Try a simpler " +
|
||||
"background or better lighting."
|
||||
)
|
||||
}
|
||||
|
||||
const dataUrl = await blobToDataUrl(blob)
|
||||
return {
|
||||
base64: dataUrl.slice(dataUrl.indexOf(",") + 1),
|
||||
previewUrl: dataUrl,
|
||||
bytes: blob.size,
|
||||
width: FACE_WIDTH,
|
||||
height: FACE_HEIGHT,
|
||||
}
|
||||
} finally {
|
||||
if ("close" in bitmap && typeof bitmap.close === "function") bitmap.close()
|
||||
}
|
||||
}
|
||||
|
||||
async function toBitmap(source: Blob | HTMLVideoElement): Promise<ImageBitmap> {
|
||||
try {
|
||||
// createImageBitmap decodes HEIC on Safari and every other format the browser knows.
|
||||
return await createImageBitmap(source as ImageBitmapSource)
|
||||
} catch {
|
||||
throw new FacePhotoError(
|
||||
"That file isn't a readable image. Use a JPEG or PNG photo, or take one with the camera."
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
function canvasToBlob(canvas: HTMLCanvasElement, quality: number): Promise<Blob | null> {
|
||||
return new Promise((resolve) => canvas.toBlob(resolve, "image/jpeg", quality))
|
||||
}
|
||||
|
||||
function blobToDataUrl(blob: Blob): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader()
|
||||
reader.onload = () => resolve(String(reader.result))
|
||||
reader.onerror = () => reject(new FacePhotoError("Could not read the processed image."))
|
||||
reader.readAsDataURL(blob)
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user