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:
@@ -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()
|
||||
Reference in New Issue
Block a user