Files
momentry_core/scripts/render_face_heatmap.py
Accusys 2992a0e650 feat: service inventory, ERP reports, sqlite-vec integration, visualize tool
- Add SERVICE_INVENTORY_V1.0.0.md (25 source-verified tools, 3.7GB)
- Add ERP_SELECTION_REPORT.md (Odoo CE vs ERPNext comparison)
- Add SFTPGO_ODOO_REPLACEMENT.md (SFTPGo migration plan)
- Add SERVICE_GO_GITEA_BUILD.md (Go compiler + Gitea build report)
- Add release visualize command (face trace heatmap + identity filter)
- Add sqlite-vec integration (160MB SQLite with vec0 vector tables)
- Add export_identities.py, export_sqlite.py, render_face_heatmap.py
- Add Go, Gitea, Rust/Cargo, Swift, yt-dlp, SQLite, sqlite-vec to service CLI
- Fix package to include identities and identity_bindings in data.sql
- Update release list to show all deployed video stats
- Add V1.0.0 YAML frontmatter to all docs (DOCS_STANDARD compliant)
2026-05-13 02:37:45 +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()
else:
# 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 IDENTITY is not None and 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")