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
This commit is contained in:
Accusys
2026-07-27 02:15:51 +08:00
parent fcdeab82e6
commit 39a2cbc65b
118 changed files with 19386 additions and 2964 deletions
+347 -211
View File
@@ -1,246 +1,205 @@
#!/opt/homebrew/bin/python3.11
"""
Face Clustering Processor
職責:將短暫的 Face ID 聚合為持續的 Person ID,並自動綁定 Speaker。
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 cv2
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
# Use FaceNet embeddings from face.json instead of DeepFace
HAS_DEEPFACE = False
print("[FACE_CLUSTER] Using FaceNet embeddings from face.json (DeepFace not required)")
# 設定
UUID = os.getenv("UUID", "quick_preview")
OUTPUT_DIR = os.getenv("MOMENTRY_OUTPUT_DIR", "./output")
VIDEO_PATH = os.path.join(OUTPUT_DIR, UUID, f"{UUID}.mp4")
FACE_JSON_PATH = os.path.join(OUTPUT_DIR, UUID, f"{UUID}.face.json")
OUTPUT_JSON_PATH = os.path.join(OUTPUT_DIR, UUID, f"{UUID}.face_clustered.json")
ASRX_JSON_PATH = os.path.join(OUTPUT_DIR, UUID, f"{UUID}.asrx.json")
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")
def optimized_clustering(embeddings):
"""
Optimized Clustering for large datasets (e.g. 25k faces).
Strategy: Sample -> Agglomerative -> Centroid Assignment
"""
import numpy as np
from sklearn.cluster import AgglomerativeClustering
from sklearn.metrics.pairwise import cosine_distances
n_faces = len(embeddings)
print(f" 🚀 Starting optimized clustering for {n_faces} faces...")
# 1. Sampling
sample_size = min(5000, n_faces)
if n_faces > sample_size:
indices = np.random.choice(n_faces, sample_size, replace=False)
sample_embeddings = embeddings[indices]
else:
sample_embeddings = embeddings
indices = np.arange(n_faces)
print(f" 📊 Sampling {len(sample_embeddings)} faces for clustering structure...")
# 2. Agglomerative Clustering on Sample
clustering = AgglomerativeClustering(
n_clusters=None, distance_threshold=0.4, metric="cosine", linkage="average"
)
sample_labels = clustering.fit_predict(sample_embeddings)
unique_labels = set(sample_labels)
n_clusters = len(unique_labels)
print(f" 🔍 Found {n_clusters} unique clusters in sample.")
# 3. Compute Centroids for each cluster
centroids = []
for label in unique_labels:
cluster_mask = sample_labels == label
cluster_faces = sample_embeddings[cluster_mask]
# Mean embedding
centroid = np.mean(cluster_faces, axis=0)
centroids.append(centroid)
centroids = np.array(centroids) # Shape: (n_clusters, 512)
# 4. Assign all faces to nearest centroid
# Batch processing to save memory
print(f" 🏃 Assigning {n_faces} faces to {n_clusters} clusters...")
all_labels = np.zeros(n_faces, dtype=int)
batch_size = 5000
for start in range(0, n_faces, batch_size):
end = min(start + batch_size, n_faces)
batch = embeddings[start:end]
dists = cosine_distances(batch, centroids)
all_labels[start:end] = np.argmin(dists, axis=1)
return all_labels
CLUSTER_THRESHOLD = 0.35
def main():
if not os.path.exists(FACE_JSON_PATH):
print("❌ Face JSON not found.")
return
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}")
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.")
return
# Get embeddings from Qdrant
print(f"[FACE_CLUSTER] Loading embeddings from Qdrant for {UUID}...")
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", "")
collection = "_faces"
qdrant_api_key = os.environ.get("QDRANT_API_KEY", "Test3200Test3200Test3200")
headers = {}
if qdrant_api_key:
headers["api-key"] = qdrant_api_key
# Query all embeddings for this file_uuid
response = requests.post(
f"{qdrant_url}/collections/{collection}/points/scroll",
json={
all_points = []
offset = None
while True:
body = {
"limit": 10000,
"with_payload": True,
"with_vector": True,
"filter": {
"must": [
{"key": "file_uuid", "match": {"value": UUID}}
{"key": "file_uuid", "match": {"value": uuid}}
]
},
"limit": 10000,
"with_vector": True
},
headers=headers
)
if response.status_code == 200:
result = response.json()
points = result.get("result", {}).get("points", [])
print(f"[FACE_CLUSTER] Loaded {len(points)} embeddings from Qdrant")
# Build face_id -> embedding map
embedding_map = {}
for point in points:
face_id = point.get("payload", {}).get("face_id")
vector = point.get("vector")
if face_id and vector:
embedding_map[face_id] = vector
else:
print(f"[FACE_CLUSTER] Qdrant query failed: {response.status_code}")
embedding_map = {}
}
}
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"[FACE_CLUSTER] Failed to load embeddings from Qdrant: {e}")
embedding_map = {}
print(f"Failed to load embeddings from Qdrant: {e}")
return []
# Use embeddings from Qdrant - match by frame + bbox
embeddings = []
face_refs = []
print(f"🔍 Collecting face embeddings for {UUID}...")
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}")
# Build a lookup: (frame, bbox_center) -> embedding
# Use frame number and approximate bbox center for matching
qdrant_by_frame = {}
for point in points:
traces = defaultdict(list)
for point in high_conf_points:
payload = point.get("payload", {})
frame = payload.get("frame")
bbox = payload.get("bbox", {})
vector = point.get("vector")
if frame is not None and vector:
# Use frame + bbox center as key
cx = bbox.get("x", 0) + bbox.get("width", 0) // 2
cy = bbox.get("y", 0) + bbox.get("height", 0) // 2
key = (frame, cx, cy)
if key not in qdrant_by_frame:
qdrant_by_frame[key] = vector
trace_id = payload.get("trace_id")
if trace_id is not None and trace_id >= 0:
traces[trace_id].append(point)
print(f"[FACE_CLUSTER] Built Qdrant lookup with {len(qdrant_by_frame)} entries")
print(f"[TRACE] Found {len(traces)} unique traces")
for frame_idx, frame_obj in enumerate(frames_list):
frame_num = frame_obj.get("frame", frame_idx)
faces = frame_obj.get("faces", [])
if not faces:
trace_embeddings = {}
for trace_id, trace_points in traces.items():
if len(trace_points) < min_faces_per_trace:
continue
for face_idx, face in enumerate(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
embeddings = []
weights = []
for p in trace_points:
embeddings.append(p["vector"])
weights.append(p.get("payload", {}).get("confidence", 1.0))
# Try exact match first
key = (frame_num, cx, cy)
if key in qdrant_by_frame:
embeddings.append(qdrant_by_frame[key])
face_refs.append({"frame_idx": frame_idx, "face_idx": face_idx})
continue
embeddings = np.array(embeddings)
weights = np.array(weights)
weights = weights / weights.sum()
# Try approximate match (within 50 pixels)
for (qf, qx, qy), vec in qdrant_by_frame.items():
if qf == frame_num and abs(qx - cx) < 50 and abs(qy - cy) < 50:
embeddings.append(vec)
face_refs.append({"frame_idx": frame_idx, "face_idx": face_idx})
break
avg_embedding = np.average(embeddings, axis=0, weights=weights)
avg_embedding = avg_embedding / np.linalg.norm(avg_embedding)
if not embeddings:
print("❌ No embeddings found in Qdrant.")
return
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
}
embeddings = np.array(embeddings)
print(f"✅ Collected {len(embeddings)} face embeddings from Qdrant.")
print(f"[TRACE] {len(trace_embeddings)} traces with >= {min_faces_per_trace} faces")
return trace_embeddings, traces
# 2. 聚類
print(f"🧠 Clustering {len(embeddings)} faces...")
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=0.4, metric="cosine", linkage="average"
n_clusters=None,
distance_threshold=CLUSTER_THRESHOLD,
metric="cosine",
linkage="average"
)
labels = clustering.fit_predict(embeddings)
unique_labels = set(labels)
label_to_person = {l: f"Person_{i}" for i, l in enumerate(unique_labels)}
print(
f"👥 Detected {len(unique_labels)} unique persons: {[label_to_person[l] for l in unique_labels]}"
)
print(f"[CLUSTER] Detected {len(unique_labels)} unique persons")
# 3. 更新 JSON
for ref, label in zip(face_refs, labels):
f_idx = ref["frame_idx"]
face_idx = ref["face_idx"]
person_id = label_to_person[label]
trace_to_person = {}
for i, trace_id in enumerate(trace_ids):
trace_to_person[trace_id] = labels[i]
if f_idx < len(frames_list):
faces = frames_list[f_idx].get("faces", [])
if face_idx < len(faces):
frames_list[f_idx]["faces"][face_idx]["person_id"] = person_id
# 保存
with open(OUTPUT_JSON_PATH, "w", encoding="utf-8") as f:
json.dump(face_data, f, indent=2, ensure_ascii=False)
print(f"✅ Saved clustered data to {OUTPUT_JSON_PATH}")
# 4. 自動綁定 Speaker
auto_bind_speakers()
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.")
print("Missing data for speaker binding.")
return
with open(OUTPUT_JSON_PATH) as f:
@@ -248,61 +207,48 @@ def auto_bind_speakers():
with open(ASRX_JSON_PATH) as f:
asrx_data = json.load(f)
print("🔗 Auto-binding Speakers to Persons...")
print("Auto-binding Speakers to Persons...")
# 建立 Face 時間列表
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("person_id")
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:
if not speaker or start is None or end is None:
continue
# 找時間重疊
candidates = [f for f in face_spans if start <= f["ts"] <= end]
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)"
)
print(f" {speaker} is likely {best_person} ({persons[best_person]} votes)")
# 1. 找或建 Talent
cur.execute("SELECT id FROM talents WHERE real_name = %s", (best_person,))
row = cur.fetchone()
if row:
talent_id = row[0]
else:
@@ -311,9 +257,8 @@ def auto_bind_speakers():
(best_person,),
)
talent_id = cur.fetchone()[0]
print(f" ✨ Created Talent #{talent_id} ({best_person})")
print(f" Created Talent #{talent_id} ({best_person})")
# 2. 綁定 Speaker
cur.execute(
"""
INSERT INTO identity_bindings (talent_id, binding_type, binding_value, source, confidence)
@@ -322,14 +267,205 @@ def auto_bind_speakers():
""",
(talent_id, speaker),
)
print(f" ✅ Bound {speaker} -> {best_person}")
print(f" Bound {speaker} -> {best_person}")
conn.commit()
cur.close()
conn.close()
except Exception as e:
print(f" ❌ DB Error: {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()
main()