From 38c27d097c593283c253208bb3bb547cb2512c32 Mon Sep 17 00:00:00 2001 From: Paul Nothaft <53005142+the-luap@users.noreply.github.com> Date: Fri, 21 Aug 2026 19:36:59 +0200 Subject: [PATCH] feat(faces): show a detected face in its source photo, outlined (#1120) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 2 of #1096. Stacked on the phase-1 branch — it needs the Postgres fix there, or the face list this reads comes back empty. A 64px avatar answers "is this a person", not "is this the same person as that other cluster". The reporter's revised use for this is the pre-merge decision: who they were standing next to, what the occasion was. So the face opens in its own photo with the detected box drawn, and prev/next walks that person's other appearances without leaving the modal. The box is positioned in PERCENTAGES of the original frame, not measured pixels: the container carries the photo's aspect ratio, so the same four numbers land correctly at any rendered size, with no resize listener. Verified against real data before writing the component — bbox [221.9, 174.9, 294.5, 405.8] on a 750x750 frame resolves to left 29.6% / top 23.3% / width 39.3% / height 54.1% and lands squarely on the face. Preview rendition, never thumbnail, and that is load-bearing rather than a quality preference: thumbnail_fit is seeded to 'cover' on every install, so a thumbnail has had its edges cut off and ratios taken against the ORIGINAL land nowhere on it. That was #1100, and it presented as a broken detector. Not built on AdminPhotoViewer, deliberately. It wants full AdminPhoto objects (this endpoint returns photo_id + bbox + dimensions), it carries delete and category actions that are wrong for "who is this?", and there is no seam to draw the box. Three things review caught, all real: - The container had a height cap but no width cap, so a panorama derived its width from the aspect ratio and overflowed the modal sideways, taking part of the outlined face off-screen. - The per-tile affordance was hover-only, so on a tablet it was permanently invisible and there was no way to inspect a specific tile. - The row action opened index 0, which is the TOP-SCORING face — the same thing as the cover only until someone uses phase 1 to pick a different one, at which point the row showed one face and opened another. It now resolves to the cover's own index. Round 2 found three more, all real: - The counter called a list truncated whenever it hit 500, so a person with exactly 500 faces was told their complete list was capped. It now compares against total_face_count. - facesLoading goes false with an empty array on a zero-face person or a failed request, so the panel sat on a spinner that would never resolve. - Five 32px actions plus a 64px avatar exceed a 320px row, and the name is what got pushed out. flex-wrap alone did not fix it — the toolbar still claimed its max-content width first — so its basis is capped at small sizes and the buttons wrap to a second line instead. A cover that falls outside the capped list opens the first face instead. That case implies the list IS capped, so the truncation note already explains it — real pagination is a bigger change and is not in this. Verified end to end: picked the 5th of 13 faces as cover, and the row action opened at 5 / 13 rather than 1 / 13. Frontend suite 178 passing, build clean, no new type errors. --- .../components/admin/PeopleManagerModal.tsx | 221 +++++++++++++++--- frontend/src/components/gallery/imageTiers.ts | 19 ++ frontend/src/i18n/locales/de.json | 7 + frontend/src/i18n/locales/en.json | 7 + 4 files changed, 227 insertions(+), 27 deletions(-) diff --git a/frontend/src/components/admin/PeopleManagerModal.tsx b/frontend/src/components/admin/PeopleManagerModal.tsx index 0de4cf72..5fe2ca57 100644 --- a/frontend/src/components/admin/PeopleManagerModal.tsx +++ b/frontend/src/components/admin/PeopleManagerModal.tsx @@ -17,12 +17,13 @@ import React, { useMemo, useState } from 'react'; import { useQuery, useQueryClient } from '@tanstack/react-query'; import { useTranslation } from 'react-i18next'; import { toast } from 'react-toastify'; -import { X, Check, Merge, Scissors, EyeOff, Ban, Loader2, Image as ImageIcon } from 'lucide-react'; +import { X, Check, Merge, Scissors, EyeOff, Ban, Loader2, Image as ImageIcon, Maximize2, ChevronLeft, ChevronRight } from 'lucide-react'; import { Button, Loading } from '../common'; import { api } from '../../config/api'; import { faceCropStyle } from '../gallery/faceCrop'; -import { adminFacePreviewUrl } from '../gallery/imageTiers'; +import { adminFacePreviewUrl, adminPhotoPreviewUrl } from '../gallery/imageTiers'; + /** Mirrors PERSON_FACES_LIMIT in adminEvents/faces.js. The endpoint caps a * person's face list, so any surface that renders it has to admit when it is @@ -110,6 +111,64 @@ const FaceThumb: React.FC<{ ); }; +/** + * One face shown in its source photo, with the detected box drawn (#1096). + * + * Positioned in PERCENTAGES of the original frame rather than measured pixels: + * the container carries the photo's aspect ratio, so the same four numbers land + * correctly at any rendered size with no resize listener. Same reasoning as + * faceCropStyle, and it holds for the same reason — provided the rendition is + * the whole frame, which is why this asks for a preview and never a thumbnail. + */ +const FaceInContext: React.FC<{ + eventId: number; + face: PersonFace; +}> = ({ eventId, face }) => { + const { t } = useTranslation(); + const [bx, by, bw, bh] = face.bbox || []; + // Without the original dimensions there is nothing to take a ratio of. Show + // the photo and say so, rather than draw a box in the wrong place — the + // failure mode that made #1100 look like a bad detector. + const canBox = !!(face.photo_width && face.photo_height && bw && bh); + + return ( +
+ {/* max-w-full matters as much as max-h: with only a height cap a + panorama derives its width from the aspect ratio and overflows the + modal sideways, taking part of the outlined face off-screen. */} +
+ + {canBox && ( + + )} +
+ {!canBox && ( +

+ {t('admin.people.contextNoBox', { + defaultValue: 'This photo has no stored dimensions, so the detected face cannot be outlined.', + })} +

+ )} +
+ ); +}; + export const PeopleManagerModal: React.FC = ({ eventId, open, onClose, onChanged, }) => { @@ -124,6 +183,10 @@ export const PeopleManagerModal: React.FC = ({ // one and a human's do not always agree — a cluster whose avatar is softer // or turned away stays that way in the guest-facing strip too. const [coverFor, setCoverFor] = useState(null); + // Face-in-context viewer (#1096). Third mode alongside split and cover: the + // job is deciding whether two similar clusters are the same person, and a + // 64px avatar cannot answer that — who they are standing next to can. + const [viewing, setViewing] = useState<{ person: AdminPerson; index: number } | null>(null); const [busy, setBusy] = useState(false); const { data, isLoading, refetch } = useQuery<{ people: AdminPerson[] }>({ @@ -134,7 +197,7 @@ export const PeopleManagerModal: React.FC = ({ // Both pickers browse the same face list; only the action on a click // differs. Keyed by person id so switching between them reuses the cache. - const facesFor = splitting || coverFor; + const facesFor = splitting || coverFor || viewing?.person || null; const { data: faceData, isLoading: facesLoading } = useQuery<{ faces: PersonFace[] }>({ queryKey: ['admin-person-faces', eventId, facesFor?.id], @@ -260,8 +323,83 @@ export const PeopleManagerModal: React.FC = ({ - {/* --- cover picker ------------------------------------------------ */} - {coverFor ? ( + {/* --- face in context ---------------------------------------------- */} + {viewing ? (() => { + const faces = faceData?.faces || []; + // -1 means "whichever face the row is showing". Resolved here rather + // than at click time because the list is not loaded yet then — and + // index 0 is NOT the answer: it is the top-scoring face, which stops + // being the cover the moment someone picks a different one. + const coverIdx = faces.findIndex((f) => f.id === viewing.person.cover?.face_id); + const index = viewing.index >= 0 + ? Math.min(viewing.index, Math.max(faces.length - 1, 0)) + : Math.max(coverIdx, 0); + const face = faces[index]; + const step = (delta: number) => setViewing((v) => + v && faces.length ? { ...v, index: (index + delta + faces.length) % faces.length } : v); + // The endpoint caps a person's list; say so rather than let the + // counter imply this is everything they appear in. + // Against the person's REAL total, not the cap: someone with exactly + // 500 faces has a complete list and should not be told otherwise. + const truncated = (viewing.person.total_face_count ?? faces.length) > faces.length; + return ( + <> +
+ {t('admin.people.contextHelp', { + defaultValue: 'The detected face, outlined in its original photo — who they were standing next to is usually what settles whether two similar people are the same one.', + })} +
+
+ {facesLoading ? : !face ? ( + // facesLoading goes false with an empty array on a zero-face + // person or a failed request; without this the panel span + // forever on a spinner that would never resolve. +

+ {t('admin.people.contextUnavailable', { + defaultValue: 'No photo could be loaded for this person.', + })} +

+ ) : ( + <> + + {truncated && ( +

+ {t('admin.people.contextTruncated', { + limit: PERSON_FACES_LIMIT, + defaultValue: `Showing the first ${PERSON_FACES_LIMIT} appearances of this person.`, + })} +

+ )} + + )} +
+
+
+ + + {faces.length ? `${index + 1} / ${faces.length}${truncated ? '+' : ''}` : '—'} + + +
+ +
+ + ); + })() : coverFor ? ( <>
{t('admin.people.coverHelp', { @@ -281,33 +419,47 @@ export const PeopleManagerModal: React.FC = ({
{facesLoading ? : (
- {(faceData?.faces || []).map((face) => { + {(faceData?.faces || []).map((face, idx) => { const current = coverFor.cover?.face_id === face.id; return ( - {current && ( - + )} - + {/* Its own affordance: the tile body already means + "use as cover", so looking needs a separate target. + Visible by default where there is no hover to reveal + it — on a tablet the opacity-0 version was simply + unreachable. */} + +
); })}
@@ -450,7 +602,22 @@ export const PeopleManagerModal: React.FC = ({

-
+ {/* Five 32px actions plus a 64px avatar exceed a + 320px row. flex-wrap alone does not help — the + toolbar still claims its max-content width first + and the name collapses to nothing. Capping the + basis makes the buttons wrap to a second line and + leaves the label its space. */} +
+