faces: force-create path for regions the detector can't see
Adds an opt-in 'force' flag to POST /image/faces. When set, the handler skips the Apollo embed call entirely and stores the row with a 2048-byte zero-vector embedding under the sentinel model_version 'manual_no_embed'. The row participates as a browse-by-person tag but is excluded from clustering and auto-bind: - face_clustering._decode_b64_embedding filters norm<=0 (already) - cluster suggester groups by model_version, so the sentinel never mixes with real buffalo_l rows - cosine_similarity with a zero vector resolves to 0/NaN, never crossing the 0.4 auto-bind threshold Use case: tag someone looking away from the camera, profile shot, heavily-occluded face — anywhere the detector returns no_face_in_crop on the user's drawn region. The frontend only sets force=true after a 422 from a strict create plus an explicit operator confirmation, so the normal "draw a centered face" UX still gets a real ArcFace embedding.
This commit is contained in:
42
src/faces.rs
42
src/faces.rs
@@ -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,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 crop_bytes = match crop_image_to_bbox(
|
||||
&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 {
|
||||
content_hash: hash.clone(),
|
||||
library_id: library.id,
|
||||
@@ -2093,13 +2121,15 @@ async fn create_face_handler<D: FaceDao>(
|
||||
return HttpResponse::UnprocessableEntity().body("no face in crop");
|
||||
}
|
||||
};
|
||||
let embedding_bytes = match detected.decode_embedding() {
|
||||
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
|
||||
// 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,
|
||||
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,
|
||||
|
||||
Reference in New Issue
Block a user