/** * Utilities for face detection and location parsing */ export interface FaceLocation { x: number; y: number; width: number; height: number; } /** * Parses face location string from database * Supports multiple formats: * - JSON object: {"x": 100, "y": 200, "width": 150, "height": 150} * - JSON array: [100, 200, 150, 150] or [x1, y1, x2, y2] * - Comma-separated: "100,200,150,150" (x,y,width,height or x1,y1,x2,y2) */ export function parseFaceLocation(location: string): FaceLocation | null { if (!location) return null; // If location is already an object (shouldn't happen but handle it) if (typeof location === 'object' && location !== null) { const loc = location as any; if (typeof loc.x === 'number' && typeof loc.y === 'number' && typeof loc.width === 'number' && typeof loc.height === 'number') { return { x: loc.x, y: loc.y, width: loc.width, height: loc.height }; } } // If it's not a string, try to convert const locationStr = String(location).trim(); if (!locationStr) return null; try { // Try JSON format first (object or array) const parsed = JSON.parse(locationStr); // Handle JSON object: {"x": 100, "y": 200, "width": 150, "height": 150} // or {"x": 100, "y": 200, "w": 150, "h": 150} if (typeof parsed === 'object' && parsed !== null) { // Handle width/height format if ( typeof parsed.x === 'number' && typeof parsed.y === 'number' && typeof parsed.width === 'number' && typeof parsed.height === 'number' ) { return { x: parsed.x, y: parsed.y, width: parsed.width, height: parsed.height, }; } // Handle w/h format (shorthand) if ( typeof parsed.x === 'number' && typeof parsed.y === 'number' && typeof parsed.w === 'number' && typeof parsed.h === 'number' ) { return { x: parsed.x, y: parsed.y, width: parsed.w, height: parsed.h, }; } // Handle object with x1, y1, x2, y2 format if ( typeof parsed.x1 === 'number' && typeof parsed.y1 === 'number' && typeof parsed.x2 === 'number' && typeof parsed.y2 === 'number' ) { return { x: parsed.x1, y: parsed.y1, width: parsed.x2 - parsed.x1, height: parsed.y2 - parsed.y1, }; } } // Handle JSON array: [x, y, width, height] or [x1, y1, x2, y2] if (Array.isArray(parsed) && parsed.length === 4) { const [a, b, c, d] = parsed.map(Number); if (parsed.every((n: any) => typeof n === 'number' && !isNaN(n))) { // Check if it's x1,y1,x2,y2 format (width/height would be negative if x2 a && d > b) { // Likely x1, y1, x2, y2 format return { x: a, y: b, width: c - a, height: d - b, }; } else { // Likely x, y, width, height format return { x: a, y: b, width: c, height: d, }; } } } } catch { // Not JSON, try comma-separated format } // Try comma-separated format: "x,y,width,height" or "x1,y1,x2,y2" const parts = locationStr.split(',').map((s) => s.trim()).map(Number); if (parts.length === 4 && parts.every((n) => !isNaN(n))) { const [a, b, c, d] = parts; // Check if it's x1,y1,x2,y2 format (width/height would be negative if x2 a && d > b) { // Likely x1, y1, x2, y2 format return { x: a, y: b, width: c - a, height: d - b, }; } else { // Likely x, y, width, height format return { x: a, y: b, width: c, height: d, }; } } return null; } /** * Checks if a mouse point is within a face bounding box * Handles image scaling (object-fit: cover) correctly */ export function isPointInFace( mouseX: number, mouseY: number, faceLocation: FaceLocation, imageNaturalWidth: number, imageNaturalHeight: number, containerWidth: number, containerHeight: number ): boolean { return isPointInFaceWithFit( mouseX, mouseY, faceLocation, imageNaturalWidth, imageNaturalHeight, containerWidth, containerHeight, 'cover' ); } /** * Checks if a mouse point is within a face bounding box * Handles both object-fit: cover and object-fit: contain */ export function isPointInFaceWithFit( mouseX: number, mouseY: number, faceLocation: FaceLocation, imageNaturalWidth: number, imageNaturalHeight: number, containerWidth: number, containerHeight: number, objectFit: 'cover' | 'contain' = 'cover' ): boolean { if (!imageNaturalWidth || !imageNaturalHeight) return false; const imageAspect = imageNaturalWidth / imageNaturalHeight; const containerAspect = containerWidth / containerHeight; let scale: number; let offsetX = 0; let offsetY = 0; if (objectFit === 'cover') { // object-fit: cover - image covers entire container, may be cropped if (imageAspect > containerAspect) { // Image is wider - scale based on height scale = containerHeight / imageNaturalHeight; const scaledWidth = imageNaturalWidth * scale; offsetX = (containerWidth - scaledWidth) / 2; } else { // Image is taller - scale based on width scale = containerWidth / imageNaturalWidth; const scaledHeight = imageNaturalHeight * scale; offsetY = (containerHeight - scaledHeight) / 2; } } else { // object-fit: contain - image fits entirely within container, may have empty space if (imageAspect > containerAspect) { // Image is wider - scale based on width scale = containerWidth / imageNaturalWidth; const scaledHeight = imageNaturalHeight * scale; offsetY = (containerHeight - scaledHeight) / 2; } else { // Image is taller - scale based on height scale = containerHeight / imageNaturalHeight; const scaledWidth = imageNaturalWidth * scale; offsetX = (containerWidth - scaledWidth) / 2; } } // Scale face location to container coordinates const scaledX = faceLocation.x * scale + offsetX; const scaledY = faceLocation.y * scale + offsetY; const scaledWidth = faceLocation.width * scale; const scaledHeight = faceLocation.height * scale; // Check if mouse is within face bounds return ( mouseX >= scaledX && mouseX <= scaledX + scaledWidth && mouseY >= scaledY && mouseY <= scaledY + scaledHeight ); }