- 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)
223 lines
11 KiB
Python
223 lines
11 KiB
Python
#!/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")
|