Files
momentry_core/scripts/render_face_heatmap.py
Accusys fff2af8ad1 fix: identity names now show in all trace tooltips (online + offline)
- Online: remove IDENTITY filter gating on identity_note — always show
- Offline: fix id_names scope bug — was overwritten by top10-only dict
- Both reports now show 'identity: PERSON_xxx' for all 2000 timeline traces
- All 5483 traces have identity mapping (verified in SQLite)
2026-05-13 03:19:26 +08:00

223 lines
11 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/opt/homebrew/bin/python3.11
"""Face Trace Heatmap + Timeline Visualization for Momentry.
Usage:
python3 render_face_heatmap.py <uuid> [output.html] [--identity ID]
"""
import sys, psycopg2, argparse
from collections import defaultdict
parser = argparse.ArgumentParser()
parser.add_argument("uuid")
parser.add_argument("output", nargs="?", default=None)
parser.add_argument("--identity", "-i", type=int, default=None, help="Filter by identity_id")
args = parser.parse_args()
UUID = args.uuid
OUT = args.output or f"/tmp/face_report_{UUID[:8]}.html"
IDENTITY = args.identity
conn = psycopg2.connect("dbname=momentry user=accusys")
cur = conn.cursor()
cur.execute("SELECT duration, file_name, COALESCE(fps, 25.0) FROM dev.videos WHERE file_uuid=%s", (UUID,))
row = cur.fetchone()
if not row:
print("UUID not found")
sys.exit(1)
duration, video_name, fps = float(row[0] or 6785), row[1] or UUID, float(row[2] or 25.0)
# Get sample interval from face.json metadata (or default 3 = 8Hz)
sample_interval = 3
hz = fps / sample_interval
# Build identity filter
identity_filter = ""
identity_params = [UUID]
identity_label = ""
identity_info = None # full identity record when filtered
top_identities = [] # top identities summary (all view)
if IDENTITY is not None:
identity_filter = " AND identity_id = %s"
identity_params.append(IDENTITY)
cur.execute("SELECT id, name, identity_type, source, status FROM dev.identities WHERE id=%s", (IDENTITY,))
id_row = cur.fetchone()
if id_row:
identity_info = {"id": id_row[0], "name": id_row[1], "type": id_row[2], "source": id_row[3], "status": id_row[4]}
identity_label = f" (identity: {id_row[1]})"
else:
identity_label = f" (identity #{IDENTITY})"
identity_params = [UUID, IDENTITY]
# Query trace spans
cur.execute(f"""
SELECT trace_id, MIN(frame_number), MAX(frame_number),
COALESCE(MIN(timestamp_secs), MIN(frame_number) / {fps}) as first_t,
COALESCE(MAX(timestamp_secs), MAX(frame_number) / {fps}) as last_t,
COUNT(*)
FROM dev.face_detections
WHERE file_uuid=%s AND trace_id IS NOT NULL{identity_filter}
GROUP BY trace_id ORDER BY first_t
""", identity_params)
trace_spans = cur.fetchall()
# Query density per time bucket (5s)
cur.execute(f"""
SELECT FLOOR(COALESCE(timestamp_secs, frame_number / {fps}) / 5)::int as bkt, COUNT(*) as cnt
FROM dev.face_detections
WHERE file_uuid=%s AND trace_id IS NOT NULL{identity_filter}
GROUP BY bkt ORDER BY bkt
""", identity_params)
density = {b: c for b, c in cur.fetchall()}
# Count total detections
cur.execute(f"SELECT COUNT(*) FROM dev.face_detections WHERE file_uuid=%s{identity_filter}", identity_params)
total_detections = cur.fetchone()[0]
# Get top identities (for all view) and trace↔identity mapping
if IDENTITY is None:
cur.execute("""
SELECT fd.identity_id, i.name, COUNT(*) as faces, COUNT(DISTINCT fd.trace_id) as traces
FROM dev.face_detections fd
LEFT JOIN dev.identities i ON i.id = fd.identity_id
WHERE fd.file_uuid=%s AND fd.identity_id IS NOT NULL
GROUP BY fd.identity_id, i.name ORDER BY faces DESC LIMIT 10
""", (UUID,))
top_identities = cur.fetchall()
# Always get trace→identity mapping for tooltip enrichment
cur.execute("""
SELECT DISTINCT fd.trace_id, i.name
FROM dev.face_detections fd
LEFT JOIN dev.identities i ON i.id = fd.identity_id
WHERE fd.file_uuid=%s AND fd.identity_id IS NOT NULL
""", (UUID,))
trace_to_identity = {r[0]: r[1] for r in cur.fetchall()}
cur.close(); conn.close()
BUCKET = 5
num_buckets = int(duration / BUCKET) + 1
max_density = max(density.values()) if density else 1
def build_html():
h = []
h.append('<!DOCTYPE html><html><head><meta charset="utf-8"><title>Face Trace Report</title>')
h.append('<style>')
h.append('body{font-family:-apple-system,BlinkMacSystemFont,sans-serif;margin:20px;background:#0d1117;color:#c9d1d9}')
h.append('h1,h2{color:#e94560}')
h.append('.stats{display:flex;gap:12px;margin:8px 0;flex-wrap:wrap}')
h.append('.stat{background:#161b22;padding:6px 14px;border-radius:6px}')
h.append('.stat .num{font-size:20px;font-weight:bold;color:#e94560}')
h.append('.stat .label{font-size:10px;color:#8b949e}')
h.append('.viz{position:relative;background:#0d1117;border:1px solid #30363d;margin:8px 0;overflow:hidden}')
h.append('.bar{display:block;position:absolute;height:3px;background:#e94560;opacity:0.7;border-radius:1px}')
h.append('.bar:hover{height:8px;opacity:1}')
h.append('</style></head><body>')
sub = identity_label if identity_label else ""
h.append('<h1>Face Trace Report — ' + video_name[:60] + sub + '</h1>')
# Identity card (when filtering by identity)
if identity_info:
h.append('<div style="background:#161b22;border:1px solid #30363d;border-radius:8px;padding:16px;margin:12px 0;">')
h.append('<h3 style="margin:0;color:#e94560">Identity Details</h3>')
h.append(f'<table style="width:100%;margin-top:8px;color:#c9d1d9;border-collapse:collapse">')
h.append(f'<tr><td style="padding:4px 12px 4px 0;color:#8b949e;width:80px">ID</td><td>{identity_info["id"]}</td></tr>')
h.append(f'<tr><td style="padding:4px 12px 4px 0;color:#8b949e">Name</td><td style="font-weight:bold">{identity_info["name"]}</td></tr>')
h.append(f'<tr><td style="padding:4px 12px 4px 0;color:#8b949e">Type</td><td>{identity_info["type"]}</td></tr>')
h.append(f'<tr><td style="padding:4px 12px 4px 0;color:#8b949e">Source</td><td>{identity_info["source"]}</td></tr>')
h.append(f'<tr><td style="padding:4px 12px 4px 0;color:#8b949e">Status</td><td>{identity_info["status"]}</td></tr>')
h.append('</table></div>')
# Top identities table (all view)
if top_identities:
h.append('<h2>Top Identities</h2>')
h.append('<div style="overflow-x:auto">')
h.append('<table style="width:100%;color:#c9d1d9;border-collapse:collapse;font-size:13px">')
h.append('<tr style="background:#161b22;text-align:left"><th style="padding:6px 10px">Identity</th><th style="padding:6px 10px">Name</th><th style="padding:6px 10px;text-align:right">Faces</th><th style="padding:6px 10px;text-align:right">Traces</th></tr>')
for iid, name, fc, tc in top_identities:
short_name = (name or f"#{iid}")[:60]
h.append(f'<tr style="border-bottom:1px solid #21262d"><td style="padding:4px 10px;color:#8b949e">{iid}</td><td style="padding:4px 10px">{short_name}</td><td style="padding:4px 10px;text-align:right">{fc:,}</td><td style="padding:4px 10px;text-align:right">{tc}</td></tr>')
h.append('</table></div>')
# Stats row
h.append('<div class="stats">')
h.append(f'<div class="stat"><div class="num">{len(trace_spans):,}</div><div class="label">traces</div></div>')
h.append(f'<div class="stat"><div class="num">{total_detections:,}</div><div class="label">detections</div></div>')
h.append(f'<div class="stat"><div class="num">{duration:.0f}s</div><div class="label">duration</div></div>')
h.append(f'<div class="stat"><div class="num">{max_density}</div><div class="label">max per {BUCKET}s</div></div>')
h.append(f'<div class="stat"><div class="num">{fps:.0f}fps</div><div class="label">video fps</div></div>')
h.append(f'<div class="stat"><div class="num">{hz:.0f}Hz</div><div class="label">sample rate (every {sample_interval}frames)</div></div>')
h.append(f'<div class="stat"><div class="num">{num_buckets}</div><div class="label">{BUCKET}s buckets</div></div>')
h.append('</div>')
# 1. Density histogram
h.append('<h2>Face Density Over Time</h2>')
h.append('<div style="color:#666;font-size:12px;margin-bottom:4px">Number of face detections per 5-second interval</div>')
w_px = num_buckets * 2 + 20
h.append(f'<div class="viz" style="width:{w_px}px;height:80px">')
for b in range(num_buckets):
v = density.get(b, 0)
h_px = max(2, int(60 * v / max(1, max_density * 0.6))) if v > 0 else 0
if v == 0:
color = "#0d1117"
else:
i = min(v / (max(1, max_density * 0.5)), 1.0)
r = int(233 * i + 13 * (1 - i))
g = int(69 * i + 13 * (1 - i))
bv = int(96 * i + 23 * (1 - i))
color = f"rgb({r},{g},{bv})"
title = f"{b * BUCKET:.0f}s: {v} faces"
h.append(f'<span style="position:absolute;left:{b*2+10}px;bottom:0;width:2px;height:{h_px}px;background:{color}" title="{title}"></span>')
h.append('</div>')
h.append(f'<div style="font-size:10px;color:#444;width:{w_px}px;display:flex;justify-content:space-between"><span>0s</span><span>{duration:.0f}s</span></div>')
# 2. Trace timeline (Gantt)
h.append('<h2>Trace Timeline</h2>')
h.append('<div style="color:#666;font-size:12px;margin-bottom:4px">First → last appearance for each trace. Hover for details.</div>')
show_traces = min(len(trace_spans), 2000)
bar_h = 2
chart_height = show_traces * (bar_h + 1) + 10
h.append(f'<div class="viz" style="width:{w_px}px;height:{chart_height}px">')
for i, (tid, fn0, fn1, t0, t1, cnt) in enumerate(trace_spans[:show_traces]):
left = int(t0 / duration * (w_px - 20)) + 10
width = max(3, int((t1 - t0) / duration * (w_px - 20)))
top = i * (bar_h + 1) + 5
opacity = 1.0 if cnt > 5 else 0.3
identity_note = ""
if tid in trace_to_identity:
identity_note = f", identity: {trace_to_identity[tid]}"
title = f"T{tid}: {t0:.0f}s{t1:.0f}s, {cnt} faces, f{fn0}f{fn1}{identity_note}"
h.append(f'<span class="bar" style="left:{left}px;top:{top}px;width:{width}px;height:{bar_h}px;opacity:{opacity}" title="{title}"></span>')
h.append('</div>')
h.append(f'<div style="font-size:10px;color:#444;width:{w_px}px;display:flex;justify-content:space-between"><span>0s (showing {show_traces}/{len(trace_spans)} traces)</span><span>{duration:.0f}s</span></div>')
# 3. Per-trace heatmap
h.append('<h2>Per-Trace Heatmap (top 500, every 10th trace)</h2>')
h.append(f'<div style="overflow-x:scroll;max-width:100%">')
step = max(1, num_buckets // 120)
for i, (tid, fn0, fn1, t0, t1, cnt) in enumerate(trace_spans[:500]):
if i % 10 != 0:
continue
start_bkt = int(t0 / BUCKET)
end_bkt = int(t1 / BUCKET) + 1
row = f'<div style="white-space:nowrap;line-height:6px"><span style="display:inline-block;width:45px;font-size:6px;color:#555">T{tid}</span>'
for b in range(0, num_buckets, step):
active = start_bkt <= b <= end_bkt
color = "#e94560" if active else "#161b22"
row += f'<span style="display:inline-block;width:2px;height:4px;background:{color};margin:0"></span>'
row += '</div>'
h.append(row)
h.append('</div>')
h.append('</body></html>')
return '\n'.join(h)
html = build_html()
with open(OUT, 'w') as f:
f.write(html)
print(f"Saved: {OUT}")
print(f"Traces: {len(trace_spans)}, Detections: {total_detections}, Density max: {max_density}, Duration: {duration:.0f}s, Sample: {hz:.0f}Hz")
print(f"Size: {len(html) / 1024:.0f}KB")