Skip to content

Commit 4e08cda

Browse files
mvalancyclaude
andauthored
feat(audit): OpenCV graph-balance metrics + flat high-contrast theme (#97)
Objective, OpenCV-driven layout measurements (metrics first; pass/fail later): - tests/helpers/balance_metrics.py — given a graph-canvas screenshot, isolates content (flat-bg colour-distance under the audit theme, edge-density fallback) and reports centroid offset from centre, bbox coverage, content usage, L/R+T/B margin balance, quadrant mass distribution, and an informational balanceScore, plus an annotated overlay (content bbox, centroid, frame centre). - tests/diagnostics/graph-balance.spec.ts (@balance) — measures the graph canvas at desktop/laptop/tablet, attaches the annotated overlay + metrics JSON. Phase 1 records numbers (no centering gate yet) so we get baselines before thresholds. Flat high-contrast "audit" theme (groundwork for real a11y theme selection): - main.tsx applies <html data-theme> from ?theme= / graphdone:theme before paint. - index.css [data-theme="contrast"] flattens the lagoon bg, grid, blur and glows to a solid #050608 render — a REAL browser render with layout unchanged, so OpenCV scans are clean and deterministic at every resolution. Baseline (localhost seed): well-centred (offMag ~0.05) — confirms the off-centre case is the small live guest graph at initial load (camera-centring fix next). The metric correctly surfaced that the minimap was skewing earlier readings. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 4258580 commit 4e08cda

4 files changed

Lines changed: 254 additions & 0 deletions

File tree

packages/web/src/index.css

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -940,3 +940,37 @@ input[type="date"]:focus {
940940
stroke-width: 5;
941941
}
942942
}
943+
944+
/* ── Audit / high-contrast theme ─────────────────────────────────────────────
945+
A deliberately flat, no-texture, no-glow render (set via <html data-theme=
946+
"contrast">). It is a REAL browser render — layout, positions and sizes are
947+
unchanged — but the decorative lagoon background, grid, blur and glows are
948+
stripped so OpenCV layout/balance scans get a clean, deterministic image.
949+
This is also the groundwork for a real high-contrast accessibility theme. */
950+
html[data-theme="contrast"] body,
951+
html[data-theme="contrast"] #root {
952+
background: #050608 !important;
953+
}
954+
html[data-theme="contrast"] .lagoon-caustics,
955+
html[data-theme="contrast"] .grid-overlay {
956+
display: none !important;
957+
}
958+
html[data-theme="contrast"] .graph-container,
959+
html[data-theme="contrast"] .graph-container svg {
960+
background: #050608 !important;
961+
}
962+
/* strip decoration that muddies pixel analysis: blur, glow, soft shadows, gradients-on-text */
963+
html[data-theme="contrast"] *,
964+
html[data-theme="contrast"] *::before,
965+
html[data-theme="contrast"] *::after {
966+
backdrop-filter: none !important;
967+
-webkit-backdrop-filter: none !important;
968+
box-shadow: none !important;
969+
text-shadow: none !important;
970+
}
971+
/* node glow/drop-shadow filters off → solid, crisp shapes for detection */
972+
html[data-theme="contrast"] .graph-container svg .node,
973+
html[data-theme="contrast"] .graph-container svg .node *,
974+
html[data-theme="contrast"] .node-selected {
975+
filter: none !important;
976+
}

packages/web/src/main.tsx

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,17 @@ import { apolloClient } from './lib/apollo';
66
import App from './App';
77
import './index.css';
88

