diff --git a/web/src/lib/components/asset-viewer/face-editor/face-editor.svelte b/web/src/lib/components/asset-viewer/face-editor/face-editor.svelte index 45cd8baefa..39088b23de 100644 --- a/web/src/lib/components/asset-viewer/face-editor/face-editor.svelte +++ b/web/src/lib/components/asset-viewer/face-editor/face-editor.svelte @@ -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) => { diff --git a/web/src/lib/components/asset-viewer/photo-sphere-viewer-adapter.svelte b/web/src/lib/components/asset-viewer/photo-sphere-viewer-adapter.svelte index f4ba6868e0..926383d9c2 100644 --- a/web/src/lib/components/asset-viewer/photo-sphere-viewer-adapter.svelte +++ b/web/src/lib/components/asset-viewer/photo-sphere-viewer-adapter.svelte @@ -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()) { diff --git a/web/src/lib/components/asset-viewer/photo-viewer.svelte b/web/src/lib/components/asset-viewer/photo-viewer.svelte index 95f3d7a73c..70b5e77d49 100644 --- a/web/src/lib/components/asset-viewer/photo-viewer.svelte +++ b/web/src/lib/components/asset-viewer/photo-viewer.svelte @@ -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(); @@ -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 @@ (visibleImageReady = true)} alt={$getAltText(toTimelineAsset(asset))} class="h-full w-full {$slideshowState === SlideshowState.None ? 'object-contain' @@ -233,7 +249,7 @@ draggable="false" /> - {#each getBoundingBox($boundingBoxesArray, assetViewerManager.zoomState, assetViewerManager.imgRef) as boundingbox} + {#each getBoundingBox($boundingBoxesArray, overlayMetrics) as boundingbox}
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 }); + }); +}); diff --git a/web/src/lib/utils/container-utils.ts b/web/src/lib/utils/container-utils.ts new file mode 100644 index 0000000000..7f770b0e21 --- /dev/null +++ b/web/src/lib/utils/container-utils.ts @@ -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, + }; +}; diff --git a/web/src/lib/utils/ocr-utils.spec.ts b/web/src/lib/utils/ocr-utils.spec.ts new file mode 100644 index 0000000000..c3ce70394d --- /dev/null +++ b/web/src/lib/utils/ocr-utils.spec.ts @@ -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'); + }); +}); diff --git a/web/src/lib/utils/ocr-utils.ts b/web/src/lib/utils/ocr-utils.ts index 01f118a4e5..3da36cf57a 100644 --- a/web/src/lib/utils/ocr-utils.ts +++ b/web/src/lib/utils/ocr-utils.ts @@ -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({ diff --git a/web/src/lib/utils/people-utils.spec.ts b/web/src/lib/utils/people-utils.spec.ts new file mode 100644 index 0000000000..8255352f68 --- /dev/null +++ b/web/src/lib/utils/people-utils.spec.ts @@ -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 => ({ + 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); + }); +}); diff --git a/web/src/lib/utils/people-utils.ts b/web/src/lib/utils/people-utils.ts index 863a3e9e88..4f5b96c6f1 100644 --- a/web/src/lib/utils/people-utils.ts +++ b/web/src/lib/utils/people-utils.ts @@ -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; };