Skip to content

Commit 23ab069

Browse files
quic-boyucclaude
andcommitted
observatory + fx_viewer: unify per-layer accuracy color scale, contrast-aware node text
The per-layer accuracy lens was coloring nodes and table cells with a color range recomputed independently per record, so the same cosine/PSNR/MSE/abs_err value mapped to different colors in different records and broke side-by-side comparison. Separately, fx_viewer was drawing node labels in theme.text regardless of node fill, so extension layers (e.g. this lens) that assigned dark or near-white fills produced unreadable text. Changes: devtools/observatory/lenses/per_layer_accuracy.py — `_MetricNumericColorRule` now accepts an optional `fixed_range`; range resolution is factored into `_resolve_range()`. `analyze()` aggregates per-metric (vmin, vmax) across every record's rows once via `_aggregate_metric_ranges()`, stashes them in `AnalysisResult.global_data["metric_ranges"]`, and threads the cosine_sim range into each per-record `_build_metric_extension(...)`. The HTML metrics table is now parameterized by `metric_ranges` and the frontend `record()` reads them from `analysis["global"]`, so table and graph extension share one scale and the 5-point legend auto-reflects the unified range. Per-record fallback is preserved for callers without analysis context. devtools/fx_viewer/templates/runtime.js — new `fxReadableTextColor(hex)` helper using WCAG 2.x relative luminance (0.2126·R + 0.7152·G + 0.0722·B on linearized sRGB). Returns `#111111` or `#f8f8f8` by higher-contrast test, or `null` on malformed input so callers can fall back to theme defaults. Palette matches the lens HTML table's `_text_color_for_bg` for visual consistency. devtools/fx_viewer/templates/canvas_renderer.js — the node draw loop now tracks `renderedFill` through the selected/preview/hovered/input/output shading branches and picks the label ink from `fxReadableTextColor(renderedFill)` whenever the node carries a custom `fill_color` from any extension; nodes without an extension color keep the theme default. No payload/schema changes, so the contrast rule applies retroactively to every existing extension layer. devtools/observatory/tests/test_per_layer_accuracy_lens.py — added `test_analyze_produces_unified_metric_ranges_across_records`: builds two synthetic digests, asserts `global_data["metric_ranges"]` spans the union across records, and asserts that a node with the same metric value in both records receives the same `fill_color` (proving the color no longer depends on which record the node happens to appear in). Review order: the lens module is the load-bearing change — start there, then the matching test, then the two fx_viewer templates which are local and additive. Authored with Claude Code. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent e3ace6e commit 23ab069

4 files changed

Lines changed: 254 additions & 39 deletions

File tree

devtools/fx_viewer/templates/canvas_renderer.js

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -361,17 +361,23 @@ class CanvasRenderer {
361361
}
362362

363363
// Apply interaction state coloring dynamically instead of overriding with theme defaults
364+
let renderedFill;
364365
if (isSelected || isPreview || isEdgeEndpoint) {
365-
ctx.fillStyle = shadeColor(baseColor, state.themeName === 'dark' ? 30 : 20);
366+
renderedFill = shadeColor(baseColor, state.themeName === 'dark' ? 30 : 20);
367+
ctx.fillStyle = renderedFill;
366368
ctx.globalAlpha = Math.max(opacity, 0.8);
367369
} else if (isHovered) {
368-
ctx.fillStyle = shadeColor(baseColor, state.themeName === 'dark' ? 20 : 20);
370+
renderedFill = shadeColor(baseColor, state.themeName === 'dark' ? 20 : 20);
371+
ctx.fillStyle = renderedFill;
369372
} else if (isInput) {
370-
ctx.fillStyle = shadeColor(baseColor, state.themeName === 'dark' ? 10 : 10);
373+
renderedFill = shadeColor(baseColor, state.themeName === 'dark' ? 10 : 10);
374+
ctx.fillStyle = renderedFill;
371375
} else if (isOutput) {
372-
ctx.fillStyle = shadeColor(baseColor, state.themeName === 'dark' ? 10 : 10);
376+
renderedFill = shadeColor(baseColor, state.themeName === 'dark' ? 10 : 10);
377+
ctx.fillStyle = renderedFill;
373378
} else {
374-
ctx.fillStyle = baseColor;
379+
renderedFill = baseColor;
380+
ctx.fillStyle = renderedFill;
375381
}
376382

377383
ctx.fillRect(node.x - node.width/2, node.y - node.height/2, node.width, node.height);
@@ -392,7 +398,9 @@ class CanvasRenderer {
392398
ctx.setLineDash([]);
393399
}
394400