9+
// Apply an explicit theme before first paint. `contrast` is a flat, no-texture,
10+
// high-contrast render used by OpenCV layout audits — and the groundwork for a
11+
// real high-contrast accessibility theme. Source priority: ?theme= URL param,
12+
// then the persisted choice (graphdone:theme). Default theme = no attribute.
13+
try {
14+
const urlTheme = new URLSearchParams(window.location.search).get('theme');
15+
if (urlTheme) localStorage.setItem('graphdone:theme', urlTheme);
16+
const theme = urlTheme || localStorage.getItem('graphdone:theme');
17+
if (theme) document.documentElement.setAttribute('data-theme', theme);
18+
} catch { /* non-browser / storage blocked */ }
19+
920
const root = ReactDOM.createRoot(
1021
document.getElementById('root') as HTMLElement
1122
);
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
import { test, expect } from '@playwright/test';
2+
import { login, TEST_USERS, getBaseURL } from '../helpers/auth';
3+
import { execFileSync } from 'node:child_process';
4+
import { mkdirSync } from 'node:fs';
5+
import * as path from 'node:path';
6+
7+
/**
8+
* Graph balance / layout metrics (@balance), OpenCV-driven.
9+
*
10+
* Clips a screenshot to the graph canvas (`.graph-container`, which excludes the
11+
* nav rail, top bar, and the body-portaled minimap), then runs
12+
* tests/helpers/balance_metrics.py to compute OBJECTIVE numbers about how the
13+
* graph is placed: centroid offset from centre, bbox coverage, content usage,
14+
* margin balance, quadrant mass distribution, and an informational balanceScore.
15+
*
16+
* PHASE 1 = measurement, not a verdict. It records numbers + an annotated
17+
* overlay into the report and only asserts that content was detected — so we get
18+
* objective baselines first. Centering/usage THRESHOLDS (pass/fail) come once the
19+
* camera-centering work lands and we know what "good" looks like numerically.
20+
*/
21+
22+
const PY = path.join(process.cwd(), 'tests/helpers/balance_metrics.py');
23+
const OUT = path.join(process.cwd(), 'test-artifacts/balance');
24+
mkdirSync(OUT, { recursive: true });
25+
26+
// Graph view is the default at >=768px; phones default to cards (graph-view on a
27+
// 390px phone is a non-standard forced state), so balance is scanned at the
28+
// resolutions where the graph canvas is a real, primary scenario.
29+
const RESOLUTIONS = [
30+
{ name: 'desktop', w: 1440, h: 900 },
31+
{ name: 'laptop', w: 1280, h: 800 },
32+
{ name: 'tablet', w: 768, h: 1024 },
33+
];
34+
35+
test.describe('graph balance metrics (OpenCV) @balance', () => {
36+
test.describe.configure({ timeout: 120_000 });
37+
38+
for (const r of RESOLUTIONS) {
39+
test(`balance @${r.name} ${r.w}x${r.h}`, async ({ page }, info) => {
40+
await page.setViewportSize({ width: r.w, height: r.h });
41+
await login(page, TEST_USERS.ADMIN);
42+
// Render in the flat high-contrast "audit" theme so OpenCV detection is clean
43+
// and deterministic (real render; only decoration is stripped, layout intact).
44+
await page.addInitScript(() => {
45+
localStorage.setItem('graphdone:viewMode', 'graph');
46+
localStorage.setItem('graphdone:theme', 'contrast');
47+
});
48+
await page.goto(`${getBaseURL()}/`, { waitUntil: 'domcontentloaded' });
49+
await page.waitForSelector('.graph-container svg .node', { timeout: 15_000 }).catch(() => {});
50+
await page.waitForTimeout(3500); // let physics settle + any camera framing run
51+
52+
// Close the minimap so it doesn't sit in the clip region (it's chrome, not graph content).
53+
await page.locator('button[title="Hide Mini-Map"]').click().catch(() => {});
54+
await page.waitForTimeout(200);
55+
const canvas = page.locator('.graph-container').first();
56+
if (!(await canvas.isVisible().catch(() => false))) test.skip(true, 'no graph canvas in this view');
57+
const img = path.join(OUT, `${r.name}.png`);
58+
const ann = path.join(OUT, `${r.name}.annotated.jpg`);
59+
await canvas.screenshot({ path: img });
60+
61+
const m = JSON.parse(execFileSync('python3', [PY, img, '--annotate', ann, '--flat']).toString());
62+
const c = m.centroid || {}, b = m.bbox || {}, q = m.quadrants || {};
63+
console.log(`[balance ${r.name}] score=${m.balanceScore} offMag=${c.offMag} (dx=${c.offX},dy=${c.offY}) usage=${m.contentFrac} bboxCov=${b.coverage} quadImb=${q.imbalance}`);
64+
65+
await info.attach(`balance-${r.name}`, { path: ann, contentType: 'image/jpeg' });
66+
await info.attach(`metrics-${r.name}`, { body: JSON.stringify(m, null, 2), contentType: 'application/json' });
67+
68+
// Phase 1 is measurement, not a gate: record even a near-empty canvas
69+
// (itself a signal the graph rendered off-screen) instead of failing.
70+
// Centering/usage THRESHOLDS become pass/fail once the camera work lands.
71+
expect(m, 'metrics computed').toBeTruthy();
72+
if ((m.contentPixels ?? 0) < 200) {
73+
console.warn(`[balance ${r.name}] near-empty canvas (${m.contentPixels}px) — graph likely rendered off-screen`);
74+
}
75+
});
76+
}
77+
});

