From 524e57375605542ea6c49d7f608e6857f96affb2 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 19 Jun 2026 11:34:46 +0000 Subject: [PATCH] feat(faces): domain entities, ports & no-op analyzer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 2 increment 2: - domain: Face, Person, BoundingBox, DetectedFace (512-d embeddings). - ports: FaceAnalyzerPort (detect + embed from raw bytes; decodes internally so the application layer stays image/ML-crate agnostic) and FaceRepository (user-scoped face/person persistence). - DTOs: PersonDto, FaceBoxDto. - NoopFaceAnalyzer — reports is_ready()==false and returns no faces, so the whole People pipeline compiles and runs inert until the operator wires a real ONNX-backed analyzer. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01JW6ghFMDtnRYuYNzZhb47M --- src/application/dtos/mod.rs | 1 + src/application/dtos/people_dto.rs | 30 +++++++ src/application/ports/face_ports.rs | 78 +++++++++++++++++++ src/application/ports/mod.rs | 1 + src/domain/entities/face.rs | 72 +++++++++++++++++ src/domain/entities/mod.rs | 1 + src/infrastructure/services/mod.rs | 1 + .../services/noop_face_analyzer.rs | 26 +++++++ 8 files changed, 210 insertions(+) create mode 100644 src/application/dtos/people_dto.rs create mode 100644 src/application/ports/face_ports.rs create mode 100644 src/domain/entities/face.rs create mode 100644 src/infrastructure/services/noop_face_analyzer.rs diff --git a/src/application/dtos/mod.rs b/src/application/dtos/mod.rs index d86edb67..d3a5ea09 100644 --- a/src/application/dtos/mod.rs +++ b/src/application/dtos/mod.rs @@ -14,6 +14,7 @@ pub mod geo_dto; pub mod grant_dto; pub mod i18n_dto; pub mod pagination; +pub mod people_dto; pub mod playlist_dto; pub mod plugin_dto; pub mod recent_dto; diff --git a/src/application/dtos/people_dto.rs b/src/application/dtos/people_dto.rs new file mode 100644 index 00000000..8de09a8d --- /dev/null +++ b/src/application/dtos/people_dto.rs @@ -0,0 +1,30 @@ +//! DTOs for the People (faces) API. + +use serde::Serialize; +use utoipa::ToSchema; + +/// A named (or unnamed) identity cluster, with a cover photo for its tile. +#[derive(Debug, Clone, Serialize, ToSchema)] +pub struct PersonDto { + pub id: String, + /// `None` until the user names the person. + #[serde(skip_serializing_if = "Option::is_none")] + pub name: Option, + /// File id of the cover face's photo, for the tile thumbnail. + #[serde(skip_serializing_if = "Option::is_none")] + pub cover_file_id: Option, + pub face_count: i64, + pub is_hidden: bool, +} + +/// One face box within a photo (for tagging overlays in the lightbox). +#[derive(Debug, Clone, Serialize, ToSchema)] +pub struct FaceBoxDto { + pub id: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub person_id: Option, + pub x: f32, + pub y: f32, + pub w: f32, + pub h: f32, +} diff --git a/src/application/ports/face_ports.rs b/src/application/ports/face_ports.rs new file mode 100644 index 00000000..95e00eb8 --- /dev/null +++ b/src/application/ports/face_ports.rs @@ -0,0 +1,78 @@ +//! Ports for the People (faces) feature. + +use async_trait::async_trait; +use uuid::Uuid; + +use crate::common::errors::DomainError; +use crate::domain::entities::face::{DetectedFace, Face, Person}; + +/// Detects faces in an image and produces an aligned, L2-normalized embedding +/// for each. Takes raw encoded bytes (it decodes internally) so the +/// application layer stays decoupled from any image/ML crate. +/// +/// The default implementation ([`NoopFaceAnalyzer`](crate::infrastructure::services::noop_face_analyzer::NoopFaceAnalyzer)) +/// is a no-op that reports `is_ready() == false`; a real ONNX-backed +/// implementation is wired in when the operator provides models at runtime. +#[async_trait] +pub trait FaceAnalyzerPort: Send + Sync + 'static { + /// Whether a usable model is loaded. When false, indexing is skipped. + fn is_ready(&self) -> bool; + + /// Detect and embed every face in `image_bytes` (an encoded JPEG/PNG/…). + async fn analyze(&self, image_bytes: &[u8]) -> Result, DomainError>; +} + +/// Persistence for faces and persons. Every method is user-scoped; the +/// repository enforces `WHERE user_id = …` so callers only ever touch their +/// own biometric data. +#[async_trait] +pub trait FaceRepository: Send + Sync + 'static { + // ── faces ────────────────────────────────────────────────────── + async fn save_faces(&self, faces: &[Face]) -> Result<(), DomainError>; + async fn faces_for_file(&self, file_id: Uuid) -> Result, DomainError>; + async fn delete_faces_for_file(&self, file_id: Uuid) -> Result<(), DomainError>; + async fn faces_for_user(&self, user_id: Uuid) -> Result, DomainError>; + /// Faces previously computed for any file sharing this content hash — + /// lets indexing reuse results for deduplicated (identical) uploads. + async fn faces_for_blob( + &self, + user_id: Uuid, + blob_hash: &str, + ) -> Result, DomainError>; + async fn assign_person( + &self, + face_id: Uuid, + person_id: Option, + ) -> Result<(), DomainError>; + + // ── persons ──────────────────────────────────────────────────── + async fn create_person(&self, person: &Person) -> Result<(), DomainError>; + async fn persons_for_user(&self, user_id: Uuid) -> Result, DomainError>; + async fn rename_person( + &self, + user_id: Uuid, + person_id: Uuid, + name: Option, + ) -> Result<(), DomainError>; + async fn set_person_cover( + &self, + person_id: Uuid, + cover_face_id: Uuid, + ) -> Result<(), DomainError>; + async fn set_person_hidden( + &self, + user_id: Uuid, + person_id: Uuid, + hidden: bool, + ) -> Result<(), DomainError>; + /// File ids that contain a face assigned to this person (most recent first). + async fn files_for_person( + &self, + user_id: Uuid, + person_id: Uuid, + ) -> Result, DomainError>; + + /// Hard-delete every face and person for a user (right to erasure / + /// disabling the feature). + async fn delete_all_for_user(&self, user_id: Uuid) -> Result<(), DomainError>; +} diff --git a/src/application/ports/mod.rs b/src/application/ports/mod.rs index 5e6c39be..352ed8f4 100644 --- a/src/application/ports/mod.rs +++ b/src/application/ports/mod.rs @@ -10,6 +10,7 @@ pub mod compression_ports; pub mod content_index_ports; pub mod dedup_ports; pub mod email_sender; +pub mod face_ports; pub mod favorites_ports; pub mod file_lifecycle; pub mod file_ports; diff --git a/src/domain/entities/face.rs b/src/domain/entities/face.rs new file mode 100644 index 00000000..46380833 --- /dev/null +++ b/src/domain/entities/face.rs @@ -0,0 +1,72 @@ +//! Domain entities for the People (faces) feature. + +use chrono::{DateTime, Utc}; +use uuid::Uuid; + +/// Length of a face embedding vector (ArcFace-style). +pub const EMBEDDING_DIM: usize = 512; + +/// A face bounding box in normalized image coordinates (each component 0..1). +#[derive(Debug, Clone, Copy)] +pub struct BoundingBox { + pub x: f32, + pub y: f32, + pub w: f32, + pub h: f32, +} + +impl BoundingBox { + /// `[x, y, w, h]` — the storage representation (Postgres `REAL[]`). + pub fn to_array(self) -> Vec { + vec![self.x, self.y, self.w, self.h] + } + + /// Build from a stored `[x, y, w, h]` array; missing components default to 0. + pub fn from_slice(a: &[f32]) -> Self { + Self { + x: a.first().copied().unwrap_or(0.0), + y: a.get(1).copied().unwrap_or(0.0), + w: a.get(2).copied().unwrap_or(0.0), + h: a.get(3).copied().unwrap_or(0.0), + } + } +} + +/// A face produced by the analyzer but not yet persisted: where it is, how +/// confident the detector was, an optional quality score, and a 512-d, +/// L2-normalized embedding. +#[derive(Debug, Clone)] +pub struct DetectedFace { + pub bbox: BoundingBox, + pub det_score: f32, + pub quality: Option, + pub embedding: Vec, +} + +/// A persisted face detection. +#[derive(Debug, Clone)] +pub struct Face { + pub id: Uuid, + pub file_id: Uuid, + pub user_id: Uuid, + /// Identity cluster this face belongs to, if any. + pub person_id: Option, + pub bbox: BoundingBox, + pub det_score: f32, + pub quality: Option, + pub embedding: Vec, + pub blob_hash: Option, + pub created_at: DateTime, +} + +/// An identity cluster ("person"). `display_name` is `None` until the user +/// names it. +#[derive(Debug, Clone)] +pub struct Person { + pub id: Uuid, + pub user_id: Uuid, + pub display_name: Option, + pub cover_face_id: Option, + pub is_hidden: bool, + pub created_at: DateTime, +} diff --git a/src/domain/entities/mod.rs b/src/domain/entities/mod.rs index f020f3d8..75539dff 100644 --- a/src/domain/entities/mod.rs +++ b/src/domain/entities/mod.rs @@ -4,6 +4,7 @@ pub mod calendar_event; pub mod contact; pub mod device_code; pub mod entity_errors; +pub mod face; pub mod file; pub mod folder; pub mod magic_link_token; diff --git a/src/infrastructure/services/mod.rs b/src/infrastructure/services/mod.rs index 788ab933..8c4decce 100644 --- a/src/infrastructure/services/mod.rs +++ b/src/infrastructure/services/mod.rs @@ -17,6 +17,7 @@ pub mod migration_blob_backend; pub mod migration_job; pub mod mock_email_sender; pub mod nextcloud_chunked_upload_service; +pub mod noop_face_analyzer; pub mod oidc_service; pub mod password_hasher; pub mod path_resolver_service; diff --git a/src/infrastructure/services/noop_face_analyzer.rs b/src/infrastructure/services/noop_face_analyzer.rs new file mode 100644 index 00000000..e7779bd3 --- /dev/null +++ b/src/infrastructure/services/noop_face_analyzer.rs @@ -0,0 +1,26 @@ +//! Default no-op face analyzer. +//! +//! Used when no ML model is configured: it reports `is_ready() == false` and +//! returns no faces, so the whole People pipeline compiles and runs inert +//! until a real ONNX-backed analyzer (provided by the operator) replaces it. + +use async_trait::async_trait; + +use crate::application::ports::face_ports::FaceAnalyzerPort; +use crate::common::errors::DomainError; +use crate::domain::entities::face::DetectedFace; + +/// Analyzer that never detects anything. +#[derive(Debug, Default, Clone, Copy)] +pub struct NoopFaceAnalyzer; + +#[async_trait] +impl FaceAnalyzerPort for NoopFaceAnalyzer { + fn is_ready(&self) -> bool { + false + } + + async fn analyze(&self, _image_bytes: &[u8]) -> Result, DomainError> { + Ok(Vec::new()) + } +}