feat(faces): domain entities, ports & no-op analyzer
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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JW6ghFMDtnRYuYNzZhb47M
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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<String>,
|
||||
/// File id of the cover face's photo, for the tile thumbnail.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub cover_file_id: Option<String>,
|
||||
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<String>,
|
||||
pub x: f32,
|
||||
pub y: f32,
|
||||
pub w: f32,
|
||||
pub h: f32,
|
||||
}
|
||||
@@ -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<Vec<DetectedFace>, 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<Vec<Face>, DomainError>;
|
||||
async fn delete_faces_for_file(&self, file_id: Uuid) -> Result<(), DomainError>;
|
||||
async fn faces_for_user(&self, user_id: Uuid) -> Result<Vec<Face>, 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<Vec<Face>, DomainError>;
|
||||
async fn assign_person(
|
||||
&self,
|
||||
face_id: Uuid,
|
||||
person_id: Option<Uuid>,
|
||||
) -> Result<(), DomainError>;
|
||||
|
||||
// ── persons ────────────────────────────────────────────────────
|
||||
async fn create_person(&self, person: &Person) -> Result<(), DomainError>;
|
||||
async fn persons_for_user(&self, user_id: Uuid) -> Result<Vec<Person>, DomainError>;
|
||||
async fn rename_person(
|
||||
&self,
|
||||
user_id: Uuid,
|
||||
person_id: Uuid,
|
||||
name: Option<String>,
|
||||
) -> 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<Vec<Uuid>, 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>;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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<f32> {
|
||||
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<f32>,
|
||||
pub embedding: Vec<f32>,
|
||||
}
|
||||
|
||||
/// 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<Uuid>,
|
||||
pub bbox: BoundingBox,
|
||||
pub det_score: f32,
|
||||
pub quality: Option<f32>,
|
||||
pub embedding: Vec<f32>,
|
||||
pub blob_hash: Option<String>,
|
||||
pub created_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
/// 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<String>,
|
||||
pub cover_face_id: Option<Uuid>,
|
||||
pub is_hidden: bool,
|
||||
pub created_at: DateTime<Utc>,
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<Vec<DetectedFace>, DomainError> {
|
||||
Ok(Vec::new())
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user