feat: Implement face clustering and media retrieval for persons

This commit is contained in:
2025-11-15 23:39:51 +01:00
parent 0f3e098d6d
commit d444871829
9 changed files with 239 additions and 8 deletions

View File

@@ -6,9 +6,9 @@ use libertas_core::{
};
use libertas_infra::factory::{
build_album_repository, build_album_share_repository, build_database_pool,
build_face_region_repository, build_media_metadata_repository, build_media_repository,
build_person_repository, build_person_share_repository, build_tag_repository,
build_user_repository,
build_face_embedding_repository, build_face_region_repository, build_media_metadata_repository,
build_media_repository, build_person_repository, build_person_share_repository,
build_tag_repository, build_user_repository,
};
use crate::{
@@ -40,6 +40,8 @@ pub async fn build_app_state(config: AppConfig) -> CoreResult<AppState> {
let face_region_repo = build_face_region_repository(&config.database, db_pool.clone()).await?;
let person_share_repo =
build_person_share_repository(&config.database, db_pool.clone()).await?;
let face_embedding_repo =
build_face_embedding_repository(&config.database, db_pool.clone()).await?;
let hasher = Arc::new(Argon2Hasher::default());
let tokenizer = Arc::new(JwtGenerator::new(config.jwt_secret.clone()));
@@ -81,6 +83,8 @@ pub async fn build_app_state(config: AppConfig) -> CoreResult<AppState> {
person_repo.clone(),
face_region_repo.clone(),
person_share_repo.clone(),
face_embedding_repo.clone(),
media_repo.clone(),
authorization_service.clone(),
));

View File

@@ -10,10 +10,12 @@ use uuid::Uuid;
use crate::{
error::ApiError,
extractors::query_options::ApiListMediaOptions,
middleware::auth::UserId,
schema::{
AssignFaceRequest, CreatePersonRequest, FaceRegionResponse, MergePersonRequest,
PersonResponse, SetPersonThumbnailRequest, SharePersonRequest, UpdatePersonRequest,
AssignFaceRequest, CreatePersonRequest, FaceRegionResponse, MediaResponse,
MergePersonRequest, PaginatedResponse, PersonResponse, SetPersonThumbnailRequest,
SharePersonRequest, UpdatePersonRequest, map_paginated_response,
},
state::AppState,
};
@@ -31,6 +33,8 @@ pub fn people_routes() -> Router<AppState> {
)
.route("/{person_id}/merge", post(merge_person))
.route("/{person_id}/thumbnail", put(set_person_thumbnail))
.route("/cluster", post(cluster_faces))
.route("/{person_id}/media", get(list_media_for_person))
}
pub fn face_routes() -> Router<AppState> {
@@ -176,3 +180,29 @@ async fn set_person_thumbnail(
.await?;
Ok(StatusCode::OK)
}
async fn cluster_faces(
State(state): State<AppState>,
UserId(user_id): UserId,
) -> Result<StatusCode, ApiError> {
state
.person_service
.cluster_unassigned_faces(user_id)
.await?;
Ok(StatusCode::OK)
}
async fn list_media_for_person(
State(state): State<AppState>,
UserId(user_id): UserId,
Path(person_id): Path<Uuid>,
ApiListMediaOptions(options): ApiListMediaOptions,
) -> Result<Json<PaginatedResponse<MediaResponse>>, ApiError> {
let core_paginated_result = state
.person_service
.list_media_for_person(person_id, user_id, options)
.await?;
let api_response = map_paginated_response(core_paginated_result);
Ok(Json(api_response))
}

View File

@@ -1,11 +1,16 @@
use std::sync::Arc;
use dbscan::{self, Classification};
use std::{collections::HashMap, sync::Arc};
use async_trait::async_trait;
use libertas_core::{
authz,
error::{CoreError, CoreResult},
models::{FaceRegion, Person, PersonPermission},
repositories::{FaceRegionRepository, PersonRepository, PersonShareRepository},
models::{FaceRegion, Media, Person, PersonPermission},
repositories::{
FaceEmbeddingRepository, FaceRegionRepository, MediaRepository, PersonRepository,
PersonShareRepository,
},
schema::{ListMediaOptions, PaginatedResponse},
services::{AuthorizationService, PersonService},
};
use uuid::Uuid;
@@ -14,6 +19,8 @@ pub struct PersonServiceImpl {
person_repo: Arc<dyn PersonRepository>,
face_repo: Arc<dyn FaceRegionRepository>,
person_share_repo: Arc<dyn PersonShareRepository>,
face_embedding_repo: Arc<dyn FaceEmbeddingRepository>,
media_repo: Arc<dyn MediaRepository>,
auth_service: Arc<dyn AuthorizationService>,
}
@@ -22,12 +29,16 @@ impl PersonServiceImpl {
person_repo: Arc<dyn PersonRepository>,
face_repo: Arc<dyn FaceRegionRepository>,
person_share_repo: Arc<dyn PersonShareRepository>,
face_embedding_repo: Arc<dyn FaceEmbeddingRepository>,
media_repo: Arc<dyn MediaRepository>,
auth_service: Arc<dyn AuthorizationService>,
) -> Self {
Self {
person_repo,
face_repo,
person_share_repo,
face_embedding_repo,
media_repo,
auth_service,
}
}
@@ -40,6 +51,13 @@ impl PersonServiceImpl {
.ok_or(CoreError::NotFound("Person".to_string(), person_id))?;
Ok(person)
}
fn bytes_to_f32_vec(bytes: &[u8]) -> Vec<f32> {
bytes
.chunks_exact(4)
.map(|chunk| f32::from_le_bytes(chunk.try_into().unwrap_or([0; 4])))
.collect()
}
}
#[async_trait]
@@ -245,4 +263,83 @@ impl PersonService for PersonServiceImpl {
.set_thumbnail_media_id(person_id, face_region.media_id)
.await
}
async fn cluster_unassigned_faces(&self, user_id: Uuid) -> CoreResult<()> {
let embedding_data = self
.face_embedding_repo
.list_unassigned_by_user(user_id)
.await?;
if embedding_data.is_empty() {
return Ok(());
}
let embeddings_f32: Vec<Vec<f32>> = embedding_data
.iter()
.map(|data| Self::bytes_to_f32_vec(&data.embedding))
.collect();
let scan = dbscan::Model::new(0.4, 2);
let clusters = scan.run(&embeddings_f32);
let mut cluster_map: HashMap<usize, Vec<usize>> = HashMap::new();
tracing::info!(
"DBSCAN found {} clusters",
clusters
.iter()
.filter(|c| match c {
Classification::Core(_) | Classification::Edge(_) => true,
Classification::Noise => false,
})
.count()
);
for (i, classification) in clusters.iter().enumerate() {
match classification {
Classification::Core(cluster_id) | Classification::Edge(cluster_id) => {
cluster_map.entry(*cluster_id).or_default().push(i);
}
Classification::Noise => {}
}
}
for (_cluster_id, indices) in cluster_map {
let person_name = format!("Person {}", Uuid::new_v4());
let new_person = self.create_person(&person_name, user_id).await?;
let face_ids: Vec<Uuid> = indices
.iter()
.map(|&i| embedding_data[i].face_region_id)
.collect();
for face_id in face_ids {
self.face_repo
.update_person_id(face_id, new_person.id)
.await?;
}
}
Ok(())
}
async fn list_media_for_person(
&self,
person_id: Uuid,
user_id: Uuid,
options: ListMediaOptions,
) -> CoreResult<PaginatedResponse<Media>> {
self.auth_service
.check_permission(Some(user_id), authz::Permission::ViewPerson(person_id))
.await?;
let (data, total_items) = self
.media_repo
.list_by_person_id(person_id, &options)
.await?;
let pagination = options.pagination.unwrap();
let response = PaginatedResponse::new(data, pagination.page, pagination.limit, total_items);
Ok(response)
}
}