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
+259
View File
@@ -0,0 +1,259 @@
#!/usr/bin/env python3
"""
Backfill trace profiles from Qdrant _faces collection.
For each (file_uuid, trace_id) group in Qdrant:
1. Compute frame_count, start_frame, end_frame, avg_confidence
2. Pick representative frame (highest confidence)
3. Extract key_frame.jpg from video via ffmpeg
4. Crop key_face.jpg from key_frame using representative bbox
5. Write output/{file_uuid}/trace_{N}/trace_profile.json
Usage:
python3 backfill_trace_profiles.py [--file-uuid UUID] [--dry-run]
"""
import argparse
import json
import os
import subprocess
import sys
import urllib.request
import urllib.error
from collections import defaultdict
OUTPUT_DIR = os.environ.get("MOMENTRY_OUTPUT_DIR", "/Users/accusys/momentry/output")
QDRANT_URL = os.environ.get("QDRANT_URL", "http://localhost:6333")
QDRANT_API_KEY = os.environ.get("QDRANT_API_KEY", "Test3200Test3200Test3200")
FACES_COLLECTION = "_faces"
BATCH_SIZE = 1000
def qdrant_scroll(filter_dict, limit=BATCH_SIZE, offset=None, with_payload=None):
"""Scroll Qdrant collection with filter."""
body = {"limit": limit, "filter": filter_dict, "with_vector": False}
if offset:
body["offset"] = offset
if with_payload:
body["with_payload"] = with_payload
url = f"{QDRANT_URL}/collections/{FACES_COLLECTION}/points/scroll"
data = json.dumps(body).encode()
req = urllib.request.Request(url, data=data, method="POST")
req.add_header("Content-Type", "application/json")
req.add_header("Api-Key", QDRANT_API_KEY)
with urllib.request.urlopen(req) as resp:
return json.loads(resp.read())
def scroll_all(filter_dict, with_payload=None):
"""Scroll all matching points."""
all_points = []
offset = None
while True:
result = qdrant_scroll(filter_dict, offset=offset, with_payload=with_payload)
points = result.get("result", {}).get("points", [])
if not points:
break
all_points.extend(points)
offset = result.get("result", {}).get("next_page_offset")
if not offset or len(points) < BATCH_SIZE:
break
return all_points
def get_video_path(file_uuid):
"""Get video file path from database."""
psql = "/opt/homebrew/Cellar/libpq/18.4/bin/psql"
result = subprocess.run(
[psql, "-U", "accusys", "-d", "momentry", "-t", "-A", "-c",
f"SELECT file_path FROM videos WHERE file_uuid = '{file_uuid}'"],
capture_output=True, text=True
)
if result.returncode == 0 and result.stdout.strip():
return result.stdout.strip()
return None
def extract_key_frame(video_path, frame_num, fps, output_path):
"""Extract a specific frame from video using ffmpeg."""
if fps <= 0:
return False
timestamp = frame_num / fps
try:
result = subprocess.run(
["ffmpeg", "-y", "-ss", f"{timestamp:.3f}",
"-i", video_path,
"-vframes", "1", "-vf", "scale=640:-1", "-q:v", "5",
output_path],
capture_output=True, timeout=30
)
return result.returncode == 0 and os.path.exists(output_path)
except Exception as e:
print(f" key_frame extraction failed: {e}", file=sys.stderr)
return False
def crop_key_face(key_frame_path, bbox, output_path):
"""Crop key_face from key_frame using bbox."""
x, y, w, h = bbox["x"], bbox["y"], bbox["width"], bbox["height"]
if w <= 0 or h <= 0:
return False
try:
result = subprocess.run(
["ffmpeg", "-y", "-i", key_frame_path,
"-vf", f"crop={w}:{h}:{x}:{y}",
"-q:v", "2", output_path],
capture_output=True, timeout=10
)
return result.returncode == 0 and os.path.exists(output_path)
except Exception as e:
print(f" key_face crop failed: {e}", file=sys.stderr)
return False
def build_trace_profiles(file_uuid=None, dry_run=False):
"""Build trace profiles from Qdrant _faces data."""
# Get all unique file_uuids with trace_id >= 0
if file_uuid:
file_uuids = [file_uuid]
else:
print("Scanning Qdrant for all file_uuids with trace_id >= 0...")
points = scroll_all(
{"must": [{"key": "trace_id", "range": {"gte": 0}}]},
with_payload={"include": ["file_uuid"]}
)
file_uuids = sorted(set(p["payload"]["file_uuid"] for p in points))
print(f"Found {len(file_uuids)} files with trace data")
total_profiles = 0
for fid in file_uuids:
print(f"\n--- {fid} ---")
# Scroll all points for this file with trace_id >= 0
points = scroll_all(
{
"must": [
{"key": "file_uuid", "match": {"value": fid}},
{"key": "trace_id", "range": {"gte": 0}},
]
},
with_payload={"include": ["frame", "trace_id", "bbox", "confidence"]}
)
if not points:
print(" No points with trace_id >= 0")
continue
# Group by trace_id
traces = defaultdict(list)
for p in points:
pl = p["payload"]
tid = pl.get("trace_id", 0)
traces[tid].append({
"frame": pl["frame"],
"bbox": pl.get("bbox", {}),
"confidence": pl.get("confidence", 0.0),
})
print(f" {len(points)} points, {len(traces)} traces")
# Get video path
video_path = get_video_path(fid)
if not video_path or not os.path.exists(video_path):
print(f" Video not found, skipping key_frame extraction")
video_path = None
# Get FPS from DB
fps = 30.0
if video_path:
psql = "/opt/homebrew/Cellar/libpq/18.4/bin/psql"
result = subprocess.run(
[psql, "-U", "accusys", "-d", "momentry", "-t", "-A", "-c",
f"SELECT COALESCE(fps, 30.0) FROM videos WHERE file_uuid = '{fid}'"],
capture_output=True, text=True
)
if result.returncode == 0 and result.stdout.strip():
try:
fps = float(result.stdout.strip())
except ValueError:
pass
for tid, faces in sorted(traces.items()):
if tid < 0:
continue
frames = [f["frame"] for f in faces]
confidences = [f["confidence"] for f in faces]
frame_count = len(faces)
start_frame = min(frames)
end_frame = max(frames)
avg_confidence = sum(confidences) / frame_count if frame_count > 0 else 0.0
# Representative frame: highest confidence
best = max(faces, key=lambda f: f["confidence"])
best_frame = best["frame"]
best_bbox = best["bbox"]
trace_dir = os.path.join(OUTPUT_DIR, fid, f"trace_{tid}")
profile_path = os.path.join(trace_dir, "trace_profile.json")
kf_path = os.path.join(trace_dir, "key_frame.jpg")
face_path = os.path.join(trace_dir, "key_face.jpg")
profile = {
"version": "1.0",
"file_uuid": fid,
"trace_id": tid,
"label": "",
"frame_count": frame_count,
"start_frame": start_frame,
"end_frame": end_frame,
"avg_confidence": round(avg_confidence, 6),
"key_frame": "key_frame.jpg" if os.path.exists(kf_path) else None,
"key_face": "key_face.jpg" if os.path.exists(face_path) else None,
"status": "pending",
}
if dry_run:
print(f" trace_{tid}: {frame_count} frames [{start_frame}-{end_frame}] "
f"conf={avg_confidence:.3f} best_frame={best_frame}")
continue
os.makedirs(trace_dir, exist_ok=True)
# Extract key_frame.jpg if not exists
if not os.path.exists(kf_path) and video_path:
extract_key_frame(video_path, best_frame, fps, kf_path)
if os.path.exists(kf_path):
profile["key_frame"] = "key_frame.jpg"
# Crop key_face.jpg from key_frame if not exists
if not os.path.exists(face_path) and os.path.exists(kf_path) and best_bbox:
crop_key_face(kf_path, best_bbox, face_path)
if os.path.exists(face_path):
profile["key_face"] = "key_face.jpg"
# Write trace_profile.json
with open(profile_path, "w") as f:
json.dump(profile, f, indent=2, ensure_ascii=False)
total_profiles += 1
if not dry_run:
print(f" Created {len([t for t in traces if t >= 0])} trace profiles")
print(f"\nDone: {total_profiles} trace profiles created")
def main():
parser = argparse.ArgumentParser(description="Backfill trace profiles from Qdrant")
parser.add_argument("--file-uuid", help="Process only this file UUID")
parser.add_argument("--dry-run", action="store_true", help="Show what would be created")
args = parser.parse_args()
build_trace_profiles(file_uuid=args.file_uuid, dry_run=args.dry_run)
if __name__ == "__main__":
main()