mirror of
https://github.com/immich-app/immich.git
synced 2026-05-18 03:10:24 +03:00
refactor: extract shared ContentMetrics for overlay position calculations (#26310)
This commit is contained in:
@@ -0,0 +1,94 @@
|
||||
import { getContentMetrics, getNaturalSize, scaleToFit } from '$lib/utils/container-utils';
|
||||
|
||||
const mockImage = (props: {
|
||||
naturalWidth: number;
|
||||
naturalHeight: number;
|
||||
width: number;
|
||||
height: number;
|
||||
}): HTMLImageElement => props as unknown as HTMLImageElement;
|
||||
|
||||
const mockVideo = (props: {
|
||||
videoWidth: number;
|
||||
videoHeight: number;
|
||||
clientWidth: number;
|
||||
clientHeight: number;
|
||||
}): HTMLVideoElement => {
|
||||
const element = Object.create(HTMLVideoElement.prototype);
|
||||
for (const [key, value] of Object.entries(props)) {
|
||||
Object.defineProperty(element, key, { value, writable: true, configurable: true });
|
||||
}
|
||||
return element;
|
||||
};
|
||||
|
||||
describe('scaleToFit', () => {
|
||||
it('should return full width when image is wider than container', () => {
|
||||
expect(scaleToFit({ width: 2000, height: 1000 }, { width: 800, height: 600 })).toEqual({ width: 800, height: 400 });
|
||||
});
|
||||
|
||||
it('should return full height when image is taller than container', () => {
|
||||
expect(scaleToFit({ width: 1000, height: 2000 }, { width: 800, height: 600 })).toEqual({ width: 300, height: 600 });
|
||||
});
|
||||
|
||||
it('should return exact fit when aspect ratios match', () => {
|
||||
expect(scaleToFit({ width: 1600, height: 900 }, { width: 800, height: 450 })).toEqual({ width: 800, height: 450 });
|
||||
});
|
||||
|
||||
it('should handle square images in landscape container', () => {
|
||||
expect(scaleToFit({ width: 500, height: 500 }, { width: 800, height: 600 })).toEqual({ width: 600, height: 600 });
|
||||
});
|
||||
|
||||
it('should handle square images in portrait container', () => {
|
||||
expect(scaleToFit({ width: 500, height: 500 }, { width: 400, height: 600 })).toEqual({ width: 400, height: 400 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('getContentMetrics', () => {
|
||||
it('should compute zero offsets when aspect ratios match', () => {
|
||||
const img = mockImage({ naturalWidth: 1600, naturalHeight: 900, width: 800, height: 450 });
|
||||
expect(getContentMetrics(img)).toEqual({
|
||||
contentWidth: 800,
|
||||
contentHeight: 450,
|
||||
offsetX: 0,
|
||||
offsetY: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it('should compute horizontal letterbox offsets for tall image', () => {
|
||||
const img = mockImage({ naturalWidth: 1000, naturalHeight: 2000, width: 800, height: 600 });
|
||||
const metrics = getContentMetrics(img);
|
||||
expect(metrics.contentWidth).toBe(300);
|
||||
expect(metrics.contentHeight).toBe(600);
|
||||
expect(metrics.offsetX).toBe(250);
|
||||
expect(metrics.offsetY).toBe(0);
|
||||
});
|
||||
|
||||
it('should compute vertical letterbox offsets for wide image', () => {
|
||||
const img = mockImage({ naturalWidth: 2000, naturalHeight: 1000, width: 800, height: 600 });
|
||||
const metrics = getContentMetrics(img);
|
||||
expect(metrics.contentWidth).toBe(800);
|
||||
expect(metrics.contentHeight).toBe(400);
|
||||
expect(metrics.offsetX).toBe(0);
|
||||
expect(metrics.offsetY).toBe(100);
|
||||
});
|
||||
|
||||
it('should use clientWidth/clientHeight for video elements', () => {
|
||||
const video = mockVideo({ videoWidth: 1920, videoHeight: 1080, clientWidth: 800, clientHeight: 600 });
|
||||
const metrics = getContentMetrics(video);
|
||||
expect(metrics.contentWidth).toBe(800);
|
||||
expect(metrics.contentHeight).toBe(450);
|
||||
expect(metrics.offsetX).toBe(0);
|
||||
expect(metrics.offsetY).toBe(75);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getNaturalSize', () => {
|
||||
it('should return naturalWidth/naturalHeight for images', () => {
|
||||
const img = mockImage({ naturalWidth: 4000, naturalHeight: 3000, width: 800, height: 600 });
|
||||
expect(getNaturalSize(img)).toEqual({ width: 4000, height: 3000 });
|
||||
});
|
||||
|
||||
it('should return videoWidth/videoHeight for videos', () => {
|
||||
const video = mockVideo({ videoWidth: 1920, videoHeight: 1080, clientWidth: 800, clientHeight: 600 });
|
||||
expect(getNaturalSize(video)).toEqual({ width: 1920, height: 1080 });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,45 @@
|
||||
export interface ContentMetrics {
|
||||
contentWidth: number;
|
||||
contentHeight: number;
|
||||
offsetX: number;
|
||||
offsetY: number;
|
||||
}
|
||||
|
||||
export const scaleToFit = (
|
||||
dimensions: { width: number; height: number },
|
||||
container: { width: number; height: number },
|
||||
): { width: number; height: number } => {
|
||||
const scaleX = container.width / dimensions.width;
|
||||
const scaleY = container.height / dimensions.height;
|
||||
const scale = Math.min(scaleX, scaleY);
|
||||
return {
|
||||
width: dimensions.width * scale,
|
||||
height: dimensions.height * scale,
|
||||
};
|
||||
};
|
||||
|
||||
const getElementSize = (element: HTMLImageElement | HTMLVideoElement): { width: number; height: number } => {
|
||||
if (element instanceof HTMLVideoElement) {
|
||||
return { width: element.clientWidth, height: element.clientHeight };
|
||||
}
|
||||
return { width: element.width, height: element.height };
|
||||
};
|
||||
|
||||
export const getNaturalSize = (element: HTMLImageElement | HTMLVideoElement): { width: number; height: number } => {
|
||||
if (element instanceof HTMLVideoElement) {
|
||||
return { width: element.videoWidth, height: element.videoHeight };
|
||||
}
|
||||
return { width: element.naturalWidth, height: element.naturalHeight };
|
||||
};
|
||||
|
||||
export const getContentMetrics = (element: HTMLImageElement | HTMLVideoElement): ContentMetrics => {
|
||||
const natural = getNaturalSize(element);
|
||||
const client = getElementSize(element);
|
||||
const { width: contentWidth, height: contentHeight } = scaleToFit(natural, client);
|
||||
return {
|
||||
contentWidth,
|
||||
contentHeight,
|
||||
offsetX: (client.width - contentWidth) / 2,
|
||||
offsetY: (client.height - contentHeight) / 2,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,116 @@
|
||||
import type { OcrBoundingBox } from '$lib/stores/ocr.svelte';
|
||||
import type { ContentMetrics } from '$lib/utils/container-utils';
|
||||
import { getOcrBoundingBoxes } from '$lib/utils/ocr-utils';
|
||||
|
||||
describe('getOcrBoundingBoxes', () => {
|
||||
it('should scale normalized coordinates by display dimensions', () => {
|
||||
const ocrData: OcrBoundingBox[] = [
|
||||
{
|
||||
id: 'box1',
|
||||
assetId: 'asset1',
|
||||
x1: 0.1,
|
||||
y1: 0.2,
|
||||
x2: 0.9,
|
||||
y2: 0.2,
|
||||
x3: 0.9,
|
||||
y3: 0.8,
|
||||
x4: 0.1,
|
||||
y4: 0.8,
|
||||
boxScore: 0.95,
|
||||
textScore: 0.9,
|
||||
text: 'hello',
|
||||
},
|
||||
];
|
||||
const metrics: ContentMetrics = { contentWidth: 1000, contentHeight: 500, offsetX: 0, offsetY: 0 };
|
||||
|
||||
const boxes = getOcrBoundingBoxes(ocrData, metrics);
|
||||
|
||||
expect(boxes).toHaveLength(1);
|
||||
expect(boxes[0].id).toBe('box1');
|
||||
expect(boxes[0].text).toBe('hello');
|
||||
expect(boxes[0].confidence).toBe(0.9);
|
||||
expect(boxes[0].points).toEqual([
|
||||
{ x: 100, y: 100 },
|
||||
{ x: 900, y: 100 },
|
||||
{ x: 900, y: 400 },
|
||||
{ x: 100, y: 400 },
|
||||
]);
|
||||
});
|
||||
|
||||
it('should apply offsets for letterboxed images', () => {
|
||||
const ocrData: OcrBoundingBox[] = [
|
||||
{
|
||||
id: 'box1',
|
||||
assetId: 'asset1',
|
||||
x1: 0,
|
||||
y1: 0,
|
||||
x2: 1,
|
||||
y2: 0,
|
||||
x3: 1,
|
||||
y3: 1,
|
||||
x4: 0,
|
||||
y4: 1,
|
||||
boxScore: 0.9,
|
||||
textScore: 0.8,
|
||||
text: 'test',
|
||||
},
|
||||
];
|
||||
const metrics: ContentMetrics = { contentWidth: 600, contentHeight: 400, offsetX: 100, offsetY: 50 };
|
||||
|
||||
const boxes = getOcrBoundingBoxes(ocrData, metrics);
|
||||
|
||||
expect(boxes[0].points).toEqual([
|
||||
{ x: 100, y: 50 },
|
||||
{ x: 700, y: 50 },
|
||||
{ x: 700, y: 450 },
|
||||
{ x: 100, y: 450 },
|
||||
]);
|
||||
});
|
||||
|
||||
it('should return empty array for empty input', () => {
|
||||
const metrics: ContentMetrics = { contentWidth: 800, contentHeight: 600, offsetX: 0, offsetY: 0 };
|
||||
expect(getOcrBoundingBoxes([], metrics)).toEqual([]);
|
||||
});
|
||||
|
||||
it('should handle multiple boxes', () => {
|
||||
const ocrData: OcrBoundingBox[] = [
|
||||
{
|
||||
id: 'a',
|
||||
assetId: 'asset1',
|
||||
x1: 0,
|
||||
y1: 0,
|
||||
x2: 0.5,
|
||||
y2: 0,
|
||||
x3: 0.5,
|
||||
y3: 0.5,
|
||||
x4: 0,
|
||||
y4: 0.5,
|
||||
boxScore: 0.9,
|
||||
textScore: 0.8,
|
||||
text: 'first',
|
||||
},
|
||||
{
|
||||
id: 'b',
|
||||
assetId: 'asset1',
|
||||
x1: 0.5,
|
||||
y1: 0.5,
|
||||
x2: 1,
|
||||
y2: 0.5,
|
||||
x3: 1,
|
||||
y3: 1,
|
||||
x4: 0.5,
|
||||
y4: 1,
|
||||
boxScore: 0.9,
|
||||
textScore: 0.7,
|
||||
text: 'second',
|
||||
},
|
||||
];
|
||||
const metrics: ContentMetrics = { contentWidth: 200, contentHeight: 200, offsetX: 0, offsetY: 0 };
|
||||
|
||||
const boxes = getOcrBoundingBoxes(ocrData, metrics);
|
||||
|
||||
expect(boxes).toHaveLength(2);
|
||||
expect(boxes[0].text).toBe('first');
|
||||
expect(boxes[1].text).toBe('second');
|
||||
});
|
||||
});
|
||||
@@ -1,16 +1,5 @@
|
||||
import type { OcrBoundingBox } from '$lib/stores/ocr.svelte';
|
||||
import type { ZoomImageWheelState } from '@zoom-image/core';
|
||||
|
||||
const getContainedSize = (img: HTMLImageElement): { width: number; height: number } => {
|
||||
const ratio = img.naturalWidth / img.naturalHeight;
|
||||
let width = img.height * ratio;
|
||||
let height = img.height;
|
||||
if (width > img.width) {
|
||||
width = img.width;
|
||||
height = img.width / ratio;
|
||||
}
|
||||
return { width, height };
|
||||
};
|
||||
import type { ContentMetrics } from '$lib/utils/container-utils';
|
||||
|
||||
export type Point = {
|
||||
x: number;
|
||||
@@ -66,53 +55,17 @@ export const calculateBoundingBoxMatrix = (points: Point[]): { matrix: number[];
|
||||
return { matrix, width, height };
|
||||
};
|
||||
|
||||
/**
|
||||
* Convert normalized OCR coordinates to screen coordinates
|
||||
* OCR coordinates are normalized (0-1) and represent the 4 corners of a rotated rectangle
|
||||
*/
|
||||
export const getOcrBoundingBoxes = (
|
||||
ocrData: OcrBoundingBox[],
|
||||
zoom: ZoomImageWheelState,
|
||||
photoViewer: HTMLImageElement | null,
|
||||
): OcrBox[] => {
|
||||
if (photoViewer === null || !photoViewer.naturalWidth || !photoViewer.naturalHeight) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const clientHeight = photoViewer.clientHeight;
|
||||
const clientWidth = photoViewer.clientWidth;
|
||||
const { width, height } = getContainedSize(photoViewer);
|
||||
|
||||
const offset = {
|
||||
x: ((clientWidth - width) / 2) * zoom.currentZoom + zoom.currentPositionX,
|
||||
y: ((clientHeight - height) / 2) * zoom.currentZoom + zoom.currentPositionY,
|
||||
};
|
||||
|
||||
return getOcrBoundingBoxesAtSize(
|
||||
ocrData,
|
||||
{ width: width * zoom.currentZoom, height: height * zoom.currentZoom },
|
||||
offset,
|
||||
);
|
||||
};
|
||||
|
||||
export const getOcrBoundingBoxesAtSize = (
|
||||
ocrData: OcrBoundingBox[],
|
||||
targetSize: { width: number; height: number },
|
||||
offset?: Point,
|
||||
) => {
|
||||
export const getOcrBoundingBoxes = (ocrData: OcrBoundingBox[], metrics: ContentMetrics): OcrBox[] => {
|
||||
const boxes: OcrBox[] = [];
|
||||
|
||||
for (const ocr of ocrData) {
|
||||
// Convert normalized coordinates (0-1) to actual pixel positions
|
||||
// OCR provides 4 corners of a potentially rotated rectangle
|
||||
const points = [
|
||||
{ x: ocr.x1, y: ocr.y1 },
|
||||
{ x: ocr.x2, y: ocr.y2 },
|
||||
{ x: ocr.x3, y: ocr.y3 },
|
||||
{ x: ocr.x4, y: ocr.y4 },
|
||||
].map((point) => ({
|
||||
x: targetSize.width * point.x + (offset?.x ?? 0),
|
||||
y: targetSize.height * point.y + (offset?.y ?? 0),
|
||||
x: point.x * metrics.contentWidth + metrics.offsetX,
|
||||
y: point.y * metrics.contentHeight + metrics.offsetY,
|
||||
}));
|
||||
|
||||
boxes.push({
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
import type { Faces } from '$lib/stores/people.store';
|
||||
import type { ContentMetrics } from '$lib/utils/container-utils';
|
||||
import { getBoundingBox } from '$lib/utils/people-utils';
|
||||
|
||||
const makeFace = (overrides: Partial<Faces> = {}): Faces => ({
|
||||
imageWidth: 4000,
|
||||
imageHeight: 3000,
|
||||
boundingBoxX1: 1000,
|
||||
boundingBoxY1: 750,
|
||||
boundingBoxX2: 2000,
|
||||
boundingBoxY2: 1500,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe('getBoundingBox', () => {
|
||||
it('should scale face coordinates to display dimensions', () => {
|
||||
const face = makeFace();
|
||||
const metrics: ContentMetrics = { contentWidth: 800, contentHeight: 600, offsetX: 0, offsetY: 0 };
|
||||
|
||||
const boxes = getBoundingBox([face], metrics);
|
||||
|
||||
expect(boxes).toHaveLength(1);
|
||||
expect(boxes[0]).toEqual({
|
||||
top: Math.round(600 * (750 / 3000)),
|
||||
left: Math.round(800 * (1000 / 4000)),
|
||||
width: Math.round(800 * (2000 / 4000) - 800 * (1000 / 4000)),
|
||||
height: Math.round(600 * (1500 / 3000) - 600 * (750 / 3000)),
|
||||
});
|
||||
});
|
||||
|
||||
it('should apply offsets for letterboxed display', () => {
|
||||
const face = makeFace({
|
||||
imageWidth: 1000,
|
||||
imageHeight: 1000,
|
||||
boundingBoxX1: 0,
|
||||
boundingBoxY1: 0,
|
||||
boundingBoxX2: 1000,
|
||||
boundingBoxY2: 1000,
|
||||
});
|
||||
const metrics: ContentMetrics = { contentWidth: 600, contentHeight: 600, offsetX: 100, offsetY: 0 };
|
||||
|
||||
const boxes = getBoundingBox([face], metrics);
|
||||
|
||||
expect(boxes[0]).toEqual({
|
||||
top: 0,
|
||||
left: 100,
|
||||
width: 600,
|
||||
height: 600,
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle zoom by pre-scaled metrics', () => {
|
||||
const face = makeFace({
|
||||
imageWidth: 1000,
|
||||
imageHeight: 1000,
|
||||
boundingBoxX1: 0,
|
||||
boundingBoxY1: 0,
|
||||
boundingBoxX2: 500,
|
||||
boundingBoxY2: 500,
|
||||
});
|
||||
const metrics: ContentMetrics = {
|
||||
contentWidth: 1600,
|
||||
contentHeight: 1200,
|
||||
offsetX: -200,
|
||||
offsetY: -100,
|
||||
};
|
||||
|
||||
const boxes = getBoundingBox([face], metrics);
|
||||
|
||||
expect(boxes[0]).toEqual({
|
||||
top: -100,
|
||||
left: -200,
|
||||
width: 800,
|
||||
height: 600,
|
||||
});
|
||||
});
|
||||
|
||||
it('should return empty array for empty faces', () => {
|
||||
const metrics: ContentMetrics = { contentWidth: 800, contentHeight: 600, offsetX: 0, offsetY: 0 };
|
||||
expect(getBoundingBox([], metrics)).toEqual([]);
|
||||
});
|
||||
|
||||
it('should handle multiple faces', () => {
|
||||
const faces = [
|
||||
makeFace({ boundingBoxX1: 0, boundingBoxY1: 0, boundingBoxX2: 1000, boundingBoxY2: 1000 }),
|
||||
makeFace({ boundingBoxX1: 2000, boundingBoxY1: 1500, boundingBoxX2: 3000, boundingBoxY2: 2500 }),
|
||||
];
|
||||
const metrics: ContentMetrics = { contentWidth: 800, contentHeight: 600, offsetX: 0, offsetY: 0 };
|
||||
|
||||
const boxes = getBoundingBox(faces, metrics);
|
||||
|
||||
expect(boxes).toHaveLength(2);
|
||||
expect(boxes[0].left).toBeLessThan(boxes[1].left);
|
||||
});
|
||||
});
|
||||
@@ -1,65 +1,27 @@
|
||||
import type { Faces } from '$lib/stores/people.store';
|
||||
import { getAssetMediaUrl } from '$lib/utils';
|
||||
import type { ContentMetrics } from '$lib/utils/container-utils';
|
||||
import { AssetTypeEnum, type AssetFaceResponseDto } from '@immich/sdk';
|
||||
import type { ZoomImageWheelState } from '@zoom-image/core';
|
||||
|
||||
const getContainedSize = (img: HTMLImageElement): { width: number; height: number } => {
|
||||
const ratio = img.naturalWidth / img.naturalHeight;
|
||||
let width = img.height * ratio;
|
||||
let height = img.height;
|
||||
if (width > img.width) {
|
||||
width = img.width;
|
||||
height = img.width / ratio;
|
||||
}
|
||||
return { width, height };
|
||||
};
|
||||
|
||||
export interface boundingBox {
|
||||
export interface BoundingBox {
|
||||
top: number;
|
||||
left: number;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
export const getBoundingBox = (
|
||||
faces: Faces[],
|
||||
zoom: ZoomImageWheelState,
|
||||
photoViewer: HTMLImageElement | undefined,
|
||||
): boundingBox[] => {
|
||||
const boxes: boundingBox[] = [];
|
||||
|
||||
if (!photoViewer) {
|
||||
return boxes;
|
||||
}
|
||||
const clientHeight = photoViewer.clientHeight;
|
||||
const clientWidth = photoViewer.clientWidth;
|
||||
|
||||
const { width, height } = getContainedSize(photoViewer);
|
||||
export const getBoundingBox = (faces: Faces[], metrics: ContentMetrics): BoundingBox[] => {
|
||||
const boxes: BoundingBox[] = [];
|
||||
|
||||
for (const face of faces) {
|
||||
/*
|
||||
*
|
||||
* Create the coordinates of the box based on the displayed image.
|
||||
* The coordinates must take into account margins due to the 'object-fit: contain;' css property of the photo-viewer.
|
||||
*
|
||||
*/
|
||||
const scaleX = metrics.contentWidth / face.imageWidth;
|
||||
const scaleY = metrics.contentHeight / face.imageHeight;
|
||||
|
||||
const coordinates = {
|
||||
x1:
|
||||
(width / face.imageWidth) * zoom.currentZoom * face.boundingBoxX1 +
|
||||
((clientWidth - width) / 2) * zoom.currentZoom +
|
||||
zoom.currentPositionX,
|
||||
x2:
|
||||
(width / face.imageWidth) * zoom.currentZoom * face.boundingBoxX2 +
|
||||
((clientWidth - width) / 2) * zoom.currentZoom +
|
||||
zoom.currentPositionX,
|
||||
y1:
|
||||
(height / face.imageHeight) * zoom.currentZoom * face.boundingBoxY1 +
|
||||
((clientHeight - height) / 2) * zoom.currentZoom +
|
||||
zoom.currentPositionY,
|
||||
y2:
|
||||
(height / face.imageHeight) * zoom.currentZoom * face.boundingBoxY2 +
|
||||
((clientHeight - height) / 2) * zoom.currentZoom +
|
||||
zoom.currentPositionY,
|
||||
x1: scaleX * face.boundingBoxX1 + metrics.offsetX,
|
||||
x2: scaleX * face.boundingBoxX2 + metrics.offsetX,
|
||||
y1: scaleY * face.boundingBoxY1 + metrics.offsetY,
|
||||
y2: scaleY * face.boundingBoxY2 + metrics.offsetY,
|
||||
};
|
||||
|
||||
boxes.push({
|
||||
@@ -69,6 +31,7 @@ export const getBoundingBox = (
|
||||
height: Math.round(coordinates.y2 - coordinates.y1),
|
||||
});
|
||||
}
|
||||
|
||||
return boxes;
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user