HaWkEye / profile_engine.py
HaWkEye Admin
Deploy HaWkEye
97c8cf9
Raw
History Blame Contribute Delete
49.7 kB
"""
Profile Engine v2 — Multi-Modal Biometric Re-Identification
=============================================================
6-Signal Enrollment & Inference Pipeline:
1. Gait (DeepGaitV2) → 256-d gait_vector (weight: 0.35)
2. Biomechanics (Pose) → 64-d biomech_vector (weight: 0.25)
3. Appearance (OSNet) → 512-d appearance_vector (weight: 0.25)
4. Height (Pinhole) → float height_cm (weight: 0.15)
5. Face (ArcFace) → 512-d face_vector (optional: 0.30 blend)
6. FAISS Vector DB → fast similarity search
Anti-Spoofing Gates:
- Signal agreement: ≥2 of (gait, pose, appearance) must independently exceed 0.70
- Pose quality: reject if avg keypoint confidence < 0.6
- Temporal smoothing: 15 consecutive frames above threshold before alert
- Height pre-filter: reject |detected - target| > 12cm
"""
import os
import cv2
import json
import time
import uuid
import threading
import numpy as np
from ultralytics import YOLO
from gait_models.gait_engine import GaitEngine
from biomech_engine import BiomechEngine
from appearance_engine import AppearanceEngine
from height_estimator import HeightEstimator
from vector_db import ProfileVectorDB
class TargetVerifier:
"""
Temporal evidence accumulator for target person verification.
Instead of making an instant decision per-frame, this collects evidence
across multiple frames and only declares a verified match after sufficient
consistent evidence has been gathered.
States:
scanning → No candidate found yet
verifying → A candidate person is being tracked; accumulating evidence
confirmed → Target identity verified with high confidence
lost → Target was confirmed but lost from view
"""
# Temporal smoothing requirements
REQUIRED_CONSECUTIVE_FRAMES = 15 # frames above threshold before alerting
MIN_VERIFY_SECONDS = 2.0 # minimum wallclock time before confirming
GAIT_CYCLE_FRAMES = 30 # frames for gait analysis
# Thresholds
HIGH_CONFIDENCE_THRESHOLD = 0.85 # GREEN box — "MATCH: XX%"
POSSIBLE_MATCH_THRESHOLD = 0.65 # YELLOW box — "POSSIBLE: XX%"
CONFIRM_SCORE_THRESHOLD = 0.55 # fused score to confirm identity
FACE_MATCH_THRESHOLD = 0.35 # per-frame face cosine sim
# Anti-spoofing
MIN_POSE_CONFIDENCE = 0.6 # reject frames below this
SIGNAL_AGREEMENT_THRESHOLD = 0.70 # per-signal threshold for agreement gate
MIN_AGREEING_SIGNALS = 2 # at least 2 must agree
# Timeouts
CANDIDATE_LOST_TIMEOUT = 3.0
CONFIRMED_LOST_TIMEOUT = 5.0
def __init__(self):
self.reset()
def reset(self):
"""Reset all verification state."""
self.state = "scanning"
self.candidate_track_id = None
self.candidate_bbox = None
# Per-modality evidence buffers
self.face_scores = []
self.face_embeddings_live = []
self.gait_silhouettes = []
self.biomech_vectors = []
self.appearance_embeddings = []
self.height_estimates = []
self.clothing_scores = []
# Anti-spoofing counters
self.consecutive_match_frames = 0
self.pose_confidence_history = []
# Timing
self.verify_start_time = 0.0
self.last_seen_time = 0.0
# Per-modality live scores (updated each frame)
self.live_scores = {
"gait": 0.0, "biomech": 0.0, "appearance": 0.0,
"height": 0.0, "face": None, "ensemble": 0.0
}
# Final result
self.confirmed_score = 0.0
self.confirmed_bbox = None
self.verification_progress = 0.0
self.status_message = "Scanning for target..."
def get_status(self):
return {
"state": self.state,
"progress": self.verification_progress,
"message": self.status_message,
"score": self.confirmed_score,
"face_hits": len(self.face_scores),
"gait_frames": len(self.gait_silhouettes),
"consecutive_frames": self.consecutive_match_frames,
"live_scores": self.live_scores.copy(),
}
class ProfileEngine:
"""
Multi-modal biometric profile engine.
Enrollment: video → 6-signal feature extraction → FAISS storage
Inference: live frame → 6-signal extraction → weighted ensemble → alert
"""
# Ensemble weights (per spec)
W_GAIT = 0.35
W_BIOMECH = 0.25
W_APPEARANCE = 0.25
W_HEIGHT = 0.15
W_FACE_BLEND = 0.30 # face blends into the 4-signal score
def __init__(self, face_app):
print("[ProfileEngine] Initializing multi-modal engine...")
self.face_app = face_app
# YOLOv8 models
self.yolo_pose = YOLO("yolov8n-pose.pt")
self.yolo_det = YOLO("yolov8n.pt")
# Sub-engines
self.gait_engine = GaitEngine()
self.biomech_engine = BiomechEngine()
self.appearance_engine = AppearanceEngine()
self.height_estimator = HeightEstimator()
# FAISS vector database
self.vector_db = ProfileVectorDB(db_dir="saved_profiles")
# Profile storage
self.PROFILES_DIR = "saved_profiles"
self.PROFILES_JSON = "profiles.json"
os.makedirs(self.PROFILES_DIR, exist_ok=True)
if not os.path.exists(self.PROFILES_JSON):
with open(self.PROFILES_JSON, "w") as f:
json.dump([], f)
self.active_jobs = {}
# Active verifier
self.verifier = TargetVerifier()
# Cache for loaded target data
self._cached_target_id = None
self._cached_face_embs = None
self._cached_gait_embs = None
self._cached_biomech_vec = None
self._cached_appearance_vec = None
self._cached_profile = None
# Match event log
self.match_log = []
print("[ProfileEngine] Multi-modal engine ready.")
# ================================================================
# Camera Calibration
# ================================================================
def update_camera_calibration(self, calibration):
"""Update height estimator with camera calibration params."""
self.height_estimator.update_calibration(calibration)
# ================================================================
# Color Extraction (kept for backward compat)
# ================================================================
def get_dominant_colors(self, image_crop, k=3):
"""Extract dominant colors from a bounding box using K-Means."""
if image_crop.size == 0:
return []
img = cv2.resize(image_crop, (50, 50))
hsv = cv2.cvtColor(img, cv2.COLOR_BGR2HSV)
pixels = hsv.reshape((-1, 3)).astype(np.float32)
criteria = (cv2.TERM_CRITERIA_EPS + cv2.TERM_CRITERIA_MAX_ITER, 10, 1.0)
_, labels, centers = cv2.kmeans(pixels, k, None, criteria, 10, cv2.KMEANS_RANDOM_CENTERS)
label_counts = np.bincount(labels.flatten())
percentages = label_counts / float(len(pixels))
colors = []
for i, center in enumerate(centers):
colors.append({
"h": float(center[0]), "s": float(center[1]),
"v": float(center[2]), "weight": float(percentages[i])
})
colors.sort(key=lambda x: x["weight"], reverse=True)
return colors
def compare_colors(self, hsv1, hsv2):
"""Compare two HSV colors (0-1 score)."""
h_diff = min(abs(hsv1['h'] - hsv2['h']), 180 - abs(hsv1['h'] - hsv2['h'])) / 90.0
s_diff = abs(hsv1['s'] - hsv2['s']) / 255.0
v_diff = abs(hsv1['v'] - hsv2['v']) / 255.0
score = 1.0 - (0.6 * h_diff + 0.2 * s_diff + 0.2 * v_diff)
return max(0.0, score)
# ================================================================
# PHASE 1: ENROLLMENT — Video Processing
# ================================================================
def process_video_bg(self, video_path, person_name, job_id):
"""Background worker to analyze video and build full multi-modal profile."""
try:
self.active_jobs[job_id] = {
"status": "processing", "progress": 0,
"message": "Reading video...", "name": person_name
}
cap = cv2.VideoCapture(video_path)
total_frames = int(cap.get(cv2.CAP_PROP_FRAME_COUNT))
fps = cap.get(cv2.CAP_PROP_FPS)
if fps <= 0:
fps = 30
sample_rate = max(1, int(total_frames / 60))
# Accumulators
face_embeddings = []
clothing_colors = []
appearance_embeddings = []
biomech_accum = BiomechEngine()
height_estimates = []
gait_bbox_track = []
frame_idx = 0
while True:
ret, frame = cap.read()
if not ret:
break
if frame_idx % sample_rate == 0:
self.active_jobs[job_id]["progress"] = int((frame_idx / total_frames) * 60)
self.active_jobs[job_id]["message"] = f"Analyzing frame {frame_idx}/{total_frames}..."
# 1. Detect person with pose
pose_results = self.yolo_pose(frame, conf=0.5, verbose=False)
best_person_box = None
best_area = 0
best_keypoints = None
best_kp_conf = None
for r in pose_results:
if r.keypoints is None or len(r.keypoints.data) == 0:
continue
for idx_p in range(len(r.boxes)):
cls = int(r.boxes.cls[idx_p]) if hasattr(r.boxes, 'cls') else 0
if cls != 0:
continue
box = r.boxes.xyxy[idx_p].cpu().numpy().astype(int)
area = (box[2] - box[0]) * (box[3] - box[1])
if area > best_area:
best_area = area
best_person_box = box
kp_data = r.keypoints.data[idx_p].cpu().numpy()
best_keypoints = kp_data[:, :2] # (17, 2)
best_kp_conf = kp_data[:, 2] # (17,)
if best_person_box is not None:
px1, py1, px2, py2 = best_person_box
person_crop = frame[py1:py2, px1:px2]
gait_bbox_track.append((frame_idx, best_person_box))
if person_crop.size > 0:
# 2. Face (InsightFace/ArcFace)
faces = self.face_app.get(person_crop)
if faces:
face_embeddings.append(faces[0].normed_embedding)
# 3. Appearance (OSNet)
app_emb = self.appearance_engine.extract_embedding(person_crop)
if app_emb is not None:
appearance_embeddings.append(app_emb)
# 4. Biomechanics (from keypoints)
if best_keypoints is not None and best_kp_conf is not None:
bvec = biomech_accum.extract_features(
best_keypoints, best_kp_conf, best_person_box
)
if bvec is not None:
biomech_accum.accumulate_template(bvec)
# 5. Height
h_est = self.height_estimator.estimate_height(
best_keypoints, best_kp_conf, best_person_box,
frame_shape=frame.shape[:2]
)
height_estimates.append(h_est)
# 6. Clothing colors
h, w = person_crop.shape[:2]
torso = person_crop[int(h*0.2):int(h*0.6), int(w*0.2):int(w*0.8)]
colors = self.get_dominant_colors(torso)
if colors:
clothing_colors.append(colors)
else:
# Track bbox for gait on non-sampled frames too
det_results = self.yolo_det(frame, conf=0.5, verbose=False)
for r in det_results:
for idx_d, cls in enumerate(r.boxes.cls):
if int(cls) == 0:
box = r.boxes.xyxy[idx_d].cpu().numpy().astype(int)
gait_bbox_track.append((frame_idx, box))
break
break
frame_idx += 1
cap.release()
# === GAIT EXTRACTION ===
self.active_jobs[job_id]["progress"] = 65
self.active_jobs[job_id]["message"] = "Extracting gait features (walking style)..."
gait_results = self.gait_engine.process_walking_video(video_path, gait_bbox_track)
# === AGGREGATE RESULTS ===
self.active_jobs[job_id]["progress"] = 85
self.active_jobs[job_id]["message"] = "Building multi-modal profile..."
profile_id = str(uuid.uuid4())[:8]
# Face embeddings
face_emb_path = ""
if face_embeddings:
emb_array = np.array(face_embeddings)
face_emb_path = os.path.join(self.PROFILES_DIR, f"{person_name}_{profile_id}_faces.npy")
np.save(face_emb_path, emb_array)
# Appearance vector (averaged)
appearance_vec_path = ""
avg_appearance = None
if appearance_embeddings:
avg_appearance = AppearanceEngine.average_embeddings(appearance_embeddings)
appearance_vec_path = os.path.join(self.PROFILES_DIR, f"{person_name}_{profile_id}_appearance.npy")
np.save(appearance_vec_path, avg_appearance)
# Biomech vector (averaged template)
biomech_vec_path = ""
avg_biomech = biomech_accum.get_averaged_template()
if avg_biomech is not None:
biomech_vec_path = os.path.join(self.PROFILES_DIR, f"{person_name}_{profile_id}_biomech.npy")
np.save(biomech_vec_path, avg_biomech)
# Gait vectors
gait_v2_path = ""
gait_gl_path = ""
gait_former_path = ""
gait_gei_path = ""
if gait_results["deepgaitv2_embedding"] is not None:
gait_v2_path = os.path.join(self.PROFILES_DIR, f"{person_name}_{profile_id}_gait_v2.npy")
np.save(gait_v2_path, gait_results["deepgaitv2_embedding"])
if gait_results.get("gaitgl_embedding") is not None:
gait_gl_path = os.path.join(self.PROFILES_DIR, f"{person_name}_{profile_id}_gait_gl.npy")
np.save(gait_gl_path, gait_results["gaitgl_embedding"])
if gait_results.get("gaitformer_embedding") is not None:
gait_former_path = os.path.join(self.PROFILES_DIR, f"{person_name}_{profile_id}_gait_former.npy")
np.save(gait_former_path, gait_results["gaitformer_embedding"])
if gait_results.get("gei_embedding") is not None:
gait_gei_path = os.path.join(self.PROFILES_DIR, f"{person_name}_{profile_id}_gait_gei.npy")
np.save(gait_gei_path, gait_results["gei_embedding"])
# GEI visualization
gei_img_path = ""
if gait_results.get("gei_image") is not None:
gei_img_path = os.path.join(self.PROFILES_DIR, f"{person_name}_{profile_id}_gei.png")
cv2.imwrite(gei_img_path, (gait_results["gei_image"] * 255).astype(np.uint8))
# Height
height_cm = None
height_ratio = 0.8
if height_estimates:
cm_vals = [h["height_cm"] for h in height_estimates if h.get("height_cm")]
ratio_vals = [h["height_ratio"] for h in height_estimates if h.get("height_ratio", 0) > 0]
if cm_vals:
height_cm = round(float(np.median(cm_vals)), 1)
if ratio_vals:
height_ratio = float(np.mean(ratio_vals))
# Clothing
final_colors = []
if clothing_colors:
avg_h = np.mean([c[0]["h"] for c in clothing_colors if c])
avg_s = np.mean([c[0]["s"] for c in clothing_colors if c])
avg_v = np.mean([c[0]["v"] for c in clothing_colors if c])
final_colors = [{"h": avg_h, "s": avg_s, "v": avg_v, "weight": 1.0}]
# Build profile entry
profile_entry = {
"id": profile_id,
"name": person_name,
# Face
"face_count": len(face_embeddings),
"embeddings_file": face_emb_path,
# Gait
"gait_v2_file": gait_v2_path,
"gait_gl_file": gait_gl_path,
"gait_former_file": gait_former_path,
"gait_gei_file": gait_gei_path,
"gait_gei_image": gei_img_path,
"gait_silhouette_count": gait_results["silhouette_count"],
# Biomech
"biomech_file": biomech_vec_path,
# Appearance
"appearance_file": appearance_vec_path,
# Height
"height_cm": height_cm,
"height_ratio": float(height_ratio),
# Clothing (backward compat)
"clothing_colors": final_colors,
}
# Save to JSON
with open(self.PROFILES_JSON, "r") as f:
db = json.load(f)
db.append(profile_entry)
with open(self.PROFILES_JSON, "w") as f:
json.dump(db, f, indent=4)
# Add to FAISS
faiss_vectors = {}
if gait_results["deepgaitv2_embedding"] is not None:
faiss_vectors["gait"] = gait_results["deepgaitv2_embedding"]
if avg_biomech is not None:
faiss_vectors["biomech"] = avg_biomech
if avg_appearance is not None:
faiss_vectors["appearance"] = avg_appearance
if face_embeddings:
avg_face = np.mean(np.array(face_embeddings), axis=0)
n = np.linalg.norm(avg_face)
if n > 0:
avg_face = avg_face / n
faiss_vectors["face"] = avg_face
if faiss_vectors:
self.vector_db.add_profile(profile_id, faiss_vectors)
# Build summary
modalities = []
if face_embeddings:
modalities.append(f"{len(face_embeddings)} faces")
if gait_results["silhouette_count"] >= 30:
modalities.append(f"{gait_results['silhouette_count']} gait frames")
if avg_biomech is not None:
modalities.append("biomechanics")
if avg_appearance is not None:
modalities.append("appearance")
if height_cm:
modalities.append(f"height: {height_cm}cm")
summary = ", ".join(modalities) if modalities else "limited data"
self.active_jobs[job_id] = {
"status": "done", "progress": 100,
"message": f"Complete! ({summary})",
"profile": profile_entry
}
# Sync profiles to cloud
try:
import hf_db
hf_db.sync_single_file(self.PROFILES_JSON)
hf_db.sync_single_folder(self.PROFILES_DIR)
except Exception as sync_err:
print(f"[ProfileEngine] Cloud sync warning: {sync_err}")
except Exception as e:
print(f"[ProfileEngine] Error processing video: {e}")
import traceback
traceback.print_exc()
self.active_jobs[job_id] = {"status": "error", "message": str(e), "progress": 0}
# Clean up video
if os.path.exists(video_path):
try:
os.remove(video_path)
except Exception:
pass
def start_video_processing(self, video_path, person_name):
job_id = str(uuid.uuid4())
threading.Thread(target=self.process_video_bg,
args=(video_path, person_name, job_id), daemon=True).start()
return job_id
def get_job_status(self, job_id):
return self.active_jobs.get(job_id, {"status": "not_found"})
# ================================================================
# Target Cache Loading
# ================================================================
def _load_npy_safe(self, path):
"""Safely load a numpy file, returning None on any error."""
if path and os.path.exists(path):
try:
return np.load(path)
except Exception:
pass
return None
def _load_target_cache(self, target_profile):
"""Load and cache target profile data for fast per-frame access."""
pid = target_profile.get("id", "")
if self._cached_target_id == pid and self._cached_profile is not None:
return
self._cached_target_id = pid
self._cached_profile = target_profile
# Face embeddings
self._cached_face_embs = self._load_npy_safe(target_profile.get("embeddings_file", ""))
# Gait embeddings
self._cached_gait_embs = {
"deepgaitv2_embedding": self._load_npy_safe(target_profile.get("gait_v2_file", "")),
"gei_embedding": self._load_npy_safe(target_profile.get("gait_gei_file", "")),
"gaitgl_embedding": self._load_npy_safe(target_profile.get("gait_gl_file", "")),
"gaitformer_embedding": self._load_npy_safe(target_profile.get("gait_former_file", "")),
}
# Biomech vector
self._cached_biomech_vec = self._load_npy_safe(target_profile.get("biomech_file", ""))
# Appearance vector
self._cached_appearance_vec = self._load_npy_safe(target_profile.get("appearance_file", ""))
modalities = []
if self._cached_face_embs is not None:
modalities.append(f"faces={self._cached_face_embs.shape[0]}")
if any(v is not None for v in self._cached_gait_embs.values()):
modalities.append("gait")
if self._cached_biomech_vec is not None:
modalities.append("biomech")
if self._cached_appearance_vec is not None:
modalities.append("appearance")
print(f"[ProfileEngine] Cached target '{target_profile.get('name', '?')}': {', '.join(modalities)}")
# ================================================================
# SEARCH SESSION MANAGEMENT
# ================================================================
def start_search(self, target_profile):
"""Initialize a new search session for a target profile."""
self._load_target_cache(target_profile)
self.verifier = TargetVerifier()
self.verifier.status_message = "Scanning for target..."
self.biomech_engine.reset()
print(f"[ProfileEngine] Search started for '{target_profile.get('name', '?')}'")
def stop_search(self):
"""Stop the current search session."""
self.verifier.reset()
self._cached_target_id = None
self._cached_face_embs = None
self._cached_gait_embs = None
self._cached_biomech_vec = None
self._cached_appearance_vec = None
self._cached_profile = None
# ================================================================
# PHASE 2: INFERENCE — Per-Frame Evidence Accumulation
# ================================================================
def _compute_face_score(self, person_crop):
"""Run face detection and compare to cached target faces."""
if self._cached_face_embs is None or len(self._cached_face_embs) == 0:
return 0.0, None, False
faces = self.face_app.get(person_crop)
if not faces:
return 0.0, None, False
emb = faces[0].normed_embedding
norm = np.linalg.norm(emb)
if norm == 0:
return 0.0, None, True
emb_normed = emb / norm
sims = np.dot(self._cached_face_embs, emb_normed)
top_k = min(5, len(sims))
top_k_sims = np.sort(sims)[-top_k:]
score = float(np.mean(top_k_sims))
return score, emb_normed, True
def _compute_appearance_score(self, person_crop):
"""Compute appearance similarity using OSNet."""
if self._cached_appearance_vec is None:
return 0.0, None
emb = self.appearance_engine.extract_embedding(person_crop)
if emb is None:
return 0.0, None
score = AppearanceEngine.compute_similarity(emb, self._cached_appearance_vec)
return score, emb
def _compute_biomech_score(self, keypoints_xy, confidence, bbox):
"""Compute biomechanical similarity."""
if self._cached_biomech_vec is None:
return 0.0, None
vec = self.biomech_engine.extract_features(keypoints_xy, confidence, bbox)
if vec is None:
return 0.0, None
score = BiomechEngine.compute_similarity(vec, self._cached_biomech_vec)
return score, vec
def _compute_height_score(self, keypoints_xy, confidence, bbox, frame_shape):
"""Compute height match score."""
target_cm = self._cached_profile.get("height_cm")
target_ratio = self._cached_profile.get("height_ratio", 0)
h_est = self.height_estimator.estimate_height(
keypoints_xy, confidence, bbox, frame_shape
)
score = HeightEstimator.compute_match_score(h_est, target_cm, target_ratio)
return score, h_est
def _compute_live_gait_score(self):
"""Compute gait similarity from accumulated live silhouettes."""
v = self.verifier
if len(v.gait_silhouettes) < self.gait_engine.MIN_FRAMES_FOR_GAIT:
return 0.0
if self._cached_gait_embs is None or not any(
val is not None for val in self._cached_gait_embs.values()
):
return 0.0
# Build live gait embeddings
gei = self.gait_engine.compute_gei(v.gait_silhouettes)
if gei is None:
return 0.0
live_gait = {
"deepgaitv2_embedding": self.gait_engine.compute_deepgaitv2_embedding(v.gait_silhouettes),
"gei_embedding": self.gait_engine.compute_gei_embedding(gei),
"gaitgl_embedding": self.gait_engine.compute_gaitgl_embedding(v.gait_silhouettes),
"gaitformer_embedding": self.gait_engine.compute_gaitformer_embedding(v.gait_silhouettes),
}
return self.gait_engine.compute_gait_similarity(live_gait, self._cached_gait_embs)
def _compute_clothing_score(self, person_crop):
"""Compare clothing colors of crop against cached target."""
target_colors = self._cached_profile.get("clothing_colors", [])
if not target_colors:
return 0.0
h, w = person_crop.shape[:2]
torso_crop = person_crop[int(h*0.2):int(h*0.6), int(w*0.2):int(w*0.8)]
if torso_crop.size == 0:
return 0.0
current_colors = self.get_dominant_colors(torso_crop, k=1)
if not current_colors:
return 0.0
return self.compare_colors(current_colors[0], target_colors[0])
def _compute_weighted_ensemble(self, gait_sim, biomech_sim, appearance_sim,
height_sim, face_sim=None):
"""Compute weighted ensemble score per spec."""
score = (self.W_GAIT * gait_sim +
self.W_BIOMECH * biomech_sim +
self.W_APPEARANCE * appearance_sim +
self.W_HEIGHT * height_sim)
if face_sim is not None and face_sim > 0:
score = (1.0 - self.W_FACE_BLEND) * score + self.W_FACE_BLEND * face_sim
return score
def _check_signal_agreement(self, gait_sim, biomech_sim, appearance_sim):
"""Anti-spoofing: at least 2 of 3 must independently exceed threshold."""
thresh = self.verifier.SIGNAL_AGREEMENT_THRESHOLD
agreeing = sum(1 for s in [gait_sim, biomech_sim, appearance_sim] if s >= thresh)
return agreeing >= self.verifier.MIN_AGREEING_SIGNALS
def _bb_iou(self, boxA, boxB):
"""IoU between two [x1,y1,x2,y2] boxes."""
xA = max(boxA[0], boxB[0]); yA = max(boxA[1], boxB[1])
xB = min(boxA[2], boxB[2]); yB = min(boxA[3], boxB[3])
inter = max(0, xB - xA) * max(0, yB - yA)
aA = (boxA[2]-boxA[0]) * (boxA[3]-boxA[1])
aB = (boxB[2]-boxB[0]) * (boxB[3]-boxB[1])
union = aA + aB - inter
return inter / float(union) if union > 0 else 0.0
def _log_match_event(self, score, bbox):
"""Log a match event for the dashboard."""
event = {
"timestamp": time.time(),
"name": self._cached_profile.get("name", "Unknown"),
"score": round(score, 3),
"bbox": [int(c) for c in bbox],
"scores": self.verifier.live_scores.copy(),
}
self.match_log.append(event)
# Keep last 50 events
if len(self.match_log) > 50:
self.match_log = self.match_log[-50:]
def _extract_keypoints_for_bbox(self, frame, bbox):
"""Run YOLOv8-pose on a person crop and return keypoints."""
x1, y1, x2, y2 = [int(c) for c in bbox]
crop = frame[y1:y2, x1:x2]
if crop.size == 0:
return None, None
try:
results = self.yolo_pose(crop, verbose=False, conf=0.3)
for r in results:
if r.keypoints is not None and len(r.keypoints.data) > 0:
kp_data = r.keypoints.data[0].cpu().numpy()
kp_xy = kp_data[:, :2]
kp_conf = kp_data[:, 2]
# Offset keypoints to full-frame coordinates
kp_xy[:, 0] += x1
kp_xy[:, 1] += y1
return kp_xy, kp_conf
except Exception:
pass
return None, None
# ================================================================
# MAIN PROCESS_FRAME — Temporal State Machine
# ================================================================
def process_frame(self, frame, person_detections):
"""
Process one frame during active target search.
Args:
frame: Full BGR frame
person_detections: List of [x1,y1,x2,y2] bounding boxes
Returns:
list of (label, score, bbox) results to draw.
"""
v = self.verifier
now = time.time()
results = []
if not person_detections or len(person_detections) == 0:
if v.state == "verifying":
if now - v.last_seen_time > v.CANDIDATE_LOST_TIMEOUT:
v.status_message = "Candidate lost. Rescanning..."
v.reset()
elif v.state == "confirmed":
if now - v.last_seen_time > v.CONFIRMED_LOST_TIMEOUT:
v.state = "lost"
v.status_message = "Target lost from view"
return results
# ---- STATE: SCANNING ----
if v.state == "scanning":
best_candidate = None
best_score = 0.0
for bbox in person_detections:
x1, y1, x2, y2 = [int(c) for c in bbox]
crop = frame[y1:y2, x1:x2]
if crop.size == 0:
continue
# Height pre-filter
kp_xy, kp_conf = self._extract_keypoints_for_bbox(frame, bbox)
if kp_xy is not None and kp_conf is not None:
h_est = self.height_estimator.estimate_height(
kp_xy, kp_conf, [x1,y1,x2,y2], frame.shape[:2]
)
if not HeightEstimator.passes_prefilter(
h_est,
self._cached_profile.get("height_cm"),
self._cached_profile.get("height_ratio")
):
results.append(("Unknown", 0.0, np.array([x1,y1,x2,y2])))
continue
# Quick multi-signal screening
face_score, face_emb, face_detected = self._compute_face_score(crop)
app_score, app_emb = self._compute_appearance_score(crop)
clothing_score = self._compute_clothing_score(crop)
# Screening score
if face_detected and face_score >= v.FACE_MATCH_THRESHOLD:
screen_score = face_score * 0.5 + app_score * 0.3 + clothing_score * 0.2
elif not face_detected:
screen_score = app_score * 0.5 + clothing_score * 0.3
screen_score *= 0.6
else:
screen_score = 0.0
if screen_score > best_score:
best_score = screen_score
best_candidate = {
"bbox": [x1, y1, x2, y2],
"face_score": face_score, "face_emb": face_emb,
"face_detected": face_detected,
"app_score": app_score, "app_emb": app_emb,
"clothing_score": clothing_score,
}
results.append(("Unknown", 0.0, np.array([x1, y1, x2, y2])))
# Transition to VERIFYING
if best_candidate and best_score >= 0.15:
v.state = "verifying"
v.candidate_bbox = best_candidate["bbox"]
v.verify_start_time = now
v.last_seen_time = now
v.consecutive_match_frames = 0
if best_candidate["face_detected"] and best_candidate["face_score"] >= v.FACE_MATCH_THRESHOLD:
v.face_scores.append(best_candidate["face_score"])
if best_candidate["app_emb"] is not None:
v.appearance_embeddings.append(best_candidate["app_emb"])
v.clothing_scores.append(best_candidate["clothing_score"])
sil = self.gait_engine.extract_silhouette(frame, best_candidate["bbox"])
if sil is not None:
v.gait_silhouettes.append(sil)
v.status_message = "Potential target found. Verifying..."
v.verification_progress = 0.05
return results
# ---- STATE: VERIFYING ----
elif v.state == "verifying":
best_iou = 0.0
matched_bbox = None
for bbox in person_detections:
iou = self._bb_iou(v.candidate_bbox, [int(c) for c in bbox])
if iou > best_iou:
best_iou = iou
matched_bbox = [int(c) for c in bbox]
if matched_bbox is None or best_iou < 0.15:
if now - v.last_seen_time > v.CANDIDATE_LOST_TIMEOUT:
v.status_message = "Candidate lost. Rescanning..."
v.reset()
for bbox in person_detections:
x1, y1, x2, y2 = [int(c) for c in bbox]
results.append(("Unknown", 0.0, np.array([x1,y1,x2,y2])))
return results
# Smooth bbox tracking
v.candidate_bbox = [
int(0.7 * v.candidate_bbox[i] + 0.3 * matched_bbox[i]) for i in range(4)
]
v.last_seen_time = now
# Collect evidence
x1, y1, x2, y2 = matched_bbox
crop = frame[y1:y2, x1:x2]
if crop.size > 0:
# Keypoints
kp_xy, kp_conf = self._extract_keypoints_for_bbox(frame, matched_bbox)
# Pose quality gate
if kp_xy is not None and kp_conf is not None:
avg_conf = BiomechEngine.avg_keypoint_confidence(kp_conf)
v.pose_confidence_history.append(avg_conf)
if avg_conf >= v.MIN_POSE_CONFIDENCE:
# Biomech
b_score, b_vec = self._compute_biomech_score(kp_xy, kp_conf, matched_bbox)
if b_vec is not None:
v.biomech_vectors.append(b_vec)
# Height
h_score, h_est = self._compute_height_score(kp_xy, kp_conf, matched_bbox, frame.shape[:2])
v.height_estimates.append(h_est)
# Face
face_score, face_emb, face_detected = self._compute_face_score(crop)
if face_detected and face_score >= v.FACE_MATCH_THRESHOLD:
v.face_scores.append(face_score)
elif face_detected and face_score < 0.20:
v.status_message = "Face mismatch detected. Rescanning..."
v.reset()
for bbox in person_detections:
bx = [int(c) for c in bbox]
results.append(("Unknown", 0.0, np.array(bx)))
return results
# Appearance
app_score, app_emb = self._compute_appearance_score(crop)
if app_emb is not None:
v.appearance_embeddings.append(app_emb)
# Clothing
clth_score = self._compute_clothing_score(crop)
v.clothing_scores.append(clth_score)
# Gait silhouette
sil = self.gait_engine.extract_silhouette(frame, matched_bbox)
if sil is not None:
v.gait_silhouettes.append(sil)
# Compute verification progress
elapsed = now - v.verify_start_time
time_factor = min(1.0, elapsed / v.MIN_VERIFY_SECONDS)
gait_factor = min(1.0, len(v.gait_silhouettes) / v.GAIT_CYCLE_FRAMES)
face_factor = min(1.0, len(v.face_scores) / 5) if self._cached_face_embs is not None else 0.5
app_factor = min(1.0, len(v.appearance_embeddings) / 5)
v.verification_progress = (time_factor * 0.2 + face_factor * 0.3 +
gait_factor * 0.25 + app_factor * 0.25)
# Compute live scores
gait_sim = self._compute_live_gait_score()
biomech_sim = BiomechEngine.compute_similarity(
BiomechEngine.average_embeddings(v.biomech_vectors) if v.biomech_vectors else None,
self._cached_biomech_vec
) if v.biomech_vectors and self._cached_biomech_vec is not None else 0.0
appearance_sim = AppearanceEngine.compute_similarity(
AppearanceEngine.average_embeddings(v.appearance_embeddings),
self._cached_appearance_vec
) if v.appearance_embeddings and self._cached_appearance_vec is not None else 0.0
height_sim = float(np.mean([
HeightEstimator.compute_match_score(h, self._cached_profile.get("height_cm"),
self._cached_profile.get("height_ratio"))
for h in v.height_estimates[-10:]
])) if v.height_estimates else 0.5
face_sim_avg = float(np.mean(v.face_scores)) if v.face_scores else None
v.live_scores = {
"gait": round(gait_sim, 3),
"biomech": round(biomech_sim, 3),
"appearance": round(appearance_sim, 3),
"height": round(height_sim, 3),
"face": round(face_sim_avg, 3) if face_sim_avg else None,
"ensemble": 0.0,
}
ensemble = self._compute_weighted_ensemble(
gait_sim, biomech_sim, appearance_sim, height_sim, face_sim_avg
)
v.live_scores["ensemble"] = round(ensemble, 3)
v.status_message = (f"Verifying... gait:{len(v.gait_silhouettes)}f "
f"face:{len(v.face_scores)} "
f"app:{len(v.appearance_embeddings)} "
f"({v.verification_progress:.0%})")
# Check if we can confirm
can_confirm = False
if elapsed >= v.MIN_VERIFY_SECONDS:
# Signal agreement gate
if self._check_signal_agreement(gait_sim, biomech_sim, appearance_sim):
can_confirm = ensemble >= v.CONFIRM_SCORE_THRESHOLD
elif face_sim_avg and face_sim_avg >= 0.5 and len(v.face_scores) >= 3:
# Strong face can override signal agreement
can_confirm = ensemble >= v.CONFIRM_SCORE_THRESHOLD
else:
# Weaker case: need higher threshold
can_confirm = ensemble >= v.CONFIRM_SCORE_THRESHOLD + 0.10
# Temporal smoothing check
if ensemble >= v.POSSIBLE_MATCH_THRESHOLD:
v.consecutive_match_frames += 1
else:
v.consecutive_match_frames = max(0, v.consecutive_match_frames - 2)
if can_confirm and v.consecutive_match_frames < v.REQUIRED_CONSECUTIVE_FRAMES:
can_confirm = False
if can_confirm:
v.state = "confirmed"
v.confirmed_score = ensemble
v.confirmed_bbox = matched_bbox
v.verification_progress = 1.0
match_pct = ensemble * 100
v.status_message = f"TARGET CONFIRMED — {match_pct:.1f}% confidence"
self._log_match_event(ensemble, matched_bbox)
print(f"[ProfileEngine] TARGET CONFIRMED: {self._cached_profile.get('name', '?')} "
f"score={ensemble:.3f} gait={gait_sim:.2f} biomech={biomech_sim:.2f} "
f"app={appearance_sim:.2f} height={height_sim:.2f} face={face_sim_avg}")
# Draw results
for bbox in person_detections:
bx1, by1, bx2, by2 = [int(c) for c in bbox]
iou = self._bb_iou(matched_bbox, [bx1, by1, bx2, by2])
if iou > 0.3:
label = f"[VERIFYING] {v.verification_progress:.0%}"
results.append((label, v.verification_progress, np.array([bx1,by1,bx2,by2])))
else:
results.append(("Unknown", 0.0, np.array([bx1,by1,bx2,by2])))
return results
# ---- STATE: CONFIRMED ----
elif v.state == "confirmed":
best_iou = 0.0
matched_bbox = None
for bbox in person_detections:
iou = self._bb_iou(v.candidate_bbox, [int(c) for c in bbox])
if iou > best_iou:
best_iou = iou
matched_bbox = [int(c) for c in bbox]
if matched_bbox and best_iou >= 0.15:
v.candidate_bbox = [
int(0.7 * v.candidate_bbox[i] + 0.3 * matched_bbox[i]) for i in range(4)
]
v.last_seen_time = now
v.confirmed_bbox = matched_bbox
# Continue updating evidence
x1, y1, x2, y2 = matched_bbox
crop = frame[y1:y2, x1:x2]
if crop.size > 0:
# Gait
sil = self.gait_engine.extract_silhouette(frame, matched_bbox)
if sil is not None:
v.gait_silhouettes.append(sil)
if len(v.gait_silhouettes) > 120:
v.gait_silhouettes = v.gait_silhouettes[-60:]
# Appearance
app_score, app_emb = self._compute_appearance_score(crop)
# Recompute ensemble
gait_sim = self._compute_live_gait_score()
biomech_sim = v.live_scores.get("biomech", 0.0)
appearance_sim = app_score if app_score > 0 else v.live_scores.get("appearance", 0.0)
height_sim = v.live_scores.get("height", 0.5)
face_sim_avg = v.live_scores.get("face")
ensemble = self._compute_weighted_ensemble(
gait_sim, biomech_sim, appearance_sim, height_sim, face_sim_avg
)
v.confirmed_score = 0.9 * v.confirmed_score + 0.1 * ensemble
v.live_scores["ensemble"] = round(v.confirmed_score, 3)
match_pct = v.confirmed_score * 100
if match_pct >= 85:
v.status_message = f"TARGET LOCKED — {match_pct:.1f}% confidence"
elif match_pct >= 65:
v.status_message = f"POSSIBLE MATCH — {match_pct:.1f}%"
else:
if now - v.last_seen_time > v.CONFIRMED_LOST_TIMEOUT:
v.state = "lost"
v.status_message = "Target lost from view. Rescanning..."
# Draw results
for bbox in person_detections:
bx1, by1, bx2, by2 = [int(c) for c in bbox]
if matched_bbox and self._bb_iou(matched_bbox, [bx1,by1,bx2,by2]) > 0.3:
name = self._cached_profile.get("name", "Target")
results.append((name, v.confirmed_score, np.array([bx1,by1,bx2,by2])))
else:
results.append(("Unknown", 0.0, np.array([bx1,by1,bx2,by2])))
return results
# ---- STATE: LOST ----
elif v.state == "lost":
v.status_message = "Target lost. Attempting re-acquisition..."
best_reacq_score = 0.0
best_reacq_bbox = None
for bbox in person_detections:
x1, y1, x2, y2 = [int(c) for c in bbox]
crop = frame[y1:y2, x1:x2]
if crop.size == 0:
continue
face_score, _, face_detected = self._compute_face_score(crop)
app_score, _ = self._compute_appearance_score(crop)
clothing_score = self._compute_clothing_score(crop)
reacq_score = 0.0
if face_detected and face_score >= v.FACE_MATCH_THRESHOLD:
reacq_score = face_score * 0.5 + app_score * 0.3 + clothing_score * 0.2
elif not face_detected:
reacq_score = app_score * 0.4 + clothing_score * 0.3
if reacq_score > best_reacq_score:
best_reacq_score = reacq_score
best_reacq_bbox = [x1, y1, x2, y2]
if best_reacq_bbox and best_reacq_score >= 0.35:
v.state = "confirmed"
v.candidate_bbox = best_reacq_bbox
v.confirmed_bbox = best_reacq_bbox
v.last_seen_time = now
v.confirmed_score = best_reacq_score
v.status_message = f"Target re-acquired — {best_reacq_score:.0%}"
elif now - v.last_seen_time > v.CONFIRMED_LOST_TIMEOUT * 3:
v.reset()
v.status_message = "Target lost too long. Rescanning..."
for bbox in person_detections:
bx1, by1, bx2, by2 = [int(c) for c in bbox]
if (best_reacq_bbox and v.state == "confirmed" and
self._bb_iou(best_reacq_bbox, [bx1,by1,bx2,by2]) > 0.3):
name = self._cached_profile.get("name", "Target")
results.append((name, v.confirmed_score, np.array([bx1,by1,bx2,by2])))
else:
results.append(("Unknown", 0.0, np.array([bx1,by1,bx2,by2])))
return results
# Fallback
for bbox in person_detections:
x1, y1, x2, y2 = [int(c) for c in bbox]
results.append(("Unknown", 0.0, np.array([x1,y1,x2,y2])))
return results
# Legacy single-frame matcher (kept for backward compat)
def match_person(self, person_crop, target_profile):
"""
Legacy single-frame matcher. Kept for API compatibility.
New code should use start_search() + process_frame() flow.
"""
self._load_target_cache(target_profile)
face_score, _, face_detected = self._compute_face_score(person_crop)
app_score, _ = self._compute_appearance_score(person_crop)
clothing_score = self._compute_clothing_score(person_crop)
has_face_data = self._cached_face_embs is not None and len(self._cached_face_embs) > 0
if face_detected and face_score >= 0.35 and has_face_data:
final = face_score * 0.50 + app_score * 0.30 + clothing_score * 0.20
return final
elif face_detected and face_score < 0.25:
return 0.0
elif not face_detected:
return min(0.40, app_score * 0.5 + clothing_score * 0.5)
else:
return 0.0