138 lines
4.6 KiB
TypeScript
138 lines
4.6 KiB
TypeScript
/**
|
|
* 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)
|
|
})
|
|
}
|