dvarfe commited on
Commit
2cecbb7
·
1 Parent(s): 0705c62

fix pages bug

Browse files
assets/examples CHANGED
@@ -1 +1 @@
1
- /home/28m_gov@lab.graphicon.ru/SAE/xIQA/assets/examples
 
1
+ /data/examples
assets/precomputed CHANGED
@@ -1 +1 @@
1
- /home/28m_gov@lab.graphicon.ru/SAE/xIQA/assets/precomputed
 
1
+ /data/precomputed
pages/__init__.py ADDED
File without changes
pages/feature_maps.py ADDED
@@ -0,0 +1,602 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+
5
+ import dash
6
+ import numpy as np
7
+ from dash import Input, Output, State, ALL, callback, clientside_callback, ctx, dcc, html, no_update
8
+
9
+ from dashboard.layout import page_back_nav
10
+ from dashboard.umap_service import (
11
+ DEFAULT_AGGREGATION_MODE,
12
+ FEATURE_UMAP_FILTERED_HELP,
13
+ FEATURE_UMAP_GARBAGE_HELP,
14
+ UMAP_DATASET,
15
+ build_features_umap,
16
+ empty_umap_figure,
17
+ format_feature_umap_status,
18
+ )
19
+ from analysis.viz.vis_heatmaps_plotly import (
20
+ TOGGLE_HEATMAP_OVERLAY_CLIENTSIDE,
21
+ empty_heatmap_figure,
22
+ heatmap_overlay_checklist_props,
23
+ set_heatmap_overlay_visible,
24
+ )
25
+ from dashboard.image_utils import overlay_show_heatmap_enabled
26
+ from dashboard.user_image_service import (
27
+ FeatureMapRequest,
28
+ list_baseline_examples,
29
+ list_baseline_example_records,
30
+ render_feature_overlay,
31
+ )
32
+ from analysis.viz.umap_plot import apply_feature_umap_highlight
33
+ from dashboard.viz_helpers import (
34
+ UMAP_TOOLTIP_CLASSNAME,
35
+ UMAP_TOOLTIP_ZINDEX,
36
+ build_feature_detail_content,
37
+ build_feature_umap_hover_tooltip,
38
+ umap_tooltip_clientside,
39
+ visualization_params,
40
+ )
41
+
42
+ HEATMAP_GRAPH_CONFIG = {
43
+ "displayModeBar": False,
44
+ "responsive": False,
45
+ "scrollZoom": False,
46
+ "doubleClick": "reset",
47
+ }
48
+ UMAP_GRAPH_CONFIG = {
49
+ "displayModeBar": False,
50
+ "responsive": True,
51
+ }
52
+
53
+
54
+ ROOT = Path(__file__).resolve().parents[1]
55
+
56
+ dash.register_page(__name__, path="/feature-maps", name="Feature maps", title="Feature maps")
57
+
58
+
59
+ def _example_label(path: str) -> str:
60
+ try:
61
+ return Path(path).name
62
+ except Exception:
63
+ return str(path)
64
+
65
+
66
+ def _help_desc_line(label: str, description: str) -> html.Div:
67
+ return html.Div(
68
+ [html.Strong(label), html.Span(f" — {description}", className="help-desc")],
69
+ className="help-desc-line",
70
+ )
71
+
72
+
73
+ def _fm_features_umap_help() -> html.Div:
74
+ return html.Div(
75
+ [
76
+ html.Span("?", className="upload-help viz-sae-help-trigger", **{"aria-label": "Features UMAP help"}),
77
+ html.Div(
78
+ [
79
+ html.Div("Features UMAP", className="viz-help-popover-title"),
80
+ html.Div(
81
+ "Each point is one SAE feature (decoder atom) projected to 2D (UMAP). "
82
+ "Click a point to select that feature id.",
83
+ className="viz-help-popover-lead",
84
+ ),
85
+ html.Div(
86
+ "Point color shows the distortion group with the strongest patch-level |correlation|.",
87
+ className="viz-help-popover-lead",
88
+ ),
89
+ _help_desc_line("filtered", FEATURE_UMAP_FILTERED_HELP),
90
+ _help_desc_line("garbage", FEATURE_UMAP_GARBAGE_HELP),
91
+ html.Div(
92
+ "filtered and garbage points are hidden by default; click their legend entries to show them.",
93
+ className="viz-help-popover-lead",
94
+ ),
95
+ html.Div(
96
+ "Hover a point for a quick summary; click to select the feature.",
97
+ className="viz-help-popover-lead",
98
+ ),
99
+ ],
100
+ className="viz-sae-help-popover viz-umap-help-popover",
101
+ ),
102
+ ],
103
+ className="viz-sae-help-wrap viz-umap-help-wrap",
104
+ )
105
+
106
+
107
+ layout = html.Div(
108
+ [
109
+ page_back_nav(),
110
+ html.Div(
111
+ [
112
+ html.Div("xIQA model browser", className="app-kicker"),
113
+ html.H1("Feature maps", className="app-title"),
114
+ ],
115
+ className="hero-card",
116
+ ),
117
+ html.Div(
118
+ [
119
+ html.Div(
120
+ [
121
+ html.Div("Input image", className="panel-label"),
122
+ html.Div(
123
+ [
124
+ html.Div("Baseline examples", className="section-label"),
125
+ html.Div(id="fm-baseline-grid", className="baseline-grid", children=[]),
126
+ html.Div(
127
+ "No baseline images found yet. Add files under assets/examples/<model_key>/",
128
+ id="fm-baseline-hint",
129
+ className="mock-summary-meta",
130
+ style={"marginTop": "8px"},
131
+ ),
132
+ ],
133
+ className="form-block",
134
+ ),
135
+ html.Div(
136
+ [
137
+ html.Div("Upload your image", className="section-label"),
138
+ dcc.Upload(
139
+ id="fm-upload",
140
+ children=html.Div(
141
+ [
142
+ html.Span(
143
+ "?",
144
+ className="upload-help",
145
+ **{
146
+ "aria-label": "Oversized images are center-cropped to 512 by 512 before processing.",
147
+ "data-tooltip": "Oversized images are center-cropped to 512×512 before processing.",
148
+ },
149
+ ),
150
+ html.Div(["Drag and drop or ", html.A("select an image")]),
151
+ ]
152
+ ),
153
+ accept="image/png,image/jpeg,image/jpg",
154
+ multiple=False,
155
+ className="upload-card",
156
+ ),
157
+ html.Div(
158
+ id="fm-upload-filename",
159
+ className="mock-summary-meta",
160
+ style={"marginTop": "8px"},
161
+ ),
162
+ ],
163
+ className="form-block",
164
+ ),
165
+ ],
166
+ className="mock-card",
167
+ ),
168
+ html.Div(
169
+ [
170
+ html.Div("Features UMAP", className="panel-label"),
171
+ _fm_features_umap_help(),
172
+ html.Div(
173
+ [
174
+ html.Div(id="fm-umap-status", className="umap-status"),
175
+ dcc.Store(id="fm-umap-cache", data=None),
176
+ dcc.Loading(
177
+ id="fm-umap-graph-loading",
178
+ type="circle",
179
+ color="#2563eb",
180
+ className="loading-block umap-graph-loading",
181
+ children=dcc.Graph(
182
+ id="fm-umap-graph",
183
+ figure=empty_umap_figure("Loading feature UMAP..."),
184
+ clear_on_unhover=True,
185
+ config=UMAP_GRAPH_CONFIG,
186
+ className="umap-graph",
187
+ style={"height": "min(48vh, 520px)", "minHeight": "360px"},
188
+ ),
189
+ ),
190
+ dcc.Tooltip(
191
+ id="fm-umap-tooltip",
192
+ className=UMAP_TOOLTIP_CLASSNAME,
193
+ zindex=UMAP_TOOLTIP_ZINDEX,
194
+ direction="right",
195
+ ),
196
+ ],
197
+ className="umap-hover-shell",
198
+ ),
199
+ ],
200
+ className="placeholder-card viz-umap-card",
201
+ ),
202
+ html.Div(
203
+ [
204
+ html.Div("Overlay controls", className="panel-label"),
205
+ html.Div(
206
+ [
207
+ html.Div("Feature id", className="section-label"),
208
+ dcc.Input(
209
+ id="fm-feature-id",
210
+ type="number",
211
+ min=0,
212
+ step=1,
213
+ value=0,
214
+ debounce=True,
215
+ className="feature-jump-input",
216
+ ),
217
+ html.Div(
218
+ dcc.Loading(
219
+ id="fm-feature-detail-loading",
220
+ type="default",
221
+ color="#2563eb",
222
+ className="loading-block feature-detail-body-loading",
223
+ children=html.Div(
224
+ id="fm-feature-detail-body",
225
+ className="feature-detail-card",
226
+ children=[
227
+ html.Div(
228
+ "Enter a feature id to see statistics.",
229
+ className="feature-empty",
230
+ ),
231
+ ],
232
+ ),
233
+ ),
234
+ style={"marginTop": "16px"},
235
+ ),
236
+ html.Div(
237
+ id="fm-status",
238
+ className="home-status",
239
+ style={"marginTop": "12px"},
240
+ ),
241
+ dcc.Checklist(
242
+ id="fm-show-heatmap-overlay",
243
+ style={"marginTop": "12px"},
244
+ **heatmap_overlay_checklist_props(),
245
+ ),
246
+ dcc.Store(id="fm-overlay-cache", data=None),
247
+ dcc.Loading(
248
+ id="fm-output-loading",
249
+ type="circle",
250
+ color="#2563eb",
251
+ className="loading-block",
252
+ children=dcc.Graph(
253
+ id="fm-overlay-graph",
254
+ figure=empty_heatmap_figure("Select an image and feature id."),
255
+ config=HEATMAP_GRAPH_CONFIG,
256
+ className="heatmap-graph fm-overlay-graph",
257
+ ),
258
+ ),
259
+ ],
260
+ className="mock-placeholder",
261
+ ),
262
+ ],
263
+ className="placeholder-card",
264
+ ),
265
+ ],
266
+ # Stack the input card above the overlay controls
267
+ className="mock-grid mock-grid-vertical",
268
+ ),
269
+ dcc.Store(id="fm-baseline-records", data=None),
270
+ dcc.Store(id="fm-selected-baseline-path", data=None),
271
+ ],
272
+ className="app-shell",
273
+ )
274
+
275
+
276
+ @callback(
277
+ Output("fm-baseline-records", "data"),
278
+ Output("fm-baseline-grid", "children"),
279
+ Output("fm-baseline-hint", "style"),
280
+ Input("_pages_location", "search"),
281
+ )
282
+ def init_baselines(search: str | None):
283
+ params = visualization_params(search)
284
+ if params is None:
285
+ return None, [], {"display": "block"}
286
+ metric, dataset, model_key = params
287
+ if not model_key:
288
+ return None, [], {"display": "block"}
289
+
290
+ records = list_baseline_example_records(model_key=model_key)
291
+
292
+ thumbs = []
293
+ for rec in records:
294
+ path = str(rec.get("path", ""))
295
+ url = str(rec.get("url", ""))
296
+ label = str(rec.get("label", ""))
297
+ thumbs.append(
298
+ html.Button(
299
+ [
300
+ html.Img(src=url, alt=label, className="baseline-thumb-img"),
301
+ html.Div(
302
+ [
303
+ html.Div(label, className="baseline-thumb-label"),
304
+ ],
305
+ className="baseline-thumb-meta",
306
+ ),
307
+ ],
308
+ id={"type": "fm-baseline-thumb", "path": path},
309
+ n_clicks=0,
310
+ className="baseline-thumb",
311
+ )
312
+ )
313
+
314
+ hint_style = {"display": "none"} if records else {"display": "block"}
315
+ return records, thumbs, hint_style
316
+
317
+
318
+ @callback(
319
+ Output("fm-selected-baseline-path", "data"),
320
+ Output("fm-feature-id", "value"),
321
+ Output("fm-upload", "contents"),
322
+ Output("fm-upload", "filename"),
323
+ Input({"type": "fm-baseline-thumb", "path": ALL}, "n_clicks"),
324
+ State("fm-baseline-records", "data"),
325
+ State("fm-feature-id", "value"),
326
+ prevent_initial_call=True,
327
+ )
328
+ def select_baseline_from_thumb(_clicks, records, current_feature_id):
329
+ triggered = ctx.triggered_id
330
+ if not isinstance(triggered, dict) or triggered.get("type") != "fm-baseline-thumb":
331
+ return no_update, no_update, no_update, no_update
332
+ selected_path = str(triggered.get("path", ""))
333
+ if not selected_path:
334
+ return no_update, no_update, no_update, no_update
335
+
336
+ default_fid = None
337
+ if isinstance(records, list):
338
+ for rec in records:
339
+ if str(rec.get("path", "")) == selected_path:
340
+ default_fid = rec.get("default_feature_id", None)
341
+ break
342
+
343
+ if default_fid is None:
344
+ return selected_path, no_update, None, None
345
+ try:
346
+ return selected_path, int(default_fid), None, None
347
+ except Exception:
348
+ return selected_path, no_update, None, None
349
+
350
+
351
+ @callback(
352
+ Output("fm-umap-cache", "data"),
353
+ Input("_pages_location", "search"),
354
+ running=[
355
+ (Output("fm-umap-status", "className"), "umap-status is-loading", "umap-status"),
356
+ (Output("fm-umap-status", "children"), "Loading feature UMAP embeddings…", ""),
357
+ ],
358
+ )
359
+ def preload_fm_umap(search: str | None):
360
+ params = visualization_params(search)
361
+ if params is None:
362
+ return None
363
+
364
+ metric, _selection_dataset, model_key = params
365
+ if not model_key:
366
+ return None
367
+
368
+ try:
369
+ features_fig, features_info = build_features_umap(
370
+ metric,
371
+ model_key,
372
+ UMAP_DATASET,
373
+ DEFAULT_AGGREGATION_MODE,
374
+ hide_filtered_garbage=True,
375
+ )
376
+ return {
377
+ "figure": features_fig.to_plotly_json(),
378
+ "status": format_feature_umap_status(features_info, "group"),
379
+ }
380
+ except Exception as exc:
381
+ return {
382
+ "figure": empty_umap_figure(f"Feature UMAP unavailable: {exc}").to_plotly_json(),
383
+ "status": f"Feature UMAP unavailable: {exc}",
384
+ }
385
+
386
+
387
+ def _parse_feature_id(value) -> int | None:
388
+ if value is None:
389
+ return None
390
+ try:
391
+ return int(value)
392
+ except (TypeError, ValueError):
393
+ return None
394
+
395
+
396
+ @callback(
397
+ Output("fm-umap-graph", "figure"),
398
+ Output("fm-umap-status", "children", allow_duplicate=True),
399
+ Output("fm-umap-status", "className", allow_duplicate=True),
400
+ Input("fm-umap-cache", "data"),
401
+ Input("fm-feature-id", "value"),
402
+ prevent_initial_call=True,
403
+ )
404
+ def display_fm_umap(cache, feature_id_value):
405
+ if not cache:
406
+ return (
407
+ empty_umap_figure("Loading feature UMAP...").to_plotly_json(),
408
+ "Loading feature UMAP embeddings…",
409
+ "umap-status is-loading",
410
+ )
411
+ figure = cache.get("figure")
412
+ if not figure:
413
+ message = cache.get("status", "Feature UMAP unavailable.")
414
+ return empty_umap_figure(message).to_plotly_json(), message, "umap-status"
415
+ highlighted = apply_feature_umap_highlight(figure, _parse_feature_id(feature_id_value))
416
+ return highlighted, cache.get("status", ""), "umap-status"
417
+
418
+
419
+ def _feature_id_from_umap_point(point: dict) -> int | None:
420
+ customdata = point.get("customdata")
421
+ if not isinstance(customdata, (list, tuple, np.ndarray)) or len(customdata) < 1:
422
+ return None
423
+ try:
424
+ return int(customdata[0])
425
+ except (TypeError, ValueError):
426
+ return None
427
+
428
+
429
+ clientside_callback(
430
+ umap_tooltip_clientside("fm-umap-graph"),
431
+ Output("fm-umap-tooltip", "show"),
432
+ Output("fm-umap-tooltip", "bbox"),
433
+ Output("fm-umap-tooltip", "direction"),
434
+ Input("fm-umap-graph", "hoverData"),
435
+ )
436
+
437
+
438
+ @callback(
439
+ Output("fm-umap-tooltip", "children"),
440
+ Input("fm-umap-graph", "hoverData"),
441
+ )
442
+ def display_fm_umap_tooltip(hover_data):
443
+ if not hover_data:
444
+ return no_update
445
+
446
+ point = hover_data["points"][0]
447
+ customdata = point.get("customdata")
448
+ if not isinstance(customdata, (list, tuple, np.ndarray)) or len(customdata) < 4:
449
+ return no_update
450
+
451
+ try:
452
+ feature_id = int(customdata[0])
453
+ mean_val = float(customdata[1])
454
+ std_val = float(customdata[2])
455
+ nonzero = float(customdata[3])
456
+ except (TypeError, ValueError):
457
+ return no_update
458
+
459
+ corr_label = str(point.get("fullData", {}).get("name", ""))
460
+ return build_feature_umap_hover_tooltip(
461
+ feature_id,
462
+ mean_val,
463
+ std_val,
464
+ nonzero,
465
+ corr_label,
466
+ )
467
+
468
+
469
+ @callback(
470
+ Output("fm-feature-id", "value", allow_duplicate=True),
471
+ Input("fm-umap-graph", "clickData"),
472
+ prevent_initial_call=True,
473
+ )
474
+ def select_feature_from_umap(click_data):
475
+ if not click_data:
476
+ return no_update
477
+
478
+ feature_id = _feature_id_from_umap_point(click_data["points"][0])
479
+ if feature_id is None:
480
+ return no_update
481
+ return feature_id
482
+
483
+
484
+ @callback(
485
+ Output("fm-upload-filename", "children"),
486
+ Input("fm-upload", "filename"),
487
+ )
488
+ def show_upload_filename(filename: str | None):
489
+ if not filename:
490
+ return ""
491
+ return f"Selected upload: {filename}"
492
+
493
+
494
+ @callback(
495
+ Output("fm-overlay-cache", "data"),
496
+ Output("fm-status", "children"),
497
+ Input("_pages_location", "search"),
498
+ Input("fm-selected-baseline-path", "data"),
499
+ Input("fm-upload", "contents"),
500
+ Input("fm-upload", "filename"),
501
+ Input("fm-feature-id", "value"),
502
+ State("fm-baseline-records", "data"),
503
+ prevent_initial_call=False,
504
+ )
505
+ def load_fm_overlay_cache(
506
+ search: str | None,
507
+ baseline_path: str | None,
508
+ upload_contents: str | None,
509
+ upload_filename: str | None,
510
+ feature_id_value,
511
+ baseline_records,
512
+ ):
513
+ params = visualization_params(search)
514
+ if params is None:
515
+ return None, "Missing page parameters."
516
+ metric, dataset, model_key = params
517
+ if not model_key:
518
+ return None, "Select a model on the Home page first."
519
+
520
+ # Decide source image
521
+ image_path = None
522
+ if baseline_path:
523
+ image_path = str(baseline_path)
524
+ elif upload_contents:
525
+ image_path = None # handled by service using upload_contents
526
+ else:
527
+ # Default to first baseline if available
528
+ if isinstance(baseline_records, list) and baseline_records:
529
+ image_path = str(baseline_records[0].get("path", ""))
530
+ baseline_path = image_path
531
+
532
+ try:
533
+ feature_id = int(feature_id_value) if feature_id_value is not None else 0
534
+ except Exception:
535
+ feature_id = 0
536
+
537
+ if not upload_contents and not image_path:
538
+ return None, "Choose a baseline image or upload your own."
539
+
540
+ request = FeatureMapRequest(
541
+ metric=str(metric),
542
+ dataset=str(dataset),
543
+ model_key=str(model_key),
544
+ feature_id=int(feature_id),
545
+ baseline_image_path=str(image_path) if image_path is not None and not upload_contents else None,
546
+ upload_contents=upload_contents,
547
+ upload_filename=upload_filename,
548
+ )
549
+ try:
550
+ overlay_figure = render_feature_overlay(request)
551
+ except Exception as exc:
552
+ return None, f"Failed to render overlay: {exc}"
553
+
554
+ if not overlay_figure:
555
+ return None, "Overlay unavailable."
556
+ return overlay_figure, ""
557
+
558
+
559
+ @callback(
560
+ Output("fm-overlay-graph", "figure"),
561
+ Input("fm-overlay-cache", "data"),
562
+ State("fm-show-heatmap-overlay", "value"),
563
+ )
564
+ def render_fm_overlay_figure(cache, show_heatmap_value):
565
+ if not isinstance(cache, dict):
566
+ return empty_heatmap_figure("Select an image and feature id.").to_plotly_json()
567
+ return set_heatmap_overlay_visible(cache, overlay_show_heatmap_enabled(show_heatmap_value))
568
+
569
+
570
+ clientside_callback(
571
+ TOGGLE_HEATMAP_OVERLAY_CLIENTSIDE,
572
+ Output("fm-overlay-graph", "figure", allow_duplicate=True),
573
+ Input("fm-show-heatmap-overlay", "value"),
574
+ State("fm-overlay-graph", "figure"),
575
+ prevent_initial_call=True,
576
+ )
577
+
578
+
579
+ @callback(
580
+ Output("fm-feature-detail-body", "children"),
581
+ Input("_pages_location", "search"),
582
+ Input("fm-feature-id", "value"),
583
+ )
584
+ def render_fm_feature_detail(search: str | None, feature_id_value):
585
+ params = visualization_params(search)
586
+ if params is None:
587
+ return html.Div("Missing page parameters.", className="feature-empty")
588
+
589
+ metric, dataset, model_key = params
590
+ if not model_key:
591
+ return html.Div("Select a model on the Home page first.", className="feature-empty")
592
+
593
+ try:
594
+ feature_id = int(feature_id_value) if feature_id_value is not None else None
595
+ except (TypeError, ValueError):
596
+ return html.Div("Enter a valid feature id.", className="feature-empty")
597
+
598
+ if feature_id is None:
599
+ return html.Div("Enter a feature id to see statistics.", className="feature-empty")
600
+
601
+ return build_feature_detail_content(metric, dataset, model_key, feature_id)
602
+
pages/home.py ADDED
@@ -0,0 +1,214 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from urllib.parse import urlencode
4
+
5
+ import dash
6
+ from dash import Input, Output, State, callback, dcc, html, no_update
7
+
8
+ from dashboard.model_catalog import (
9
+ METRIC_DESCRIPTIONS,
10
+ MODEL_FAMILIES,
11
+ build_model_option_label,
12
+ discover_dashboard_dataset_options,
13
+ discover_dashboard_datasets,
14
+ discover_models_for_metric,
15
+ )
16
+
17
+ dash.register_page(__name__, path="/", name="Home", title="xIQA Dash Browser")
18
+
19
+ # Paste your git README URL (Docker deployment section) here.
20
+ DOCKER_DEPLOY_README_URL = "https://github.com/dvarfe/QualityLens"
21
+
22
+ initial_metric = MODEL_FAMILIES[0]
23
+ initial_records = discover_models_for_metric(initial_metric)
24
+ initial_options = [{"label": build_model_option_label(r), "value": r.model_key} for r in initial_records]
25
+
26
+
27
+ def _metric_description_content(metric: str | None):
28
+ if not metric:
29
+ return ""
30
+ key = metric.strip().upper()
31
+ paragraphs = METRIC_DESCRIPTIONS.get(key)
32
+ if not paragraphs:
33
+ return ""
34
+ return html.Div(
35
+ [
36
+ html.Div(key, className="metric-desc-name"),
37
+ *[html.P(text, className="metric-desc-text") for text in paragraphs],
38
+ ],
39
+ className="metric-desc-block",
40
+ )
41
+
42
+
43
+ def _deployment_warning() -> html.Div:
44
+ link = str(DOCKER_DEPLOY_README_URL or "").strip()
45
+ deploy_ref = (
46
+ html.A(
47
+ "Docker deployment",
48
+ href=link,
49
+ target="_blank",
50
+ rel="noopener noreferrer",
51
+ className="home-deploy-warning-link",
52
+ )
53
+ if link
54
+ else html.Span("Docker deployment", className="home-deploy-warning-em")
55
+ )
56
+ return html.Div(
57
+ [
58
+ html.Div("Hosting notice", className="home-deploy-warning-title"),
59
+ html.P(
60
+ [
61
+ "This Hugging Face Spaces demo can be slow or unstable (cold starts, memory limits, timeouts). ",
62
+ "For reliable use we recommend ",
63
+ deploy_ref,
64
+ " — see the repository README for setup instructions.",
65
+ ],
66
+ className="home-deploy-warning-text",
67
+ ),
68
+ ],
69
+ className="home-deploy-warning",
70
+ role="note",
71
+ )
72
+
73
+
74
+ layout = html.Div(
75
+ [
76
+ _deployment_warning(),
77
+ html.Div(
78
+ [
79
+ html.Div("xIQA model browser", className="app-kicker"),
80
+ html.H1("Choose a trained model and dataset", className="app-title"),
81
+ ],
82
+ className="hero-card",
83
+ ),
84
+ html.Div(
85
+ [
86
+ html.Div(
87
+ [
88
+ html.Div(
89
+ [
90
+ html.Label("1. Metric family", className="section-label"),
91
+ dcc.RadioItems(
92
+ id="metric-choice",
93
+ options=[{"label": m, "value": m} for m in MODEL_FAMILIES],
94
+ value=MODEL_FAMILIES[0],
95
+ className="metric-switch",
96
+ labelClassName="metric-pill",
97
+ inputClassName="metric-input",
98
+ ),
99
+ html.Div(
100
+ _metric_description_content(initial_metric),
101
+ id="metric-description",
102
+ className="metric-descriptions",
103
+ ),
104
+ ],
105
+ className="form-block",
106
+ ),
107
+ html.Div(
108
+ [
109
+ html.Label("2. Dataset", className="section-label"),
110
+ dcc.Dropdown(
111
+ id="dataset-choice",
112
+ options=discover_dashboard_dataset_options(),
113
+ value=discover_dashboard_datasets()[0],
114
+ clearable=False,
115
+ className="dash-dropdown",
116
+ ),
117
+ ],
118
+ className="form-block",
119
+ ),
120
+ html.Div(
121
+ [
122
+ html.Label("3. Trained model", className="section-label"),
123
+ dcc.Dropdown(
124
+ id="model-choice",
125
+ options=initial_options,
126
+ value=initial_options[0]["value"] if initial_options else None,
127
+ clearable=False,
128
+ placeholder="Select a metric first",
129
+ disabled=False,
130
+ persistence=False,
131
+ className="dash-dropdown model-dropdown",
132
+ ),
133
+ ],
134
+ className="form-block",
135
+ ),
136
+ html.Div(
137
+ dcc.Loading(
138
+ id="home-nav-loading",
139
+ type="circle",
140
+ color="#2563eb",
141
+ className="loading-block home-nav-loading",
142
+ children=html.Div(
143
+ [
144
+ dcc.Link(
145
+ "SAE explorer",
146
+ id="home-page-1-link",
147
+ href="",
148
+ className="confirm-button",
149
+ ),
150
+ dcc.Link(
151
+ "Run on image",
152
+ id="home-page-2-link",
153
+ href="",
154
+ className="confirm-button",
155
+ style={"marginLeft": "10px"},
156
+ ),
157
+ html.Div(id="home-status", className="home-status"),
158
+ ],
159
+ className="action-row",
160
+ ),
161
+ ),
162
+ ),
163
+ ],
164
+ className="form-card",
165
+ ),
166
+ ]
167
+ ),
168
+ ],
169
+ className="app-shell",
170
+ )
171
+
172
+
173
+ @callback(
174
+ Output("metric-description", "children"),
175
+ Input("metric-choice", "value"),
176
+ )
177
+ def update_metric_description(metric: str | None):
178
+ return _metric_description_content(metric)
179
+
180
+
181
+ @callback(
182
+ Output("model-choice", "options"),
183
+ Output("model-choice", "value"),
184
+ Input("metric-choice", "value"),
185
+ )
186
+ def load_models(metric_value: str | None):
187
+ if not metric_value:
188
+ return [], None
189
+
190
+ metric_value = metric_value.strip().upper()
191
+ records = discover_models_for_metric(metric_value)
192
+ if not records:
193
+ return [], None
194
+
195
+ options = [
196
+ {"label": build_model_option_label(r), "value": r.model_key}
197
+ for r in records
198
+ ]
199
+ return options, records[0].model_key
200
+
201
+
202
+ @callback(
203
+ Output("home-page-1-link", "href"),
204
+ Output("home-page-2-link", "href"),
205
+ Output("home-status", "children"),
206
+ Input("metric-choice", "value"),
207
+ Input("dataset-choice", "value"),
208
+ Input("model-choice", "value"),
209
+ )
210
+ def update_nav_links(metric: str | None, dataset: str | None, model: str | None):
211
+ if not model:
212
+ return "", "", "Please select a model first."
213
+ params = urlencode({"metric": metric, "dataset": dataset, "model": model})
214
+ return f"/visualizations?{params}", f"/feature-maps?{params}", ""
pages/visualizations.py ADDED
@@ -0,0 +1,1120 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+
5
+ import dash
6
+ import numpy as np
7
+ from dash import Input, Output, State, callback, clientside_callback, ctx, dcc, html, no_update
8
+
9
+ from analysis.viz.umap_plot import apply_feature_umap_highlight
10
+ from analysis.viz.vis_heatmaps_plotly import (
11
+ TOGGLE_HEATMAP_OVERLAY_CLIENTSIDE,
12
+ empty_heatmap_figure,
13
+ heatmap_overlay_checklist_props,
14
+ set_heatmap_overlay_visible,
15
+ )
16
+ from dashboard.image_utils import get_top_feature_overlays, overlay_show_heatmap_enabled
17
+ from dashboard.layout import page_back_nav
18
+ from dashboard.model_catalog import (
19
+ dashboard_dataset_label,
20
+ get_model_record,
21
+ summarize_model_record,
22
+ summarize_selector_cache,
23
+ )
24
+ from dashboard.umap_service import (
25
+ AGGREGATION_MODE_LABELS,
26
+ AGGREGATION_MODES,
27
+ DEFAULT_AGGREGATION_MODE,
28
+ DEFAULT_COLOR_MODE,
29
+ FEATURE_COLOR_MODE,
30
+ FEATURE_UMAP_FILTERED_HELP,
31
+ FEATURE_UMAP_GARBAGE_HELP,
32
+ IMAGE_COLOR_MODE_LABELS,
33
+ IMAGE_COLOR_MODES,
34
+ THUMB_SIZE,
35
+ UMAP_DATASET,
36
+ build_features_umap,
37
+ build_images_umap,
38
+ empty_umap_figure,
39
+ format_feature_umap_status,
40
+ format_umap_status,
41
+ )
42
+ from dashboard.viz_helpers import (
43
+ UMAP_TOOLTIP_CLASSNAME,
44
+ UMAP_TOOLTIP_ZINDEX,
45
+ build_feature_detail_content,
46
+ build_feature_umap_hover_tooltip,
47
+ cached_hover_meta_line,
48
+ umap_tooltip_clientside,
49
+ cached_hover_thumb,
50
+ feature_empty_state,
51
+ feature_ids,
52
+ feature_rows_for_selector,
53
+ feature_unavailable_message,
54
+ hover_image_idx,
55
+ select_feature_id,
56
+ selector_option_label,
57
+ visualization_params,
58
+ )
59
+
60
+ ROOT = Path(__file__).resolve().parents[1]
61
+ FEATURE_TOP_IMAGE_SLOTS = 1
62
+ FEATURE_TOP_IMAGE_TOP_N = 5
63
+ HEATMAP_GRAPH_CONFIG = {
64
+ "displayModeBar": False,
65
+ "responsive": True,
66
+ "scrollZoom": False,
67
+ "doubleClick": "reset+autosize",
68
+ }
69
+
70
+ dash.register_page(__name__, path="/visualizations", name="Visualizations", title="SAE Explorer")
71
+
72
+
73
+ def _umap_images_cache_key(
74
+ metric: str,
75
+ model_key: str,
76
+ aggregation_mode: str,
77
+ color_mode: str,
78
+ selected_category: str | None,
79
+ ) -> str:
80
+ category = "" if selected_category in {None, ""} else str(selected_category)
81
+ return f"{metric}:{model_key}:{aggregation_mode}:{color_mode}:{category}"
82
+
83
+
84
+ _HYPERPARAM_HELP_ROWS: tuple[tuple[str, str], ...] = (
85
+ ("layer_num", "IQA layer used as SAE input"),
86
+ ("swin_num", "Swin stage (MANIQA only)"),
87
+ # ("liqe_variant", "LIQE backbone variant"),
88
+ ("lambda_param", "sparsity penalty in the SAE loss"),
89
+ ("scaling_factor", "activation scaling before the SAE"),
90
+ ("n_epochs", "number of training epochs"),
91
+ ("sae_type", "sparse autoencoder architecture(sae - standard ReLU SAE, mp_sae - Matching Pursuit SAE)"),
92
+ ("l0_loss", "mean active SAE features per validation image"),
93
+ )
94
+
95
+
96
+ def _label_desc_line(label: str, description: str, *, tag: str = "div") -> html.Div | html.Li:
97
+ line = [
98
+ html.Strong(label),
99
+ html.Span(f" — {description}", className="help-desc"),
100
+ ]
101
+ if tag == "li":
102
+ return html.Li(line)
103
+ return html.Div(line, className="help-desc-line")
104
+
105
+
106
+ def _sae_hyperparams_help() -> html.Div:
107
+ return html.Div(
108
+ [
109
+ html.Span("?", className="upload-help viz-sae-help-trigger", **{"aria-label": "SAE hyperparameters help"}),
110
+ html.Div(
111
+ [
112
+ html.Div("Hyperparameters (from training logs):", className="viz-help-popover-title"),
113
+ *[
114
+ _label_desc_line(label, desc)
115
+ for label, desc in _HYPERPARAM_HELP_ROWS
116
+ ],
117
+ ],
118
+ className="viz-sae-help-popover",
119
+ ),
120
+ ],
121
+ className="viz-sae-help-wrap",
122
+ )
123
+
124
+
125
+ def _umap_help() -> html.Div:
126
+ return html.Div(
127
+ [
128
+ html.Span("?", className="upload-help viz-sae-help-trigger", **{"aria-label": "UMAP help"}),
129
+ html.Div(
130
+ [
131
+ html.Div("UMAP views", className="viz-help-popover-title"),
132
+ html.Div("Image UMAP", className="viz-help-popover-subtitle"),
133
+ html.Div(
134
+ f"Each point is one image from {UMAP_DATASET}: aggregated SAE activations "
135
+ "projected to 2D (UMAP).",
136
+ className="viz-help-popover-lead",
137
+ ),
138
+ _label_desc_line(
139
+ "Mean activation",
140
+ "aggregate patch activations per image with the mean over non-zero values.",
141
+ ),
142
+ _label_desc_line("Maximum", "aggregate with the per-image maximum activation."),
143
+ _label_desc_line("Sum", "aggregate with the per-image sum of activations."),
144
+ _label_desc_line(
145
+ "Distortion group",
146
+ "color points by distortion group.",
147
+ ),
148
+ _label_desc_line(
149
+ "Distortion type",
150
+ "color points by distortion type.",
151
+ ),
152
+ html.Div("Feature UMAP", className="viz-help-popover-subtitle"),
153
+ html.Div(
154
+ "Each point is one sparse SAE feature (latent direction) after filtering, "
155
+ "projected to 2D (UMAP).",
156
+ className="viz-help-popover-lead",
157
+ ),
158
+ _label_desc_line(
159
+ "Distortion group",
160
+ "color each feature by the distortion group with the strongest patch-level |correlation|.",
161
+ ),
162
+ _label_desc_line("filtered", FEATURE_UMAP_FILTERED_HELP),
163
+ _label_desc_line("garbage", FEATURE_UMAP_GARBAGE_HELP),
164
+ html.Div(
165
+ "filtered and garbage points are hidden by default; click their legend entries to show them.",
166
+ className="viz-help-popover-lead",
167
+ ),
168
+ ],
169
+ className="viz-sae-help-popover viz-umap-help-popover",
170
+ ),
171
+ ],
172
+ className="viz-sae-help-wrap viz-umap-help-wrap",
173
+ )
174
+
175
+
176
+ def _feature_workbench_help() -> html.Div:
177
+ return html.Div(
178
+ [
179
+ html.Span(
180
+ "?",
181
+ className="upload-help viz-sae-help-trigger",
182
+ **{"aria-label": "Feature workbench help"},
183
+ ),
184
+ html.Div(
185
+ [
186
+ html.Div("Feature workbench", className="viz-help-popover-title"),
187
+ html.Div("Feature sort selector", className="viz-help-popover-subtitle"),
188
+ html.Div(
189
+ "Picks which precomputed ranking defines the ordered feature list "
190
+ "for this model and dataset (correlation, ROC-AUC, precision,"
191
+ "recall and similar metrics).",
192
+ className="viz-help-popover-lead",
193
+ ),
194
+ html.Div("Feature navigation", className="viz-help-popover-subtitle"),
195
+ _label_desc_line(
196
+ "Prev / Next",
197
+ "step through features in the active selector ranking.",
198
+ ),
199
+ _label_desc_line(
200
+ "Jump to feature id",
201
+ "go directly to a global SAE feature id (even if it is not top-ranked).",
202
+ ),
203
+ _label_desc_line(
204
+ "Feature UMAP click",
205
+ "in Features UMAP mode, clicking a point selects that feature id.",
206
+ ),
207
+ html.Div("Image ranking", className="viz-help-popover-subtitle"),
208
+ _label_desc_line(
209
+ "IoU",
210
+ "top images with the largest overlap between the feature heatmap and "
211
+ "dataset distortion masks.",
212
+ ),
213
+ _label_desc_line(
214
+ "Maximum activation",
215
+ "top images with the highest peak SAE activation for this feature.",
216
+ ),
217
+ ],
218
+ className="viz-sae-help-popover viz-feature-workbench-help-popover",
219
+ ),
220
+ ],
221
+ className="viz-sae-help-wrap viz-feature-workbench-help-wrap",
222
+ )
223
+
224
+
225
+ def _feature_filter_intro_block() -> html.Div:
226
+ return html.Div(
227
+ [
228
+ html.P(
229
+ "Cached activations are filtered before further analysis.",
230
+ className="viz-filter-intro-lead",
231
+ ),
232
+ html.P("Available filters:", className="viz-filter-intro-lead"),
233
+ html.Ul(
234
+ [
235
+ _label_desc_line(
236
+ "nonzero_max",
237
+ "removes features whose maximum activation is 0 (they never fire on any image).",
238
+ tag="li",
239
+ ),
240
+ _label_desc_line(
241
+ "kruskal_wallis",
242
+ "keeps features with statistically significant differences across "
243
+ "distortion groups (Kruskal–Wallis test on activations).",
244
+ tag="li",
245
+ ),
246
+ ],
247
+ className="viz-filter-intro-list",
248
+ ),
249
+ html.P(
250
+ "Each line below is one applied filter step and reports before / after / removed feature counts.",
251
+ className="viz-filter-intro-lead",
252
+ ),
253
+ ],
254
+ className="viz-filter-intro",
255
+ )
256
+
257
+
258
+ def _format_filter_result_line(line: str) -> html.Div:
259
+ if ":" not in line:
260
+ return html.Div(line, className="viz-filter-line")
261
+ name, rest = line.split(":", 1)
262
+ return html.Div(
263
+ [
264
+ html.Strong(f"{name.strip()}:"),
265
+ html.Span(rest, className="help-desc"),
266
+ ],
267
+ className="viz-filter-line",
268
+ )
269
+
270
+
271
+ def _skeleton_lines(n: int = 3, short_last: bool = True) -> list[html.Div]:
272
+ lines = []
273
+ for index in range(n):
274
+ class_name = "skeleton-line"
275
+ if short_last and index == n - 1:
276
+ class_name += " skeleton-line-short"
277
+ lines.append(html.Div(className=class_name))
278
+ return lines
279
+
280
+
281
+ def _feature_image_slot(slot_idx: int) -> html.Div:
282
+ return html.Div(
283
+ [
284
+ dcc.Graph(
285
+ id=f"feature-image-{slot_idx}",
286
+ figure=empty_heatmap_figure("Loading..."),
287
+ config=HEATMAP_GRAPH_CONFIG,
288
+ className="heatmap-graph",
289
+ ),
290
+ html.Div(
291
+ "Top images overlay is not available yet.",
292
+ id=f"feature-image-caption-{slot_idx}",
293
+ className="feature-image-caption",
294
+ ),
295
+ ],
296
+ id=f"feature-image-card-{slot_idx}",
297
+ className="feature-image-card",
298
+ )
299
+
300
+
301
+ def _empty_feature_image_cache(message: str) -> tuple[object, str, str]:
302
+ return None, message, "Top images overlay is not available yet."
303
+
304
+
305
+ layout = html.Div(
306
+ [
307
+ page_back_nav(),
308
+ html.Div(
309
+ [html.H1("SAE Explorer", className="app-title")],
310
+ className="hero-card",
311
+ ),
312
+ html.Div(
313
+ [
314
+ html.Div(
315
+ [
316
+ html.Div("Selected SAE", className="panel-label"),
317
+ _sae_hyperparams_help(),
318
+ dcc.Loading(
319
+ id="viz-model-info-loading",
320
+ type="default",
321
+ color="#2563eb",
322
+ className="loading-block viz-model-info-loading",
323
+ children=html.Div(
324
+ id="viz-model-info",
325
+ className="mock-summary-lines",
326
+ children=_skeleton_lines(4),
327
+ ),
328
+ ),
329
+ ],
330
+ className="mock-card viz-sae-card",
331
+ ),
332
+ html.Div(
333
+ [
334
+ html.Div("UMAP SAE vector", className="panel-label"),
335
+ _umap_help(),
336
+ html.Div(
337
+ [
338
+ html.Div(
339
+ [
340
+ html.Div(
341
+ [
342
+ html.Div("UMAP mode", className="umap-mode-label"),
343
+ dcc.RadioItems(
344
+ id="umap-mode-dropdown",
345
+ options=[
346
+ {"label": "Images", "value": "images"},
347
+ {"label": "Features", "value": "features"},
348
+ ],
349
+ value="images",
350
+ className="umap-mode-switch",
351
+ labelClassName="umap-mode-pill",
352
+ inputClassName="umap-mode-input",
353
+ ),
354
+ ],
355
+ className="umap-mode-primary",
356
+ ),
357
+ html.Div(
358
+ [
359
+ html.Div(
360
+ [
361
+ html.Div("Aggregation mode", className="section-label"),
362
+ dcc.Dropdown(
363
+ id="aggregation-dropdown",
364
+ options=[
365
+ {
366
+ "label": AGGREGATION_MODE_LABELS[mode],
367
+ "value": mode,
368
+ }
369
+ for mode in AGGREGATION_MODES
370
+ ],
371
+ value=DEFAULT_AGGREGATION_MODE,
372
+ clearable=False,
373
+ className="dash-dropdown model-dropdown",
374
+ ),
375
+ ],
376
+ id="aggregation-control-block",
377
+ className="umap-control-block",
378
+ ),
379
+ html.Div(
380
+ [
381
+ html.Div("Color mode", className="section-label"),
382
+ dcc.Dropdown(
383
+ id="color-dropdown",
384
+ options=[
385
+ {
386
+ "label": IMAGE_COLOR_MODE_LABELS[mode],
387
+ "value": mode,
388
+ }
389
+ for mode in IMAGE_COLOR_MODES
390
+ ],
391
+ value=DEFAULT_COLOR_MODE,
392
+ clearable=False,
393
+ className="dash-dropdown model-dropdown",
394
+ ),
395
+ ],
396
+ id="color-control-block",
397
+ className="umap-control-block",
398
+ ),
399
+ html.Div(
400
+ [],
401
+ id="feature-color-source-control-block",
402
+ className="umap-control-block",
403
+ style={"display": "none"},
404
+ ),
405
+ ],
406
+ className="umap-controls-row umap-controls-secondary",
407
+ ),
408
+ ],
409
+ className="umap-controls",
410
+ ),
411
+ html.Div(
412
+ [
413
+ html.Div(id="umap-status", className="umap-status"),
414
+ dcc.Loading(
415
+ id="umap-graph-loading",
416
+ type="circle",
417
+ color="#2563eb",
418
+ className="loading-block umap-graph-loading",
419
+ children=dcc.Graph(
420
+ id="umap-graph",
421
+ figure=empty_umap_figure("Loading UMAP..."),
422
+ clear_on_unhover=True,
423
+ config={"displayModeBar": False, "responsive": True},
424
+ className="umap-graph",
425
+ style={"height": "min(58vh, 640px)", "minHeight": "430px"},
426
+ ),
427
+ ),
428
+ dcc.Tooltip(
429
+ id="umap-tooltip",
430
+ className=UMAP_TOOLTIP_CLASSNAME,
431
+ zindex=UMAP_TOOLTIP_ZINDEX,
432
+ direction="right",
433
+ ),
434
+ ],
435
+ className="umap-hover-shell",
436
+ ),
437
+ dcc.Store(id="category-filter-store", data=None),
438
+ dcc.Store(id="umap-figures-cache", data=None),
439
+ ],
440
+ className="mock-placeholder",
441
+ ),
442
+ ],
443
+ className="placeholder-card viz-umap-card",
444
+ ),
445
+ ],
446
+ className="mock-grid",
447
+ ),
448
+ html.Div(
449
+ [
450
+ html.Div(
451
+ [
452
+ html.Div("Feature workbench", className="panel-label"),
453
+ _feature_workbench_help(),
454
+ ],
455
+ className="feature-workbench-title-row",
456
+ ),
457
+ html.Div(
458
+ [
459
+ html.Div(
460
+ [
461
+ html.Div(
462
+ [
463
+ html.Div("Feature sort selector", className="section-label"),
464
+ dcc.Dropdown(
465
+ id="feature-sort-selector",
466
+ options=[],
467
+ value=None,
468
+ clearable=False,
469
+ disabled=True,
470
+ className="dash-dropdown model-dropdown",
471
+ ),
472
+ ],
473
+ className="feature-workbench-controls feature-workbench-controls-sort",
474
+ ),
475
+ html.Div(
476
+ [
477
+ html.Div("Feature navigation", className="section-label"),
478
+ html.Div(
479
+ [
480
+ html.Button(
481
+ "Prev", id="feature-prev", n_clicks=0, className="nav-button"
482
+ ),
483
+ dcc.Input(
484
+ id="feature-jump-input",
485
+ type="text",
486
+ inputMode="numeric",
487
+ pattern="[0-9]*",
488
+ debounce=True,
489
+ className="feature-jump-input",
490
+ value="",
491
+ ),
492
+ html.Button(
493
+ "Next", id="feature-next", n_clicks=0, className="nav-button"
494
+ ),
495
+ ],
496
+ className="feature-nav-row",
497
+ ),
498
+ ],
499
+ className="feature-workbench-controls feature-workbench-controls-nav",
500
+ ),
501
+ html.Div(
502
+ [
503
+ html.Div("Image ranking", className="section-label"),
504
+ dcc.Dropdown(
505
+ id="feature-image-ranking",
506
+ options=[
507
+ {"label": "IoU", "value": "iou"},
508
+ {"label": "Maximum activation", "value": "activation"},
509
+ ],
510
+ value="iou",
511
+ clearable=False,
512
+ className="dash-dropdown feature-image-ranking-dropdown",
513
+ ),
514
+ ],
515
+ className="feature-workbench-controls feature-workbench-controls-ranking",
516
+ ),
517
+ ],
518
+ className="feature-workbench-toolbar",
519
+ ),
520
+ html.Div(id="feature-status", className="feature-status"),
521
+ ],
522
+ className="feature-workbench-header",
523
+ ),
524
+ html.Div(
525
+ id="feature-detail-card",
526
+ className="feature-detail-card",
527
+ children=[
528
+ dcc.Loading(
529
+ id="feature-detail-body-loading",
530
+ type="default",
531
+ color="#2563eb",
532
+ className="loading-block feature-detail-body-loading",
533
+ children=html.Div(
534
+ id="feature-detail-body",
535
+ children=[
536
+ html.Div("Loading feature details…", className="feature-empty"),
537
+ ],
538
+ ),
539
+ ),
540
+ html.Div(
541
+ [
542
+ html.Div(
543
+ [
544
+ html.Div("Top images", className="feature-placeholder-title"),
545
+ dcc.Checklist(
546
+ id="feature-show-heatmap-overlay",
547
+ **heatmap_overlay_checklist_props(),
548
+ ),
549
+ ],
550
+ className="feature-top-images-header",
551
+ ),
552
+ dcc.Store(id="feature-top-images-cache", data=None),
553
+ dcc.Loading(
554
+ id="feature-images-loading",
555
+ type="dot",
556
+ color="#2563eb",
557
+ className="loading-block feature-images-loading",
558
+ children=[
559
+ html.Div(
560
+ "No feature selected.",
561
+ id="feature-top-images-status",
562
+ className="feature-empty",
563
+ ),
564
+ html.Div(
565
+ id="feature-top-images",
566
+ className="feature-top-images-grid",
567
+ children=[_feature_image_slot(idx) for idx in range(FEATURE_TOP_IMAGE_SLOTS)],
568
+ ),
569
+ ],
570
+ ),
571
+ ],
572
+ className="feature-placeholder",
573
+ ),
574
+ ],
575
+ ),
576
+ dcc.Store(id="selected-feature-id", data=None),
577
+ ],
578
+ className="feature-workbench",
579
+ ),
580
+ ],
581
+ className="app-shell",
582
+ )
583
+
584
+
585
+ @callback(
586
+ Output("viz-model-info", "children"),
587
+ Input("_pages_location", "search"),
588
+ )
589
+ def render_model_info(search: str | None):
590
+ params = visualization_params(search)
591
+ if params is None:
592
+ return html.Div("Missing visualization parameters.", className="feature-empty")
593
+
594
+ metric, selection_dataset, model_key = params
595
+ record = get_model_record(metric, model_key)
596
+
597
+ children = [
598
+ html.Div([html.Span("Metric: "), html.Strong(metric)]),
599
+ html.Div([html.Span("Dataset: "), html.Strong(dashboard_dataset_label(selection_dataset))]),
600
+ html.Div([html.Span("Image UMAP embeddings: "), html.Strong(UMAP_DATASET)]),
601
+ ]
602
+ if record is None:
603
+ children.append(html.Div("SAE checkpoint not found.", className="mock-summary-meta"))
604
+ return children
605
+
606
+ children.append(
607
+ html.Div(summarize_model_record(record), className="mock-summary-hyperparams")
608
+ )
609
+ try:
610
+ from dashboard.model_catalog import summarize_feature_filter_cache
611
+
612
+ filter_lines = summarize_feature_filter_cache(record.checkpoint_path, selection_dataset)
613
+ except Exception:
614
+ filter_lines = ["Feature filter summary unavailable"]
615
+
616
+ children.append(html.Hr(className="viz-summary-divider"))
617
+ children.append(
618
+ html.Div(
619
+ [
620
+ _feature_filter_intro_block(),
621
+ *[_format_filter_result_line(line) for line in filter_lines],
622
+ ],
623
+ className="viz-filter-summary",
624
+ )
625
+ )
626
+ return children
627
+
628
+
629
+ @callback(
630
+ Output("feature-sort-selector", "options"),
631
+ Output("feature-sort-selector", "value"),
632
+ Output("feature-sort-selector", "disabled"),
633
+ Input("_pages_location", "search"),
634
+ )
635
+ def init_feature_selector(search: str | None):
636
+ params = visualization_params(search)
637
+ if params is None:
638
+ return [], None, True
639
+
640
+ metric, selection_dataset, model_key = params
641
+ record = get_model_record(metric, model_key)
642
+ if record is None:
643
+ return [], None, True
644
+
645
+ summaries = summarize_selector_cache(record.checkpoint_path, selection_dataset)
646
+ if not summaries:
647
+ return [], None, True
648
+
649
+ options = [
650
+ {"label": selector_option_label(summary.selector_name), "value": summary.selector_name}
651
+ for summary in summaries
652
+ ]
653
+ return options, summaries[0].selector_name, False
654
+
655
+
656
+ @callback(
657
+ Output("aggregation-control-block", "style"),
658
+ Output("color-control-block", "style"),
659
+ Input("umap-mode-dropdown", "value"),
660
+ )
661
+ def toggle_umap_secondary_controls(umap_mode: str | None):
662
+ if str(umap_mode or "images") == "features":
663
+ return (
664
+ {"display": "none"},
665
+ {"display": "none"},
666
+ )
667
+ return ({}, {})
668
+
669
+
670
+ @callback(
671
+ Output("category-filter-store", "data"),
672
+ Input("umap-graph", "clickData"),
673
+ State("category-filter-store", "data"),
674
+ State("umap-mode-dropdown", "value"),
675
+ prevent_initial_call=True,
676
+ )
677
+ def update_category_filter(click_data, current_category, umap_mode):
678
+ if str(umap_mode or "images") != "images" or not click_data:
679
+ return no_update
680
+
681
+ point = click_data["points"][0]
682
+ customdata = point.get("customdata", None)
683
+ clicked_category = None
684
+ if isinstance(customdata, (list, tuple, np.ndarray)) and len(customdata) >= 2:
685
+ clicked_category = str(customdata[1])
686
+
687
+ if not clicked_category:
688
+ return no_update
689
+
690
+ if current_category is not None and str(current_category) == clicked_category:
691
+ return None
692
+
693
+ return clicked_category
694
+
695
+
696
+ @callback(
697
+ Output("umap-figures-cache", "data"),
698
+ Input("_pages_location", "search"),
699
+ Input("aggregation-dropdown", "value"),
700
+ Input("color-dropdown", "value"),
701
+ Input("category-filter-store", "data"),
702
+ State("umap-figures-cache", "data"),
703
+ running=[
704
+ (Output("umap-status", "className"), "umap-status is-loading", "umap-status"),
705
+ (Output("umap-status", "children"), "Loading UMAP embeddings…", ""),
706
+ ],
707
+ )
708
+ def preload_umap_figures(search, aggregation_mode, color_mode, selected_category, prev_cache):
709
+ params = visualization_params(search)
710
+ if params is None:
711
+ return None
712
+
713
+ metric, _selection_dataset, model_key = params
714
+ if not model_key or aggregation_mode is None:
715
+ return no_update
716
+
717
+ aggregation_mode = str(aggregation_mode)
718
+ images_color_mode = str(color_mode or DEFAULT_COLOR_MODE)
719
+ if images_color_mode not in IMAGE_COLOR_MODES:
720
+ images_color_mode = DEFAULT_COLOR_MODE
721
+ prev_cache = prev_cache or {}
722
+ cache_key = _umap_images_cache_key(
723
+ metric, model_key, aggregation_mode, images_color_mode, selected_category
724
+ )
725
+ prev_images = prev_cache.get("images") if isinstance(prev_cache.get("images"), dict) else None
726
+ if prev_images and prev_images.get("cache_key") == cache_key:
727
+ return no_update
728
+
729
+ try:
730
+ images_fig, images_info = build_images_umap(
731
+ metric,
732
+ model_key,
733
+ UMAP_DATASET,
734
+ aggregation_mode,
735
+ color_mode=images_color_mode,
736
+ selected_category=selected_category,
737
+ )
738
+ images_payload = {
739
+ "figure": images_fig.to_plotly_json(),
740
+ "status": format_umap_status(images_info, aggregation_mode, images_color_mode),
741
+ "cache_key": cache_key,
742
+ }
743
+ return {
744
+ "images": images_payload,
745
+ "features": prev_cache.get("features") if ctx.triggered_id == "category-filter-store" else None,
746
+ }
747
+ except Exception as exc:
748
+ error_fig = empty_umap_figure(f"UMAP unavailable: {exc}").to_plotly_json()
749
+ message = f"UMAP unavailable: {exc}"
750
+ error_payload = {"figure": error_fig, "status": message, "cache_key": cache_key}
751
+ return {"images": error_payload, "features": prev_cache.get("features")}
752
+
753
+
754
+ @callback(
755
+ Output("umap-figures-cache", "data", allow_duplicate=True),
756
+ Input("umap-figures-cache", "data"),
757
+ Input("umap-mode-dropdown", "value"),
758
+ State("_pages_location", "search"),
759
+ State("aggregation-dropdown", "value"),
760
+ prevent_initial_call=True,
761
+ running=[
762
+ (Output("umap-status", "className"), "umap-status is-loading", "umap-status"),
763
+ (Output("umap-status", "children"), "Preloading feature UMAP embeddings…", ""),
764
+ ],
765
+ )
766
+ def preload_feature_umap(cache, umap_mode, search, aggregation_mode):
767
+ # Eager preload: runs when images cache is ready (first page load), not only on mode switch.
768
+ params = visualization_params(search)
769
+ if params is None:
770
+ return no_update
771
+
772
+ metric, _selection_dataset, model_key = params
773
+ if not model_key or aggregation_mode is None:
774
+ return no_update
775
+
776
+ if not cache or not cache.get("images"):
777
+ return no_update
778
+
779
+ if ctx.triggered_id == "umap-mode-dropdown" and str(umap_mode or "images") != "features":
780
+ return no_update
781
+
782
+ aggregation_mode = str(aggregation_mode)
783
+ feature_cache = cache.get("features") if isinstance(cache.get("features"), dict) else {}
784
+ if feature_cache.get("figure"):
785
+ return no_update
786
+
787
+ try:
788
+ features_fig, features_info = build_features_umap(
789
+ metric,
790
+ model_key,
791
+ UMAP_DATASET,
792
+ aggregation_mode,
793
+ hide_filtered_garbage=True,
794
+ )
795
+ features_payload = {
796
+ "figure": features_fig.to_plotly_json(),
797
+ "status": format_feature_umap_status(features_info, "group"),
798
+ }
799
+ next_cache = dict(cache)
800
+ feature_cache = dict(feature_cache)
801
+ feature_cache.update(features_payload)
802
+ next_cache["features"] = feature_cache
803
+ return next_cache
804
+ except Exception as exc:
805
+ error_fig = empty_umap_figure(f"UMAP unavailable: {exc}").to_plotly_json()
806
+ message = f"UMAP unavailable: {exc}"
807
+ next_cache = dict(cache)
808
+ feature_cache = dict(feature_cache)
809
+ feature_cache.update({"figure": error_fig, "status": message})
810
+ return {
811
+ "images": cache["images"],
812
+ "features": feature_cache,
813
+ }
814
+
815
+
816
+ def _parse_selected_feature_id(value) -> int | None:
817
+ if value is None:
818
+ return None
819
+ try:
820
+ return int(value)
821
+ except (TypeError, ValueError):
822
+ return None
823
+
824
+
825
+ @callback(
826
+ Output("umap-graph", "figure"),
827
+ Output("umap-status", "children"),
828
+ Output("umap-status", "className"),
829
+ Input("umap-mode-dropdown", "value"),
830
+ Input("umap-figures-cache", "data"),
831
+ Input("selected-feature-id", "data"),
832
+ )
833
+ def display_umap_from_cache(umap_mode, cache, selected_feature_id):
834
+ if not cache:
835
+ return empty_umap_figure("Loading UMAP..."), "Preloading UMAP embeddings...", "umap-status is-loading"
836
+
837
+ mode = str(umap_mode or "images")
838
+ if mode == "features":
839
+ payload = cache.get("features")
840
+ if payload is None:
841
+ return (
842
+ empty_umap_figure("Loading feature UMAP..."),
843
+ "Preloading feature UMAP embeddings...",
844
+ "umap-status is-loading",
845
+ )
846
+ if not payload:
847
+ return empty_umap_figure("UMAP unavailable."), "UMAP unavailable.", "umap-status"
848
+ figure = payload.get("figure")
849
+ highlighted = apply_feature_umap_highlight(figure, _parse_selected_feature_id(selected_feature_id))
850
+ return highlighted, payload.get("status", ""), "umap-status"
851
+
852
+ payload = cache.get(mode)
853
+ if payload is None and mode == "images":
854
+ return (
855
+ empty_umap_figure("Loading UMAP..."),
856
+ "Preloading UMAP embeddings...",
857
+ "umap-status is-loading",
858
+ )
859
+ if payload is None:
860
+ return empty_umap_figure("UMAP unavailable."), "UMAP unavailable.", "umap-status"
861
+
862
+ if not payload:
863
+ return empty_umap_figure("UMAP unavailable."), "UMAP unavailable.", "umap-status"
864
+
865
+ return payload["figure"], payload.get("status", ""), "umap-status"
866
+
867
+
868
+ clientside_callback(
869
+ umap_tooltip_clientside("umap-graph"),
870
+ Output("umap-tooltip", "show"),
871
+ Output("umap-tooltip", "bbox"),
872
+ Output("umap-tooltip", "direction"),
873
+ Input("umap-graph", "hoverData"),
874
+ )
875
+
876
+
877
+ @callback(
878
+ Output("umap-tooltip", "children"),
879
+ Input("umap-graph", "hoverData"),
880
+ State("_pages_location", "search"),
881
+ State("umap-mode-dropdown", "value"),
882
+ )
883
+ def display_umap_hover_content(hover_data, search: str | None, umap_mode: str | None):
884
+ if hover_data is None:
885
+ return no_update
886
+
887
+ umap_mode = str(umap_mode or "images")
888
+ pt = hover_data["points"][0]
889
+ customdata = pt.get("customdata", None)
890
+
891
+ if umap_mode == "features":
892
+ if not isinstance(customdata, (list, tuple, np.ndarray)) or len(customdata) < 4:
893
+ return no_update
894
+ try:
895
+ fid = int(customdata[0])
896
+ mean_val = float(customdata[1])
897
+ std_val = float(customdata[2])
898
+ nonzero = float(customdata[3])
899
+ except (TypeError, ValueError):
900
+ return no_update
901
+
902
+ corr_label = str(pt.get("fullData", {}).get("name", ""))
903
+ return build_feature_umap_hover_tooltip(
904
+ fid,
905
+ mean_val,
906
+ std_val,
907
+ nonzero,
908
+ corr_label,
909
+ corr_source_label="corr(group_patch)",
910
+ )
911
+
912
+ params = visualization_params(search)
913
+ if params is None:
914
+ return no_update
915
+
916
+ metric, _selection_dataset, model_key = params
917
+ if not model_key:
918
+ return no_update
919
+
920
+ img_idx = hover_image_idx(hover_data)
921
+ if img_idx is None or img_idx < 0:
922
+ return no_update
923
+
924
+ meta_line = cached_hover_meta_line(metric, model_key, img_idx)
925
+ if meta_line is None:
926
+ return no_update
927
+
928
+ b64 = cached_hover_thumb(metric, model_key, img_idx)
929
+ if b64 is None:
930
+ body = html.Div("Preview unavailable", style={"fontSize": "12px"})
931
+ else:
932
+ body = html.Img(
933
+ src=f"data:image/jpeg;base64,{b64}",
934
+ style={
935
+ "width": f"{THUMB_SIZE}px",
936
+ "height": f"{THUMB_SIZE}px",
937
+ "objectFit": "cover",
938
+ "border": "1px solid #CBD5E1",
939
+ },
940
+ )
941
+
942
+ return html.Div(
943
+ [
944
+ html.Div(meta_line, style={"fontSize": "12px", "marginBottom": "8px"}),
945
+ body,
946
+ ],
947
+ style={
948
+ "width": f"{max(250, THUMB_SIZE + 20)}px",
949
+ "whiteSpace": "normal",
950
+ "padding": "10px",
951
+ "backgroundColor": "#F8FAFC",
952
+ "border": "1px solid #CBD5E1",
953
+ "borderRadius": "8px",
954
+ },
955
+ )
956
+
957
+
958
+ @callback(
959
+ Output("selected-feature-id", "data"),
960
+ Output("feature-status", "children"),
961
+ Output("feature-jump-input", "value"),
962
+ Input("feature-sort-selector", "value"),
963
+ Input("feature-prev", "n_clicks"),
964
+ Input("feature-next", "n_clicks"),
965
+ Input("feature-jump-input", "value"),
966
+ Input("umap-graph", "clickData"),
967
+ State("selected-feature-id", "data"),
968
+ State("_pages_location", "search"),
969
+ State("umap-mode-dropdown", "value"),
970
+ )
971
+ def update_selected_feature(
972
+ selector_name: str | None,
973
+ _prev_clicks: int,
974
+ _next_clicks: int,
975
+ jump_value,
976
+ click_data,
977
+ current_feature_id,
978
+ search: str | None,
979
+ umap_mode: str | None,
980
+ ):
981
+ params = visualization_params(search)
982
+ if params is None:
983
+ return None, "", ""
984
+
985
+ metric, selection_dataset, model_key = params
986
+ rows = feature_rows_for_selector(metric, selection_dataset, model_key, selector_name)
987
+
988
+ trigger_id = ctx.triggered_id
989
+ requested_feature_id: int | float | str | None = jump_value
990
+
991
+ if trigger_id == "umap-graph":
992
+ if str(umap_mode or "images") != "features" or not click_data:
993
+ return no_update, no_update, no_update
994
+ customdata = click_data["points"][0].get("customdata", None)
995
+ if not isinstance(customdata, (list, tuple, np.ndarray)) or len(customdata) < 1:
996
+ return no_update, no_update, no_update
997
+ try:
998
+ requested_feature_id = int(customdata[0])
999
+ except (TypeError, ValueError):
1000
+ return no_update, no_update, no_update
1001
+
1002
+ selected_feature_id, status = select_feature_id(
1003
+ feature_rows=rows,
1004
+ current_feature_id=current_feature_id,
1005
+ trigger_id=trigger_id,
1006
+ requested_feature_id=requested_feature_id,
1007
+ )
1008
+ if selected_feature_id is not None:
1009
+ unavailable = feature_unavailable_message(
1010
+ metric,
1011
+ model_key,
1012
+ int(selected_feature_id),
1013
+ activation_dataset=UMAP_DATASET,
1014
+ )
1015
+ if unavailable is not None:
1016
+ status = unavailable
1017
+ jump_display = str(selected_feature_id) if selected_feature_id is not None else ""
1018
+ return selected_feature_id, status, jump_display
1019
+
1020
+
1021
+ @callback(
1022
+ Output("feature-detail-body", "children"),
1023
+ Input("selected-feature-id", "data"),
1024
+ State("_pages_location", "search"),
1025
+ State("feature-sort-selector", "value"),
1026
+ )
1027
+ def render_feature_detail(
1028
+ selected_feature_id,
1029
+ search: str | None,
1030
+ selector_name: str | None,
1031
+ ):
1032
+ params = visualization_params(search)
1033
+ if params is None:
1034
+ return html.Div("No selector cache files were found for this model.", className="feature-empty")
1035
+
1036
+ metric, selection_dataset, model_key = params
1037
+ return build_feature_detail_content(
1038
+ metric,
1039
+ selection_dataset,
1040
+ model_key,
1041
+ selected_feature_id,
1042
+ selector_name,
1043
+ )
1044
+
1045
+
1046
+ @callback(
1047
+ Output("feature-top-images-cache", "data"),
1048
+ Output("feature-top-images-status", "children"),
1049
+ Output("feature-image-caption-0", "children"),
1050
+ Input("selected-feature-id", "data"),
1051
+ Input("feature-image-ranking", "value"),
1052
+ State("_pages_location", "search"),
1053
+ )
1054
+ def load_feature_top_images_cache(
1055
+ selected_feature_id,
1056
+ ranking_mode: str | None,
1057
+ search: str | None,
1058
+ ):
1059
+ params = visualization_params(search)
1060
+ if params is None:
1061
+ return _empty_feature_image_cache("No images available.")
1062
+
1063
+ metric, selection_dataset, model_key = params
1064
+
1065
+ try:
1066
+ feature_id = int(selected_feature_id)
1067
+ except Exception:
1068
+ return _empty_feature_image_cache("No feature selected.")
1069
+
1070
+ unavailable = feature_unavailable_message(
1071
+ metric,
1072
+ model_key,
1073
+ feature_id,
1074
+ activation_dataset=selection_dataset,
1075
+ )
1076
+ if unavailable is not None:
1077
+ return _empty_feature_image_cache(unavailable)
1078
+
1079
+ ranking_mode = str(ranking_mode or "iou")
1080
+ try:
1081
+ overlays = get_top_feature_overlays(
1082
+ metric,
1083
+ selection_dataset,
1084
+ model_key,
1085
+ feature_id,
1086
+ top_n=FEATURE_TOP_IMAGE_TOP_N,
1087
+ ranking_mode=ranking_mode,
1088
+ )
1089
+ except Exception as exc:
1090
+ return _empty_feature_image_cache(f"Failed to build top images: {exc}")
1091
+ if not overlays:
1092
+ return _empty_feature_image_cache("No top images found for this feature.")
1093
+
1094
+ figure = overlays[0].get("figure")
1095
+ if not isinstance(figure, dict):
1096
+ return _empty_feature_image_cache("No top images found for this feature.")
1097
+
1098
+ caption = str(overlays[0].get("caption") or "Top image 1")
1099
+ return figure, "", caption
1100
+
1101
+
1102
+ @callback(
1103
+ Output("feature-image-0", "figure"),
1104
+ Input("feature-top-images-cache", "data"),
1105
+ State("feature-show-heatmap-overlay", "value"),
1106
+ )
1107
+ def load_feature_top_images_figure(cache, show_heatmap_value):
1108
+ show_overlay = overlay_show_heatmap_enabled(show_heatmap_value)
1109
+ if not isinstance(cache, dict):
1110
+ return empty_heatmap_figure("Top images overlay is not available yet.").to_plotly_json()
1111
+ return set_heatmap_overlay_visible(cache, show_overlay)
1112
+
1113
+ clientside_callback(
1114
+ TOGGLE_HEATMAP_OVERLAY_CLIENTSIDE,
1115
+ Output("feature-image-0", "figure", allow_duplicate=True),
1116
+ Input("feature-show-heatmap-overlay", "value"),
1117
+ State("feature-image-0", "figure"),
1118
+ prevent_initial_call=True,
1119
+ )
1120
+