feat: Add thumbnail management for albums and people, implement face embedding functionality

This commit is contained in:
2025-11-15 22:50:53 +01:00
parent 98f56e4f1e
commit 0f3e098d6d
28 changed files with 560 additions and 26 deletions

View File

@@ -1,2 +1,3 @@
pub mod remote_detector;
pub mod tract_detector;
pub mod tract_embedder;

View File

@@ -0,0 +1,89 @@
use async_trait::async_trait;
use image::imageops;
use libertas_core::{
ai::FaceEmbedder,
error::{CoreError, CoreResult},
};
use std::sync::Arc;
use tokio::task;
use tract_onnx::{prelude::*, tract_core::ndarray::Array4};
type TractModel = SimplePlan<TypedFact, Box<dyn TypedOp>, Graph<TypedFact, Box<dyn TypedOp>>>;
pub struct TractFaceEmbedder {
model: Arc<TractModel>,
}
impl TractFaceEmbedder {
pub fn new(model_path: &str) -> CoreResult<Self> {
let model = tract_onnx::onnx()
.model_for_path(model_path)
.map_err(|e| CoreError::Config(format!("Failed to load embedding model: {}", e)))?
.with_input_fact(0, f32::fact([1, 112, 112, 3]).into())
.map_err(|e| CoreError::Config(format!("Failed to set input fact: {}", e)))?
.into_optimized()
.map_err(|e| CoreError::Config(format!("Failed to optimize model: {}", e)))?
.into_runnable()
.map_err(|e| CoreError::Config(format!("Failed to make model runnable: {}", e)))?;
Ok(Self {
model: Arc::new(model),
})
}
}
#[async_trait]
impl FaceEmbedder for TractFaceEmbedder {
async fn generate_embedding(&self, image_bytes: &[u8]) -> CoreResult<Vec<f32>> {
let start_time = std::time::Instant::now();
let image_bytes = image_bytes.to_vec();
let model = self.model.clone();
let embedding = task::spawn_blocking(move || {
println!("Running face embedding locally on the CPU...");
let img = image::load_from_memory(&image_bytes)
.map_err(|e| CoreError::Unknown(format!("Failed to load cropped face: {}", e)))?;
let resized = imageops::resize(&img, 112, 112, imageops::FilterType::Triangle);
let tensor: Tensor = Array4::from_shape_fn((1, 112, 112, 3), |(_, y, x, c)| {
(resized.get_pixel(x as u32, y as u32)[c] as f32 - 127.5) / 128.0
})
.into();
let result = model
.run(tvec!(tensor.into()))
.map_err(|e| CoreError::Unknown(format!("Failed to run embedding model: {}", e)))?;
let output_tensor = result[0].to_array_view::<f32>().map_err(|e| {
CoreError::Unknown(format!("Failed to convert output tensor: {}", e))
})?;
let output_vec: Vec<f32> = output_tensor.as_slice().unwrap_or(&[]).to_vec();
if output_vec.is_empty() {
return Err(CoreError::Unknown(
"Embedding model returned empty output".to_string(),
));
}
let norm = (output_vec.iter().map(|&x| x * x).sum::<f32>()).sqrt();
if norm > 1e-5 {
let normalized_vec: Vec<f32> = output_vec.iter().map(|&x| x / norm).collect();
Ok(normalized_vec)
} else {
Ok(output_vec)
}
})
.await
.map_err(|e| CoreError::Unknown(format!("Embedding task failed: {}", e)))?;
let duration = start_time.elapsed();
println!("Face embedding generated in {} ms", duration.as_millis());
embedding
}
}

View File

@@ -18,7 +18,6 @@ pub enum PostgresMediaMetadataSource {
TrackInfo,
}
#[derive(sqlx::FromRow)]
pub struct PostgresUser {
pub id: Uuid,
@@ -116,4 +115,12 @@ pub struct PostgresPersonShared {
pub owner_id: Uuid,
pub name: String,
pub permission: PostgresPersonPermission,
}
}
#[derive(sqlx::FromRow)]
pub struct PostgresFaceEmbedding {
pub id: Uuid,
pub face_region_id: Uuid,
pub model_id: i16,
pub embedding: Vec<u8>,
}

View File

@@ -177,3 +177,19 @@ pub async fn build_media_import_repository(
)),
}
}
pub async fn build_face_embedding_repository(
_db_config: &DatabaseConfig,
pool: DatabasePool,
) -> CoreResult<Arc<dyn libertas_core::repositories::FaceEmbeddingRepository>> {
match pool {
DatabasePool::Postgres(pg_pool) => Ok(Arc::new(
crate::repositories::face_embedding_repository::PostgresFaceEmbeddingRepository::new(
pg_pool,
),
)),
DatabasePool::Sqlite(_sqlite_pool) => Err(CoreError::Database(
"Sqlite face embedding repository not implemented".to_string(),
)),
}
}

View File

