Some checks failed
CI / skip-ci-check (push) Successful in 1m27s
CI / skip-ci-check (pull_request) Successful in 1m27s
CI / lint-and-type-check (pull_request) Has been cancelled
CI / python-lint (pull_request) Has been cancelled
CI / test-backend (pull_request) Has been cancelled
CI / build (pull_request) Has been cancelled
CI / secret-scanning (pull_request) Has been cancelled
CI / dependency-scan (pull_request) Has been cancelled
CI / sast-scan (pull_request) Has been cancelled
CI / workflow-summary (pull_request) Has been cancelled
CI / lint-and-type-check (push) Successful in 2m4s
CI / python-lint (push) Successful in 1m53s
CI / test-backend (push) Successful in 2m37s
CI / build (push) Failing after 2m13s
CI / secret-scanning (push) Successful in 1m40s
CI / dependency-scan (push) Successful in 1m34s
CI / sast-scan (push) Successful in 2m42s
CI / workflow-summary (push) Successful in 1m26s
This commit modifies the `.gitignore` file to exclude Python library directories while ensuring the viewer-frontend's `lib` directory is not ignored. It also updates the `package.json` to activate the virtual environment during backend tests, improving the testing process. Additionally, the CI workflow is enhanced to prevent duplicate runs for branches with open pull requests. Various components in the viewer frontend are updated to ensure consistent naming conventions and improve type safety. These changes contribute to a cleaner codebase and a more efficient development workflow.
235 lines
6.6 KiB
TypeScript
235 lines
6.6 KiB
TypeScript
/**
|
|
* 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<x1 or y2<y1)
|
|
// Or if values suggest it's coordinates rather than size
|
|
if (c > 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<x1 or y2<y1)
|
|
if (c > 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
|
|
);
|
|
}
|
|
|