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.
#[serde(default)]
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)]
@@ -2023,7 +2033,10 @@ async fn create_face_handler<D: FaceDao>(
let span = global_tracer().start_with_context("faces.create_manual", &context);
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");
}
@@ -2049,56 +2062,73 @@ async fn create_face_handler<D: FaceDao>(
}
};
// 2. Read full image, crop to bbox, encode as JPEG for transport.
let abs_path = library.resolve(&normalized_path);
let crop_bytes = match crop_image_to_bbox(
&abs_path,
body.bbox.x,
body.bbox.y,
body.bbox.w,
body.bbox.h,
) {
Ok(b) => b,
Err(e) => {
warn!("crop_image_to_bbox failed for {:?}: {:?}", abs_path, e);
return HttpResponse::BadRequest().body(format!("cannot crop photo: {}", e));
}
};
// 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 crop_bytes = match crop_image_to_bbox(
&abs_path,
body.bbox.x,
body.bbox.y,
body.bbox.w,
body.bbox.h,
) {
Ok(b) => b,
Err(e) => {
warn!("crop_image_to_bbox failed for {:?}: {:?}", abs_path, e);
return HttpResponse::BadRequest().body(format!("cannot crop photo: {}", e));
}
};
// 3. Send the crop to Apollo for embedding extraction.
let meta = DetectMeta {
content_hash: hash.clone(),
library_id: library.id,
rel_path: normalized_path.clone(),
orientation: None,
model_version: None,
};
let detect = match face_client.embed(crop_bytes, meta).await {
Ok(r) => r,
Err(FaceDetectError::Permanent(e)) => {
return HttpResponse::UnprocessableEntity().body(format!("{}", e));
}
Err(FaceDetectError::Transient(e)) => {
return HttpResponse::ServiceUnavailable().body(format!("{}", e));
}
Err(FaceDetectError::Disabled) => {
return HttpResponse::ServiceUnavailable().body("face client disabled");
}
};
let meta = DetectMeta {
content_hash: hash.clone(),
library_id: library.id,
rel_path: normalized_path.clone(),
orientation: None,
model_version: None,
};
let detect = match face_client.embed(crop_bytes, meta).await {
Ok(r) => r,
Err(FaceDetectError::Permanent(e)) => {
return HttpResponse::UnprocessableEntity().body(format!("{}", e));
}
Err(FaceDetectError::Transient(e)) => {
return HttpResponse::ServiceUnavailable().body(format!("{}", e));
}
Err(FaceDetectError::Disabled) => {
return HttpResponse::ServiceUnavailable().body("face client disabled");
}
};
let detected = match detect.faces.first() {
Some(f) => f.clone(),
None => {
// Apollo would have returned 422 on no_face_in_crop; defensive.
return HttpResponse::UnprocessableEntity().body("no face in crop");
}
};
let embedding_bytes = match detected.decode_embedding() {
Ok(b) => b,
Err(e) => {
warn!("manual face: decode embedding failed: {:?}", e);
return HttpResponse::BadGateway().body("invalid embedding from face service");
}
let detected = match detect.faces.first() {
Some(f) => f.clone(),
None => {
// Apollo would have returned 422 on no_face_in_crop; defensive.
return HttpResponse::UnprocessableEntity().body("no face in crop");
}
};
let bytes = match detected.decode_embedding() {
Ok(b) => b,
Err(e) => {
warn!("manual face: decode embedding failed: {:?}", e);
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
@@ -2114,11 +2144,11 @@ async fn create_face_handler<D: FaceDao>(
rel_path: normalized_path,
bbox: Some((body.bbox.x, body.bbox.y, body.bbox.w, body.bbox.h)),
embedding: Some(embedding_bytes),
confidence: Some(detected.confidence),
confidence: Some(confidence),
source: "manual".to_string(),
person_id: body.person_id,
status: "detected".to_string(),
model_version: detect.model_version,
model_version,
},
) {
Ok(r) => r,