@@ -1,6 +1,14 @@
use libertas_core::models::{Album, AlbumPermission, AlbumShare, FaceRegion, Media, MediaMetadata, MediaMetadataSource, Person, PersonPermission, Role, Tag, User};
use libertas_core::models::{
Album, AlbumPermission, AlbumShare, FaceEmbedding, FaceRegion, Media, MediaMetadata,
MediaMetadataSource, Person, PersonPermission, Role, Tag, User,
};
use crate::db_models::{PostgresAlbum, PostgresAlbumPermission, PostgresAlbumShare, PostgresFaceRegion, PostgresMedia, PostgresMediaMetadata, PostgresMediaMetadataSource, PostgresPerson, PostgresPersonPermission, PostgresPersonShared, PostgresRole, PostgresTag, PostgresUser};
use crate::db_models::{
PostgresAlbum, PostgresAlbumPermission, PostgresAlbumShare, PostgresFaceEmbedding,
PostgresFaceRegion, PostgresMedia, PostgresMediaMetadata, PostgresMediaMetadataSource,
PostgresPerson, PostgresPersonPermission, PostgresPersonShared, PostgresRole, PostgresTag,
PostgresUser,
};
impl From<PostgresRole> for Role {
fn from(pg_role: PostgresRole) -> Self {
@@ -186,4 +194,15 @@ impl From<PostgresPersonShared> for (Person, PersonPermission) {
let permission = PersonPermission::from(pg_shared.permission);
(person, permission)
}
}
}
impl From<PostgresFaceEmbedding> for FaceEmbedding {
fn from(pg_embedding: PostgresFaceEmbedding) -> Self {
Self {
id: pg_embedding.id,
face_region_id: pg_embedding.face_region_id,
model_id: pg_embedding.model_id,
embedding: pg_embedding.embedding,
}
}
}

View File

@@ -166,4 +166,21 @@ impl AlbumRepository for PostgresAlbumRepository {
Ok(result.exists)
}
async fn set_thumbnail_media_id(&self, album_id: Uuid, media_id: Uuid) -> CoreResult<()> {
sqlx::query!(
r#"
UPDATE albums
SET thumbnail_media_id = $1
WHERE id = $2
"#,
media_id,
album_id
)
.execute(&self.pool)
.await
.map_err(|e| CoreError::Database(e.to_string()))?;
Ok(())
}
}

View File

@@ -0,0 +1,61 @@
use async_trait::async_trait;
use libertas_core::{
error::{CoreError, CoreResult},
models::FaceEmbedding,
repositories::FaceEmbeddingRepository,
};
use sqlx::PgPool;
use uuid::Uuid;
use crate::db_models::PostgresFaceEmbedding;
#[derive(Clone)]
pub struct PostgresFaceEmbeddingRepository {
pool: PgPool,
}
impl PostgresFaceEmbeddingRepository {
pub fn new(pool: PgPool) -> Self {
Self { pool }
}
}
#[async_trait]
impl FaceEmbeddingRepository for PostgresFaceEmbeddingRepository {
async fn create(&self, embedding: &FaceEmbedding) -> CoreResult<()> {
sqlx::query!(
r#"
INSERT INTO face_embeddings (id, face_region_id, model_id, embedding)
VALUES ($1, $2, $3, $4)
"#,
embedding.id,
embedding.face_region_id,
embedding.model_id,
embedding.embedding
)
.execute(&self.pool)
.await
.map_err(|e| CoreError::Database(e.to_string()))?;
Ok(())
}
async fn find_by_face_region_id(
&self,
face_region_id: Uuid,
) -> CoreResult<Option<FaceEmbedding>> {
let pg_embedding = sqlx::query_as!(
PostgresFaceEmbedding,
r#"
SELECT id, face_region_id, model_id, embedding
FROM face_embeddings
WHERE face_region_id = $1
"#,
face_region_id
)
.fetch_optional(&self.pool)
.await
.map_err(|e| CoreError::Database(e.to_string()))?;
Ok(pg_embedding.map(FaceEmbedding::from))
}
}

View File

@@ -1,5 +1,6 @@
pub mod album_repository;
pub mod album_share_repository;
pub mod face_embedding_repository;
pub mod face_region_repository;
pub mod media_import_repository;
pub mod media_metadata_repository;

View File

@@ -1,5 +1,9 @@
use async_trait::async_trait;
use libertas_core::{error::{CoreError, CoreResult}, models::Person, repositories::PersonRepository};
use libertas_core::{
error::{CoreError, CoreResult},
models::Person,
repositories::PersonRepository,
};
use sqlx::PgPool;
use uuid::Uuid;
@@ -95,4 +99,20 @@ impl PersonRepository for PostgresPersonRepository {
.map_err(|e| CoreError::Database(e.to_string()))?;
Ok(())
}
}
async fn set_thumbnail_media_id(&self, person_id: Uuid, media_id: Uuid) -> CoreResult<()> {
sqlx::query!(
r#"
UPDATE people
SET thumbnail_media_id = $1
WHERE id = $2
"#,
media_id,
person_id
)
.execute(&self.pool)
.await
.map_err(|e| CoreError::Database(e.to_string()))?;
Ok(())
}
}