mirror of
https://github.com/immich-app/immich.git
synced 2026-03-22 16:09:29 +03:00
refactor: extract shared ContentMetrics for overlay position calculations (#26310)
This commit is contained in:
@@ -3,6 +3,7 @@
|
||||
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
|
||||
import { isFaceEditMode } from '$lib/stores/face-edit.svelte';
|
||||
import { getPeopleThumbnailUrl } from '$lib/utils';
|
||||
import { getContentMetrics, getNaturalSize } from '$lib/utils/container-utils';
|
||||
import { handleError } from '$lib/utils/handle-error';
|
||||
import { createFace, getAllPeople, type PersonResponseDto } from '@immich/sdk';
|
||||
import { Button, Input, modalManager, toastManager } from '@immich/ui';
|
||||
@@ -81,17 +82,13 @@
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
const { actualWidth, actualHeight } = getContainedSize(htmlElement);
|
||||
const offsetArea = {
|
||||
width: (containerWidth - actualWidth) / 2,
|
||||
height: (containerHeight - actualHeight) / 2,
|
||||
};
|
||||
const metrics = getContentMetrics(htmlElement);
|
||||
|
||||
const imageBoundingBox = {
|
||||
top: offsetArea.height,
|
||||
left: offsetArea.width,
|
||||
width: containerWidth - offsetArea.width * 2,
|
||||
height: containerHeight - offsetArea.height * 2,
|
||||
top: metrics.offsetY,
|
||||
left: metrics.offsetX,
|
||||
width: metrics.contentWidth,
|
||||
height: metrics.contentHeight,
|
||||
};
|
||||
|
||||
if (!canvas) {
|
||||
@@ -116,35 +113,6 @@
|
||||
positionFaceSelector();
|
||||
});
|
||||
|
||||
const getNaturalSize = (element: HTMLImageElement | HTMLVideoElement) => {
|
||||
if (element instanceof HTMLImageElement) {
|
||||
return {
|
||||
naturalWidth: element.naturalWidth,
|
||||
naturalHeight: element.naturalHeight,
|
||||
displayWidth: element.width,
|
||||
displayHeight: element.height,
|
||||
};
|
||||
}
|
||||
return {
|
||||
naturalWidth: element.videoWidth,
|
||||
naturalHeight: element.videoHeight,
|
||||
displayWidth: element.clientWidth,
|
||||
displayHeight: element.clientHeight,
|
||||
};
|
||||
};
|
||||
|
||||
const getContainedSize = (element: HTMLImageElement | HTMLVideoElement) => {
|
||||
const { naturalWidth, naturalHeight, displayWidth, displayHeight } = getNaturalSize(element);
|
||||
const ratio = naturalWidth / naturalHeight;
|
||||
let actualWidth = displayHeight * ratio;
|
||||
let actualHeight = displayHeight;
|
||||
if (actualWidth > displayWidth) {
|
||||
actualWidth = displayWidth;
|
||||
actualHeight = displayWidth / ratio;
|
||||
}
|
||||
return { actualWidth, actualHeight };
|
||||
};
|
||||
|
||||
const cancel = () => {
|
||||
isFaceEditMode.value = false;
|
||||
};
|
||||
@@ -245,22 +213,23 @@
|
||||
return;
|
||||
}
|
||||
|
||||
const faceBox = faceRect.getBoundingRect();
|
||||
const { actualWidth, actualHeight } = getContainedSize(htmlElement);
|
||||
const { naturalWidth, naturalHeight } = getNaturalSize(htmlElement);
|
||||
const { left, top, width, height } = faceRect.getBoundingRect();
|
||||
const metrics = getContentMetrics(htmlElement);
|
||||
const natural = getNaturalSize(htmlElement);
|
||||
|
||||
const offsetX = (containerWidth - actualWidth) / 2;
|
||||
const offsetY = (containerHeight - actualHeight) / 2;
|
||||
const scaleX = natural.width / metrics.contentWidth;
|
||||
const scaleY = natural.height / metrics.contentHeight;
|
||||
const imageX = (left - metrics.offsetX) * scaleX;
|
||||
const imageY = (top - metrics.offsetY) * scaleY;
|
||||
|
||||
const scaleX = naturalWidth / actualWidth;
|
||||
const scaleY = naturalHeight / actualHeight;
|
||||
|
||||
const x = Math.floor((faceBox.left - offsetX) * scaleX);
|
||||
const y = Math.floor((faceBox.top - offsetY) * scaleY);
|
||||
const width = Math.floor(faceBox.width * scaleX);
|
||||
const height = Math.floor(faceBox.height * scaleY);
|
||||
|
||||
return { imageWidth: naturalWidth, imageHeight: naturalHeight, x, y, width, height };
|
||||
return {
|
||||
imageWidth: natural.width,
|
||||
imageHeight: natural.height,
|
||||
x: Math.floor(imageX),
|
||||
y: Math.floor(imageY),
|
||||
width: Math.floor(width * scaleX),
|
||||
height: Math.floor(height * scaleY),
|
||||
};
|
||||
};
|
||||
|
||||
const tagFace = async (person: PersonResponseDto) => {
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
import { ocrManager, type OcrBoundingBox } from '$lib/stores/ocr.svelte';
|
||||
import { boundingBoxesArray, type Faces } from '$lib/stores/people.store';
|
||||
import { alwaysLoadOriginalFile } from '$lib/stores/preferences.store';
|
||||
import { calculateBoundingBoxMatrix, getOcrBoundingBoxesAtSize, type Point } from '$lib/utils/ocr-utils';
|
||||
import { calculateBoundingBoxMatrix, getOcrBoundingBoxes, type Point } from '$lib/utils/ocr-utils';
|
||||
import {
|
||||
EquirectangularAdapter,
|
||||
Viewer,
|
||||
@@ -127,9 +127,11 @@
|
||||
markersPlugin.clearMarkers();
|
||||
}
|
||||
|
||||
const boxes = getOcrBoundingBoxesAtSize(ocrData, {
|
||||
width: viewer.state.textureData.panoData.croppedWidth,
|
||||
height: viewer.state.textureData.panoData.croppedHeight,
|
||||
const boxes = getOcrBoundingBoxes(ocrData, {
|
||||
contentWidth: viewer.state.textureData.panoData.croppedWidth,
|
||||
contentHeight: viewer.state.textureData.panoData.croppedHeight,
|
||||
offsetX: 0,
|
||||
offsetY: 0,
|
||||
});
|
||||
|
||||
for (const [index, box] of boxes.entries()) {
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
import { SlideshowLook, SlideshowState, slideshowLookCssMapping, slideshowStore } from '$lib/stores/slideshow.store';
|
||||
import { getAssetUrl, targetImageSize as getTargetImageSize, handlePromiseError } from '$lib/utils';
|
||||
import { canCopyImageToClipboard, copyImageToClipboard } from '$lib/utils/asset-utils';
|
||||
import { type ContentMetrics, getContentMetrics } from '$lib/utils/container-utils';
|
||||
import { handleError } from '$lib/utils/handle-error';
|
||||
import { getOcrBoundingBoxes } from '$lib/utils/ocr-utils';
|
||||
import { getBoundingBox } from '$lib/utils/people-utils';
|
||||
@@ -52,6 +53,7 @@
|
||||
let imageLoaded: boolean = $state(false);
|
||||
let originalImageLoaded: boolean = $state(false);
|
||||
let imageError: boolean = $state(false);
|
||||
let visibleImageReady: boolean = $state(false);
|
||||
|
||||
let loader = $state<HTMLImageElement>();
|
||||
|
||||
@@ -67,11 +69,23 @@
|
||||
$boundingBoxesArray = [];
|
||||
});
|
||||
|
||||
let ocrBoxes = $derived(
|
||||
ocrManager.showOverlay && assetViewerManager.imgRef
|
||||
? getOcrBoundingBoxes(ocrManager.data, assetViewerManager.zoomState, assetViewerManager.imgRef)
|
||||
: [],
|
||||
);
|
||||
const overlayMetrics = $derived.by((): ContentMetrics => {
|
||||
if (!assetViewerManager.imgRef || !visibleImageReady) {
|
||||
return { contentWidth: 0, contentHeight: 0, offsetX: 0, offsetY: 0 };
|
||||
}
|
||||
|
||||
const { contentWidth, contentHeight, offsetX, offsetY } = getContentMetrics(assetViewerManager.imgRef);
|
||||
const { currentZoom, currentPositionX, currentPositionY } = assetViewerManager.zoomState;
|
||||
|
||||
return {
|
||||
contentWidth: contentWidth * currentZoom,
|
||||
contentHeight: contentHeight * currentZoom,
|
||||
offsetX: offsetX * currentZoom + currentPositionX,
|
||||
offsetY: offsetY * currentZoom + currentPositionY,
|
||||
};
|
||||
});
|
||||
|
||||
let ocrBoxes = $derived(ocrManager.showOverlay ? getOcrBoundingBoxes(ocrManager.data, overlayMetrics) : []);
|
||||
|
||||
let isOcrActive = $derived(ocrManager.showOverlay);
|
||||
|
||||
@@ -176,6 +190,7 @@
|
||||
imageLoaded = false;
|
||||
originalImageLoaded = false;
|
||||
imageError = false;
|
||||
visibleImageReady = false;
|
||||
});
|
||||
}
|
||||
lastUrl = imageLoaderUrl;
|
||||
@@ -226,6 +241,7 @@
|
||||
<img
|
||||
bind:this={assetViewerManager.imgRef}
|
||||
src={imageLoaderUrl}
|
||||
onload={() => (visibleImageReady = true)}
|
||||
alt={$getAltText(toTimelineAsset(asset))}
|
||||
class="h-full w-full {$slideshowState === SlideshowState.None
|
||||
? 'object-contain'
|
||||
@@ -233,7 +249,7 @@
|
||||
draggable="false"
|
||||
/>
|
||||
<!-- eslint-disable-next-line svelte/require-each-key -->
|
||||
{#each getBoundingBox($boundingBoxesArray, assetViewerManager.zoomState, assetViewerManager.imgRef) as boundingbox}
|
||||
{#each getBoundingBox($boundingBoxesArray, overlayMetrics) as boundingbox}
|
||||
<div
|
||||
class="absolute border-solid border-white border-3 rounded-lg"
|
||||
style="top: {boundingbox.top}px; left: {boundingbox.left}px; height: {boundingbox.height}px; width: {boundingbox.width}px;"
|
||||
|
||||
94
web/src/lib/utils/container-utils.spec.ts
Normal file
94
web/src/lib/utils/container-utils.spec.ts
Normal file
@@ -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 });
|
||||
});
|
||||
});
|
||||
45
web/src/lib/utils/container-utils.ts
Normal file
45
web/src/lib/utils/container-utils.ts
Normal file
@@ -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,
|
||||
};
|
||||
};
|
||||
116
web/src/lib/utils/ocr-utils.spec.ts
Normal file
116
web/src/lib/utils/ocr-utils.spec.ts
Normal file
@@ -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({
|
||||
|
||||
95
web/src/lib/utils/people-utils.spec.ts
Normal file
95
web/src/lib/utils/people-utils.spec.ts
Normal file
@@ -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