feat: Implement face clustering and media retrieval for persons
This commit is contained in:
@@ -36,3 +36,4 @@ tower = { version = "0.5.2", features = ["util"] }
|
||||
tower-http = { version = "0.6.6", features = ["fs", "trace"] }
|
||||
tracing = "0.1.41"
|
||||
tracing-subscriber = { version = "0.3.20", features = ["env-filter"] }
|
||||
dbscan = "0.3.1"
|
||||
|
||||
@@ -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(),
|
||||
));
|
||||
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user