tests/helpers/balance_metrics.py

Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
#!/usr/bin/env python3
2+
"""OpenCV screen-balance / layout metrics for a graph-canvas screenshot.
3+
4+
Given an image (ideally already cropped to the graph drawing area), it isolates
5+
the rendered graph content from the calm gradient background via edge density
6+
(robust to a smooth background), then reports OBJECTIVE numeric measurements of
7+
how the content is placed and how much of the canvas it uses:
8+
9+
centroid offset from center, bounding-box coverage, content pixel usage,
10+
left/right & top/bottom margin balance, quadrant mass distribution, and a
11+
single informational balanceScore (0-100, higher = better-centered/balanced).
12+
13+
These are measurements, not verdicts — thresholds/pass-fail live in the caller
14+
(Playwright / the live audit), so the same numbers can drive reports first and
15+
gates later.
16+
17+
Usage:
18+
python3 balance_metrics.py <image> [--annotate <out.jpg>]
19+
Prints a JSON object to stdout.
20+
"""
21+
import json
22+
import sys
23+
24+
import cv2
25+
import numpy as np
26+
27+
MAX_QUAD_STD = 0.4330 # std of [1,0,0,0] — all mass in one quadrant (worst case)
28+
29+
30+
def measure(path, annotate=None, force="auto"):
31+
img = cv2.imread(path)
32+
if img is None:
33+
return {"error": f"could not read {path}"}
34+
h, w = img.shape[:2]
35+
gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
36+
37+
# Background sampled from a thin border ring. If it's near-uniform (the flat
38+
# high-contrast "audit" theme), detect content by COLOUR DISTANCE from it —
39+
# accurate for solid node fills, not just edges. Otherwise fall back to edge
40+
# density, which is robust against a gradient/textured background.
41+
ring = np.concatenate([
42+
img[:6].reshape(-1, 3), img[-6:].reshape(-1, 3),
43+
img[:, :6].reshape(-1, 3), img[:, -6:].reshape(-1, 3),
44+
]).astype(np.int16)
45+
bg = np.median(ring, axis=0)
46+
flat = float(ring.std(axis=0).mean()) < 12.0
47+
if force == "edge":
48+
flat = False
49+
elif force == "flat":
50+
flat = True
51+
if flat:
52+
diff = np.abs(img.astype(np.int16) - bg).max(axis=2).astype(np.uint8)
53+
mask = ((diff > 28) * 255).astype(np.uint8)
54+
mask = cv2.morphologyEx(mask, cv2.MORPH_CLOSE, np.ones((5, 5), np.uint8))
55+
else:
56+
edges = cv2.Canny(gray, 50, 140)
57+
mask = cv2.dilate(edges, np.ones((9, 9), np.uint8), iterations=2)
58+
ys, xs = np.where(mask > 0)
59+
60+
out = {"image": path, "w": w, "h": h, "method": "flat" if flat else "edge", "contentPixels": int(len(xs))}
61+
if len(xs) < 200: # effectively empty canvas
62+
out.update({"empty": True, "balanceScore": None})
63+
return out
64+
65+
cx, cy = float(xs.mean()), float(ys.mean())
66+
off_x = (cx - w / 2) / (w / 2)
67+
off_y = (cy - h / 2) / (h / 2)
68+
off_mag = float((off_x ** 2 + off_y ** 2) ** 0.5)
69+
70+
x0, x1, y0, y1 = int(xs.min()), int(xs.max()), int(ys.min()), int(ys.max())
71+
bw, bh = x1 - x0, y1 - y0
72+
coverage = (bw * bh) / (w * h) # bbox area / canvas area
73+
usage = len(xs) / (w * h) # actual content pixels / canvas
74+
fill = len(xs) / max(1, bw * bh) # content density within its bbox
75+
76+
left, right, top, bottom = x0, w - 1 - x1, y0, h - 1 - y1
77+
bal_x = abs(left - right) / max(1, left + right)
78+
bal_y = abs(top - bottom) / max(1, top + bottom)
79+
80+
q = [
81+
int(((xs < w / 2) & (ys < h / 2)).sum()), # tl
82+
int(((xs >= w / 2) & (ys < h / 2)).sum()), # tr
83+
int(((xs < w / 2) & (ys >= h / 2)).sum()), # bl
84+
int(((xs >= w / 2) & (ys >= h / 2)).sum()), # br
85+
]
86+
qf = [v / len(xs) for v in q]
87+
quad_imbalance = float(np.std(qf))
88+
89+
center_pen = min(1.0, off_mag)
90+
margin_pen = (bal_x + bal_y) / 2
91+
quad_pen = min(1.0, quad_imbalance / MAX_QUAD_STD)
92+
score = round(100 * max(0.0, 1 - 0.45 * center_pen - 0.30 * margin_pen - 0.25 * quad_pen))
93+
94+
out.update({
95+
"contentFrac": round(usage, 4),
96+
"centroid": {"x": round(cx, 1), "y": round(cy, 1),
97+
"offX": round(off_x, 3), "offY": round(off_y, 3), "offMag": round(off_mag, 3)},
98+
"bbox": {"x": x0, "y": y0, "w": bw, "h": bh,
99+
"coverage": round(coverage, 4), "fill": round(fill, 3)},
100+
"margins": {"left": left, "right": right, "top": top, "bottom": bottom,
101+
"balanceX": round(bal_x, 3), "balanceY": round(bal_y, 3)},
102+
"quadrants": {"tl": round(qf[0], 3), "tr": round(qf[1], 3),
103+
"bl": round(qf[2], 3), "br": round(qf[3], 3),
104+
"imbalance": round(quad_imbalance, 3)},
105+
"balanceScore": score,
106+
})
107+
108+
if annotate:
109+
vis = img.copy()
110+
cv2.rectangle(vis, (x0, y0), (x1, y1), (80, 200, 80), 2) # content bbox (green)
111+
cv2.drawMarker(vis, (int(cx), int(cy)), (80, 80, 240), cv2.MARKER_CROSS, 26, 3) # centroid (red)
112+
cv2.drawMarker(vis, (w // 2, h // 2), (240, 200, 80), cv2.MARKER_TILTED_CROSS, 22, 2) # frame center (blue)
113+
cv2.line(vis, (w // 2, 0), (w // 2, h), (90, 90, 90), 1)
114+
cv2.line(vis, (0, h // 2), (w, h // 2), (90, 90, 90), 1)
115+
label = f"score {score} | off {off_mag:.2f} | use {usage*100:.0f}% | quad {quad_imbalance:.2f}"
116+
cv2.rectangle(vis, (0, 0), (max(360, 9 * len(label)), 26), (20, 20, 20), -1)
117+
cv2.putText(vis, label, (8, 18), cv2.FONT_HERSHEY_SIMPLEX, 0.5, (240, 240, 240), 1, cv2.LINE_AA)
118+
cv2.imwrite(annotate, vis, [cv2.IMWRITE_JPEG_QUALITY, 80])
119+
out["annotated"] = annotate
120+
121+
return out
122+
123+
124+
if __name__ == "__main__":
125+
if len(sys.argv) < 2:
126+
print(json.dumps({"error": "usage: balance_metrics.py <image> [--annotate <out.jpg>]"}))
127+
sys.exit(2)
128+
ann = None
129+
if "--annotate" in sys.argv:
130+
ann = sys.argv[sys.argv.index("--annotate") + 1]
131+
force = "flat" if "--flat" in sys.argv else "edge" if "--edge" in sys.argv else "auto"
132+
print(json.dumps(measure(sys.argv[1], ann, force)))

0 commit comments

Comments
 (0)