mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-12 22:13:05 +00:00
feat(C244): collection curada faceFigure, migração e matcher puro
This commit is contained in:
parent
8a87f5f81b
commit
625ab5c10e
8 changed files with 21441 additions and 1 deletions
157
src/collections/FaceFigure.ts
Normal file
157
src/collections/FaceFigure.ts
Normal file
|
|
@ -0,0 +1,157 @@
|
|||
import { FACE_SEARCH_MODEL } from '@/lib/faceSearch'
|
||||
import { slugify } from '@/lib/slug'
|
||||
import { payloadAdminOnly } from '@/utilities/campaignAccess'
|
||||
import { revalidateArchivePhotosListing } from '@/utilities/documents'
|
||||
import type {
|
||||
CollectionAfterChangeHook,
|
||||
CollectionAfterDeleteHook,
|
||||
CollectionConfig,
|
||||
} from 'payload'
|
||||
|
||||
/**
|
||||
* C244 — the curated catalog of public figures the album's `pessoa` facet may
|
||||
* name. Each row is a person of the mandate's curated catalog (the figure) and
|
||||
* its reference descriptors (1–8 official/archive portraits, enrolled by
|
||||
* `pnpm faces:enroll-figure`), compared against the anonymous index of C242 at
|
||||
* read time. This is biometric identification of NAMED public figures only,
|
||||
* under the legal sign-off registered in the runbook; a face outside this
|
||||
* catalog stays anonymous in the index and is never named.
|
||||
*
|
||||
* The collection is admin-only and auditable: curation can deactivate a figure
|
||||
* (it leaves the facet) or remove a reference, and both revalidate the public
|
||||
* album. The descriptors never leave the server (`admin.hidden` on the vector).
|
||||
*/
|
||||
const revalidateAlbum: CollectionAfterChangeHook = ({ doc }) => {
|
||||
revalidateArchivePhotosListing()
|
||||
return doc
|
||||
}
|
||||
|
||||
const revalidateAlbumAfterDelete: CollectionAfterDeleteHook = ({ doc }) => {
|
||||
revalidateArchivePhotosListing()
|
||||
return doc
|
||||
}
|
||||
|
||||
export const FaceFigure: CollectionConfig = {
|
||||
slug: 'faceFigure',
|
||||
labels: {
|
||||
singular: 'Figura pública',
|
||||
plural: 'Figuras públicas',
|
||||
},
|
||||
admin: {
|
||||
group: 'Comunicação',
|
||||
useAsTitle: 'name',
|
||||
description:
|
||||
'Catálogo curado de figuras públicas do filtro "Pessoa pública" do álbum (C244). Só entram nomes com aval jurídico registrado; a referência biométrica é curada e auditável, e nunca inclui terceiros. Descritores são gravados pela CLI faces:enroll-figure.',
|
||||
},
|
||||
access: {
|
||||
create: payloadAdminOnly,
|
||||
read: payloadAdminOnly,
|
||||
update: payloadAdminOnly,
|
||||
delete: payloadAdminOnly,
|
||||
},
|
||||
hooks: {
|
||||
afterChange: [revalidateAlbum],
|
||||
afterDelete: [revalidateAlbumAfterDelete],
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
name: 'name',
|
||||
type: 'text',
|
||||
label: 'Nome público',
|
||||
required: true,
|
||||
unique: true,
|
||||
admin: {
|
||||
description: 'Nome exibido na faceta (ex.: "Lula", "Jerônimo").',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'slug',
|
||||
type: 'text',
|
||||
label: 'Slug',
|
||||
required: true,
|
||||
unique: true,
|
||||
index: true,
|
||||
admin: {
|
||||
description:
|
||||
'Valor de ?pessoa=<slug> no álbum; gerado do nome quando vazio. Mudar o slug quebra a URL pública do filtro.',
|
||||
},
|
||||
hooks: {
|
||||
beforeValidate: [
|
||||
({ value, siblingData }) => {
|
||||
if (value) return slugify(value as string)
|
||||
if (siblingData?.name) return slugify(siblingData.name as string)
|
||||
return value
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'fullName',
|
||||
type: 'text',
|
||||
label: 'Nome completo',
|
||||
admin: {
|
||||
description: 'Proveniência da curadoria; não aparece no site.',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'active',
|
||||
type: 'checkbox',
|
||||
label: 'Ativa',
|
||||
defaultValue: true,
|
||||
index: true,
|
||||
admin: {
|
||||
description: 'Desmarcar tira a figura do filtro imediatamente.',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'references',
|
||||
type: 'array',
|
||||
label: 'Descritores de referência',
|
||||
labels: {
|
||||
singular: 'Descritor de referência',
|
||||
plural: 'Descritores de referência',
|
||||
},
|
||||
maxRows: 3,
|
||||
admin: {
|
||||
description:
|
||||
'Retratos oficiais/arquivo, 1–3 por figura, gravados pela CLI faces:enroll-figure. Remover uma linha corrige a curadoria; adicionar só pela CLI (o vetor não é editável à mão).',
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
name: 'vector',
|
||||
type: 'json',
|
||||
label: 'Descriptor',
|
||||
admin: {
|
||||
hidden: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'model',
|
||||
type: 'text',
|
||||
label: 'Modelo',
|
||||
admin: {
|
||||
readOnly: true,
|
||||
description: `Modelo do descriptor (atual: ${FACE_SEARCH_MODEL}).`,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'source',
|
||||
type: 'text',
|
||||
label: 'Origem',
|
||||
required: true,
|
||||
admin: {
|
||||
description: 'Retrato/arquivo de origem da referência (proveniência da curadoria).',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'addedAt',
|
||||
type: 'date',
|
||||
label: 'Adicionado em',
|
||||
admin: {
|
||||
readOnly: true,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
115
src/lib/faceFigureCatalog.ts
Normal file
115
src/lib/faceFigureCatalog.ts
Normal file
|
|
@ -0,0 +1,115 @@
|
|||
/**
|
||||
* C244 — pure contract of the curated public-figure layer over the anonymous
|
||||
* face index of C242. A `faceFigure` row carries the curated reference
|
||||
* descriptors of a named public figure; this module compares those references
|
||||
* against the indexed descriptors of the approved archive and answers WHICH
|
||||
* photos show each figure — never a distance, a score or a percentage, not even
|
||||
* internally exposed (the caller only receives photo ids and identity).
|
||||
*
|
||||
* Only an explicitly active figure with at least one reference of the current
|
||||
* model participates; a malformed or foreign-model reference is ignored
|
||||
* (fail-closed). No I/O and no `server-only`: the cached read and the unit
|
||||
* tests share this module.
|
||||
*/
|
||||
import {
|
||||
FACE_SEARCH_MODEL,
|
||||
findFaceDescriptorPhotoIds,
|
||||
readFaceVector,
|
||||
type FaceDescriptorEntry,
|
||||
} from '@/lib/faceSearch'
|
||||
|
||||
/** One curated reference portrait of a figure (vector + the model that made it). */
|
||||
type FaceFigureReference = {
|
||||
model?: string | null
|
||||
vector?: unknown
|
||||
}
|
||||
|
||||
/** The slice of a `faceFigure` row this contract needs. */
|
||||
export type FaceFigure = {
|
||||
slug: string
|
||||
name: string
|
||||
active?: boolean | null
|
||||
references?: readonly FaceFigureReference[] | null
|
||||
}
|
||||
|
||||
/** The public identity of a matched figure; no numeric field by shape. */
|
||||
type FaceFigurePerson = {
|
||||
slug: string
|
||||
name: string
|
||||
}
|
||||
|
||||
/** One figure with the distinct photo ids it was recognized in, in index order. */
|
||||
export type FaceFigureMatch = FaceFigurePerson & {
|
||||
photoIds: number[]
|
||||
}
|
||||
|
||||
/** One photo with the figures recognized in it (the album's "Quem aparece"). */
|
||||
export type PhotoFigureMatch = {
|
||||
photoId: number
|
||||
figures: FaceFigurePerson[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Every active figure with at least one photo under the shared C242 threshold;
|
||||
* a figure only participates when `active === true` (an absent flag never
|
||||
* names anyone) and only current-model references count. Photos are deduped per
|
||||
* figure (a group photo with several faces of the same person is one match).
|
||||
*/
|
||||
export const findFaceFigureMatches = ({
|
||||
figures,
|
||||
descriptors,
|
||||
}: {
|
||||
figures: readonly FaceFigure[]
|
||||
descriptors: readonly FaceDescriptorEntry[]
|
||||
}): FaceFigureMatch[] => {
|
||||
const matches: FaceFigureMatch[] = []
|
||||
|
||||
for (const figure of [...figures].sort((a, b) => a.slug.localeCompare(b.slug))) {
|
||||
if (figure.active !== true) continue
|
||||
|
||||
const photoIds = new Set<number>()
|
||||
for (const reference of figure.references ?? []) {
|
||||
if (reference.model !== FACE_SEARCH_MODEL) continue
|
||||
const vector = readFaceVector(reference.vector)
|
||||
if (!vector) continue
|
||||
for (const photoId of findFaceDescriptorPhotoIds(vector, descriptors)) {
|
||||
photoIds.add(photoId)
|
||||
}
|
||||
}
|
||||
|
||||
if (photoIds.size > 0) {
|
||||
matches.push({ slug: figure.slug, name: figure.name, photoIds: [...photoIds] })
|
||||
}
|
||||
}
|
||||
|
||||
return matches
|
||||
}
|
||||
|
||||
/**
|
||||
* Pivots the per-figure matches into the per-photo map the album read merges
|
||||
* into each item: photos in id order, figures in slug order. Pure and
|
||||
* serializable (`unstable_cache` stores its result).
|
||||
*/
|
||||
export const groupFaceFigureMatchesByPhoto = (
|
||||
matches: readonly FaceFigureMatch[],
|
||||
): PhotoFigureMatch[] => {
|
||||
const byPhoto = new Map<number, Map<string, FaceFigurePerson>>()
|
||||
|
||||
for (const match of matches) {
|
||||
for (const photoId of match.photoIds) {
|
||||
let figures = byPhoto.get(photoId)
|
||||
if (!figures) {
|
||||
figures = new Map()
|
||||
byPhoto.set(photoId, figures)
|
||||
}
|
||||
if (!figures.has(match.slug)) figures.set(match.slug, { slug: match.slug, name: match.name })
|
||||
}
|
||||
}
|
||||
|
||||
return [...byPhoto.entries()]
|
||||
.sort((a, b) => a[0] - b[0])
|
||||
.map(([photoId, figures]) => ({
|
||||
photoId,
|
||||
figures: [...figures.values()].sort((a, b) => a.slug.localeCompare(b.slug)),
|
||||
}))
|
||||
}
|
||||
20893
src/migrations/20261001_235632_add_face_figure.json
Normal file
20893
src/migrations/20261001_235632_add_face_figure.json
Normal file
File diff suppressed because it is too large
Load diff
47
src/migrations/20261001_235632_add_face_figure.ts
Normal file
47
src/migrations/20261001_235632_add_face_figure.ts
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
import { MigrateUpArgs, MigrateDownArgs, sql } from '@payloadcms/db-postgres'
|
||||
|
||||
export async function up({ db, payload, req }: MigrateUpArgs): Promise<void> {
|
||||
await db.execute(sql`
|
||||
CREATE TABLE "face_figure_references" (
|
||||
"_order" integer NOT NULL,
|
||||
"_parent_id" integer NOT NULL,
|
||||
"id" varchar PRIMARY KEY NOT NULL,
|
||||
"vector" jsonb,
|
||||
"model" varchar,
|
||||
"source" varchar NOT NULL,
|
||||
"added_at" timestamp(3) with time zone
|
||||
);
|
||||
|
||||
CREATE TABLE "face_figure" (
|
||||
"id" serial PRIMARY KEY NOT NULL,
|
||||
"name" varchar NOT NULL,
|
||||
"slug" varchar NOT NULL,
|
||||
"full_name" varchar,
|
||||
"active" boolean DEFAULT true,
|
||||
"updated_at" timestamp(3) with time zone DEFAULT now() NOT NULL,
|
||||
"created_at" timestamp(3) with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
|
||||
ALTER TABLE "payload_locked_documents_rels" ADD COLUMN "face_figure_id" integer;
|
||||
ALTER TABLE "face_figure_references" ADD CONSTRAINT "face_figure_references_parent_id_fk" FOREIGN KEY ("_parent_id") REFERENCES "public"."face_figure"("id") ON DELETE cascade ON UPDATE no action;
|
||||
CREATE INDEX "face_figure_references_order_idx" ON "face_figure_references" USING btree ("_order");
|
||||
CREATE INDEX "face_figure_references_parent_id_idx" ON "face_figure_references" USING btree ("_parent_id");
|
||||
CREATE UNIQUE INDEX "face_figure_name_idx" ON "face_figure" USING btree ("name");
|
||||
CREATE UNIQUE INDEX "face_figure_slug_idx" ON "face_figure" USING btree ("slug");
|
||||
CREATE INDEX "face_figure_active_idx" ON "face_figure" USING btree ("active");
|
||||
CREATE INDEX "face_figure_updated_at_idx" ON "face_figure" USING btree ("updated_at");
|
||||
CREATE INDEX "face_figure_created_at_idx" ON "face_figure" USING btree ("created_at");
|
||||
ALTER TABLE "payload_locked_documents_rels" ADD CONSTRAINT "payload_locked_documents_rels_face_figure_fk" FOREIGN KEY ("face_figure_id") REFERENCES "public"."face_figure"("id") ON DELETE cascade ON UPDATE no action;
|
||||
CREATE INDEX "payload_locked_documents_rels_face_figure_id_idx" ON "payload_locked_documents_rels" USING btree ("face_figure_id");`)
|
||||
}
|
||||
|
||||
export async function down({ db, payload, req }: MigrateDownArgs): Promise<void> {
|
||||
await db.execute(sql`
|
||||
ALTER TABLE "face_figure_references" DISABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE "face_figure" DISABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE "payload_locked_documents_rels" DROP CONSTRAINT "payload_locked_documents_rels_face_figure_fk";
|
||||
DROP INDEX "payload_locked_documents_rels_face_figure_id_idx";
|
||||
ALTER TABLE "payload_locked_documents_rels" DROP COLUMN "face_figure_id";
|
||||
DROP TABLE "face_figure_references" CASCADE;
|
||||
DROP TABLE "face_figure" CASCADE;`)
|
||||
}
|
||||
|
|
@ -92,6 +92,7 @@ import * as migration_20260926_132106_add_archive_photo_catalog from './20260926
|
|||
import * as migration_20260926_163248_add_archive_photo_publication from './20260926_163248_add_archive_photo_publication';
|
||||
import * as migration_20260927_193954_add_face_subject from './20260927_193954_add_face_subject';
|
||||
import * as migration_20261001_040904_add_archive_photo_face from './20261001_040904_add_archive_photo_face';
|
||||
import * as migration_20261001_235632_add_face_figure from './20261001_235632_add_face_figure';
|
||||
|
||||
export const migrations = [
|
||||
{
|
||||
|
|
@ -562,6 +563,11 @@ export const migrations = [
|
|||
{
|
||||
up: migration_20261001_040904_add_archive_photo_face.up,
|
||||
down: migration_20261001_040904_add_archive_photo_face.down,
|
||||
name: '20261001_040904_add_archive_photo_face'
|
||||
name: '20261001_040904_add_archive_photo_face',
|
||||
},
|
||||
{
|
||||
up: migration_20261001_235632_add_face_figure.up,
|
||||
down: migration_20261001_235632_add_face_figure.down,
|
||||
name: '20261001_235632_add_face_figure'
|
||||
},
|
||||
];
|
||||
|
|
|
|||
|
|
@ -97,6 +97,7 @@ export interface Config {
|
|||
contentMedia: ContentMedia;
|
||||
archivePhoto: ArchivePhoto;
|
||||
archivePhotoFace: ArchivePhotoFace;
|
||||
faceFigure: FaceFigure;
|
||||
contentEvent: ContentEvent;
|
||||
recording: Recording;
|
||||
recordingMedia: RecordingMedia;
|
||||
|
|
@ -155,6 +156,7 @@ export interface Config {
|
|||
contentMedia: ContentMediaSelect<false> | ContentMediaSelect<true>;
|
||||
archivePhoto: ArchivePhotoSelect<false> | ArchivePhotoSelect<true>;
|
||||
archivePhotoFace: ArchivePhotoFaceSelect<false> | ArchivePhotoFaceSelect<true>;
|
||||
faceFigure: FaceFigureSelect<false> | FaceFigureSelect<true>;
|
||||
contentEvent: ContentEventSelect<false> | ContentEventSelect<true>;
|
||||
recording: RecordingSelect<false> | RecordingSelect<true>;
|
||||
recordingMedia: RecordingMediaSelect<false> | RecordingMediaSelect<true>;
|
||||
|
|
@ -1613,6 +1615,59 @@ export interface ArchivePhotoFace {
|
|||
updatedAt: string;
|
||||
createdAt: string;
|
||||
}
|
||||
/**
|
||||
* Catálogo curado de figuras públicas do filtro "Pessoa pública" do álbum (C244). Só entram nomes com aval jurídico registrado; a referência biométrica é curada e auditável, e nunca inclui terceiros. Descritores são gravados pela CLI faces:enroll-figure.
|
||||
*
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "faceFigure".
|
||||
*/
|
||||
export interface FaceFigure {
|
||||
id: number;
|
||||
/**
|
||||
* Nome exibido na faceta (ex.: "Lula", "Jerônimo").
|
||||
*/
|
||||
name: string;
|
||||
/**
|
||||
* Valor de ?pessoa=<slug> no álbum; gerado do nome quando vazio. Mudar o slug quebra a URL pública do filtro.
|
||||
*/
|
||||
slug: string;
|
||||
/**
|
||||
* Proveniência da curadoria; não aparece no site.
|
||||
*/
|
||||
fullName?: string | null;
|
||||
/**
|
||||
* Desmarcar tira a figura do filtro imediatamente.
|
||||
*/
|
||||
active?: boolean | null;
|
||||
/**
|
||||
* Retratos oficiais/arquivo, 1–3 por figura, gravados pela CLI faces:enroll-figure. Remover uma linha corrige a curadoria; adicionar só pela CLI (o vetor não é editável à mão).
|
||||
*/
|
||||
references?:
|
||||
| {
|
||||
vector?:
|
||||
| {
|
||||
[k: string]: unknown;
|
||||
}
|
||||
| unknown[]
|
||||
| string
|
||||
| number
|
||||
| boolean
|
||||
| null;
|
||||
/**
|
||||
* Modelo do descriptor (atual: face-api@1.7.15/faceRecognitionNet).
|
||||
*/
|
||||
model?: string | null;
|
||||
/**
|
||||
* Retrato/arquivo de origem da referência (proveniência da curadoria).
|
||||
*/
|
||||
source: string;
|
||||
addedAt?: string | null;
|
||||
id?: string | null;
|
||||
}[]
|
||||
| null;
|
||||
updatedAt: string;
|
||||
createdAt: string;
|
||||
}
|
||||
/**
|
||||
* Eventos anônimos de circulação (abertura, download, compartilhamento).
|
||||
*
|
||||
|
|
@ -2451,6 +2506,10 @@ export interface PayloadLockedDocument {
|
|||
relationTo: 'archivePhotoFace';
|
||||
value: number | ArchivePhotoFace;
|
||||
} | null)
|
||||
| ({
|
||||
relationTo: 'faceFigure';
|
||||
value: number | FaceFigure;
|
||||
} | null)
|
||||
| ({
|
||||
relationTo: 'contentEvent';
|
||||
value: number | ContentEvent;
|
||||
|
|
@ -3273,6 +3332,27 @@ export interface ArchivePhotoFaceSelect<T extends boolean = true> {
|
|||
updatedAt?: T;
|
||||
createdAt?: T;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "faceFigure_select".
|
||||
*/
|
||||
export interface FaceFigureSelect<T extends boolean = true> {
|
||||
name?: T;
|
||||
slug?: T;
|
||||
fullName?: T;
|
||||
active?: T;
|
||||
references?:
|
||||
| T
|
||||
| {
|
||||
vector?: T;
|
||||
model?: T;
|
||||
source?: T;
|
||||
addedAt?: T;
|
||||
id?: T;
|
||||
};
|
||||
updatedAt?: T;
|
||||
createdAt?: T;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "contentEvent_select".
|
||||
|
|
@ -4164,6 +4244,7 @@ export interface TaskCreateCollectionExport {
|
|||
| 'contentMedia'
|
||||
| 'archivePhoto'
|
||||
| 'archivePhotoFace'
|
||||
| 'faceFigure'
|
||||
| 'contentEvent'
|
||||
| 'recording'
|
||||
| 'recordingMedia'
|
||||
|
|
|
|||
|
|
@ -27,6 +27,7 @@ import { ContentPiece } from './collections/ContentPiece'
|
|||
import { ElectionCandidate } from './collections/ElectionCandidate'
|
||||
import { ElectionCandidateVote } from './collections/ElectionCandidateVote'
|
||||
import { ElectionTally } from './collections/ElectionTally'
|
||||
import { FaceFigure } from './collections/FaceFigure'
|
||||
import { GoogleCalendarSync } from './collections/GoogleCalendarSync'
|
||||
import { InternetSpeechMedia } from './collections/InternetSpeechMedia'
|
||||
import { Jingle } from './collections/Jingle'
|
||||
|
|
@ -141,6 +142,7 @@ export default buildConfig({
|
|||
ContentMedia,
|
||||
ArchivePhoto,
|
||||
ArchivePhotoFace,
|
||||
FaceFigure,
|
||||
ContentEvent,
|
||||
Recording,
|
||||
RecordingMedia,
|
||||
|
|
|
|||
139
tests/unit/faceFigureCatalog.unit.spec.ts
Normal file
139
tests/unit/faceFigureCatalog.unit.spec.ts
Normal file
|
|
@ -0,0 +1,139 @@
|
|||
// @vitest-environment node
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import {
|
||||
findFaceFigureMatches,
|
||||
groupFaceFigureMatchesByPhoto,
|
||||
type FaceFigure,
|
||||
} from '@/lib/faceFigureCatalog'
|
||||
import {
|
||||
FACE_SEARCH_DESCRIPTOR_LENGTH,
|
||||
FACE_SEARCH_MAX_DISTANCE,
|
||||
FACE_SEARCH_MODEL,
|
||||
type FaceDescriptorEntry,
|
||||
} from '@/lib/faceSearch'
|
||||
|
||||
// C244 — the pure curated-figure matcher: only active figures with a
|
||||
// current-model reference, only photos under the shared threshold, no score in
|
||||
// the answer, deterministic grouping for the album read.
|
||||
|
||||
const zeroVector = (): number[] => Array.from({ length: FACE_SEARCH_DESCRIPTOR_LENGTH }, () => 0)
|
||||
|
||||
/** Constant vector whose euclidean distance to the zero vector is `distance`. */
|
||||
const vectorAtDistance = (distance: number): number[] => {
|
||||
const component = distance / Math.sqrt(FACE_SEARCH_DESCRIPTOR_LENGTH)
|
||||
return Array.from({ length: FACE_SEARCH_DESCRIPTOR_LENGTH }, () => component)
|
||||
}
|
||||
|
||||
const reference = (distance: number, model: string = FACE_SEARCH_MODEL) => ({
|
||||
model,
|
||||
vector: vectorAtDistance(distance),
|
||||
})
|
||||
|
||||
const figure = (overrides: Partial<FaceFigure> = {}): FaceFigure => ({
|
||||
slug: 'lula',
|
||||
name: 'Lula',
|
||||
active: true,
|
||||
references: [reference(0)],
|
||||
...overrides,
|
||||
})
|
||||
|
||||
const descriptors: FaceDescriptorEntry[] = [
|
||||
{ photoId: 10, vector: zeroVector() },
|
||||
{ photoId: 20, vector: zeroVector() },
|
||||
]
|
||||
|
||||
describe('findFaceFigureMatches', () => {
|
||||
it('matches the distinct photos of an active figure and keeps the index order', () => {
|
||||
expect(findFaceFigureMatches({ figures: [figure()], descriptors })).toEqual([
|
||||
{ slug: 'lula', name: 'Lula', photoIds: [10, 20] },
|
||||
])
|
||||
})
|
||||
|
||||
it('never matches under the threshold distance (strictly below)', () => {
|
||||
const below = findFaceFigureMatches({
|
||||
figures: [figure({ references: [reference(FACE_SEARCH_MAX_DISTANCE - 0.01)] })],
|
||||
descriptors,
|
||||
})
|
||||
const at = findFaceFigureMatches({
|
||||
figures: [figure({ references: [reference(FACE_SEARCH_MAX_DISTANCE)] })],
|
||||
descriptors,
|
||||
})
|
||||
|
||||
expect(below).toHaveLength(1)
|
||||
expect(at).toEqual([])
|
||||
})
|
||||
|
||||
it('ignores a figure that is not explicitly active', () => {
|
||||
expect(findFaceFigureMatches({ figures: [figure({ active: false })], descriptors })).toEqual([])
|
||||
expect(findFaceFigureMatches({ figures: [figure({ active: null })], descriptors })).toEqual([])
|
||||
expect(
|
||||
findFaceFigureMatches({ figures: [figure({ active: undefined })], descriptors }),
|
||||
).toEqual([])
|
||||
})
|
||||
|
||||
it('ignores foreign-model references and malformed vectors', () => {
|
||||
const foreignModel = figure({ references: [reference(0, 'outro-modelo')] })
|
||||
const malformed = figure({ references: [{ model: FACE_SEARCH_MODEL, vector: [1, 2, 3] }] })
|
||||
const poisoned = figure({
|
||||
references: [{ model: FACE_SEARCH_MODEL, vector: zeroVector().fill(Number.NaN) }],
|
||||
})
|
||||
|
||||
expect(findFaceFigureMatches({ figures: [foreignModel], descriptors })).toEqual([])
|
||||
expect(findFaceFigureMatches({ figures: [malformed], descriptors })).toEqual([])
|
||||
expect(findFaceFigureMatches({ figures: [poisoned], descriptors })).toEqual([])
|
||||
})
|
||||
|
||||
it('dedupes the same photo across faces and references and sorts by slug', () => {
|
||||
const matches = findFaceFigureMatches({
|
||||
figures: [
|
||||
figure({ slug: 'wagner', name: 'Wagner' }),
|
||||
figure({
|
||||
slug: 'lula',
|
||||
name: 'Lula',
|
||||
references: [reference(0), reference(0.05)],
|
||||
}),
|
||||
],
|
||||
descriptors: [
|
||||
{ photoId: 10, vector: zeroVector() },
|
||||
{ photoId: 10, vector: zeroVector() },
|
||||
],
|
||||
})
|
||||
|
||||
expect(matches.map((match) => match.slug)).toEqual(['lula', 'wagner'])
|
||||
expect(matches[0]!.photoIds).toEqual([10])
|
||||
})
|
||||
|
||||
it('carries only slug, name and photoIds — no distance or score by shape', () => {
|
||||
const [match] = findFaceFigureMatches({ figures: [figure()], descriptors })
|
||||
expect(Object.keys(match!).sort()).toEqual(['name', 'photoIds', 'slug'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('groupFaceFigureMatchesByPhoto', () => {
|
||||
it('pivots into photos ordered by id and figures ordered by slug', () => {
|
||||
const grouped = groupFaceFigureMatchesByPhoto([
|
||||
{ slug: 'wagner', name: 'Wagner', photoIds: [20] },
|
||||
{ slug: 'lula', name: 'Lula', photoIds: [20, 10] },
|
||||
])
|
||||
|
||||
expect(grouped).toEqual([
|
||||
{ photoId: 10, figures: [{ slug: 'lula', name: 'Lula' }] },
|
||||
{
|
||||
photoId: 20,
|
||||
figures: [
|
||||
{ slug: 'lula', name: 'Lula' },
|
||||
{ slug: 'wagner', name: 'Wagner' },
|
||||
],
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
it('keeps only the public identity of each figure', () => {
|
||||
const [photoMatch] = groupFaceFigureMatchesByPhoto([
|
||||
{ slug: 'lula', name: 'Lula', photoIds: [10] },
|
||||
])
|
||||
expect(Object.keys(photoMatch!.figures[0]!).sort()).toEqual(['name', 'slug'])
|
||||
})
|
||||
})
|
||||
Loading…
Reference in a new issue