Skip to content

Commit d952b62

Browse files
authored
feat(web): show detected faces in spherical photos (#23974)
1 parent 9f3eeed commit d952b62

File tree

3 files changed

+93
-11
lines changed

3 files changed

+93
-11
lines changed

pnpm-lock.yaml

Lines changed: 17 additions & 5 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

web/package.json

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -31,11 +31,12 @@
3131
"@immich/ui": "^0.43.0",
3232
"@mapbox/mapbox-gl-rtl-text": "0.2.3",
3333
"@mdi/js": "^7.4.47",
34-
"@photo-sphere-viewer/core": "^5.11.5",
35-
"@photo-sphere-viewer/equirectangular-video-adapter": "^5.11.5",
36-
"@photo-sphere-viewer/resolution-plugin": "^5.11.5",
37-
"@photo-sphere-viewer/settings-plugin": "^5.11.5",
38-
"@photo-sphere-viewer/video-plugin": "^5.11.5",
34+
"@photo-sphere-viewer/core": "^5.14.0",
35+
"@photo-sphere-viewer/equirectangular-video-adapter": "^5.14.0",
36+
"@photo-sphere-viewer/markers-plugin": "^5.14.0",
37+
"@photo-sphere-viewer/resolution-plugin": "^5.14.0",
38+
"@photo-sphere-viewer/settings-plugin": "^5.14.0",
39+
"@photo-sphere-viewer/video-plugin": "^5.14.0",
3940
"@types/geojson": "^7946.0.16",
4041
"@zoom-image/core": "^0.41.0",
4142
"@zoom-image/svelte": "^0.3.0",

web/src/lib/components/asset-viewer/photo-sphere-viewer-adapter.svelte

Lines changed: 70 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
<script lang="ts">
2+
import { boundingBoxesArray, type Faces } from '$lib/stores/people.store';
23
import { alwaysLoadOriginalFile } from '$lib/stores/preferences.store';
34
import {
45
EquirectangularAdapter,
@@ -8,11 +9,21 @@
89
type PluginConstructor,
910
} from '@photo-sphere-viewer/core';
1011
import '@photo-sphere-viewer/core/index.css';
12+
import { MarkersPlugin } from '@photo-sphere-viewer/markers-plugin';
13+
import '@photo-sphere-viewer/markers-plugin/index.css';
1114
import { ResolutionPlugin } from '@photo-sphere-viewer/resolution-plugin';
1215
import { SettingsPlugin } from '@photo-sphere-viewer/settings-plugin';
1316
import '@photo-sphere-viewer/settings-plugin/index.css';
1417
import { onDestroy, onMount } from 'svelte';
1518
19+
// Adapted as well as possible from classlist 'border-solid border-white border-3 rounded-lg'
20+
const FACE_BOX_SVG_STYLE = {
21+
fill: 'rgba(0, 0, 0, 0)',
22+
stroke: '#ffffff',
23+
strokeWidth: '3px',
24+
strokeLinejoin: 'round',
25+
};
26+
1627
interface Props {
1728
panorama: string | { source: string };
1829
originalPanorama?: string | { source: string };
@@ -26,6 +37,62 @@
2637
let container: HTMLDivElement | undefined = $state();
2738
let viewer: Viewer;
2839
40+
let animationInProgress: { cancel: () => void } | undefined;
41+
let previousFaces: Faces[] = [];
42+
43+
const boundingBoxesUnsubscribe = boundingBoxesArray.subscribe((faces: Faces[]) => {
44+
// Debounce; don't do anything when the data didn't actually change.
45+
if (faces === previousFaces) {
46+
return;
47+
}
48+
previousFaces = faces;
49+
50+
if (animationInProgress) {
51+
animationInProgress.cancel();
52+
animationInProgress = undefined;
53+
}
54+
if (!viewer || !viewer.state.textureData || !viewer.getPlugin(MarkersPlugin)) {
55+
return;
56+
}
57+
const markersPlugin = viewer.getPlugin<MarkersPlugin>(MarkersPlugin);
58+
59+
// croppedWidth is the size of the texture, which might be cropped to be less than 360/180 degrees.
60+
// This is what we want because the facial recognition is done on the image, not the sphere.
61+
const currentTextureWidth = viewer.state.textureData.panoData.croppedWidth;
62+
63+
markersPlugin.clearMarkers();
64+
for (const [index, face] of faces.entries()) {
65+
const { boundingBoxX1: x1, boundingBoxY1: y1, boundingBoxX2: x2, boundingBoxY2: y2 } = face;
66+
const ratio = currentTextureWidth / face.imageWidth;
67+
// Pixel values are translated to spherical coordinates and only then added to the panorama;
68+
// no need to recalculate when the texture image changes to the original size.
69+
markersPlugin.addMarker({
70+
id: `face_${index}`,
71+
polygonPixels: [
72+
[x1 * ratio, y1 * ratio],
73+
[x2 * ratio, y1 * ratio],
74+
[x2 * ratio, y2 * ratio],
75+
[x1 * ratio, y2 * ratio],
76+
],
77+
svgStyle: FACE_BOX_SVG_STYLE,
78+
});
79+
}
80+
81+
// Smoothly pan to the highlighted (hovered-over) face.
82+
if (faces.length === 1) {
83+
const { boundingBoxX1: x1, boundingBoxY1: y1, boundingBoxX2: x2, boundingBoxY2: y2, imageWidth: w } = faces[0];
84+
const ratio = currentTextureWidth / w;
85+
const x = ((x1 + x2) * ratio) / 2;
86+
const y = ((y1 + y2) * ratio) / 2;
87+
animationInProgress = viewer.animate({
88+
textureX: x,
89+
textureY: y,
90+
zoom: Math.min(viewer.getZoomLevel(), 75),
91+
speed: 500, // duration in ms
92+
});
93+
}
94+
});
95+
2996
onMount(() => {
3097
if (!container) {
3198
return;
@@ -34,6 +101,7 @@
34101
viewer = new Viewer({
35102
adapter,
36103
plugins: [
104+
MarkersPlugin,
37105
SettingsPlugin,
38106
[
39107
ResolutionPlugin,
@@ -68,7 +136,7 @@
68136
zoomSpeed: 0.5,
69137
fisheye: false,
70138
});
71-
const resolutionPlugin = viewer.getPlugin(ResolutionPlugin) as ResolutionPlugin;
139+
const resolutionPlugin = viewer.getPlugin<ResolutionPlugin>(ResolutionPlugin);
72140
const zoomHandler = ({ zoomLevel }: events.ZoomUpdatedEvent) => {
73141
// zoomLevel range: [0, 100]
74142
if (Math.round(zoomLevel) >= 75) {
@@ -89,6 +157,7 @@
89157
if (viewer) {
90158
viewer.destroy();
91159
}
160+
boundingBoxesUnsubscribe();
92161
});
93162
</script>
94163

0 commit comments

Comments
 (0)