Face Recognition / People Integration #61

Merged
cameron merged 23 commits from feature/face-recog-phase3-file-watch into master 2026-04-30 17:22:09 +00:00
Showing only changes of commit 891a9982ef - Show all commits

View File

@@ -287,6 +287,16 @@ pub struct CreateFaceReq {
/// box and immediately picks a name from the autocomplete. /// box and immediately picks a name from the autocomplete.
#[serde(default)] #[serde(default)]
pub person_id: Option<i32>, pub person_id: Option<i32>,
/// Skip the embedding step. Set when the user wants to tag a region
/// the detector can't find a face in (back of head, profile partly
/// occluded, etc.). The row is stored with a zero-vector embedding,
/// which the cluster suggester filters on `norm <= 0` and auto-bind
/// cosine resolves to 0 against — so the row participates only as a
/// browse-by-person tag, not in similarity matching. The frontend
/// only sets this after a 422 from a strict create plus an explicit
/// operator confirmation.
#[serde(default)]
pub force: bool,
} }
#[derive(Deserialize, Debug)] #[derive(Deserialize, Debug)]
@@ -2023,7 +2033,10 @@ async fn create_face_handler<D: FaceDao>(
let span = global_tracer().start_with_context("faces.create_manual", &context); let span = global_tracer().start_with_context("faces.create_manual", &context);
let span_context = opentelemetry::Context::current_with_span(span); let span_context = opentelemetry::Context::current_with_span(span);
if !face_client.is_enabled() { // The force path doesn't need Apollo at all (no embed call); the
// strict path does. Surface the disabled state only when we'd
// actually use the client.
if !body.force && !face_client.is_enabled() {
return HttpResponse::ServiceUnavailable().body("face client disabled"); return HttpResponse::ServiceUnavailable().body("face client disabled");
} }
@@ -2049,7 +2062,23 @@ async fn create_face_handler<D: FaceDao>(
} }
}; };
// 2. Read full image, crop to bbox, encode as JPEG for transport. // 2 + 3. Crop + embed via Apollo (strict path), or skip both (force).
//
// Force is the "tag a face the detector can't see" path — back of
// head, heavily-occluded profile, etc. We store a zero-vector
// embedding under a sentinel model_version so the row participates
// only as a browse-by-person tag: clustering filters norm<=0 (see
// face_clustering._decode_b64_embedding) and auto-bind cosine
// resolves to 0 / NaN, never crossing the threshold. Cluster
// suggester also groups by model_version so this sentinel never
// mixes with real buffalo_l rows.
let (embedding_bytes, model_version, confidence) = if body.force {
info!(
"manual face (force): skipping detection for {:?} bbox=({},{},{},{})",
normalized_path, body.bbox.x, body.bbox.y, body.bbox.w, body.bbox.h
);
(vec![0u8; 2048], "manual_no_embed".to_string(), 0.0_f32)
} else {
let abs_path = library.resolve(&normalized_path); let abs_path = library.resolve(&normalized_path);
let crop_bytes = match crop_image_to_bbox( let crop_bytes = match crop_image_to_bbox(
&abs_path, &abs_path,
@@ -2065,7 +2094,6 @@ async fn create_face_handler<D: FaceDao>(
} }
}; };
// 3. Send the crop to Apollo for embedding extraction.
let meta = DetectMeta { let meta = DetectMeta {
content_hash: hash.clone(), content_hash: hash.clone(),
library_id: library.id, library_id: library.id,
@@ -2093,13 +2121,15 @@ async fn create_face_handler<D: FaceDao>(
return HttpResponse::UnprocessableEntity().body("no face in crop"); return HttpResponse::UnprocessableEntity().body("no face in crop");
} }
}; };
let embedding_bytes = match detected.decode_embedding() { let bytes = match detected.decode_embedding() {
Ok(b) => b, Ok(b) => b,
Err(e) => { Err(e) => {
warn!("manual face: decode embedding failed: {:?}", e); warn!("manual face: decode embedding failed: {:?}", e);
return HttpResponse::BadGateway().body("invalid embedding from face service"); return HttpResponse::BadGateway().body("invalid embedding from face service");
} }
}; };
(bytes, detect.model_version, detected.confidence)
};
// 4. Insert the manual row using the bbox the user drew (NOT the // 4. Insert the manual row using the bbox the user drew (NOT the
// detector's tighter box around their drawing — they get what they // detector's tighter box around their drawing — they get what they
@@ -2114,11 +2144,11 @@ async fn create_face_handler<D: FaceDao>(
rel_path: normalized_path, rel_path: normalized_path,
bbox: Some((body.bbox.x, body.bbox.y, body.bbox.w, body.bbox.h)), bbox: Some((body.bbox.x, body.bbox.y, body.bbox.w, body.bbox.h)),
embedding: Some(embedding_bytes), embedding: Some(embedding_bytes),
confidence: Some(detected.confidence), confidence: Some(confidence),
source: "manual".to_string(), source: "manual".to_string(),
person_id: body.person_id, person_id: body.person_id,
status: "detected".to_string(), status: "detected".to_string(),
model_version: detect.model_version, model_version,
}, },
) { ) {
Ok(r) => r, Ok(r) => r,