diff --git a/CHANGELOG.md b/CHANGELOG.md index bcd013b1e..ebab24e36 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,10 @@ All notable changes to this project will be documented in this file. - **Fix**: Improved and 20x faster planet database setup - **Feature**: Huawei Moving Picture support +## [v8.0.1] - 2026-04-16 + +- **Feature**: Compatibility with manual tagging in face recognition. + ## [v8.0.0] - 2026-04-04 - **Update**: Compatibility with Nextcloud 33 diff --git a/appinfo/info.xml b/appinfo/info.xml index 74e5f1295..83bea3d4c 100644 --- a/appinfo/info.xml +++ b/appinfo/info.xml @@ -29,7 +29,7 @@ Memories is a *batteries-included* photo management solution for Nextcloud with 1. Run `php occ memories:index` to generate metadata indices for existing photos. 1. Open the 📷 Memories app in Nextcloud and set the directory containing your photos. ]]> - 8.1.0 + 8.1.1 agpl Varun Patil Memories diff --git a/lib/ClustersBackend/Covers.php b/lib/ClustersBackend/Covers.php index 5621f3ec9..711fc44bb 100644 --- a/lib/ClustersBackend/Covers.php +++ b/lib/ClustersBackend/Covers.php @@ -19,10 +19,16 @@ final class Covers * @param string $clusterTableId Column name for the cluster ID in clusterTable * @param string $objectTable Table name for the object mapping * @param string $objectTableObjectId Column name for the object ID in objectTable - * @param string $objectTableClusterId Column name for the cluster ID in objectTable + * @param string $objectTableClusterId Column name for the cluster ID in objectTable. + * May be qualified with an alias (e.g. "foo.bar"), in which case it is + * used verbatim instead of being resolved against the object table. * @param bool $validateCluster Whether to validate the cluster * @param bool $validateFilecache Whether to validate the filecache * @param mixed $user Query expression for user ID to use for the covers + * @param null|\Closure $objectTableJoin Optional hook to join further tables into the cluster validation + * subquery, for backends where the object reaches its cluster over + * more than one hop. Receives the subquery builder, whose object + * table is aliased "cov_objs". */ public static function selectCover( IQueryBuilder &$query, @@ -36,7 +42,14 @@ public static function selectCover( bool $validateFilecache = true, string $field = 'cover', mixed $user = null, + ?\Closure $objectTableJoin = null, ): void { + // Where the cluster ID lives on the object side. A qualified name points + // at a table the caller joins in through $objectTableJoin. + $objectClusterIdRef = str_contains($objectTableClusterId, '.') + ? $objectTableClusterId + : "cov_objs.{$objectTableClusterId}"; + // Clauses for the WHERE $clauses = [ $query->expr()->eq('mcov.uid', $user ?? $query->expr()->literal(Util::getUser()->getUID())), @@ -50,9 +63,15 @@ public static function selectCover( $validSq->select($validSq->expr()->literal(1)) ->from($objectTable, 'cov_objs') ->where($validSq->expr()->eq($query->expr()->castColumn("cov_objs.{$objectTableObjectId}", IQueryBuilder::PARAM_INT), 'mcov.objectid')) - ->andWhere($validSq->expr()->eq("cov_objs.{$objectTableClusterId}", "{$clusterTable}.{$clusterTableId}")) ; + // Let the backend bridge any extra hops to the cluster table + if (null !== $objectTableJoin) { + $objectTableJoin($validSq); + } + + $validSq->andWhere($validSq->expr()->eq($objectClusterIdRef, "{$clusterTable}.{$clusterTableId}")); + $clauses[] = SQL::exists($query, $validSq); } @@ -90,7 +109,10 @@ public static function selectCover( * @param string $type Cluster type * @param string $objectTable Table name for the object mapping * @param string $objectTableObjectId Column name for the object ID in objectTable - * @param string $objectTableClusterId Column name for the cluster ID in objectTable + * @param string $objectTableClusterId Column name for the cluster ID in objectTable. + * May be qualified with an alias (e.g. "foo.bar") that the caller has + * already joined, for backends that reach the cluster over more + * than one hop. */ public static function filterCover( IQueryBuilder &$query, @@ -99,10 +121,14 @@ public static function filterCover( string $objectTableObjectId, string $objectTableClusterId, ): void { + $clusterIdRef = str_contains($objectTableClusterId, '.') + ? $objectTableClusterId + : "{$objectTable}.{$objectTableClusterId}"; + $query->innerJoin($objectTable, 'memories_covers', 'm_cov', $query->expr()->andX( $query->expr()->eq('m_cov.uid', $query->expr()->literal(Util::getUser()->getUID())), $query->expr()->eq('m_cov.clustertype', $query->expr()->literal($type)), - $query->expr()->eq('m_cov.clusterid', "{$objectTable}.{$objectTableClusterId}"), + $query->expr()->eq('m_cov.clusterid', $clusterIdRef), $query->expr()->eq('m_cov.objectid', $query->expr()->castColumn("{$objectTable}.{$objectTableObjectId}", IQueryBuilder::PARAM_INT)), )); } diff --git a/lib/ClustersBackend/FaceRecognitionBackend.php b/lib/ClustersBackend/FaceRecognitionBackend.php index 29d26a717..9ebcc144f 100644 --- a/lib/ClustersBackend/FaceRecognitionBackend.php +++ b/lib/ClustersBackend/FaceRecognitionBackend.php @@ -31,6 +31,17 @@ use OCP\IAppConfig; use OCP\IRequest; +/** + * Backend for the Face Recognition app. + * + * Schema note: a face no longer points at a person directly. The chain is + * + * facerecog_faces.cluster -> facerecog_clusters.id + * facerecog_clusters.person -> facerecog_persons.id (NULL while unnamed) + * + * so facerecog_persons holds only named people, and the unnamed groupings + * live in facerecog_clusters. Everything below joins through that extra hop. + */ final class FaceRecognitionBackend extends Backend { use PeopleBackendUtils; @@ -81,13 +92,23 @@ public function transformDayQuery(IQueryBuilder &$query, bool $aggregate): void // Join with faces $query->innerJoin('fri', 'facerecog_faces', 'frf', $query->expr()->eq('frf.image', 'fri.id')); - // Join with persons - $nameField = is_numeric($personName) ? 'frp.id' : 'frp.name'; - $query->innerJoin('frf', 'facerecog_persons', 'frp', $query->expr()->andX( - $query->expr()->eq('frf.person', 'frp.id'), - $query->expr()->eq('frp.user', $query->createNamedParameter($personUid)), - $query->expr()->eq($nameField, $query->createNamedParameter($personName)), - )); + // Join with clusters: every face belongs to at most one cluster + $query->innerJoin('frf', 'facerecog_clusters', 'frc', $query->expr()->eq('frc.id', 'frf.cluster')); + + if (is_numeric($personName)) { + // An unnamed cluster is addressed by its numeric id + $query->andWhere($query->expr()->andX( + $query->expr()->eq('frc.id', $query->createNamedParameter($personName, \PDO::PARAM_INT)), + $query->expr()->eq('frc.user', $query->createNamedParameter($personUid)), + )); + } else { + // A named person is addressed by name, through the cluster + $query->innerJoin('frc', 'facerecog_persons', 'frp', $query->expr()->andX( + $query->expr()->eq('frp.id', 'frc.person'), + $query->expr()->eq('frp.user', $query->createNamedParameter($personUid)), + $query->expr()->eq('frp.name', $query->createNamedParameter($personName)), + )); + } if (!$aggregate) { // Multiple detections for the same image @@ -153,10 +174,13 @@ public function getPhotos(string $name, ?int $limit = null, ?int $fileid = null) { $query = $this->tq->getBuilder(); + // A numeric name is the id of an unnamed cluster; anything else is a + // person name that has to be resolved through facerecog_persons. + $isClusterId = is_numeric($name); + // SELECT face detections $query->select( 'frf.id as faceid', // Face ID - 'frp.id as cluster_id', // Cluster ID 'fri.file as file_id', // Get actual file 'frf.x', // Image cropping 'frf.y', @@ -177,18 +201,34 @@ public function getPhotos(string $name, ?int $limit = null, ?int $fileid = null) // WHERE these photos are memories indexed $query->innerJoin('fri', 'memories', 'm', $query->expr()->eq('m.fileid', 'fri.file')); - $query->innerJoin('frf', 'facerecog_persons', 'frp', $query->expr()->eq('frp.id', 'frf.person')); + // WHERE the face belongs to a cluster + $query->innerJoin('frf', 'facerecog_clusters', 'frc', $query->expr()->eq('frc.id', 'frf.cluster')); - // WHERE faces are from id persons (or a cluster). - $nameField = is_numeric($name) ? 'frp.id' : 'frp.name'; - $query->where($query->expr()->eq($nameField, $query->createNamedParameter($name))); + if ($isClusterId) { + // WHERE faces are in this unnamed cluster + $query->selectAlias('frc.id', 'cluster_id'); + $query->where($query->expr()->eq('frc.id', $query->createNamedParameter($name, \PDO::PARAM_INT))); + } else { + // WHERE faces belong to a cluster of this named person + $query->innerJoin('frc', 'facerecog_persons', 'frp', $query->expr()->eq('frp.id', 'frc.person')); + $query->selectAlias('frp.id', 'cluster_id'); + $query->where($query->expr()->eq('frp.name', $query->createNamedParameter($name))); + } // WHERE these photos are in the user's requested folder recursively $query = $this->tq->filterFilecache($query); // LIMIT results if (-6 === $limit) { - Covers::filterCover($query, self::clusterType(), 'frf', 'id', 'person'); + // The cover is keyed by whatever getClusterIdFrom() reports: the + // cluster id for unnamed clusters, the person id for named ones. + Covers::filterCover( + $query, + self::clusterType(), + 'frf', + 'id', + $isClusterId ? 'frf.cluster' : 'frc.person', + ); } elseif (null !== $limit) { $query->setMaxResults($limit); } @@ -254,18 +294,21 @@ private function minFaceInClusters(): int return (int) $this->appConfig->getValueString('facerecognition', 'min_faces_in_cluster', (string) 5); } + /** + * Unnamed clusters: rows of facerecog_clusters with no person yet. + */ private function getFaceRecognitionClusters(int $fileid = 0): array { $query = $this->tq->getBuilder(); // SELECT all face clusters $count = $query->func()->count(SQL::distinct($query, 'm.fileid')); - $query->select('frp.id')->from('facerecog_persons', 'frp'); + $query->select('frc.id')->from('facerecog_clusters', 'frc'); $query->selectAlias($count, 'count'); - $query->selectAlias('frp.user', 'user_id'); + $query->selectAlias('frc.user', 'user_id'); // WHERE there are faces with this cluster - $query->innerJoin('frp', 'facerecog_faces', 'frf', $query->expr()->eq('frp.id', 'frf.person')); + $query->innerJoin('frc', 'facerecog_faces', 'frf', $query->expr()->eq('frc.id', 'frf.cluster')); // WHERE faces are from images. $query->innerJoin('frf', 'facerecog_images', 'fri', $query->expr()->eq('fri.id', 'frf.image')); @@ -280,8 +323,10 @@ private function getFaceRecognitionClusters(int $fileid = 0): array $query = $this->tq->filterFilecache($query); // GROUP by ID of face cluster - $query->addGroupBy('frp.id', 'frp.user'); - $query->andWhere($query->expr()->isNull('frp.name')); + $query->addGroupBy('frc.id', 'frc.user'); + + // WHERE the cluster has not been assigned to a person yet + $query->andWhere($query->expr()->isNull('frc.person')); // The query change if we want the people in an fileid, or the unnamed clusters if ($fileid > 0) { @@ -291,36 +336,39 @@ private function getFaceRecognitionClusters(int $fileid = 0): array // WHERE these clusters has a minimum number of faces $query->having($query->expr()->gte($count, SQL::literal($query, $this->minFaceInClusters(), \PDO::PARAM_INT))); // WHERE these clusters were not hidden due inconsistencies - $query->andWhere($query->expr()->eq('frp.is_visible', $query->expr()->literal(1))); + $query->andWhere($query->expr()->eq('frc.is_visible', $query->expr()->literal(1))); } // ORDER by number of faces in cluster and id for response stability. $query->addOrderBy('count', 'DESC'); - $query->addOrderBy('frp.id', 'DESC'); + $query->addOrderBy('frc.id', 'DESC'); // It is not worth displaying all unnamed clusters. We show 15 to name them progressively, $query->setMaxResults(15); // SELECT covers - $query = SQL::materialize($query, 'frp'); + $query = SQL::materialize($query, 'frc'); Covers::selectCover( query: $query, type: self::clusterType(), - clusterTable: 'frp', + clusterTable: 'frc', clusterTableId: 'id', objectTable: 'facerecog_faces', objectTableObjectId: 'id', - objectTableClusterId: 'person', + objectTableClusterId: 'cluster', ); // SELECT etag for the cover - $query = SQL::materialize($query, 'frp'); + $query = SQL::materialize($query, 'frc'); $this->tq->selectEtag($query, 'cover', 'cover_etag'); // FETCH all faces return $this->tq->executeQueryWithCTEs($query)->fetchAll() ?: []; } + /** + * Named people, reached through the clusters that point at them. + */ private function getFaceRecognitionPersons(int $fileid = 0): array { $query = $this->tq->getBuilder(); @@ -333,8 +381,11 @@ private function getFaceRecognitionPersons(int $fileid = 0): array ->from('facerecog_persons', 'frp') ; - // WHERE there are faces with this cluster - $query->innerJoin('frp', 'facerecog_faces', 'frf', $query->expr()->eq('frp.id', 'frf.person')); + // WHERE there are clusters for this person + $query->innerJoin('frp', 'facerecog_clusters', 'frc', $query->expr()->eq('frp.id', 'frc.person')); + + // WHERE there are faces in those clusters + $query->innerJoin('frc', 'facerecog_faces', 'frf', $query->expr()->eq('frc.id', 'frf.cluster')); // WHERE faces are from images. $query->innerJoin('frf', 'facerecog_images', 'fri', $query->expr()->eq('fri.id', 'frf.image')); @@ -371,7 +422,12 @@ private function getFaceRecognitionPersons(int $fileid = 0): array clusterTableId: 'id', objectTable: 'facerecog_faces', objectTableObjectId: 'id', - objectTableClusterId: 'person', + // A face points at a cluster and the cluster at the person, so the + // cover validation needs the extra hop joined in below. + objectTableClusterId: 'cov_frc.person', + objectTableJoin: static function (IQueryBuilder $sq): void { + $sq->innerJoin('cov_objs', 'facerecog_clusters', 'cov_frc', $sq->expr()->eq('cov_frc.id', 'cov_objs.cluster')); + }, ); // SELECT etag for the cover diff --git a/lib/Controller/ImageController.php b/lib/Controller/ImageController.php index 7d8263cb8..9766cbb41 100644 --- a/lib/Controller/ImageController.php +++ b/lib/Controller/ImageController.php @@ -244,13 +244,31 @@ public function info( // Get clusters for this file if ($clusters) { $clist = []; + $cfailed = []; foreach (explode(',', $clusters) as $type) { - $backend = \OC::$server->get(\OCA\Memories\ClustersBackend\Manager::class)->get($type); - if ($backend->isEnabled()) { - $clist[$type] = $backend->getClusters($id); + // One broken backend must not take down the whole + // metadata response. This happens in practice when a + // companion app (e.g. facerecognition) changes its + // schema underneath us: report that single feature as + // unavailable and keep serving everything else. + try { + $backend = \OC::$server->get(\OCA\Memories\ClustersBackend\Manager::class)->get($type); + if ($backend->isEnabled()) { + $clist[$type] = $backend->getClusters($id); + } + } catch (\Throwable $e) { + $cfailed[] = $type; + $this->logger->warning("Clusters backend \"{$type}\" failed for file {$id}: ".$e->getMessage(), [ + 'exception' => $e, + 'app' => 'memories', + ]); } } $info['clusters'] = $clist; + + if ($cfailed) { + $info['clustersFailed'] = $cfailed; + } } } elseif ($shareNode = $this->fs->getShareNode()) { // For public shares, get path relative to share root diff --git a/src/components/Metadata.vue b/src/components/Metadata.vue index 6a8b16bf9..7be752b3f 100644 --- a/src/components/Metadata.vue +++ b/src/components/Metadata.vue @@ -20,6 +20,31 @@ +
+
+
{{ t('memories', 'Face Recognition') }}
+ + + {{ t('memories', 'Add person') }} + + + +
+
+ {{ t('memories', 'Face Recognition is unavailable. The app may need an update to match its database schema.') }} +
+ +
+ {{ t('memories', 'No faces detected — click + to tag manually') }} +
+
+ + +
{{ t('memories', 'Albums') }}
@@ -88,7 +113,9 @@ import { DateTime } from 'luxon'; import UserConfig from '@mixins/UserConfig'; import Cluster from '@components/frame/Cluster.vue'; import AlbumsList from '@components/modal/AlbumsList.vue'; +import FaceManualAddModal from '@components/modal/FaceManualAddModal.vue'; +import AddIcon from 'vue-material-design-icons/AccountPlus.vue'; import EditIcon from 'vue-material-design-icons/Pencil.vue'; import CalendarIcon from 'vue-material-design-icons/Calendar.vue'; import CameraIrisIcon from 'vue-material-design-icons/CameraIris.vue'; @@ -119,6 +146,8 @@ export default defineComponent({ NcAvatar, AlbumsList, Cluster, + FaceManualAddModal, + AddIcon, EditIcon, }, @@ -389,14 +418,19 @@ export default defineComponent({ }, people(): IFace[] { - const clusters = this.baseInfo?.clusters; + return this.baseInfo?.clusters?.recognize ?? []; + }, - // force face-recognition on its own route, or if recognize is disabled - if (this.routeIsFaceRecognition || !this.config.recognize_enabled) { - return clusters?.facerecognition ?? []; - } + facerecognitionPeople(): IFace[] { + return this.baseInfo?.clusters?.facerecognition ?? []; + }, - return clusters?.recognize ?? []; + /** + * The server could not build the face recognition clusters for this file. + * The rest of the metadata is still valid, so only this section degrades. + */ + facerecognitionFailed(): boolean { + return this.baseInfo?.clustersFailed?.includes('facerecognition') ?? false; }, isShared(): boolean { @@ -469,6 +503,21 @@ export default defineComponent({ _m.modals.editMetadata([_m.viewer.currentPhoto!], [4]); }, + openManualAdd() { + const modal = this.$refs.manualAddModal as InstanceType | undefined; + if (!modal) return; + if (this.fileid) { + modal.openForFile({ + fileid: this.fileid, + etag: this.baseInfo?.etag, + w: this.baseInfo?.w, + h: this.baseInfo?.h, + }); + } else { + modal.open(); + } + }, + handleFileUpdated({ fileid }: utils.BusEvent['files:file:updated']) { if (fileid && this.fileid === fileid) { this.refresh(); @@ -536,6 +585,17 @@ export default defineComponent({ > .section-title { margin-bottom: 4px; } + > .section-header { + display: flex; + align-items: center; + justify-content: space-between; + padding-right: 4px; + margin-bottom: 4px; + + > .section-title { + flex: 1; + } + } > .container { width: calc(100% / 3); aspect-ratio: 1; @@ -548,6 +608,17 @@ export default defineComponent({ font-size: 0.95em; } } + > .empty-hint { + padding: 6px 8px 10px; + font-size: 0.9em; + color: var(--color-text-lighter); + } + + > .error-hint { + padding: 6px 8px 10px; + font-size: 0.9em; + color: var(--color-error-text, var(--color-error)); + } } .albums { diff --git a/src/components/modal/FaceEditModal.vue b/src/components/modal/FaceEditModal.vue index 8071ed7e8..d85051bee 100644 --- a/src/components/modal/FaceEditModal.vue +++ b/src/components/modal/FaceEditModal.vue @@ -5,6 +5,11 @@
+ + + +
@@ -52,6 +58,7 @@ export default defineComponent({ data: () => ({ rawInput: String(), + knownNames: [] as string[], }), computed: { @@ -83,12 +90,33 @@ export default defineComponent({ this.rawInput = isNaN(Number(this.name)) ? this.name : String(); this.show = true; + this.loadKnownNames(); }, cleanup() { this.show = false; }, + /** + * Load already-known person names from the active backend so the name field + * can offer them as autocompletion (same comfort as the manual-face modal). + * Failure is non-fatal: it just means no suggestions are shown. + */ + async loadKnownNames(): Promise { + try { + const app = this.routeIsRecognize ? 'recognize' : 'facerecognition'; + const faces = await dav.getFaceList(app); + const names = faces + .map((f) => f.name) + // Keep only real names; unnamed clusters expose a numeric id as their name. + .filter((n): n is string => !!n && Number.isNaN(Number(n))); + this.knownNames = Array.from(new Set(names)).sort((a, b) => a.localeCompare(b)); + } catch (e) { + console.error(e); + this.knownNames = []; + } + }, + async save() { if (!this.canSave) return; diff --git a/src/components/modal/FaceManualAddModal.vue b/src/components/modal/FaceManualAddModal.vue new file mode 100644 index 000000000..6b55874fa --- /dev/null +++ b/src/components/modal/FaceManualAddModal.vue @@ -0,0 +1,619 @@ + + + + + diff --git a/src/components/top-matter/FaceTopMatter.vue b/src/components/top-matter/FaceTopMatter.vue index fc053fe20..beb605cf2 100644 --- a/src/components/top-matter/FaceTopMatter.vue +++ b/src/components/top-matter/FaceTopMatter.vue @@ -23,6 +23,14 @@ + + + @@ -74,6 +83,7 @@ import NcActionCheckbox from '@nextcloud/vue/dist/Components/NcActionCheckbox.js import FaceEditModal from '@components/modal/FaceEditModal.vue'; import FaceDeleteModal from '@components/modal/FaceDeleteModal.vue'; import FaceMergeModal from '@components/modal/FaceMergeModal.vue'; +import FaceManualAddModal from '@components/modal/FaceManualAddModal.vue'; import * as utils from '@services/utils'; @@ -82,6 +92,7 @@ import EditIcon from 'vue-material-design-icons/Pencil.vue'; import DeleteIcon from 'vue-material-design-icons/Close.vue'; import MergeIcon from 'vue-material-design-icons/Merge.vue'; import UnassignedIcon from 'vue-material-design-icons/AccountQuestion.vue'; +import AddIcon from 'vue-material-design-icons/AccountPlus.vue'; export default defineComponent({ name: 'FaceTopMatter', @@ -92,11 +103,13 @@ export default defineComponent({ FaceEditModal, FaceDeleteModal, FaceMergeModal, + FaceManualAddModal, BackIcon, EditIcon, DeleteIcon, MergeIcon, UnassignedIcon, + AddIcon, }, mixins: [UserConfig], @@ -107,6 +120,7 @@ export default defineComponent({ editModal: InstanceType; deleteModal: InstanceType; mergeModal: InstanceType; + manualAddModal: InstanceType; }; }, @@ -153,6 +167,14 @@ export default defineComponent({ this.updateSetting('show_face_rect'); utils.bus.emit('memories:timeline:hard-refresh', null); }, + + openManualAdd() { + this.refs.manualAddModal.open(); + }, + + onManualAdded() { + utils.bus.emit('memories:timeline:hard-refresh', null); + }, }, }); diff --git a/src/services/dav/face.ts b/src/services/dav/face.ts index 0d090e700..df1f3a06d 100644 --- a/src/services/dav/face.ts +++ b/src/services/dav/face.ts @@ -143,3 +143,66 @@ export async function recognizeRenameFace(user: string, name: string, target: st export async function recognizeCreateFace(user: string, name: string) { return await client.createDirectory(`/recognize/${user}/faces/${name}`); } + +/** One face rectangle on a photo, in original-image pixel coordinates. */ +export type IFaceRectForFile = { + id: number; + x: number; + y: number; + width: number; + height: number; + person: number | null; + personName: string | null; + isManual: boolean; +}; + +/** + * Fetch all known face rectangles for a single file (face recognition app). + * Used by the manual-face dialog to show existing detections as context. + */ +export async function faceRecognitionGetFacesForFile(fileId: number) { + const url = generateUrl(`/apps/facerecognition/api/2.0/file/${fileId}/faces`); + return (await axios.get(url)).data; +} + +/** + * Create a manually drawn face on a photo and attach it to a (possibly new) + * named person cluster. Coordinates are fractions 0..1 of the original image. + * imageWidth/imageHeight are the natural pixel dimensions of the photo. + */ +export async function faceRecognitionAddManualFace(params: { + fileId: number; + personName: string; + x: number; + y: number; + width: number; + height: number; + imageWidth: number; + imageHeight: number; + useForClustering?: boolean; +}) { + const url = generateUrl(`/apps/facerecognition/api/2.0/face/manual`); + return ( + await axios.post<{ faceId: number; personId: number; name: string; clusteringQueued: boolean }>(url, { + fileId: params.fileId, + personName: params.personName, + x: params.x, + y: params.y, + width: params.width, + height: params.height, + imageWidth: params.imageWidth, + imageHeight: params.imageHeight, + useForClustering: params.useForClustering ?? false, + }) + ).data; +} + +/** + * Reassign a single detected face to a different person cluster. + * Affects only THIS face; other faces in the original cluster (on other + * photos) stay where they are. + */ +export async function faceRecognitionReassignFace(faceId: number, personName: string) { + const url = generateUrl(`/apps/facerecognition/api/2.0/face/${faceId}/reassign`); + return (await axios.post<{ faceId: number; personId: number; name: string }>(url, { personName })).data; +} diff --git a/src/typings/data.d.ts b/src/typings/data.d.ts index a9607654b..5c2185456 100644 --- a/src/typings/data.d.ts +++ b/src/typings/data.d.ts @@ -131,6 +131,9 @@ declare module '@typings' { recognize?: IFace[]; facerecognition?: IFace[]; }; + + /** Cluster backends that threw while building this response. */ + clustersFailed?: string[]; } export interface IExif {