Files
momentry_core/scripts/face_clustering_processor.py
T
Accusys 39a2cbc65b fix: face group name read consistency, sync_file_status fix, cleanup ghost records, identity_agent replaced with face_dedup
- get_face_groups_handler: COALESCE(tp.name, tn.label) for name consistency
- sync_file_status: compare JSON vs pre_chunks (not chunk table)
- face consistency: compare frames.len() not total_faces
- cleanup 2 ghost records with NULL file_name/file_path
- replace identity_agent with face_dedup in pipeline stages
- remove identity_agent_api.rs and all references
- update required_processors to match actual processors
- update AGENTS.md with team responsibilities
- add Studio pipeline changes documentation
2026-07-27 02:15:51 +08:00

471 lines
17 KiB
Python

#!/opt/homebrew/bin/python3.11
"""
Face Clustering Processor V3 - Single-stage trace clustering (Stage 2 merge removed)
Strategy:
1. Load face embeddings from Qdrant _faces collection
2. Group by trace_id, compute weighted average embedding per trace
3. Single-stage AgglomerativeClustering (no Stage 2 merge)
4. Assign person_id to all faces in each trace
5. Output face_cluster.json with auto speaker binding
Output format:
{"status", "file_uuid", "clusters": [{cluster_id, face_count, representative_face}], "frames": [{frame, timestamp, faces: [{face_id, cluster_id, confidence}]}]}
Changes from V2:
- Added argparse for CLI arguments
- Added Redis progress reporting
- Added status + file_uuid to output
- Added auto_bind_speakers() integration
Changes from previous (broken) version:
- REMOVED Stage 2 merge logic (was incorrectly merging different people)
"""
import argparse
import json
import numpy as np
import os
import sys
import psycopg2
from collections import defaultdict
from sklearn.cluster import AgglomerativeClustering
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from redis_publisher import RedisPublisher
UUID = os.getenv("UUID", "quick_preview")
OUTPUT_DIR = os.getenv("MOMENTRY_OUTPUT_DIR", "./output")
OUTPUT_JSON_PATH = os.path.join(OUTPUT_DIR, f"{UUID}.face_clustered.json")
ASRX_JSON_PATH = os.path.join(OUTPUT_DIR, f"{UUID}.asrx.json")
DB_URL = os.getenv("DATABASE_URL", "postgresql://accusys@localhost:5432/momentry")
CLUSTER_THRESHOLD = 0.35
def write_empty_output(status: str, file_uuid: str, output_path: str):
output_data = {
"status": status,
"file_uuid": file_uuid,
"clusters": [],
"frames": []
}
with open(output_path, "w", encoding="utf-8") as f:
json.dump(output_data, f, indent=2, ensure_ascii=False)
print(f"Wrote empty output ({status}) to {output_path}")
def load_qdrant_faces(uuid: str, publisher: RedisPublisher) -> list:
"""Load all face points (payload + vector) from Qdrant _faces for a file."""
publisher.progress("face_cluster", 0, 100, "Loading embeddings from Qdrant")
try:
import requests
qdrant_url = os.environ.get("QDRANT_URL", "http://localhost:6333")
qdrant_api_key = os.environ.get("QDRANT_API_KEY", "Test3200Test3200Test3200")
headers = {}
if qdrant_api_key:
headers["api-key"] = qdrant_api_key
all_points = []
offset = None
while True:
body = {
"limit": 10000,
"with_payload": True,
"with_vector": True,
"filter": {
"must": [
{"key": "file_uuid", "match": {"value": uuid}}
]
}
}
if offset:
body["offset"] = offset
resp = requests.post(
f"{qdrant_url}/collections/_faces/points/scroll",
json=body,
headers=headers,
timeout=60
)
if resp.status_code != 200:
print(f"Qdrant scroll error: {resp.status_code}")
break
data = resp.json()
batch = data.get("result", {}).get("points", [])
all_points.extend(batch)
next_offset = data.get("result", {}).get("next_page_offset")
if not next_offset:
break
offset = next_offset
print(f"Loaded {len(all_points)} points from Qdrant _faces")
return all_points
except Exception as e:
print(f"Failed to load embeddings from Qdrant: {e}")
return []
def cluster_by_trace(points: list, min_faces_per_trace: int = 1, confidence_threshold: float = 0.5) -> tuple:
"""
Cluster faces by trace_id:
1. Filter low-confidence faces
2. Group by trace_id
3. Compute weighted average embedding per trace
"""
high_conf_points = [
p for p in points
if p.get("payload", {}).get("confidence", 0) >= confidence_threshold
]
print(f"[FILTER] {len(high_conf_points)}/{len(points)} faces pass confidence >= {confidence_threshold}")
traces = defaultdict(list)
for point in high_conf_points:
payload = point.get("payload", {})
trace_id = payload.get("trace_id")
if trace_id is not None and trace_id >= 0:
traces[trace_id].append(point)
print(f"[TRACE] Found {len(traces)} unique traces")
trace_embeddings = {}
for trace_id, trace_points in traces.items():
if len(trace_points) < min_faces_per_trace:
continue
embeddings = []
weights = []
for p in trace_points:
embeddings.append(p["vector"])
weights.append(p.get("payload", {}).get("confidence", 1.0))
embeddings = np.array(embeddings)
weights = np.array(weights)
weights = weights / weights.sum()
avg_embedding = np.average(embeddings, axis=0, weights=weights)
avg_embedding = avg_embedding / np.linalg.norm(avg_embedding)
trace_embeddings[trace_id] = {
"embedding": avg_embedding,
"face_count": len(trace_points),
"avg_confidence": float(np.mean(weights)),
"frames": [p.get("payload", {}).get("frame") for p in trace_points],
"points": trace_points
}
print(f"[TRACE] {len(trace_embeddings)} traces with >= {min_faces_per_trace} faces")
return trace_embeddings, traces
def single_stage_clustering(trace_embeddings: dict) -> dict:
"""
Single-stage AgglomerativeClustering on trace means.
No Stage 2 merge (the problematic logic has been removed).
"""
if not trace_embeddings:
return {}
trace_ids = list(trace_embeddings.keys())
n_traces = len(trace_ids)
if n_traces == 0:
return {}
if n_traces == 1:
return {trace_ids[0]: 0}
embeddings = np.array([trace_embeddings[tid]["embedding"] for tid in trace_ids])
print(f"[CLUSTER] Clustering {n_traces} traces (threshold={CLUSTER_THRESHOLD})...")
clustering = AgglomerativeClustering(
n_clusters=None,
distance_threshold=CLUSTER_THRESHOLD,
metric="cosine",
linkage="average"
)
labels = clustering.fit_predict(embeddings)
unique_labels = set(labels)
print(f"[CLUSTER] Detected {len(unique_labels)} unique persons")
trace_to_person = {}
for i, trace_id in enumerate(trace_ids):
trace_to_person[trace_id] = labels[i]
return trace_to_person
def auto_bind_speakers():
if not os.path.exists(OUTPUT_JSON_PATH) or not os.path.exists(ASRX_JSON_PATH):
print("Missing data for speaker binding.")
return
with open(OUTPUT_JSON_PATH) as f:
face_clustered = json.load(f)
with open(ASRX_JSON_PATH) as f:
asrx_data = json.load(f)
print("Auto-binding Speakers to Persons...")
face_spans = []
for frame_obj in face_clustered.get("frames", []):
ts = frame_obj.get("timestamp")
for face in frame_obj.get("faces", []):
person_id = face.get("cluster_id")
if person_id and ts is not None:
face_spans.append({"ts": ts, "person_id": person_id})
speaker_person_counts = {}
for seg in asrx_data.get("segments", []):
start = seg.get("start")
end = seg.get("end")
speaker = seg.get("speaker_id")
if not speaker or start is None or end is None:
continue
candidates = [f for f in face_spans if f.get("ts") is not None and start <= f["ts"] <= end]
if candidates:
person_counts = {}
for c in candidates:
pid = c["person_id"]
person_counts[pid] = person_counts.get(pid, 0) + 1
if speaker not in speaker_person_counts:
speaker_person_counts[speaker] = {}
best_person = max(person_counts, key=person_counts.get)
speaker_person_counts[speaker][best_person] = (
speaker_person_counts[speaker].get(best_person, 0) + 1
)
try:
conn = psycopg2.connect(DB_URL)
cur = conn.cursor()
for speaker, persons in speaker_person_counts.items():
if not persons:
continue
best_person = max(persons, key=persons.get)
print(f" {speaker} is likely {best_person} ({persons[best_person]} votes)")
cur.execute("SELECT id FROM talents WHERE real_name = %s", (best_person,))
row = cur.fetchone()
if row:
talent_id = row[0]
else:
cur.execute(
"INSERT INTO talents (real_name) VALUES (%s) RETURNING id",
(best_person,),
)
talent_id = cur.fetchone()[0]
print(f" Created Talent #{talent_id} ({best_person})")
cur.execute(
"""
INSERT INTO identity_bindings (talent_id, binding_type, binding_value, source, confidence)
VALUES (%s, 'speaker', %s, 'auto_cluster', 0.8)
ON CONFLICT (binding_type, binding_value) DO UPDATE SET talent_id = EXCLUDED.talent_id
""",
(talent_id, speaker),
)
print(f" Bound {speaker} -> {best_person}")
conn.commit()
cur.close()
conn.close()
except Exception as e:
print(f" DB Error: {e}")
def main():
global OUTPUT_JSON_PATH, UUID
parser = argparse.ArgumentParser(description="Face Clustering Processor V3")
parser.add_argument("video_path", help="Path to video file")
parser.add_argument("output_path", help="Path to output JSON file")
parser.add_argument("--uuid", help="Video UUID (optional, overrides env var)")
parser.add_argument("--force", action="store_true", help="Overwrite existing output")
parser.add_argument("--frames", type=str, default=None, help=argparse.SUPPRESS)
args = parser.parse_args()
OUTPUT_JSON_PATH = args.output_path
output_dir = os.path.dirname(args.output_path)
if args.uuid:
uuid = args.uuid
else:
uuid = os.path.basename(args.video_path).rsplit(".", 1)[0]
UUID = uuid
face_json_path = os.path.join(output_dir, f"{uuid}.face.json")
if not os.path.exists(face_json_path):
face_json_path = os.path.join(output_dir, uuid, f"{uuid}.face.json")
publisher = RedisPublisher(uuid)
publisher.info("face_cluster", "Face clustering started")
if not os.path.exists(face_json_path):
print("Face JSON not found.")
write_empty_output("no_face_json", uuid, OUTPUT_JSON_PATH)
publisher.complete("face_cluster", "No face.json found")
return
with open(face_json_path) as f:
face_data = json.load(f)
frames_list = face_data.get("frames", [])
if not frames_list:
print("No frames in JSON (no faces).")
write_empty_output("no_faces", uuid, OUTPUT_JSON_PATH)
publisher.complete("face_cluster", "No faces to cluster")
return
fps_value = face_data.get("fps", 23.98)
points = load_qdrant_faces(uuid, publisher)
if not points:
print("No embeddings found in Qdrant.")
write_empty_output("no_embeddings", uuid, OUTPUT_JSON_PATH)
publisher.complete("face_cluster", "No embeddings in Qdrant")
return
publisher.progress("face_cluster", 10, 100, f"Loaded {len(points)} Qdrant points")
trace_embeddings, traces = cluster_by_trace(
points,
min_faces_per_trace=1,
confidence_threshold=0.5
)
if not trace_embeddings:
print("No valid traces found.")
write_empty_output("no_embeddings", uuid, OUTPUT_JSON_PATH)
publisher.complete("face_cluster", "No valid traces")
return
publisher.progress("face_cluster", 30, 100, f"Found {len(trace_embeddings)} traces")
trace_to_person = single_stage_clustering(trace_embeddings)
if not trace_to_person:
print("Clustering produced no results.")
write_empty_output("no_embeddings", uuid, OUTPUT_JSON_PATH)
publisher.complete("face_cluster", "Clustering empty")
return
n_persons = len(set(trace_to_person.values()))
print(f"Clustering result: {n_persons} persons ({len(trace_to_person)} traces)")
publisher.progress("face_cluster", 50, 100, f"Found {n_persons} persons")
qdrant_by_frame_trace = {}
for point in points:
payload = point.get("payload", {})
frame = payload.get("frame")
trace_id = payload.get("trace_id", -1)
bbox = payload.get("bbox", {})
if frame is not None and trace_id >= 0:
cx = bbox.get("x", 0) + bbox.get("width", 0) // 2
cy = bbox.get("y", 0) + bbox.get("height", 0) // 2
key = (frame, cx, cy)
qdrant_by_frame_trace[key] = trace_id
matched_count = 0
for frame_idx, frame_obj in enumerate(frames_list):
frame_num = frame_obj.get("frame", frame_idx)
for face in frame_obj.get("faces", []):
x = face.get("x", 0)
y = face.get("y", 0)
w = face.get("width", 0)
h = face.get("height", 0)
cx = x + w // 2
cy = y + h // 2
key = (frame_num, cx, cy)
trace_id = qdrant_by_frame_trace.get(key)
if trace_id is None:
for (qf, qx, qy), tid in qdrant_by_frame_trace.items():
if qf == frame_num and abs(qx - cx) < 50 and abs(qy - cy) < 50:
trace_id = tid
break
if trace_id is not None and trace_id in trace_to_person:
person_label = trace_to_person[trace_id]
face["person_id"] = f"Person_{person_label}"
matched_count += 1
print(f" Assigned person_id to {matched_count} faces")
if matched_count == 0:
print("No faces matched to any trace - check Qdrant data integrity")
publisher.progress("face_cluster", 100, 100, "No trace matches")
write_empty_output("no_faces", uuid, OUTPUT_JSON_PATH)
return
publisher.progress("face_cluster", 70, 100, f"Matched {matched_count} faces")
person_face_count = defaultdict(int)
person_best_face = {}
for frame_idx, frame_obj in enumerate(frames_list):
for face in frame_obj.get("faces", []):
person_id = face.get("person_id")
if not person_id:
continue
person_face_count[person_id] += 1
confidence = face.get("confidence", 0.9)
if person_id not in person_best_face or confidence > person_best_face[person_id]["confidence"]:
person_best_face[person_id] = {
"face_id": f"face_{frame_idx}_{frame_idx}",
"confidence": confidence,
"frame": frame_obj.get("frame", frame_idx),
"bbox": {
"x": face.get("x"),
"y": face.get("y"),
"width": face.get("width"),
"height": face.get("height")
}
}
person_labels_sorted = sorted(person_face_count.keys(), key=lambda p: -person_face_count[p])
clusters = []
for person_id in person_labels_sorted:
cluster_entry = {
"cluster_id": person_id,
"face_count": person_face_count[person_id],
"representative_face": person_best_face.get(person_id)
}
clusters.append(cluster_entry)
output_frames = []
for frame_idx, frame_obj in enumerate(frames_list):
timestamp = frame_obj.get("timestamp", frame_obj.get("frame", 0) / fps_value if fps_value > 0 else 0)
output_faces = []
for face in frame_obj.get("faces", []):
person_id = face.get("person_id")
if person_id:
output_faces.append({
"face_id": f"face_{frame_idx}_{frame_idx}",
"cluster_id": person_id,
"confidence": face.get("confidence", 0.9),
})
if output_faces:
output_frames.append({
"frame": frame_obj.get("frame", frame_idx),
"timestamp": timestamp,
"faces": output_faces,
})
output_data = {
"status": "has_faces",
"file_uuid": UUID,
"clusters": clusters,
"frames": output_frames,
}
with open(OUTPUT_JSON_PATH, "w", encoding="utf-8") as f:
json.dump(output_data, f, indent=2, ensure_ascii=False)
print(f"Saved clustered data to {OUTPUT_JSON_PATH}")
publisher.progress("face_cluster", 90, 100, f"{len(clusters)} clusters")
auto_bind_speakers()
publisher.complete("face_cluster", f"{len(clusters)} clusters")
if __name__ == "__main__":
main()