395-
ctx.fillStyle = theme.text;
401+
ctx.fillStyle = node.fill_color
402+
? (fxReadableTextColor(renderedFill) || theme.text)
403+
: theme.text;
396404
let allLines = [node.label || node.id];
397405
if (node.label_append && node.label_append.length > 0) {
398406
allLines = allLines.concat(node.label_append);

devtools/fx_viewer/templates/runtime.js

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,21 @@ function fxEsc(s) {
1818
return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;').replace(/'/g, '&#39;');
1919
}
2020

21+
// Pick a readable ink for a given background hex. Uses WCAG 2.x relative
22+
// luminance and returns one of two standard inks — dark #111111 or light
23+
// #f8f8f8 — whichever has higher contrast against the background. Returns
24+
// null for malformed input so callers can fall back to theme defaults.
25+
function fxReadableTextColor(hex) {
26+
if (typeof hex !== 'string' || hex.charAt(0) !== '#' || hex.length !== 7) return null;
27+
const r = parseInt(hex.substring(1, 3), 16) / 255;
28+
const g = parseInt(hex.substring(3, 5), 16) / 255;
29+
const b = parseInt(hex.substring(5, 7), 16) / 255;
30+
if (!isFinite(r) || !isFinite(g) || !isFinite(b)) return null;
31+
const lin = (c) => (c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4));
32+
const L = 0.2126 * lin(r) + 0.7152 * lin(g) + 0.0722 * lin(b);
33+
return L > 0.179 ? '#111111' : '#f8f8f8';
34+
}
35+
2136
const THEMES = {
2237
'light': {
2338
bg: '#ffffff',

devtools/observatory/lenses/per_layer_accuracy.py

Lines changed: 129 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,12 @@ def run_node(self, n: torch.fx.Node) -> Any:
6868

6969

7070
class _MetricNumericColorRule(ColorRule):
71-
"""Numeric color rule with optional inverse severity direction."""
71+
"""Numeric color rule with optional inverse severity direction.
72+
73+
When ``fixed_range`` is supplied, the given ``(vmin, vmax)`` is used for
74+
normalization instead of computing it from ``nodes_data``. This lets the
75+
caller share a single color scale across multiple records.
76+
"""
7277

7378
def __init__(
7479
self,
@@ -77,11 +82,13 @@ def __init__(
7782
low_rgb: Tuple[int, int, int],
7883
high_rgb: Tuple[int, int, int],
7984
inverse: bool = False,
85+
fixed_range: Optional[Tuple[float, float]] = None,
8086
) -> None:
8187
super().__init__(attribute)
8288
self.low_rgb = low_rgb
8389
self.high_rgb = high_rgb
8490
self.inverse = inverse
91+
self.fixed_range = fixed_range
8592

8693
@staticmethod
8794
def _interp(low: int, high: int, ratio: float) -> int:
@@ -96,6 +103,21 @@ def _color(self, ratio: float) -> str:
96103
b = self._interp(lb, hb, ratio)
97104
return f"#{r:02x}{g:02x}{b:02x}"
98105

106+
def _resolve_range(self, vals: List[float]) -> Optional[Tuple[float, float]]:
107+
if self.fixed_range is not None:
108+
rmin, rmax = self.fixed_range
109+
if math.isfinite(rmin) and math.isfinite(rmax):
110+
vmin, vmax = float(rmin), float(rmax)
111+
if vmin == vmax:
112+
vmax = vmin + 1e-12
113+
return vmin, vmax
114+
if not vals:
115+
return None
116+
vmin, vmax = min(vals), max(vals)
117+
if vmin == vmax:
118+
vmax = vmin + 1e-12
119+
return vmin, vmax
120+
99121
def apply(self, nodes_data: dict) -> tuple[dict, list]:
100122
vals = []
101123
for data in nodes_data.values():
@@ -104,12 +126,11 @@ def apply(self, nodes_data: dict) -> tuple[dict, list]:
104126
fv = float(v)
105127
if math.isfinite(fv):
106128
vals.append(fv)
107-
if not vals:
108-
return {}, []
109129

110-
vmin, vmax = min(vals), max(vals)
111-
if vmin == vmax:
112-
vmax = vmin + 1e-12
130+
resolved = self._resolve_range(vals)
131+
if resolved is None:
132+
return {}, []
133+
vmin, vmax = resolved
113134

114135
node_colors = {}
115136
for node_id, data in nodes_data.items():
@@ -236,7 +257,10 @@ def _build_sparse_node_index(
236257

237258
@staticmethod
238259
def _resolve_dataset() -> Optional[List[Any]]:
239-
if isinstance(AccuracyLens._captured_dataset, list) and AccuracyLens._captured_dataset:
260+
if (
261+
isinstance(AccuracyLens._captured_dataset, list)
262+
and AccuracyLens._captured_dataset
263+
):
240264
return AccuracyLens._captured_dataset
241265
if (
242266
isinstance(PipelineGraphCollectorLens._last_calibration_dataset, list)
@@ -268,7 +292,10 @@ def _pick_sample_index(
268292
for metric_name in priority:
269293
idx = AccuracyLens._worst_indices.get(str(metric_name))
270294
if isinstance(idx, int):
271-
return min(max(idx, 0), len(dataset) - 1), f"accuracy.worst[{metric_name}]"
295+
return (
296+
min(max(idx, 0), len(dataset) - 1),
297+
f"accuracy.worst[{metric_name}]",
298+
)
272299

273300
return 0, "default(0)"
274301

@@ -302,7 +329,9 @@ def _capture_outputs(
302329
@staticmethod
303330
def _flatten_for_metric(value: Any) -> Tuple[Optional[torch.Tensor], str]:
304331
if isinstance(value, torch.Tensor):
305-
return value.detach().cpu().to(torch.float64).reshape(-1), str(tuple(value.shape))
332+
return value.detach().cpu().to(torch.float64).reshape(-1), str(
333+
tuple(value.shape)
334+
)
306335

307336
if isinstance(value, (tuple, list)):
308337
tensors = [
@@ -311,13 +340,22 @@ def _flatten_for_metric(value: Any) -> Tuple[Optional[torch.Tensor], str]:
311340
if isinstance(v, torch.Tensor)
312341
]
313342
if tensors:
314-
shape = "[" + ", ".join(
315-
str(tuple(v.shape)) for v in value if isinstance(v, torch.Tensor)
316-
) + "]"
343+
shape = (
344+
"["
345+
+ ", ".join(
346+
str(tuple(v.shape))
347+
for v in value
348+
if isinstance(v, torch.Tensor)
349+
)
350+
+ "]"
351+
)
317352
return torch.cat(tensors), shape
318353
scalars = [float(v) for v in value if isinstance(v, (int, float, bool))]
319354
if scalars:
320-
return torch.tensor(scalars, dtype=torch.float64), f"list(len={len(scalars)})"
355+
return (
356+
torch.tensor(scalars, dtype=torch.float64),
357+
f"list(len={len(scalars)})",
358+
)
321359
return None, "unsupported_sequence"
322360

323361
if isinstance(value, (int, float, bool)):
@@ -455,7 +493,10 @@ def observe(cls, artifact: Any, context: ObservationContext) -> Any:
455493
for key in matched_keys:
456494
anchor_ref = cls._anchor_sparse_index[key]
457495
target_ref = sparse_index[key]
458-
if anchor_ref.node_id not in anchor_outputs or target_ref.node_id not in target_outputs:
496+
if (
497+
anchor_ref.node_id not in anchor_outputs
498+
or target_ref.node_id not in target_outputs
499+
):
459500
continue
460501

461502
metrics = cls._compute_pair_metrics(
@@ -526,6 +567,8 @@ def _build_metric_extension(
526567
cls,
527568
rows: List[Dict[str, Any]],
528569
metric_name: str,
570+
*,
571+
fixed_range: Optional[Tuple[float, float]] = None,
529572
) -> GraphExtension:
530573
spec = cls._metric_specs().get(metric_name)
531574
if not spec:
@@ -583,13 +626,51 @@ def _tooltip_formatter(d: Dict[str, Any]) -> List[str]:
583626
low_rgb=(254, 224, 210),
584627
high_rgb=(165, 15, 21),
585628
inverse=bool(spec["inverse"]),
629+
fixed_range=fixed_range,
586630
)
587631
)
588632
return ext
589633

634+
@staticmethod
635+
def _aggregate_metric_ranges(
636+
records: List[RecordDigest],
637+
) -> Dict[str, List[float]]:
638+
"""Union (min, max) per metric across every record's rows.
639+
640+
Returned as list-of-floats for clean JSON round-tripping through
641+
``AnalysisResult.global_data``.
642+
"""
643+
pools: Dict[str, List[float]] = {
644+
metric: [] for metric in PerLayerAccuracyLens._metric_specs().keys()
645+
}
646+
for record in records:
647+
digest = record.data.get("per_layer_accuracy")
648+
if not isinstance(digest, dict):
649+
continue
650+
rows = digest.get("rows")
651+
if not isinstance(rows, list):
652+
continue
653+
for row in rows:
654+
for metric in pools:
655+
v = row.get(metric)
656+
if isinstance(v, (int, float)):
657+
fv = float(v)
658+
if math.isfinite(fv):
659+
pools[metric].append(fv)
660+
661+
ranges: Dict[str, List[float]] = {}
662+
for metric, vals in pools.items():
663+
if vals:
664+
ranges[metric] = [min(vals), max(vals)]
665+
return ranges
666+
590667
@staticmethod
591668
def analyze(records: List[RecordDigest], config: Dict[str, Any]) -> AnalysisResult:
592669
result = AnalysisResult()
670+
metric_ranges = PerLayerAccuracyLens._aggregate_metric_ranges(records)
671+
if metric_ranges:
672+
result.global_data["metric_ranges"] = metric_ranges
673+
593674
for record in records:
594675
digest = record.data.get("per_layer_accuracy")
595676
if not isinstance(digest, dict):
@@ -608,8 +689,10 @@ def analyze(records: List[RecordDigest], config: Dict[str, Any]) -> AnalysisResu
608689

609690
for metric_name in ("cosine_sim",):
610691
# TODO other options "psnr" "mse", "abs_err"
692+
r = metric_ranges.get(metric_name)
693+
fixed_range = (r[0], r[1]) if r else None
611694
metric_ext = PerLayerAccuracyLens._build_metric_extension(
612-
rows, metric_name
695+
rows, metric_name, fixed_range=fixed_range
613696
)
614697
analysis.add_graph_layer(metric_name, metric_ext)
615698

@@ -672,7 +755,9 @@ def _metric_cell_style(
672755

673756
@classmethod
674757
def _merged_metrics_table_html(
675-
cls, rows: Iterable[Dict[str, Any]]
758+
cls,
759+
rows: Iterable[Dict[str, Any]],
760+
metric_ranges: Optional[Dict[str, List[float]]] = None,
676761
) -> str:
677762
row_list = list(rows)
678763
if not row_list:
@@ -688,14 +773,20 @@ def _merged_metrics_table_html(
688773
)
689774
)
690775

691-
def _minmax(k: str) -> Tuple[float, float]:
692-
vals = [PerLayerAccuracyLens._safe_float(r.get(k, 0.0)) for r in row_list]
776+
def _range(k: str) -> Tuple[float, float]:
777+
if metric_ranges and k in metric_ranges:
778+
r = metric_ranges[k]
779+
if len(r) == 2:
780+
return (float(r[0]), float(r[1]))
781+
vals = [
782+
PerLayerAccuracyLens._safe_float(r.get(k, 0.0)) for r in row_list
783+
]
693784
return (min(vals), max(vals)) if vals else (0.0, 0.0)
694785

695-
psnr_min, psnr_max = _minmax("psnr")
696-
cos_min, cos_max = _minmax("cosine_sim")
697-
mse_min, mse_max = _minmax("mse")
698-
abs_min, abs_max = _minmax("abs_err")
786+
psnr_min, psnr_max = _range("psnr")
787+
cos_min, cos_max = _range("cosine_sim")
788+
mse_min, mse_max = _range("mse")
789+
abs_min, abs_max = _range("abs_err")
699790

700791
parts = [
701792
"<table class='pla-metric-table'>",
@@ -794,9 +885,20 @@ def record(
794885
return None
795886

796887
rows = digest.get("rows") if isinstance(digest.get("rows"), list) else []
797-
summary = digest.get("summary", {}) if isinstance(digest.get("summary"), dict) else {}
888+
summary = (
889+
digest.get("summary", {})
890+
if isinstance(digest.get("summary"), dict)
891+
else {}
892+
)
798893
graph_ref = str(digest.get("graph_ref") or context.get("name") or "")
799894
lens_name = PerLayerAccuracyLens.get_name()
895+
metric_ranges = None
896+
if isinstance(analysis, dict):
897+
raw = analysis.get("global")
898+
if isinstance(raw, dict):
899+
mr = raw.get("metric_ranges")
900+
if isinstance(mr, dict):
901+
metric_ranges = mr
800902

801903
summary_data = {
802904
"anchor_record": digest.get("anchor_record", "n/a"),
@@ -834,7 +936,7 @@ def record(
834936
id="per_layer_accuracy_metrics_table",
835937
title="Per-layer Metrics (Worst → Best)",
836938
record=HtmlRecordSpec(
837-
content=self._merged_metrics_table_html(rows)
939+
content=self._merged_metrics_table_html(rows, metric_ranges)
838940
),
839941
order=22,
840942
),
@@ -845,7 +947,9 @@ def check_badges(
845947
self, digest: Any, analysis: Dict[str, Any]
846948
) -> List[Dict[str, str]]:
847949
if isinstance(digest, dict) and int(digest.get("match_count", 0)) > 0:
848-
return [{"label": "PLA", "class": "badge", "title": "Per-layer accuracy"}]
950+
return [
951+
{"label": "PLA", "class": "badge", "title": "Per-layer accuracy"}
952+
]
849953
return []
850954

851955
@staticmethod

0 commit comments

Comments
 (0)