diff --git a/AI-CONTEXT.md b/AI-CONTEXT.md
index 79d456d..66d3a38 100644
--- a/AI-CONTEXT.md
+++ b/AI-CONTEXT.md
@@ -40,7 +40,7 @@ Read `ProjectStructure.md` in this directory for the full reference (file struct
| `manifest_utils.py` | 316 | Load/save/merge manifest. `save_manifest()` reloads from disk first to preserve concurrent user edits (favorites, votes, metrics) |
| `metadata_packer.py` | 561 | PNG metadata embedding for CivitAI. `pack_metadata_into_image()` accepts `workflow_data` kwarg |
| `run_api.py` | 196 | Pure helpers behind `POST /config_tester/run`, `GET /config_tester/run_status` and `GET /config_tester/manifest` (`load_manifest_for_fetch`) |
-| `image_metrics.py`, `metrics_runner.py` | 668, 167 | Optional per-image metrics at save time; background job that backfills them for old sessions |
+| `image_metrics.py`, `metrics_runner.py` | 668, 167 | Optional per-image metrics at save time (incl. `sobel`); background job that backfills them (and `fill_missing` keys) for old sessions |
| `workflow_sweep.py`, `workflow_sweep_runner.py`, `workflow_sweep_collect.py` | 1106, 1027, 281 | Workflow Sweep: pure expansion core, in-process queue feeder (never blocks the executor), `USCGSweepCollect` / `USCGSweepCollectVideo` output nodes |
| `web/conf_builder/conf-builder-main.js` | 779 | Node registration. Default state and its migration blocks define ALL fields including upscaling/cooldown |
| `web/conf_builder/conf-builder-utilities.js` | 866 | Model-list cache (`LS_CACHE_VERSION`), `getIterationCount()`, LoRA string helpers. No config converter: the preview calls `/configbuilder/preview` |
@@ -50,6 +50,7 @@ Read `ProjectStructure.md` in this directory for the full reference (file struct
| `resources/logic_ui.js` | 2907 | Dashboard DOM: cards, modals, `buildComfyNodesWorkflow()`, analytics |
| `resources/logic_virtual.js` | 1092 | Virtual scroller: renders only visible cards from thousands |
| `resources/logic_ratings.js`, `resources/logic_compare.js` | 538, 824 | Pairwise review: pure Bradley-Terry / pairing / scoreboard logic, and the compare overlay that uses it |
+| `resources/logic_analytics.js`, `resources/logic_analytics_panel.js` | new | Settings Stats panel: `computeAnalytics()` (pure) groups items per setting axis (fav/reject rate, time, metrics, rating); the panel is docked, live (hooked into `updateJSONs`), has Copy Markdown |
| `resources/logic_wfparams.js`, `resources/logic_dragdrop.js` | 81, small | Pure helpers (node-testable): Workflow Sweep `wf_params` keys/matching/labels; Revise-image drag payload + `[output]` path for Load Image |
| `resources/logic_standalone.js` | 170 | Standalone-page mode (own websocket, run-API GENERATE NEW); loaded last |
@@ -166,4 +167,4 @@ Images saved as WebP at `output/benchmarks/{session}/images/img_{id}.webp`
**Modify dashboard UI:**
1. Edit files in `resources/` (HTML in `template.html`, JS in `logic_*.js`, CSS in `report.css`)
2. Remember: changes require HTML regeneration to take effect
-3. JS load order is `html_generator.js_files`: logic_state.js → logic_utils.js → logic_ratings.js → logic_wfparams.js → logic_dragdrop.js → logic_ui.js → logic_upscale_modal.js → logic_virtual.js → logic_pipeline.js → logic_compare.js → logic_events.js → logic_init.js → logic_standalone.js (last). All of them share ONE inlined script scope, so top-level names must be unique
+3. JS load order is `html_generator.js_files`: logic_state.js → logic_utils.js → logic_ratings.js → logic_wfparams.js → logic_analytics.js → logic_dragdrop.js → logic_ui.js → logic_upscale_modal.js → logic_virtual.js → logic_pipeline.js → logic_compare.js → logic_analytics_panel.js → logic_events.js → logic_init.js → logic_standalone.js (last). All of them share ONE inlined script scope, so top-level names must be unique
diff --git a/ProjectStructure.md b/ProjectStructure.md
index f44eb28..6245b37 100644
--- a/ProjectStructure.md
+++ b/ProjectStructure.md
@@ -40,8 +40,8 @@ ComfyUI-Ultimate-Sampler-Grid/
│
├── generation_orchestrator.py # The "Conductor" (Loop Logic, job plan, preview pass, 3892 lines)
├── image_generation.py # KSampler & VAE Wrappers (1321 lines)
-├── image_metrics.py # Optional per-image metrics (sharpness/entropy/...), run context, scorer-pack adapters (ADAPTERS registry: node ids/signatures pinned to pack shas, loader defaults + `prefer`, `scale`, `max_batch`, `vram_mb`; `adapter_status()` reports `problem`) (668 lines)
-├── metrics_runner.py # Background job: compute metrics for an existing session (CPU, manifest lock) (167 lines)
+├── image_metrics.py # Optional per-image metrics (sharpness/entropy/sobel/...), run context, scorer-pack adapters (ADAPTERS registry: node ids/signatures pinned to pack shas, loader defaults + `prefer`, `scale`, `max_batch`, `vram_mb`; `adapter_status()` reports `problem`) (668 lines)
+├── metrics_runner.py # Background job: compute metrics for an existing session; `fill_missing` adds only missing keys (CPU, manifest lock) (182 lines)
├── model_loader.py # Checkpoint/LoRA/VAE/GGUF Loading Logic, latent geometry, CLIP type (907 lines)
├── model_cache.py # 3-Tier Model Caching System (935 lines)
├── remote_vae.py # Remote VAE decode worker + facade for the RemoteVAE companion (314 lines)
@@ -87,6 +87,7 @@ ComfyUI-Ultimate-Sampler-Grid/
│ ├── logic_state.js # State Store (Redux-style: filters, favorites) (149 lines)
│ ├── logic_utils.js # Helpers (Export, Scan, Session loading) (1073 lines)
│ ├── logic_ratings.js # Pairwise review pure logic: axis normalisers, Bradley-Terry, pairing, scoreboard (no DOM, node-testable) (538 lines)
+│ ├── logic_analytics.js # Settings stats pure logic: computeAnalytics (one pass, per-axis groups), sortAnalyticsRows, analyticsToMarkdown (node-testable)
│ ├── logic_wfparams.js # Workflow Sweep wf_params helpers (keys, numeric detection, match, short labels); Quick/Logic Filter + label overlay use them (pure, node-testable)
│ ├── logic_dragdrop.js # Revise-modal image drag/send helpers (issue #22): uscgImageRef, asset-info + uri-list + DownloadURL carriers, annotated `[output]` path (no DOM, node-testable)
│ ├── logic_ui.js # UI Renderer (DOM creation, Modal handling) (2907 lines)
@@ -94,6 +95,7 @@ ComfyUI-Ultimate-Sampler-Grid/
│ ├── logic_virtual.js # Virtual DOM / Infinite Scroller Engine (1092 lines)
│ ├── logic_pipeline.js # Data Processing (Filter -> Sort -> Layout) (831 lines)
│ ├── logic_compare.js # Pairwise review DOM glue: compare overlay, vote queue, scoreboard modal, rating getters (824 lines)
+│ ├── logic_analytics_panel.js # Docked Settings Stats panel (DOM): scope, sortable tables, Copy Markdown, live refresh hooks
│ ├── logic_events.js # Interaction Handler (Hotkeys, API calls) (101 lines)
│ ├── logic_init.js # Bootstrapper (Load JSON, Init State) (112 lines)
│ └── logic_standalone.js # Standalone-page mode: own websocket, run-API GENERATE NEW, openStandalone() (loaded last, 170 lines)
@@ -254,7 +256,7 @@ All routes registered via `PromptServer.instance.routes`. One row per route this
| POST | `/config_tester/upscale_images` | `__init__.py` | `{session_name, image_ids, upscale_config: {pipelines: [...]}, all_favorited?}` | Start an async dashboard upscale job; returns `{job_id, total_images}` |
| GET | `/config_tester/upscale_status` | `__init__.py` | `?job_id=` | Progress of an upscale job |
| POST | `/config_tester/cancel_upscale` | `__init__.py` | `{job_id}` | Cancel an upscale job; returns `{cancelled}` |
-| POST | `/config_tester/compute_metrics` | `__init__.py` | `{session_name, overwrite?}` | Start the background metrics job for a session; returns `{job_id, total_images}` |
+| POST | `/config_tester/compute_metrics` | `__init__.py` | `{session_name, overwrite?, fill_missing?}` | Start the background metrics job for a session (`fill_missing`: only the metric keys an item lacks, disk values win); returns `{job_id, total_images}` |
| GET | `/config_tester/metrics_status` | `__init__.py` | `?job_id=` | Metrics job progress |
| POST | `/config_tester/cancel_metrics` | `__init__.py` | `{job_id}` | Cancel a metrics job; returns `{cancelled}` |
| POST | `/config_tester/sweep/parse` | `__init__.py` | `{api_json}` | Workflow Sweep: describe an API-format workflow (nodes, outputs, `image_sources`, `video_sources`, `default_output`, suggested bindings) |
@@ -332,7 +334,7 @@ All routes registered via `PromptServer.instance.routes`. One row per route this
* **`html_generator.py`** (57 lines)
* `get_html_template(title, manifest_data, node_id, standalone=False, inline_manifest=False)` — reads all `/resources/` files, inlines them into `template.html`. By default the page is a **lazy shell**: `fullManifest` is empty and flagged `__uscg_lazy__`, `manifest_data` is ignored, and the SPA fetches `GET /config_tester/manifest?session=
` itself (`inline_manifest=True` restores the old inlined data; used by tests)
- * **JS Load Order:** `logic_state.js` → `logic_utils.js` → `logic_ratings.js` → `logic_wfparams.js` → `logic_dragdrop.js` → `logic_ui.js` → `logic_upscale_modal.js` → `logic_virtual.js` → `logic_pipeline.js` → `logic_compare.js` → `logic_events.js` → `logic_init.js` → `logic_standalone.js` (all files share ONE inlined script: top-level names must be unique across them)
+ * **JS Load Order:** `logic_state.js` → `logic_utils.js` → `logic_ratings.js` → `logic_wfparams.js` → `logic_analytics.js` → `logic_dragdrop.js` → `logic_ui.js` → `logic_upscale_modal.js` → `logic_virtual.js` → `logic_pipeline.js` → `logic_compare.js` → `logic_analytics_panel.js` → `logic_events.js` → `logic_init.js` → `logic_standalone.js` (all files share ONE inlined script: top-level names must be unique across them)
* Replaces placeholders: `__CSS_CONTENT__`, `__JS_CONTENT__`, `__TITLE__`, `__NODE_ID__`
* Zero external HTTP requests for code (JS/CSS inlined); the manifest comes from `/config_tester/manifest`
@@ -899,7 +901,7 @@ __init__.py
35. **Dynamic-VRAM re-prepare**: on ComfyUI 0.4x (comfy_aimdo) `load_models_gpu` re-runs `ModelPatcherDynamic.load()` on every encode; the repeated "prepared for dynamic VRAM loading" log line is the symptom. TE batching (`te_batch_size`) self-checks batch 1 against a single encode and halves on OOM; non-batchable prompts keep the per-prompt path.
-36. **`html_generator.js_files` order is load-bearing** (one shared script scope): `logic_ratings.js` (pure) before `logic_ui.js`, `logic_compare.js` after `logic_pipeline.js`, `logic_standalone.js` LAST (top-level-page overrides). CI assembles the page and syntax-checks it.
+36. **`html_generator.js_files` order is load-bearing** (one shared script scope): `logic_ratings.js` (pure) before `logic_ui.js`, `logic_analytics.js` after `logic_ratings.js` + `logic_wfparams.js`, `logic_compare.js` after `logic_pipeline.js`, `logic_analytics_panel.js` after `logic_compare.js` (uses `cmpEl`/`cmpCopyText`/`cmpRatings`) and `pipelineFilterCtx`/`pipelineMatches` from `logic_pipeline.js`, `logic_standalone.js` LAST (top-level-page overrides). CI assembles the page and syntax-checks it.
37. **Workflow Sweep video outputs.** `workflow_output` carries optional `media: "video"` and (VHS frames) `frames_from: `; both are emitted only for video, and `fingerprint()` / `template_hash()` add `m`/`f` to the hashed payload only for video, so image fingerprints stay byte-identical (pinned by golden values in `tests/test_workflow_sweep_video.py`; changing the image payload would orphan every old session's resume). VHS frames are converted IN THE GRAPH by a spliced core `CreateVideo` (`build_job_prompt`), never in the collector, so the executor handles cache/hidden inputs. `USCGSweepCollectVideo` saves with `VideoInput.save_to` (SaveVideo itself needs executor-populated hidden inputs). `manifest_utils.append_items(remove_ids=...)` deletes a removed video's `.preview.png` along with the mp4. No new top-level Builder field, so the rule-5 checklist does not apply.
@@ -941,7 +943,7 @@ __init__.py
| `distribution.py` | `is_distribution_available()`, `create_manager()`, `notify_workers_to_start()`, `stop_all_workers()`, `get_master_url()` (facade; the manager and worker live in the companion) | |
| `run_api.py` | `build_run_prompt()`, `normalize_configs_payload()`, `resolve_saved_config()`, `summarize_history()`, `RunTracker` | |
| `image_metrics.py` | `compute_metrics_numpy()`, `compute_metrics_torch()`, `begin_run()`, `end_run()`, `finalize()`, `adapter_status()` | |
-| `metrics_runner.py` | `start_metrics_job()`, `get_metrics_status()`, `cancel_metrics_job()` | |
+| `metrics_runner.py` | `start_metrics_job(session, overwrite, keys, fill_missing)`, `get_metrics_status()`, `cancel_metrics_job()` | |
| `workflow_sweep.py` | `parse_api_workflow()`, `suggest_bindings()`, `parse_for_ui()` (pure; no torch or server imports) | |
| `workflow_sweep_runner.py` | `dry_run()`, `api_start()`, `start_sweep()`, `get_status()`, `pause()`, `resume()`, `cancel()`, `ComfyBridge` | |
@@ -959,6 +961,9 @@ __init__.py
| `logic_utils.js` | `loadSession()`, `exportFavorites()`, `scanDirectory()`, `triggerGen()`, `toggleFullscreen()`, `deleteNonFavorites()` |
| `logic_virtual.js` | `renderDOM()`, `updateVisibleItems()`, `calculateVisibleRange()`, `autoFitZoom()`, `goToImage()` |
| `logic_pipeline.js` | `updateDataPipeline()`, `executePipeline()`, `processNewData()`, `incrementalFilter()` |
+| `logic_analytics.js` | `computeAnalytics()`, `sortAnalyticsRows()`, `analyticsToMarkdown()` (pure; tests in `tests/js/analytics.test.js`) |
+| `logic_analytics_panel.js` | `openAnalyticsPanel()`, `toggleAnalyticsPanel()`, `closeAnalyticsPanel()`, `renderAnalyticsPanel()`, `analyticsPanelOnDataChanged()` (live refresh hook from `updateJSONs`/`cmpAfterRatingsChanged`), `analyticsPanelMarkdown()` |
+| `logic_pipeline.js` (filters) | `pipelineFilterCtx()`, `pipelineMatches(d, ctx, ignoreVisibility)`: the filter callback of `executePipeline`, shared with the Stats panel |
| `logic_wfparams.js` | `wfParamKeys()`, `wfParamValue/Number()`, `wfParamMatches()`, `wfShortLabel()`, `wfDisplay()` (pure; tests in `tests/js/wfparams.test.js`) |
| `logic_ratings.js` | `computeBradleyTerry()`, `buildPairSource()`, `nextPair()`, `axisDiff()`, `computeScoreboard()` (pure; tests in `tests/js/ratings.test.js`, run `node --test tests/js/*.test.js`) |
| `logic_dragdrop.js` | `uscgImageRef()`, `uscgImageAssetInfo()`, `uscgAnnotatedPath()`, `uscgFillDragData()`, `USCG_DRAG_UPLOAD` (pure; tests in `tests/js/dragdrop.test.js`). Used by `logic_ui.js` (`dragstart` on `#m-img`, `sendModalToLoadImage()`). Parent bridge: iframe posts `uscg_send_to_load_image` {url, filename, subfolder, imageType, upload, node_id}, `web/dashboard.js` fills the selected/only/new LoadImage and answers `uscg_send_to_load_image_result` {ok, message} |
diff --git a/README.md b/README.md
index e0e5091..b4928b4 100644
--- a/README.md
+++ b/README.md
@@ -119,6 +119,7 @@ If you'd rather build the graph yourself:
- [JSON Bars](#json-bars-bottom--horizontal-layout)
- [Revision Modal](#revision-modal)
- [Pairwise Review](#pairwise-review-blind-ab-compare--scoreboard)
+ - [Stats Panel](#stats-panel-per-setting-analytics)
- [Standalone Dashboard](#standalone-dashboard-phone--second-monitor)
- [Example Workflows](#-example-workflows)
- [Quick Quality Test](#quick-quality-test-40-images)
@@ -208,7 +209,7 @@ If you'd rather build the graph yourself:
* **Seed Behavior Settings:** Per-config seed control — `fixed` uses the node seed, `randomize` generates a new seed per image, `full_run_seed` overrides the node seed entirely.
* **Configurable Label Mode:** Toggle overlay labels on dashboard cards showing model, sampler, scheduler, steps, CFG, and other parameters directly on each image.
* **GPU Cooldown:** Automatic pause between generations to prevent thermal throttling. Configurable interval, duration, and optional VRAM clearing.
-* **Image Metrics (optional):** Run Settings checkbox that stores sharpness, entropy, colorfulness, contrast, luma and saturation for every saved image in the manifest (see [Image Metrics](#image-metrics-optional)).
+* **Image Metrics (optional):** Run Settings checkbox that stores sharpness, entropy, colorfulness, contrast, luma, saturation and Sobel edges for every saved image in the manifest (see [Image Metrics](#image-metrics-optional)).
* **Workflow Sweep:** sweep any ComfyUI workflow exported as API JSON. Bind any input as an axis, dry run it, then pause, resume or cancel; every image lands in a normal USCG session (see [Workflow Sweep](#workflow-sweep-sweep-any-comfyui-workflow)).
* **Job Order (Minimize reloads):** reorders the whole job list so each model and LoRA stack loads as few times as possible (see [Job Order & Preview Pass](#job-order--preview-pass-run-settings)).
* **Preview Pass (generator side):** renders the whole grid cheaply first; reject the losers and only the survivors render at full quality (see [Job Order & Preview Pass](#job-order--preview-pass-run-settings)).
@@ -882,12 +883,14 @@ Run Settings has a **Compute Image Metrics** checkbox (default OFF). When it is
| `contrast` | Standard deviation of luma (RMS contrast) | 0-0.5 |
| `luma` | Mean brightness (Rec.601 luma) | 0-1 |
| `saturation` | Mean HSV saturation | 0-1 |
+| `sobel` | Mean Sobel gradient magnitude of luma (0-1 scale; kernels /4, so a full black-to-white step reads 1) | 0 = flat; most generations land well under 0.2 (measure your own); max ~1.41 |
Notes:
+* `sobel` complements `sharpness`: it responds to real edges and ignores pixel-level noise (a 1-pixel checkerboard reads 0 Sobel but a huge sharpness), so a high Sobel with a moderate sharpness means structure, not grain. It is absent for images smaller than 3 px on a side.
* Metrics are measured on an "analysis image": the long side is block-averaged down to at most 1024 px (by the smallest integer factor), so a 2x upscale stays comparable with its 1024 base image and the cost is capped. The constant is `ANALYSIS_MAX_SIDE` in `image_metrics.py`; changing it would make old sessions incomparable.
* They measure image **signal, not taste**: noise raises sharpness and entropy, and a dark moody image is not a worse one. Use them to rank and filter, then look.
* Upscales are measured again (a derived image never inherits its source's numbers). Dashboard-triggered upscales measure only when the source image had metrics.
-* Items from remote Distributed workers are measured too: the worker computes the built-in metrics on its decoded image, and the master scores the optional scorer packs for every worker image in one pass after collection (so workers do not need the scorer pack installed). An older companion/worker sends no metrics and the master measures the saved file instead.
+* Items from remote Distributed workers are measured too: the worker computes the built-in metrics on its decoded image, and the master scores the optional scorer packs for every worker image in one pass after collection (so workers do not need the scorer pack installed). An older companion/worker sends no metrics and the master measures the saved file instead; workers on an older USCG omit Sobel, and the master measures it from the saved file.
* Optional scorer packs plug in through `nodes.NODE_CLASS_MAPPINGS` without any import, and their scores are stored next to the built-ins. The Builder lists each pack with `installed`, `installed, but ` (for example "no model file in models/aesthetic") or `not installed (install ...)` (text only, nothing is downloaded); the checkbox is only clickable when the pack is usable. See **Optional scorer packs** below.
**Optional scorer packs**
@@ -900,7 +903,7 @@ Notes:
USCG passes **one image per call** (the node only scores image 1 of a batch) and rescales the node's integer x100 output. The node reloads CLIP from disk on every call, so expect several seconds per image (measured 7.6 s per image on an RTX 3070 8 GB next to an SDXL 1024x1024 run, about 0.9 GB extra VRAM; the SDXL run itself took 10-16 s per image). USCG asks ComfyUI's memory manager to free VRAM before each call. On a card this small a model may be evicted and reloaded, which is slower but correct. The node hardcodes `cuda`, so CPU/MPS/AMD hosts fail and the adapter switches itself off for the run with one warning (the built-in metrics are unaffected). A per-adapter timing line (`[GridTester] 📏 metric adapter 'aesthetic': N images, mean X ms`) is printed when the run ends.
* **ImageReward** has a registry entry checked against the source of `ZaneA/ComfyUI-ImageReward@5e9936d` (prompt comes from the item's positive prompt, score is the FLOAT output, roughly -2 to +2 and relative to the prompt, so only compare scores of the same prompt), but it ships **disabled**, and a wave-3 feasibility check (11/4/26) confirmed why: the only PyPI release the pack pins, `image-reward==1.4`, fails to import under transformers 5 (`find_pruneable_heads_and_indices` and `PreTrainedModel.get_head_mask` were removed), so it needs a hand patch of `site-packages/ImageReward/models/BLIP/med.py` on every transformers-5 host; it also pins `timm==0.6.13`, builds `fairscale`, downloads 1.8 GB on first load and may trip `torch.load(weights_only=True)`. On a transformers-4 host that has the pack working, enabling it is a one-word change in `image_metrics.ADAPTERS` (set `enabled` to True); note the score uses only the first ~35 BERT tokens of the prompt, so compare within one prompt only.
* **In the Dashboard:** once a session has metrics, the Sort popup gains one option per metric (descending, items without the metric sort last, in both the primary and secondary lists), the Logic Filter field list gains a **Metrics** group, and a **Metric range** row adds `>= min` / `<= max` filters in one click (placeholders show the observed range). The label panel has a **Metrics** checkbox that prints `Shp:812 Ent:7.41 Col:41.2 ...` on each card. Logic filters skip items that lack the field, so images without metrics (older items, videos) stay visible; Quick Filter is categorical and does not list metrics, use the Logic Filter for ranges. Sessions without metrics show none of this.
-* **Older sessions:** the cog panel's **IMAGE METRICS** section has **Compute metrics for this session**, a background job (CPU only, no VRAM, about 50 ms per image) that measures every image without metrics, skips videos, shows progress and can be cancelled by clicking the button again. It rewrites only the `metrics` field under the manifest lock, so favorites and notes you change meanwhile survive. Do not run it on a session that is still generating.
+* **Older sessions:** the cog panel's **IMAGE METRICS** section has **Compute metrics for this session**, a background job (CPU only, no VRAM, about 50 ms per image) that measures every image that is missing any metric (for example Sobel on sessions measured before it existed; values already stored are never changed), skips videos, shows progress and can be cancelled by clicking the button again. It rewrites only the `metrics` field under the manifest lock, so favorites and notes you change meanwhile survive. Do not run it on a session that is still generating.
* The setting is session-level (like GPU Cooldown), so it is NOT a per-config field: it lives in `_session_settings.metrics`, not in `_EXPECTED_CONFIG_KEYS` or `expand_configs`.
### Workflow Sweep (sweep any ComfyUI workflow)
@@ -1012,6 +1015,17 @@ The star is an absolute signal that says little about which *setting* wins. Comp
* **Manifest fields:** every upscaled item carries `base_width` / `base_height` (the size of the generated image the upscale started from; compare's Resolution axis uses them, older manifests fall back to an estimate from `upscale_ratio`). Scanned folders get `source_relpath` and **stable ids** (derived from the relative path, in `[2^52, 2^53)`, so they never collide with generated ids), so re-scanning a folder keeps favorites, notes, metrics and votes.
* **Caveat:** win % across mixed opponents is not a controlled effect size; pair choice is biased by the filter and the low-vote preference. Use it to rank candidates, then confirm with a matched one-axis run.
+### Stats Panel (per-setting analytics)
+
+The **bar-chart button** in the header bar (or **SETTINGS STATS (PER AXIS)** in the cog panel's Manifest Analytics section) opens a docked panel in the top-right corner (bottom of the screen on phones). It is not a modal: it stays open while you favorite, reject, filter, vote and while new images arrive, and refreshes itself (at most 4 times per second).
+
+* **One table per setting:** model, LoRA, sampler, scheduler, steps, CFG, denoise, resolution, attention mode, upscale, pass (preview or full), prompt, plus every Workflow Sweep parameter (`wf_params`; parameters with more than 64 distinct values, such as seeds, are skipped). Axes where every item has the same value are folded into a "Same for every item" line.
+* **Columns:** n, Fav % and Rej % (share of that value's items), Avg s (mean generation time), one column per metric the session has (mean over the items that have it) and Rating (mean pairwise rating, only after you voted). Click a header to sort; the choice is remembered.
+* **Small samples:** rows with fewer than 3 items are dimmed and always sorted below the rest; the best Fav % row (3 or more items) is highlighted.
+* **Scope:** *Current filter* applies the Quick/Button/Logic/Search filters but not the Favorites / Non-Favorited / Rejected visibility toggles, so rejects and favorites are still counted when the grid hides them (otherwise Reject % would always read 0). *Whole session* counts everything except superseded previews.
+* **Copy Markdown** copies every table for the current scope and sort, ready to paste into an issue or chat.
+* Press `Escape` (after the Filters popup) or the X to close; the panel can also be collapsed to its title bar.
+
### Standalone Dashboard (phone / second monitor)
The dashboard is also served as a normal web page, with the same live updates as the node:
@@ -1566,7 +1580,7 @@ Bot pattern: `POST /run`, post `dashboard_url` to the channel, poll `run_status`
| GET | `/config_tester/manifest` | A session's manifest as compact JSON `?session=` (gzip, ETag / 304); what the Dashboard fetches |
| POST | `/config_tester/save_comparisons` | Append / remove Pairwise Review votes `{session_name, append, remove}` |
| POST | `/config_tester/clear_comparisons` | Delete every vote of a session `{session_name}` |
-| POST | `/config_tester/compute_metrics` | Start the background metrics job for a session `{session_name, overwrite?}`; returns `{job_id, total_images}` |
+| POST | `/config_tester/compute_metrics` | Start the background metrics job for a session `{session_name, overwrite?, fill_missing?}`; returns `{job_id, total_images}` |
| GET | `/config_tester/metrics_status` | Progress of a metrics job, `?job_id=` |
| POST | `/config_tester/cancel_metrics` | Cancel a metrics job `{job_id}` |
| POST | `/config_tester/sweep/parse` | Workflow Sweep: describe an API-format workflow `{api_json}` |
@@ -1625,7 +1639,7 @@ ComfyUI/output/benchmarks/model-data/
| `+/-` | Zoom in/out |
| `0` | Reset zoom to 1:1 |
| `F` | Auto-fit first row to viewport width |
-| `Escape` | Close active modal/popup (priority: Revision > Scoreboard > Analytics > Settings > Filters) |
+| `Escape` | Close active modal/popup (priority: Revision > Scoreboard > Analytics > Settings > Filters > Stats panel) |
| `C` | Open Compare mode (blind pairwise review, see [Pairwise Review](#pairwise-review-blind-ab-compare--scoreboard)) |
### Dashboard: Compare mode (blind pairwise review)
@@ -1832,10 +1846,12 @@ After modifying backend Python files, restart ComfyUI. After modifying `web/` JS
* 📐 **Latent input size:** a connected latent's image size was always cells x 8; it is now cells x the loaded model's latent ratio (16 for Flux.2/MOSS-Image/Krea 2).
### Update 11/4/26: Standalone dashboard, pairwise review, image metrics, preview pass, workflow sweeps
+* 📊 **Stats panel:** header button (and cog entry) opens a live, docked panel with favorites rate, reject rate, mean time, mean metrics and mean rating per sampler, scheduler, model, LoRA, steps, CFG, resolution, pass and every workflow-sweep parameter, with a Copy Markdown button (see [Stats Panel](#stats-panel-per-setting-analytics)).
+* 🔲 **Sobel edge metric:** new built-in image metric `sobel` (mean Sobel gradient of luma, 0-1) next to Sharpness; "Compute metrics for this session" now also fills in metrics that older items are missing, without changing stored values.
* 🧩 **Regional prompts (`COUPLE`):** `base COUPLE left | right` (or `COUPLE(rows)`, 2-4 regions, per-region `:w` weight) gives each part of the image its own prompt using native masked conditioning. Triggers, model prefix/suffix and HiRes "Append to end" text go into the base; works with HiRes, tiled HiRes and Florence2 crops. The conditioning cache on disk now keeps every entry (AND prompts included), so older cache files are re-encoded once. See [Prompt Combinators & Regional Prompts (COUPLE)](#prompt-combinators--regional-prompts-couple).
* 📱 **Standalone Dashboard + Run API:** open the dashboard as a normal web page at `/config_tester/dashboard?session=` (phone, second monitor) with live updates over its own websocket. `POST /config_tester/run` and `GET /config_tester/run_status` start and track headless runs (Discord bot, curl) without any workflow JSON.
* ⚖️ **Pairwise Review:** blind A/B compare mode (`C` key) with matched, same-settings, one-axis and random pairing, Bradley-Terry ratings (Sort by Rating) and a per-setting scoreboard with 95% intervals. Votes are stored in the session manifest under `comparisons`.
-* 📏 **Image Metrics (optional):** a Run Settings checkbox stores sharpness, entropy, colorfulness, contrast, luma and saturation for every saved image. Sort, filter and label by them in the Dashboard, or compute them for older sessions from the cog panel.
+* 📏 **Image Metrics (optional):** a Run Settings checkbox stores sharpness, entropy, colorfulness, contrast, luma, saturation and Sobel edges for every saved image. Sort, filter and label by them in the Dashboard, or compute them for older sessions from the cog panel.
* 🧭 **Job Order & Preview Pass:** `Minimize model/LoRA reloads` reorders the whole job list so each model and LoRA stack loads as few times as possible. Preview Pass renders the whole grid cheaply first; reject the losers and only the survivors render at full quality (PREVIEW badge, Pass filter, "Full pass for survivors" button).
* 🔀 **Workflow Sweep:** sweep any ComfyUI workflow exported as API JSON. Bind any input as an axis (prompts, LoRAs, resolution, seeds, any widget), dry run it, then pause, resume or cancel; every image lands in a normal USCG session.
* ⚡ **Faster prompt encoding:** the text encoder now loads once per pre-encode pass instead of once per prompt, and the new `te_batch_size` setting encodes several prompts per pass for MOSS-Image, Krea 2 and Qwen-Image (off by default, self-checked, OOM-safe).
diff --git a/Roadmap.md b/Roadmap.md
index fd49f7a..e35d578 100644
--- a/Roadmap.md
+++ b/Roadmap.md
@@ -150,10 +150,10 @@ Compute objective quality metrics during generation and store in manifest:
- **Sobel edge quality** (detail preservation)
Add as sortable/filterable fields in Dashboard. Enables data-driven parameter selection instead of purely visual comparison. Prior art: SamplerSchedulerMetricsTester (proves the concept, only 9 stars but validates demand).
-DONE (11/4/26): built-in metrics (sharpness, entropy, colorfulness, contrast, luma, saturation), Dashboard sort/filter/labels, backfill job for old sessions. Still open: Sobel edge quality, scorer packs.
+DONE (11/4/26): Sobel edge quality (sobel), backfill fills missing keys. Built-in metrics (sharpness, entropy, colorfulness, contrast, luma, saturation, sobel), Dashboard sort/filter/labels, backfill job for old sessions. Still open: scorer packs.
#### **26. Analytics Panel in Dashboard** (transforms tool from "look at pictures" to "data-driven decisions")
-* **Milestone 1:** Aggregate stats bar — avg generation time by sampler/scheduler, favorites rate by model/LoRA
+* **Milestone 1:** Aggregate stats bar — avg generation time by sampler/scheduler, favorites rate by model/LoRA. DONE (11/4/26) Milestone 1: Stats panel (computeAnalytics in resources/logic_analytics.js is the data source for M2).
* **Milestone 2:** Canvas-based charts — bar charts, scatter plots (steps vs duration, CFG vs quality metrics)
* **Milestone 3:** Export — downloadable reports with charts + top images
Pure client-side using canvas API from existing `activeData`. Prior art: W&B dashboards.
diff --git a/__init__.py b/__init__.py
index e647d92..eab2b30 100644
--- a/__init__.py
+++ b/__init__.py
@@ -1452,7 +1452,8 @@ async def compute_metrics(request):
if not _is_path_within(base_dir, _get_benchmarks_base()):
return web.json_response({"error": "Forbidden: path outside benchmarks directory"}, status=403)
from .metrics_runner import start_metrics_job, get_metrics_status
- job_id, error = start_metrics_job(session_name, overwrite=bool(data.get("overwrite", False)))
+ job_id, error = start_metrics_job(session_name, overwrite=bool(data.get("overwrite", False)),
+ fill_missing=bool(data.get("fill_missing", False)))
if error:
return web.json_response({"error": error}, status=400)
return web.json_response({"job_id": job_id, "total_images": get_metrics_status(job_id)["total"]})
diff --git a/generation_orchestrator.py b/generation_orchestrator.py
index f167c1e..048a046 100644
--- a/generation_orchestrator.py
+++ b/generation_orchestrator.py
@@ -3324,7 +3324,9 @@ def _distributed_metrics_pass(existing_data, pre_ids, images_dir, image_path_for
have = item.get("metrics") or {}
need_builtins = bool(spec.get("builtins")) and not have
need_adapters = any(a not in have for a in adapter_names)
- if not (need_builtins or need_adapters):
+ # A worker on an older USCG omits builtins it does not know (e.g. sobel): fill just those from the file
+ missing_b = [k for k in (spec.get("builtins") or []) if have and k not in have]
+ if not (need_builtins or need_adapters or missing_b):
continue
path = image_path_for(item, images_dir)
if path is None:
@@ -3339,7 +3341,13 @@ def _distributed_metrics_pass(existing_data, pre_ids, images_dir, image_path_for
image_metrics.attach(item, pil)
out["builtins_from_file"] += 1
else:
- image_metrics.attach_adapters(item, pil)
+ if missing_b:
+ extra = image_metrics.compute_for_pil(pil, missing_b)
+ cur = item.setdefault("metrics", {})
+ cur.update({k: v for k, v in extra.items() if k not in cur})
+ out["builtins_from_file"] += 1
+ if need_adapters:
+ image_metrics.attach_adapters(item, pil)
if need_adapters:
out["adapters"] += 1
return out
diff --git a/html_generator.py b/html_generator.py
index a30b418..62a78d5 100644
--- a/html_generator.py
+++ b/html_generator.py
@@ -34,12 +34,14 @@ def get_html_template(title, manifest_data, node_id, standalone=False, inline_ma
"logic_utils.js", # API & Helper functions
"logic_ratings.js", # Pairwise review: pure logic (normalisers, Bradley-Terry, pairing, scoreboard)
"logic_wfparams.js", # Workflow Sweep: wf_params helpers (pure; must load before logic_ui/logic_pipeline)
+ "logic_analytics.js", # Settings stats: computeAnalytics/analyticsToMarkdown (pure; after ratings + wfparams)
"logic_dragdrop.js", # Revise-modal image drag/send helpers (issue #22; pure, node-testable)
"logic_ui.js", # DOM Creation, Modals, JSON Bars
"logic_upscale_modal.js", # Upscale Modal (preset management, async upscale)
"logic_virtual.js", # The Virtual Scroll Engine & Viewport
"logic_pipeline.js", # Sorting & Filtering Logic
"logic_compare.js", # Pairwise review: compare overlay, votes, scoreboard modal
+ "logic_analytics_panel.js", # Settings stats panel (DOM; uses cmpEl/cmpCopyText/cmpRatings from logic_compare)
"logic_events.js", # Event Listeners (Message, Keyboard)
"logic_init.js", # Initialization
"logic_standalone.js" # Standalone-page mode (own websocket, run API); must stay LAST
diff --git a/image_metrics.py b/image_metrics.py
index 75f514d..a33215f 100644
--- a/image_metrics.py
+++ b/image_metrics.py
@@ -16,6 +16,7 @@ upscale stays comparable with its base and the cost is capped.
contrast std of luma (RMS contrast, 0..0.5)
luma mean of Y = 0.299 R + 0.587 G + 0.114 B
saturation mean HSV saturation
+ sobel mean Sobel gradient magnitude of luma (0-1 scale, kernels /4: a full step edge = 1)
These measure signal, not taste: noise raises sharpness and entropy.
ANALYSIS_MAX_SIDE is a versioned constant; changing it breaks comparisons
@@ -28,8 +29,9 @@ import time
import numpy as np
-BUILTIN_METRICS = ("sharpness", "entropy", "colorfulness", "contrast", "luma", "saturation")
+BUILTIN_METRICS = ("sharpness", "entropy", "colorfulness", "contrast", "luma", "saturation", "sobel")
ANALYSIS_MAX_SIDE = 1024
+_SOBEL_SCALE = 0.25
_LUMA_W = (0.299, 0.587, 0.114)
@@ -183,6 +185,11 @@ def compute_metrics_numpy(arr, keys=BUILTIN_METRICS):
mn = a.min(axis=-1)
s = np.where(mx >= 1e-6, (mx - mn) / np.maximum(mx, np.float32(1e-6)), np.float32(0.0))
out["saturation"] = float(s.mean(dtype=np.float64))
+ if "sobel" in keys and y.shape[0] >= 3 and y.shape[1] >= 3:
+ two = np.float32(2.0)
+ gx = (y[:-2, 2:] + two * y[1:-1, 2:] + y[2:, 2:]) - (y[:-2, :-2] + two * y[1:-1, :-2] + y[2:, :-2])
+ gy = (y[2:, :-2] + two * y[2:, 1:-1] + y[2:, 2:]) - (y[:-2, :-2] + two * y[:-2, 1:-1] + y[:-2, 2:])
+ out["sobel"] = float((np.sqrt(gx * gx + gy * gy) * np.float32(_SOBEL_SCALE)).mean(dtype=np.float64))
return {k: _round4(v) for k, v in out.items() if math.isfinite(v)}
@@ -278,6 +285,12 @@ def compute_metrics_torch(images, keys=BUILTIN_METRICS):
s = torch.where(mx >= 1e-6, (mx - mn) / mx.clamp_min(1e-6), torch.zeros_like(mx))
cols.append(s.reshape(B, -1).mean(dim=1))
names.append("saturation")
+ if "sobel" in keys and H >= 3 and W >= 3:
+ kx = torch.tensor([[-1.0, 0.0, 1.0], [-2.0, 0.0, 2.0], [-1.0, 0.0, 1.0]], device=y.device, dtype=y.dtype)
+ k = (torch.stack([kx, kx.t()]) * _SOBEL_SCALE)[:, None] # [2,1,3,3]
+ gr = torch.nn.functional.conv2d(y[:, None], k) # [B,2,H-2,W-2]
+ cols.append(gr.pow(2).sum(dim=1).sqrt().reshape(B, -1).mean(dim=1))
+ names.append("sobel")
if not cols:
return [{} for _ in range(B)]
table = torch.stack([c.float() for c in cols], dim=1).cpu().tolist() # the one sync
diff --git a/metrics_runner.py b/metrics_runner.py
index 5fad9b5..9c029be 100644
--- a/metrics_runner.py
+++ b/metrics_runner.py
@@ -48,7 +48,7 @@ def _image_path_for(item, images_dir):
return path
-def start_metrics_job(session_name, overwrite=False, keys=None):
+def start_metrics_job(session_name, overwrite=False, keys=None, fill_missing=False):
"""Start the job. Returns (job_id, error). session_name must already be sanitized by the caller."""
import folder_paths
benchmarks_dir = os.path.realpath(os.path.join(folder_paths.get_output_directory(), "benchmarks"))
@@ -67,8 +67,16 @@ def start_metrics_job(session_name, overwrite=False, keys=None):
with manifest_utils._manifest_lock:
with open(manifest_path, "r", encoding="utf-8") as f:
items = json.load(f).get("items", [])
- targets = [it["id"] for it in items if "id" in it and (overwrite or not it.get("metrics"))
- and _image_path_for(it, images_dir)]
+ if overwrite:
+ targets = [it["id"] for it in items if "id" in it and _image_path_for(it, images_dir)]
+ elif fill_missing:
+ # Opt-in: items that have SOME metrics but lack a requested key (e.g. Sobel on older sessions)
+ targets = [it["id"] for it in items if "id" in it
+ and any(k not in (it.get("metrics") or {}) for k in spec["builtins"])
+ and _image_path_for(it, images_dir)]
+ else:
+ targets = [it["id"] for it in items if "id" in it and not it.get("metrics")
+ and _image_path_for(it, images_dir)]
if not targets:
return None, "Nothing to compute (every image already has metrics, or there are no images)"
for jid, j in list(_jobs.items()):
@@ -80,7 +88,7 @@ def start_metrics_job(session_name, overwrite=False, keys=None):
"failed": 0, "error": None, "cancel": threading.Event()}
_jobs[job_id] = job
threading.Thread(target=_run_metrics_thread,
- args=(job, targets, spec["builtins"], overwrite, manifest_path, images_dir, session_name),
+ args=(job, targets, spec["builtins"], overwrite, manifest_path, images_dir, session_name, fill_missing),
daemon=True).start()
return job_id, None
@@ -100,8 +108,11 @@ def cancel_metrics_job(job_id):
return False
-def _flush(manifest_path, results, overwrite):
- """Merge `results` ({id: metrics}) into the manifest on disk under the lock; atomic replace."""
+def _flush(manifest_path, results, overwrite, merge=False):
+ """Merge `results` ({id: metrics}) into the manifest on disk under the lock; atomic replace.
+
+ merge=True (fill_missing): add only keys the item on disk does not have (disk wins; adapter keys survive).
+ """
if not results:
return
with manifest_utils._manifest_lock:
@@ -109,7 +120,10 @@ def _flush(manifest_path, results, overwrite):
data = json.load(f)
for it in data.get("items", []):
m = results.get(it.get("id"))
- if m and (overwrite or not it.get("metrics")):
+ if m and merge:
+ cur = it.get("metrics") or {}
+ it["metrics"] = {**cur, **{k: v for k, v in m.items() if k not in cur}}
+ elif m and (overwrite or not it.get("metrics")):
it["metrics"] = m
tmp = manifest_path + ".metrics.tmp"
with open(tmp, "w", encoding="utf-8") as f:
@@ -128,7 +142,7 @@ def _send_progress(job, session_name):
pass
-def _run_metrics_thread(job, target_ids, keys, overwrite, manifest_path, images_dir, session_name):
+def _run_metrics_thread(job, target_ids, keys, overwrite, manifest_path, images_dir, session_name, fill_missing=False):
pending = {}
try:
with manifest_utils._manifest_lock:
@@ -144,7 +158,8 @@ def _run_metrics_thread(job, target_ids, keys, overwrite, manifest_path, images_
raise ValueError("no image file")
with Image.open(path) as im:
arr = np.asarray(im.convert("RGB"))
- m = image_metrics.compute_metrics_numpy(arr, keys)
+ need = [k for k in keys if k not in (it.get("metrics") or {})] if fill_missing else keys
+ m = image_metrics.compute_metrics_numpy(arr, need) if need else {}
if m:
pending[tid] = m
else:
@@ -154,10 +169,10 @@ def _run_metrics_thread(job, target_ids, keys, overwrite, manifest_path, images_
print(f"[GridTester] ⚠️ metrics skipped for item {tid}: {e}")
job["completed"] += 1
if len(pending) >= FLUSH_EVERY:
- _flush(manifest_path, pending, overwrite)
+ _flush(manifest_path, pending, overwrite, fill_missing)
pending = {}
_send_progress(job, session_name)
- _flush(manifest_path, pending, overwrite)
+ _flush(manifest_path, pending, overwrite, fill_missing)
job["status"] = "cancelled" if job["cancel"].is_set() else "complete"
print(f"[GridTester] 📏 metrics job {job['status']}: {job['completed']}/{job['total']} images, {job['failed']} skipped")
except Exception as e:
diff --git a/resources/logic_analytics.js b/resources/logic_analytics.js
new file mode 100644
index 0000000..92452a5
--- /dev/null
+++ b/resources/logic_analytics.js
@@ -0,0 +1,269 @@
+/**
+ * SETTINGS STATS (ANALYTICS) - PURE LOGIC (no DOM access)
+ * Groups the items by every setting axis (model, LoRA, sampler, ... plus workflow-sweep params) and
+ * reduces each group to favorite/reject rate, mean duration, mean metrics and mean pairwise rating.
+ * The dashboard inlines every logic_*.js into ONE