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

@@ -3,9 +3,9 @@ use std::{path::PathBuf, sync::Arc};
use futures_util::StreamExt;
use libertas_core::plugins::PluginContext;
use libertas_infra::factory::{
build_album_repository, build_database_pool, build_face_region_repository,
build_media_metadata_repository, build_media_repository, build_person_repository,
build_tag_repository, build_user_repository,
build_album_repository, build_database_pool, build_face_embedding_repository,
build_face_region_repository, build_media_metadata_repository, build_media_repository,
build_person_repository, build_tag_repository, build_user_repository,
};
use serde::Deserialize;
use tokio::fs;
@@ -45,6 +45,8 @@ async fn main() -> anyhow::Result<()> {
let tag_repo = build_tag_repository(&config.database, db_pool.clone()).await?;
let person_repo = build_person_repository(&config.database, db_pool.clone()).await?;
let face_region_repo = build_face_region_repository(&config.database, db_pool.clone()).await?;
let face_embedding_repo =
build_face_embedding_repository(&config.database, db_pool.clone()).await?;
let context = Arc::new(PluginContext {
media_repo,
@@ -53,6 +55,7 @@ async fn main() -> anyhow::Result<()> {
tag_repo,
person_repo,
face_region_repo,
face_embedding_repo,
metadata_repo,
media_library_path: config.media_library_path.clone(),
config: Arc::new(config.clone()),

View File

@@ -1,19 +1,20 @@
use std::sync::Arc;
use libertas_core::{
ai::FaceDetector,
config::{AiConfig, AppConfig, FaceDetectorRuntime},
ai::{FaceDetector, FaceEmbedder},
config::{AiConfig, AppConfig, FaceDetectorRuntime, FaceEmbedderRuntime},
error::{CoreError, CoreResult},
models::Media,
plugins::{MediaProcessorPlugin, PluginContext},
};
use libertas_infra::ai::{
remote_detector::RemoteNatsFaceDetector, tract_detector::TractFaceDetector,
tract_embedder::TractFaceEmbedder,
};
use crate::plugins::{
exif_reader::ExifReaderPlugin, face_detector::FaceDetectionPlugin, thumbnail::ThumbnailPlugin,
xmp_writer::XmpWriterPlugin,
embedding_generator::EmbeddingGeneratorPlugin, exif_reader::ExifReaderPlugin,
face_detector::FaceDetectionPlugin, thumbnail::ThumbnailPlugin, xmp_writer::XmpWriterPlugin,
};
pub struct PluginManager {
@@ -25,7 +26,7 @@ impl PluginManager {
let mut plugins: Vec<Arc<dyn MediaProcessorPlugin>> = Vec::new();
if let Some(ai_config) = &config.ai_config {
match build_face_detector(ai_config, nats_client) {
match build_face_detector(ai_config, nats_client.clone()) {
Ok(detector) => {
plugins.push(Arc::new(FaceDetectionPlugin::new(detector)));
println!("FaceDetectionPlugin loaded.");
@@ -34,6 +35,16 @@ impl PluginManager {
eprintln!("Failed to load FaceDetectionPlugin: {}", e);
}
}
match build_face_embedder(ai_config, nats_client.clone()) {
Ok(embedder) => {
plugins.push(Arc::new(EmbeddingGeneratorPlugin::new(embedder)));
println!("EmbeddingGeneratorPlugin loaded.");
}
Err(e) => {
eprintln!("Failed to load EmbeddingGeneratorPlugin: {}", e);
}
}
}
plugins.push(Arc::new(ExifReaderPlugin));
@@ -86,3 +97,27 @@ fn build_face_detector(
))),
}
}
fn build_face_embedder(
config: &AiConfig,
_nats_client: async_nats::Client,
) -> CoreResult<Box<dyn FaceEmbedder>> {
match &config.face_embedder_runtime {
FaceEmbedderRuntime::Tract => {
let model_path =
config
.face_embedder_model_path
.as_deref()
.ok_or(CoreError::Config(
"Tract runtime needs 'face_embedder_model_path'".to_string(),
))?;
Ok(Box::new(TractFaceEmbedder::new(model_path)?))
}
FaceEmbedderRuntime::Onnx => {
unimplemented!("ONNX face embedder not implemented yet");
}
FaceEmbedderRuntime::RemoteNats { subject: _ } => {
unimplemented!("RemoteNats face embedder not implemented yet");
}
}
}

View File

@@ -0,0 +1,110 @@
use std::{io::Cursor, path::PathBuf};
use async_trait::async_trait;
use image::{ImageFormat, ImageReader};
use libertas_core::{
ai::FaceEmbedder,
error::{CoreError, CoreResult},
models::{FaceEmbedding, Media},
plugins::{MediaProcessorPlugin, PluginContext, PluginData},
};
use tokio::fs;
pub struct EmbeddingGeneratorPlugin {
embedder: Box<dyn FaceEmbedder>,
model_id: i16,
}
impl EmbeddingGeneratorPlugin {
pub fn new(embedder: Box<dyn FaceEmbedder>) -> Self {
Self {
embedder,
model_id: 1, // todo: come from config or something
}
}
fn f32_vec_to_bytes(vec: &[f32]) -> Vec<u8> {
vec.iter().flat_map(|&f| f.to_le_bytes()).collect()
}
}
#[async_trait]
impl MediaProcessorPlugin for EmbeddingGeneratorPlugin {
fn name(&self) -> &'static str {
"embedding_generator"
}
async fn process(&self, media: &Media, context: &PluginContext) -> CoreResult<PluginData> {
if !media.mime_type.starts_with("image/") {
return Ok(PluginData {
message: "Not an image, skipping.".to_string(),
});
}
// 1. Get all face regions for this media
let faces = context.face_region_repo.find_by_media_id(media.id).await?;
if faces.is_empty() {
return Ok(PluginData {
message: "No faces found to embed.".to_string(),
});
}
// 2. Load the full original image
let file_path = PathBuf::from(&context.media_library_path).join(&media.storage_path);
let image_bytes = fs::read(file_path).await?;
let img = ImageReader::new(Cursor::new(&image_bytes))
.with_guessed_format()?
.decode()
.map_err(|e| CoreError::Unknown(format!("Failed to decode image: {}", e)))?;
let mut new_embeddings = 0;
for face in faces {
// 3. Check if embedding already exists
if context
.face_embedding_repo
.find_by_face_region_id(face.id)
.await?
.is_some()
{
continue;
}
// 4. Crop the face from the main image
let cropped_face = img.crop_imm(
face.x_min as u32,
face.y_min as u32,
(face.x_max - face.x_min) as u32,
(face.y_max - face.y_min) as u32,
);
// 5. Convert cropped image back to bytes (as JPEG)
let mut buf = Cursor::new(Vec::new());
cropped_face
.write_to(&mut buf, ImageFormat::Jpeg)
.map_err(|e| {
CoreError::Unknown(format!("Failed to encode cropped image: {}", e))
})?;
let cropped_bytes = buf.into_inner();
// 6. Generate the embedding
let embedding_f32 = self.embedder.generate_embedding(&cropped_bytes).await?;
let embedding_bytes = Self::f32_vec_to_bytes(&embedding_f32);
// 7. Save to database
let embedding_model = FaceEmbedding {
id: uuid::Uuid::new_v4(),
face_region_id: face.id,
model_id: self.model_id,
embedding: embedding_bytes,
};
context.face_embedding_repo.create(&embedding_model).await?;
new_embeddings += 1;
}
Ok(PluginData {
message: format!("Generated {} new embeddings.", new_embeddings),
})
}
}

View File

@@ -1,3 +1,4 @@
pub mod embedding_generator;
pub mod exif_reader;
pub mod face_detector;
pub mod thumbnail;