diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 55032aa..082f568 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -16,9 +16,9 @@ jobs: timeout-minutes: 20 steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - - uses: actions/setup-python@v5 + - uses: actions/setup-python@v6 with: python-version: "3.12" cache: pip @@ -31,7 +31,7 @@ jobs: - name: Upload coverage if: always() - uses: codecov/codecov-action@v4 + uses: codecov/codecov-action@v5 with: token: ${{ secrets.CODECOV_TOKEN }} files: coverage.xml @@ -48,9 +48,9 @@ jobs: timeout-minutes: 45 steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - - uses: actions/setup-python@v5 + - uses: actions/setup-python@v6 with: python-version: "3.12" cache: pip @@ -71,7 +71,7 @@ jobs: run: pyinstaller AstroImageLab.spec - name: Upload artifact - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v5 with: name: AstroImageLab-${{ matrix.os }} path: dist/AstroImageLab-*.zip diff --git a/AstroImageLab.py b/AstroImageLab.py index e6354f0..f4eeeeb 100644 --- a/AstroImageLab.py +++ b/AstroImageLab.py @@ -4,8 +4,8 @@ # # PR → merge to main (CI runs tests + build to verify everything works) # Tag the merge commit on main → triggers the release workflow -# git tag v0.0.9 -# git push origin v0.0.9 +# git tag v0.0.10 +# git push origin v0.0.10 import sys import os diff --git a/CLAUDE.md b/CLAUDE.md index 10cfb48..b117d2f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -42,6 +42,9 @@ report/ synthetic/ cameras.py Camera database — 24 models (ZWO, QHY, Player One) generator.py Image generation engine: Moffat PSF, aberrations, nebula, starless export +tools/ + generate_icon.py One-off dev script: regenerates resources/icon.ico + generate_screenshots.py One-off dev script: regenerates resources/*.png screenshots used by README.md/QuickStart.md ``` --- @@ -54,6 +57,7 @@ synthetic/ | `_val(v, fmt, fallback="—")` | `report_builder.py:184` | Null-safe table cell formatter | | `fig_to_b64(fig)` | `core/fig_utils.py` | Embeds matplotlib figure as base64 PNG string | | `finalize_layout(fig, **kwargs)` | `core/fig_utils.py` | Runs `fig.tight_layout(**kwargs)` under the same process-wide lock as `fig_to_b64()`'s `savefig()`. Call this instead of `fig.tight_layout()` directly in any figure-building code that can execute concurrently with other figure-building code — see the mathtext race pitfall below | +| `locked_draw_call(fn, *args, **kwargs)` | `core/fig_utils.py` | Runs a matplotlib call that can trigger draw-adjacent text/layout measurement (`fig.colorbar(...)`, `ax.legend(...)` — especially `loc="best"` auto-placement) under the same `_MPL_DRAW_LOCK` as `finalize_layout()`/`fig_to_b64()`. Call this instead of `fig.colorbar(...)`/`ax.legend(...)` directly in any figure-building code that can execute concurrently with other figure-building code — see the mathtext race pitfall below | | `normalize_for_display(arr)` | `core/stretch.py` | STF-stretch float32 array → uint8 [0,255] for QImage display | | `stf_stretch(data)` | `core/stretch.py` | STF midtone-balance stretch → float32 [0,1]; maps sky to ~20 % grey | | `load_path(path)` | `gui/image_panel.py` | Load image by path with no dialog and no starless prompt | @@ -74,6 +78,7 @@ synthetic/ | `_format_significance_html(p, delta)` / `_sig_td(html, p)` | `report_builder.py` | Shared star-rating/p-value HTML cell + colored `
| ` — direction is per-metric via `higher_is_better`, not global. Used across Section 4's PSF table, Section 6's edge table, Section 7's power-spectrum table, and Section 10's summary table; reuse this rather than hand-rolling a new green/red comparison whenever a table gains an A-vs-B metric column |
---
@@ -134,6 +139,15 @@ Use significant-figure formats for any value that can span orders of magnitude:
| Dimensionless ratios (noise factor) | `.3f` |
| Percentages | `.4f` |
+**Use literal `e` format (`.2e`/`.3e`), not `.Ng`, when scientific notation is
+specifically required.** `.Ng` only switches to exponential form when the value's
+exponent falls outside roughly `[-4, N)` — `0.0034` under `.3g` prints as plain
+`0.0034`, not scientific notation. For a column that must always render in
+`d.dde±dd` form regardless of magnitude (e.g. Section 6's Gradient magnitude,
+typically sub-0.01 ADU-scale Sobel gradients), use `.2e` directly rather than
+reaching for this codebase's usual `.Ng` significant-figure convention — the two
+solve different problems (never-collapses-to-zero vs. always-scientific).
+
### Long f-string HTML blocks
Pre-compute any Python variable **before** a `return f"""..."""` block. Do not nest
@@ -281,6 +295,22 @@ When adding a new A-vs-B ratio curve to a report figure (precedent: `_power_rati
values — same tool as the existing display-clipping precedent
(`_plot_side_by_side`'s `np.percentile(arr, 0.5)`), applied to the epsilon floor
instead of just the color scale.
+- **Resample onto a common grid instead of requiring exact bin alignment when the
+ domain is fixed by a shared constant, even if bin count varies per-image.** The
+ "guard bin alignment, return `None`" rule above is correct when two frequency axes
+ come from independently-sized data with no guaranteed relationship (Section 7's
+ power-spectrum ROI). It's the wrong tool when the axis *domain* is fixed by a
+ global constant even though *bin count* varies per-image — e.g. Section 4's MTF
+ ratio (`_mtf_ratio_db`/`_plot_mtf_ratio_db`), where each image's MTF bin count
+ depends on that image's own median star FWHM (`_compute_mtf`'s `nbins = n // 2`,
+ with `n` derived from `box_size`/`fwhm_estimate`) but the frequency domain is
+ always `[0, 0.5 * EPSF_OVERSAMPLING]` cycles/native-px since `EPSF_OVERSAMPLING`
+ is a fixed module constant, not per-image. Requiring exact alignment there would
+ make the ratio figure almost never render — differing FWHM between the two images
+ being compared is the point of the comparison, not an edge case. Resample both
+ curves onto a shared `np.interp`-built grid before dividing instead (precedented
+ on this exact data: `mtf_nyq = float(np.interp(0.5, freq, mtf))`,
+ `psf_analyzer.py:142`).
### Background estimation — compute once via the pre-pass, never redundantly
@@ -535,8 +565,53 @@ pytest tests/ --cov=analysis,core,synthetic,report --cov-report=html
| OS-level screenshots of the PyQt6 GUI are unreliable for verifying a layout change | `Get-WindowRect`/`SetWindowPos`/`Screen.Bounds` from a non-DPI-aware PowerShell process and the actual rendered window disagreed with each other by inconsistent ratios (not a single uniform scale factor) on a scaled Windows display, making a window that should fit on-screen appear clipped, and vice versa — even maximizing the window didn't reliably show all of it. Prefer `QWidget.grab()` (or `QMainWindow.grab()`) from a small headless script that constructs the widget/window directly and saves the returned `QPixmap` to a PNG — this renders through Qt's own coordinate system with a consistent `devicePixelRatio`, sidestepping OS/DPI virtualization entirely, and doesn't require a visible window at all. For precise sizing decisions (e.g. tuning a `setMaximumHeight`), read `QWidget.sizeHint()`/`minimumSizeHint()` directly instead of eyeballing a rendered image — see the "Adding GUI parameter rows..." pitfall above for the exact approach. |
| Overview figure's box/marker count silently drifted from its own caption | `EdgeAnalyzer.analyze()`'s gradient-magnitude overview map was given `rois_used` — every *searched* candidate ROI (`N_CANDIDATE_EDGES = EDGE_N_TOP_EDGES * 3`, e.g. 9) — instead of only the edges actually *accepted* into `edges` (capped at `EDGE_N_TOP_EDGES`, e.g. 3), so the map drew 9 cyan boxes while its own caption said "three selected." Root cause: `rois_used` was assigned once, early, from the full candidate list, and never reassigned after low-quality candidates got filtered out of `edges`. Whenever a display figure loops over a list to draw one marker/box per entry, verify that list is the actually-used subset the caption describes, not the broader search/candidate pool that produced it — fixed by reassigning `rois_used = [e["roi_used"] for e in edges]` right after `edges` is finalized, before it's read by `_plot_gradient_map`/stored in the result dict. |
| A "plausible-looking" derived metric can still be measuring the wrong thing entirely | `EdgeAnalyzer._extract_esf`'s `rotation_angle = -(90.0 - angle_deg)` (present since the file's first commit) looked like a reasonable 90°-complement but actually rotated the edge **horizontally**, not vertically as its own design requires (`esf_raw = nanmean(rotated, axis=0)` averages *down columns*, so the edge must run vertically for that average to stay on one side of the transition). The bug was invisible to `tests/test_analysis/test_edge_analyzer.py` because every test there checked `_esf_quality` (a monotonicity ratio) or structural shape (no NaN, normalized range) — never the actual measured width against a *known* ground truth — and the wrong-orientation artifact (a disc-boundary/interpolation trend) happened to also be smooth and monotonic, so it passed every existing gate while over-measuring width by 7–14x on a synthetic edge with a known Gaussian blur sigma. Diagnosed by rendering `rotate()`'s output for several known angles and inspecting it directly (ASCII-art / value dump, not just numbers) — the same "don't hand-derive `rotate()`'s convention" principle documented above ("Locating a point across `scipy.ndimage.rotate()`..."), applied to the rotation *angle formula* itself rather than just a post-hoc point lookup. When a metric's test suite only checks shape/monotonicity/range properties, add at least one test with an analytically-known true value (`tests/test_analysis/test_edge_analyzer.py::TestEdgeWidthAccuracy`, using the erf-profile width of a Gaussian-blurred step edge) — shape-only checks can pass on a metric that's confidently, monotonically, consistently wrong. |
-| Locking `savefig()` alone doesn't fix the mathtext `ParseException` race | `core/fig_utils.py`'s `_MPL_DRAW_LOCK` (formerly `_SAVEFIG_LOCK`) was originally applied only inside `fig_to_b64()`'s `savefig()` call, on the theory that `savefig()` was the only draw-triggering call site. It wasn't: `fig.tight_layout()` also runs a full draw pass to measure text extents (titles, tick labels, legends), which hits the same non-thread-safe pyparsing packrat cache mathtext uses. `PowerSpectrumAnalyzer.analyze()` calls `fig.tight_layout()` unprotected inside its `_plot_results()`, and `gui/analysis_thread.py` runs image A/B's `PowerSpectrumAnalyzer().analyze()` concurrently in a 2-worker `ThreadPoolExecutor` (and, in parallel mode, alongside every other analyzer's figure building too) — so an unlocked `tight_layout()` in one thread reliably corrupted the cache mid-parse in another, surfacing as `⚠ Analysis failed: ... ParseException: exception raised in parse action (at char 0), (line:1, col:1)` on Section 7. Reproduced directly: 60 concurrent `tight_layout()` + locked-`savefig()` calls threw the exact same `ParseException` in a stress test; wrapping `tight_layout()` in the same lock (`core/fig_utils.py::finalize_layout()`) brought the error count to zero over the same stress test. Fixed at every call site that can run concurrently with other figure-building code: `power_spectrum.py`, `snr_analyzer.py`, `psf_analyzer.py`, `image_filters.py`, `halo_analyzer.py`, `edge_analyzer.py`, and `gui/halo_dialog.py` (its `_AnalyzeThread` isn't gated against a concurrent `Run Analysis` pass, so it's a real concurrent path too). `report/report_builder.py`'s `tight_layout()` calls were deliberately left as plain `fig.tight_layout()` — report generation runs strictly serially after every analyzer thread has already joined (`gui/analysis_thread.py`'s comment: "Report generation (always serial — needs all results)"), so there's nothing for those calls to race against; wrapping them would be lock overhead with no behavioral benefit. When adding a new figure-building method anywhere that *can* run inside a `ThreadPoolExecutor` alongside other figure code, call `finalize_layout(fig, **kwargs)` instead of `fig.tight_layout(**kwargs)` — never assume only `savefig()` needs the lock. |
+| Locking `savefig()` alone doesn't fix the mathtext `ParseException` race | `core/fig_utils.py`'s `_MPL_DRAW_LOCK` (formerly `_SAVEFIG_LOCK`) was originally applied only inside `fig_to_b64()`'s `savefig()` call, on the theory that `savefig()` was the only draw-triggering call site. It wasn't: `fig.tight_layout()` also runs a full draw pass to measure text extents (titles, tick labels, legends), which hits the same non-thread-safe pyparsing packrat cache mathtext uses. `PowerSpectrumAnalyzer.analyze()` calls `fig.tight_layout()` unprotected inside its `_plot_results()`, and `gui/analysis_thread.py` runs image A/B's `PowerSpectrumAnalyzer().analyze()` concurrently in a 2-worker `ThreadPoolExecutor` (and, in parallel mode, alongside every other analyzer's figure building too) — so an unlocked `tight_layout()` in one thread reliably corrupted the cache mid-parse in another, surfacing as `⚠ Analysis failed: ... ParseException: exception raised in parse action (at char 0), (line:1, col:1)` on Section 7. Reproduced directly: 60 concurrent `tight_layout()` + locked-`savefig()` calls threw the exact same `ParseException` in a stress test; wrapping `tight_layout()` in the same lock (`core/fig_utils.py::finalize_layout()`) brought the error count to zero over the same stress test. Fixed at every call site that can run concurrently with other figure-building code: `power_spectrum.py`, `snr_analyzer.py`, `psf_analyzer.py`, `image_filters.py`, `halo_analyzer.py`, `edge_analyzer.py`, and `gui/halo_dialog.py` (its `_AnalyzeThread` isn't gated against a concurrent `Run Analysis` pass, so it's a real concurrent path too). `report/report_builder.py`'s `tight_layout()` calls were deliberately left as plain `fig.tight_layout()` — report generation runs strictly serially after every analyzer thread has already joined (`gui/analysis_thread.py`'s comment: "Report generation (always serial — needs all results)"), so there's nothing for those calls to race against; wrapping them would be lock overhead with no behavioral benefit. When adding a new figure-building method anywhere that *can* run inside a `ThreadPoolExecutor` alongside other figure code, call `finalize_layout(fig, **kwargs)` instead of `fig.tight_layout(**kwargs)` — never assume only `savefig()` needs the lock. The lock's own comment already said "savefig() is not the only trigger," but coverage still had a gap: `image_filters.py`'s `fig.colorbar(...)`/`ax.legend(...)` calls (13 sites, several using `loc="best"` auto-placement, which needs text-extent measurement to find a non-overlapping spot) ran unlocked inside `SpatialDetailAnalyzer`'s always-on 5-way `ThreadPoolExecutor`. Extended in the same session that fixed the `_compute_std_map` bottleneck below: added `locked_draw_call(fn, *args, **kwargs)` to `core/fig_utils.py` and wrapped every colorbar/legend call site in `image_filters.py` with it. |
| Dropping a new file into `resources/` is enough — no `.spec` edit needed | `AstroImageLab.spec` bundles the entire directory in one line (`datas += [("resources", "resources")]`), not a per-file list. Any new asset placed in `resources/` (e.g. `AstroImageLabSplash.png`) is automatically included in the Windows/macOS/Linux PyInstaller builds without touching the spec file — confirmed when the splash screen was switched from a procedurally-painted `QPixmap` to loading `resources/AstroImageLabSplash.png` directly via `QPixmap(path).scaledToWidth(...)`. |
+| A headless script can't regain control while a `QMessageBox` is up — only a pre-armed `QTimer` can | `QMessageBox.information()`/`.question()` block the caller via a *nested* Qt event loop. A plain Python `while` loop calling `app.processEvents()` cannot run any of its own code again until the dialog closes, so it can neither detect nor dismiss the dialog itself. A `QTimer` already running *before* the blocking call is made keeps firing inside that nested loop (the same mechanism that keeps the rest of the UI responsive during any modal dialog) — so `tools/generate_screenshots.py`'s `arm_modal_capture()` arms a repeating 100 ms `QTimer` that polls `QApplication.activeModalWidget()`, grabs+saves it, and clicks its button to unblock the caller. It must be armed *before* triggering the action that raises the dialog, never after. For a mid-analysis-run screenshot, gate the capture on the real `metric_started` signal count (not a wall-clock `QTimer.singleShot` guess) so timing stays correct regardless of machine speed. |
+| `MainWindow._on_roi_selected` only stores ROI state — it never draws the overlay | Unlike the line overlay (`ZoomableImageLabel.set_line_normalised()`, a public method `_on_line_selected` calls directly), there is no equivalent public setter for the ROI box. The mouse-driven `mouseReleaseEvent` writes straight to `ZoomableImageLabel`'s private `_roi_norm` attribute and calls `.update()`. Any code that needs to draw an ROI programmatically (e.g. `tools/generate_screenshots.py`) must poke `panel._img_label._roi_norm = (x0n, y0n, x1n, y1n)` + `.update()` itself, in addition to calling `_on_roi_selected(x0, y0, x1, y1)` (pixel coords, not normalised) to keep `MainWindow`/`AnalysisControlPanel` state consistent. |
+| Removing a feature doesn't auto-sync README.md/QuickStart.md | Ghost detection (removed 2026-05-22) and PDF export (removed 2026-06-01) both stayed documented as live, working features in README.md and QuickStart.md for nearly two months after removal — QuickStart's own Troubleshooting section told users to `pip install weasyprint` to fix a feature that no longer existed anywhere in the codebase. Root cause: the removal commits didn't touch either doc, and nothing else prompts a check. QuickStart.md is opened directly by the app's own **Help → Quick Start Guide** menu item, so this isn't just GitHub-browsing staleness — it's live in-app UX. When removing or fundamentally changing a user-facing feature, grep README.md and QuickStart.md for it as part of the same change, not as a separate later cleanup pass. |
+| `_compute_std_map` used `generic_filter(np.std)` long after entropy was migrated off the same pattern | `SpatialDetailAnalyzer._compute_std_map` (`analysis/image_filters.py`) computed local σ via a per-pixel `generic_filter(data, np.std, size=kernel_size)` callback — the exact pattern `_compute_entropy_map`'s own docstring documents as "~10-50x slower" than a vectorized `uniform_filter` approach (entropy was migrated off it; std was not). It ran up to 6x sequentially (3 `STD_KERNEL_SIZES` × A/B) inside `_std_analysis`, one of 5 families `SpatialDetailAnalyzer.analyze()` runs concurrently via its own always-on `ThreadPoolExecutor(max_workers=5)` — the likely cause of a user report where running "Spatial Detail" alone took 10+ minutes and never completed instead of the usual ~2. (The user's own hypothesis — a serial-vs-parallel dispatch bug in `gui/analysis_thread.py` — did not hold up: see the next pitfall row.) Fixed by computing variance as `mean_sq - mean**2` from two `uniform_filter` passes (float64 accumulation to avoid cancellation error, `mode="reflect"`, cast to float32 on return), mirroring the entropy precedent exactly. When adding any new windowed per-pixel statistic, check `_compute_entropy_map`'s docstring first — `generic_filter` with a Python callable is a documented anti-pattern in this codebase, not a reasonable first draft. |
+| "Run metrics in parallel" checkbox is a no-op whenever only one metric is selected | `gui/analysis_thread.py`'s dispatch gate is `if parallel and len(tasks) > 1: self._run_parallel(...) else: self._run_serial(...)`. With a single metric checked, `len(tasks) == 1`, so this is always `False` regardless of the checkbox — both settings take the identical `_run_serial()` path, calling that one metric's closure directly with no executor at all. A slow/hung single-metric run is therefore never explained by the parallel checkbox; look inside that analyzer's own internal concurrency instead (e.g. `SpatialDetailAnalyzer`'s always-on 5-way `ThreadPoolExecutor`, unrelated to this outer setting). Separately, the background/RMS pre-pass at `gui/analysis_thread.py:115-127` ("Pre-compute background once per distinct image object") also runs unconditionally regardless of `parallel` or which metrics are selected — it cannot explain a serial-only slowdown either, though it is real, currently-unfiltered waste when a selected metric doesn't touch every image object. `control_panel.py`'s `_parallel_cb` now defaults to checked (`setChecked(True)`) — parallel mode has no known downside besides RAM, and multi-metric runs benefit from it by default. |
+| MTF frequency axis mislabeled by `EPSF_OVERSAMPLING²` | `PSFAnalyzer._compute_mtf` (`analysis/psf_analyzer.py`) builds the ePSF at `EPSF_OVERSAMPLING`× finer sampling than native pixels, so the FFT's own Nyquist bin (`r = max_r = n/2`) actually corresponds to `0.5 * EPSF_OVERSAMPLING` cycles/native-px — oversampling lets you resolve frequencies *beyond* the native Nyquist, so native Nyquist (0.5 cyc/px) sits at the *midpoint* of the array, not its edge. The code instead set `freq_max = 0.5 / EPSF_OVERSAMPLING` (dividing, not multiplying), compressing the whole axis by `EPSF_OVERSAMPLING²` (4× at the default oversampling=2): the Section 4 MTF plot silently stalled at 0.25 cyc/px instead of reaching 0.5, `mtf50_cycles_per_px` was under-reported by ~4×, and `mtf_nyquist = np.interp(0.5, freq, mtf)` (`psf_analyzer.py:142`) clamped to the array's stale edge instead of interpolating at true Nyquist, since `freq` never actually reached 0.5. The bug passed every existing test because `_compute_mtf`'s output stayed monotonic and bounded [0,1] — it just meant something different than its own axis label claimed. Confirmed and fixed by feeding a synthetic ePSF containing a pure cosine at a *known* frequency through the real function: the peak was mislabeled ~0.10 cyc/px for a true 0.4 cyc/px signal before the fix, ~0.41 after. Fixed with `freq_max = 0.5 * EPSF_OVERSAMPLING`. When adding any new oversampled-grid frequency axis, verify calibration with a known-frequency synthetic test signal rather than trusting the scaling formula by inspection. |
+
+---
+
+## Documentation Screenshots — Key Patterns
+
+`tools/generate_screenshots.py` regenerates every screenshot embedded in README.md and
+QuickStart.md (`resources/*.png`) from synthetic data — no real FITS files or manual
+GUI interaction required. Run it (`python tools/generate_screenshots.py`, needs a real
+interactive desktop session so dark-mode chrome matches the OS theme) whenever a GUI
+change makes existing screenshots stale, rather than leaving them to drift the way the
+pre-toolbar screenshots did.
+
+### Sample data
+
+Two full-resolution (1920×1080) images from `SyntheticGenerator`, camera `"Player One —
+Mercury-M"` (the smallest full camera — see the Testing section below), sharing the same
+`n_stars` so the two-RNG convention gives matching star positions while differing
+`fwhm_arcsec`/`halo`/`moffat_beta` make Image A vs B visibly distinguishable. Reusing the
+generator (rather than real data) keeps the script fully reproducible with zero external
+dependencies.
+
+### Capture technique
+
+`QWidget.grab()` from a normal (non-`offscreen`) `QApplication` — construct each
+widget/dialog directly, no user interaction, no visible window required (see the
+"OS-level screenshots... unreliable" pitfall above for why this is the right primitive).
+Modal dialogs and mid-run states need the two techniques captured as their own pitfall
+rows above (`arm_modal_capture()`'s pre-armed `QTimer`; `metric_started`-signal-gated
+mid-run capture) — both live in the script as reusable helpers, not one-off hacks.
+
+### Manifest
+
+15 files total: 8 full/panel/group-box states of the main window (empty, both images
+loaded, line drawn, ROI drawn, Parameters group, Metrics+Region&Run composite via
+`grab_side_by_side()`), the manual starless prompt, a mid-run and a completion-dialog
+capture from one real six-metric analysis run, the Report Inspector that run produces,
+and the three Tools-menu dialogs (Synthetic Data Generator, Spatial Target Generator,
+Halo Analyzer — the latter with a star pre-clicked via `dlg._on_star_clicked(x, y)` so
+the results table and PSF/RDF charts are populated rather than blank).
---
diff --git a/QuickStart.md b/QuickStart.md
index c2eb180..209f64e 100644
--- a/QuickStart.md
+++ b/QuickStart.md
@@ -13,7 +13,7 @@ different cameras, or different conditions), and the tool produces a side-by-sid
- **Spatial detail** — local std, Laplacian of Gaussian, and wavelet maps
- **Signal / Noise** — sky background, noise factor, and per-star SNR
-Output is an HTML or PDF report and an interactive Report Inspector window.
+Output is a self-contained HTML report and an interactive Report Inspector window. Image B is optional — loading only Image A runs the app in single-image analysis mode.
---
@@ -171,7 +171,7 @@ shows the filename and starless status (cyan arrows).
---
-### Step 3 — Load Image B
+### Step 3 — Load Image B *(optional)*
Repeat Step 2 for the Image B panel (right side). Use the menu **File → Open Image B…**
or the **"Open FITS / XISF…"** button in the Image B panel header.
@@ -179,6 +179,11 @@ or the **"Open FITS / XISF…"** button in the Image B panel header.
> Load images in order — Image A first, then Image B — so the starless prompts are
> presented one at a time.
+**Image B is optional.** If you only load Image A, clicking **Run Analysis** shows a
+confirmation dialog and then runs in **single-image analysis mode**: PSF, SNR, halo,
+edge, power spectrum, and spatial detail all still run on Image A alone, but comparison
+tables and A/B differential metrics are unavailable.
+
---
### Step 4 — Enter Filter Metadata
@@ -192,7 +197,7 @@ Each image panel header contains two small input fields:
**Filter thickness (mm)**
- Enter the glass substrate thickness (e.g. `1` for a 1 mm filter).
-- Used for: ghost reflection geometry analysis.
+- Used for: the expected-halo-radius estimate shown in the Halo Analysis section, computed from filter thickness, focal ratio, and pixel size.
- Default is `1` mm if not changed.
---
@@ -235,6 +240,10 @@ focusing on the nebula core and excluding frame edges, vignetting, or noisy corn
Metrics that use the ROI (shown by **●** in the Metrics grid): Edge Analysis,
Power Spectrum, Spatial Detail.
+> **Toolbar shortcut:** the **Select ROI…** and **Select Line…** actions in the toolbar
+> above the image panels do the same thing as the matching control-panel buttons — use
+> whichever is more convenient.
+
> **💡 Tip:** The ROI does not need to cover the entire image. A tight ROI around the
> nebula of interest produces cleaner local statistics than analysing the full frame
> including dark sky borders.
@@ -270,13 +279,13 @@ run time. Each row also has:
**Output settings**
-- **Output directory** — click Browse and select a folder. The HTML report and any
- exported PNGs are written here.
-- **Report format** — choose HTML (default, always available) or PDF (requires
- WeasyPrint; falls back to HTML if unavailable).
+- **Output directory** — click Browse and select a folder. The self-contained HTML
+ report and any exported PNGs are written here.
- **Run metrics in parallel** — when checked, all selected metrics compute simultaneously
in separate threads. Significantly faster on multi-core CPUs; uses more RAM because
- all analyses hold their working data at once.
+ all analyses hold their working data at once. Unchecked by default.
+- **Dark mode graphics** — when checked (default), report figures use a dark
+ matplotlib theme instead of a white background.

*Metrics grid with Export / ROI / XS / Time columns, plus output directory and format.*
@@ -311,8 +320,18 @@ Click **Run Analysis**. The button disables and the following happens:
After a successful run:
- **HTML report** opens automatically in your default web browser.
-- **Report Inspector** opens as a separate window for interactive side-by-side image
- comparison with Before/After slider, zoom, and pan.
+- **Report Inspector** opens as a separate window for interactive comparison of every
+ figure the report generated.
+
+The Report Inspector is more capable than a simple slider: **Left**/**Right** dropdowns
+let you independently choose what each panel shows (Image A, Image B, a computed
+Reference, or a Diff), a **Mode** selector switches between side-by-side and
+before/after-slider views, and a **Section** dropdown exposes every Section 8 spatial-
+detail sub-map (LoG, wavelet, gradient, local σ, local entropy, at every scale) alongside
+the input images. Scroll to zoom and right-click-drag to pan — both panels stay
+synchronized. Drag directly on either image to draw a cross-section line; the chart
+below updates live with a profile plot that supports a log-scale Y-axis toggle, a hover
+tooltip, and a crosshair.
You can reopen the inspector at any time via **File → Open Report Inspector…** and
selecting either the `.html` report or the `_inspector.npz` data file.
@@ -327,11 +346,17 @@ selecting either the `.html` report or the `_inspector.npz` data file.
| Parameter | Default | Range | Description |
|-----------|---------|-------|-------------|
| Min star S/N | 30 | 5 – 500 | SNR threshold for star inclusion in ePSF fitting. Raise to use only bright, unambiguous stars in sparse fields; lower cautiously if too few stars are detected. |
-| ePSF max stars | 500 | 10 – 2000 | Maximum candidate stars passed to the ePSF builder. Stars are ranked by peak flux; the brightest N are used. Reduce to 200–300 on crowded fields to cut computation time dramatically. |
-| PSF reference seeing (arcsec) | 2.00″ | 0.5 – 10.0″ | FWHM of the benchmark Moffat PSF plotted in PSF/MTF report figures. Set to the typical seeing for your site and session conditions. |
+| ePSF max stars | 600 | 10 – 2000 | Maximum candidate stars passed to the ePSF builder. Stars are ranked by peak flux; the brightest N are used. Reduce to 200–300 on crowded fields to cut computation time dramatically. |
+| PSF reference seeing (arcsec) | 3.00″ | 0.5 – 10.0″ | FWHM of the benchmark Moffat PSF plotted in PSF/MTF report figures. Set to the typical seeing for your site and session conditions. |
| Seeing warn threshold | 3.00″ | 0.5 – 10.0″ | If the measured median star FWHM exceeds this value, the report flags a poor-seeing warning for that image. |
| XS SNR region width (px) | 15 | 3 – 100 | Width in pixels of the bright and dark sample windows used in the Cross-Section SNR calculation. Wider windows average over more pixels (more stable); narrower windows are more spatially selective. |
| Wavelet levels | 4 | 2 – 6 | Number of wavelet decomposition levels in Spatial Detail analysis. Level 1 ≈ 2 px finest detail; level 4 ≈ 16 px structure; level 6 ≈ 64 px. |
+| Nebula mask threshold (× RMS) | 1.70 | 0.5 – 5.0 | Nebula mask threshold for Section 8 spatial detail analysis. Pixels above this many RMS units over background are classified as Nebula. |
+| Nebula mask dilation (px) | 3 | 0 – 20 | Grows the Section 8 nebula mask outward by this many pixels to capture dim/dark transition regions at nebula edges. |
+| Nebula mask hole-fill (px) | 5 | 0 – 20 | Fills enclosed background gaps up to this size (px per side) inside the Section 8 nebula mask, before dilation. |
+| Local-maxima footprint (× scale) | 2.00 | 1.0 – 6.0 | Section 8j local-maxima mask: neighbourhood size, as a multiple of each metric's own spatial scale, used to test whether a pixel is a local maximum. |
+| Local-maxima prominence (pctl) | 99.0 | 50.0 – 99.9 | Section 8j local-maxima mask: minimum peak height, as a percentile of each scale's own combined \|A\|,\|B\| peak-source values. |
+| Local-maxima top-bright (%) | 5.0 | 0.5 – 25.0 | Section 8j local-maxima mask: pixels in the top N% of Image A's or Image B's own value distribution are unioned (OR) into the mask, so broad bright plateaus are captured even when they never register as an isolated local-maximum peak. |
| Pixel scale override | 0.0 (from header) | 0.0 – 20.0 ″/px | Forces a specific plate scale instead of reading from the FITS/XISF WCS header. Set this when your header is missing or incorrect. Leave at 0.0 to use the header value automatically. |
---
@@ -345,12 +370,65 @@ selecting either the `.html` report or the `_inspector.npz` data file.
| **Halo analysis** | — | — | Measures the extended scattering halo around bright stars: halo radius, brightness profile, and integrated halo flux compared to star core flux. Broad halos indicate internal reflection or coating scatter in the filter. |
| **Edge analysis (LSF)** | ● | — | Locates high-contrast edges in the image (or the user-drawn cross-section), fits an Edge Spread Function and Line Spread Function, and derives MTF50 and Edge Contrast Ratio. Restricted to the ROI when one is set. Uses the starless image when available. |
| **Power spectrum** | ● | — | Radial azimuthally-averaged power spectral density from 0 to the Nyquist frequency. Reports the mid/high-frequency ratio as a single measure of fine-detail preservation. Restricted to the ROI when set; uses the starless image when available. |
-| **Spatial detail (std / LoG / wavelet)** | ● | ● | Three complementary spatial analysis techniques: local standard-deviation maps (texture), Laplacian of Gaussian maps (edge/feature response), and wavelet per-level SNR. Also produces cross-section profiles along the drawn line and a Cross-Section SNR estimate with bright/dark sample windows. |
+| **Spatial detail** | ● | ● | Five complementary spatial analysis families — local standard deviation (texture), Laplacian of Gaussian (edge/feature response), wavelet decomposition, gradient magnitude (edge sharpness), and local entropy (texture complexity) — each compared via a log-ratio map and correlation scatter, plus a noise-corrected cross-method overview and scale-adaptive local-maxima masked metrics. Also produces cross-section profiles along the drawn line and a Cross-Section SNR estimate with bright/dark sample windows. |
**Legend:** ● = metric uses this input when provided; — = not applicable.
---
+## Additional Tools
+
+Three standalone utilities live in the **Tools** menu. They are independent of the
+Steps 1–10 workflow above — use them before generating input data, or on the side for
+per-star inspection.
+
+### Synthetic Data Generator
+
+**Tools → Synthetic Star Data…**
+
+Generates a fully synthetic FITS star field for testing the app without real data.
+Choose from a 24-camera database (ZWO, QHY, Player One), configure a Moffat PSF and
+field-position-dependent optical aberrations (coma, field curvature, astigmatism,
+spherical, collimation, defocus, backfocus, guiding error, halo), and optionally add a
+simulated nebula and sky background at a chosen Bortle class. A live STF-stretched
+preview updates as you adjust sliders. Click **Generate** to write a FITS file — a
+matching `_starless` companion is always written alongside it — and optionally load the
+result directly into Image A or Image B.
+
+
+*The Synthetic Data Generator, configured for a ZWO ASI2600MM Pro field with a simulated nebula.*
+
+### Spatial Target Generator
+
+**Tools → Synthetic Spatial Detail Target…**
+
+Generates a calibrated 4-column × 3-row grid of test patterns at known spatial
+frequencies — sine and square-wave gratings at frequencies aligned to the wavelet
+decomposition levels, a Siemens star, and a slant edge — with a contrast ramp from top
+to bottom. Always produces a clean/degraded FITS pair. Load the clean version into
+Image A and the degraded version into Image B to calibrate the Spatial Detail metrics
+against a known ground truth, rather than real (and therefore unknown) filter
+differences.
+
+
+*The Spatial Target Generator dialog.*
+
+### Halo Analyzer
+
+**Tools → Halo Analyzer…**
+
+An interactive, click-a-star inspector for PSF and halo shape, independent of the main
+report's Halo Analysis section. Requires Image A to already be loaded (Image B is
+optional). Click any detected star to see a Moffat fit, shape metrics (eccentricity,
+ellipticity, orientation), a cross-section profile, and a radial distribution function
+(RDF) plot — all recomputed live as you click different stars or adjust the sample
+radius. Saturated stars are flagged "Sat." in the results table.
+
+
+*The Halo Analyzer, showing the Moffat-fit cutout, cross-section, and RDF for a selected star.*
+
+---
+
## Tips and Troubleshooting
**Zoom and pan**
@@ -379,12 +457,6 @@ Section 8 (Spatial Detail) profiles will clip the line to the ROI for derived-ma
sampling. To avoid clipping, either redraw the line entirely within the ROI, or clear
the ROI (click Select ROI → Cancel ROI before the warning appears).
-**PDF report fails to generate**
-WeasyPrint is required for PDF output. If it is not installed or missing a system font
-dependency, the report automatically falls back to HTML format. Install WeasyPrint with
-`pip install weasyprint` and ensure GTK or Pango fonts are available on your system
-(platform-specific — see the WeasyPrint documentation for Windows/macOS details).
-
**Inspector file not found**
The Report Inspector reads a `_inspector.npz` data file saved alongside the HTML report.
If you move or rename the HTML report, move the `.npz` file with it. If the file is
diff --git a/README.md b/README.md
index 74d9573..b71b92b 100644
--- a/README.md
+++ b/README.md
@@ -1,6 +1,6 @@
# Astro Image Lab
-A Python desktop application for characterizing narrowband astrophotography filters through quantitative image analysis. Load two calibrated images taken through different filters and generate a detailed comparison report covering PSF quality, halo artifacts, ghost images, edge sharpness, spatial frequency content, multi-scale detail preservation, and signal-to-noise ratio.
+A Python desktop application for characterizing narrowband astrophotography filters through quantitative image analysis. Load one or two calibrated images taken through different filters and generate a detailed comparison report covering PSF quality, halo artifacts, edge sharpness, spatial frequency content, multi-scale detail preservation, and signal-to-noise ratio. Includes built-in tools for generating synthetic test images, calibration targets, and interactive per-star halo inspection.
---
@@ -10,12 +10,9 @@ A Python desktop application for characterizing narrowband astrophotography filt
|--------|-------------|----------------------|
| **PSF / MTF** | Moffat profile fitting, empirical PSF, MTF curve and MTF50 | ✓ Yes |
| **Halo analysis** | Two-component radial profile fit; halo-to-core ratio | ✓ Yes |
-| **Ghost detection** | Secondary reflection search around bright stars | ✓ Yes |
| **Edge analysis (LSF)** | Edge Spread Function, 10–90% edge width, Line Spread Function | ✓ Yes (width) / ⚠ (contrast ratio) |
| **Power spectrum** | Signal-normalised 2D FFT, mid/high spatial frequency ratio | ✓ Normalised |
-| **Local std maps** | Local standard deviation at 3 kernel scales; contrast ratio metric | ✓ Normalised |
-| **Laplacian of Gaussian** | Edge/detail enhancement at 3 spatial scales | ✓ Normalised |
-| **Wavelet decomposition** | 4-level Daubechies-4 decomposition; per-level SNR; detail images | ✓ Normalised |
+| **Spatial detail** | Local σ, Laplacian of Gaussian, wavelet, gradient magnitude, and local entropy maps across multiple scales; log-ratio and noise-corrected cross-method comparison; scale-adaptive local-maxima masked metrics | ✓ Normalised |
| **Signal / Noise (SNR)** | Global sky-σ SNR, median star SNR ± IQR, per-pixel SNR map, pixel percentile table | ✓ Yes |
All analysis runs on linear (unstretched) calibrated image data. Images with different filter bandwidths are handled correctly — metrics are clearly labelled as bandwidth-independent or bandwidth-sensitive, and a warning banner appears in the report when bandwidths differ.
@@ -70,14 +67,13 @@ This creates a conda environment named `astrolab` with all required packages. **
### Manual installation
-Install the scientific stack via conda-forge, then add PyQt6 and XISF support via pip:
+Install the whole scientific stack via pip into an existing conda environment (not conda-forge — see the note below):
```bash
-conda install -c conda-forge numpy scipy matplotlib astropy photutils bottleneck pywavelets astroalign pillow lz4 zstandard
-pip install pyqt6 xisf
+pip install numpy scipy seaborn matplotlib astropy photutils bottleneck pywavelets astroalign pillow lz4 zstandard pyqt6 xisf
```
-> **Important:** Install PyQt6 via `pip`, not `conda install pyqt6`. The conda-forge PyQt6 package uses a different DLL layout that conflicts with PyInstaller's hook discovery and with the pip-installed Qt runtime. Using pip for PyQt6 avoids this conflict.
+> **Important:** Install the entire stack — including PyQt6 — via `pip`, not `conda install`. The conda-forge builds of PyQt6 and several scientific packages pull in a `qt6-main` package with a DLL layout that conflicts with PyInstaller's hook discovery and with the pip-installed Qt runtime. `environment.yml` installs everything via pip specifically to avoid this.
---
@@ -92,8 +88,7 @@ conda env create -f environment.yml
conda activate astrolab
# Option B — manual install into an existing environment
-conda install -c conda-forge numpy scipy matplotlib astropy photutils bottleneck pywavelets astroalign pillow lz4 zstandard
-pip install pyqt6 xisf
+pip install numpy scipy seaborn matplotlib astropy photutils bottleneck pywavelets astroalign pillow lz4 zstandard pyqt6 xisf
```
---
@@ -107,10 +102,10 @@ python AstroImageLab.py
### Image Preparation
-**Required — two images of the same sky region:**
-- Image A and Image B must cover the same field of view, captured through different filters (or different filter configurations you want to compare).
+**Required — one or two images of the same sky region:**
+- Image A is required; Image B is optional — loading only Image A runs the app in single-image analysis mode (per-image metrics still run, but comparison tables and A/B differential metrics are unavailable). When both are loaded, Image A and Image B must cover the same field of view, captured through different filters (or different filter configurations you want to compare).
- Images should be **calibrated and stacked** (bias/dark/flat corrected) but **not stretched**. Linear data is required for valid metric calculations.
-- Supported formats: `.fits`, `.fit`, `.fts`, `.xisf`.
+- Supported formats: `.fits`, `.fit`, `.fts`, `.xisf`, `.tiff`, `.tif`.
**Suggested — starless versions of each image:**
- Creating starless counterparts (using tools such as [Star XTerminator](https://www.rc-astro.com/resources/StarXTerminator/), [StarNet++](https://www.starnetastro.com/), or equivalent) and loading them alongside the original images significantly improves edge, power spectrum, and spatial detail analysis by removing the PSF contribution of stars from nebula regions.
@@ -134,26 +129,27 @@ python AstroImageLab.py
4. **Draw a cross-section line** *(recommended)* — Click **Select Line…** and drag a line across a region of interest. The line appears overlaid on both images. This drives cross-section profile analysis in the report.
5. **Select ROI** *(optional)* — Click **Select ROI…** and draw a rectangle to target a specific nebula region for edge and power spectrum analysis. If no ROI is selected, the app auto-detects the strongest edge and a star-free region automatically.
6. **Select metrics** — Check or uncheck the metrics you want to run in the control panel. Each metric can also have its figures exported as standalone PNG files using the corresponding export checkbox.
-7. **Set output directory and format** — Browse to where the report should be saved. Choose HTML (default) or PDF from the format selector. PDF requires WeasyPrint or xhtml2pdf (see Requirements).
-8. **Run** — Click **Run Analysis**. Images are aligned automatically using `astroalign` before per-pixel comparisons. A progress bar and elapsed timer are shown during analysis.
-9. **Review report** — The HTML report opens automatically in your default browser when analysis completes.
+7. **Set output directory** — Browse to where the self-contained HTML report should be saved.
+8. **Run** — Click **Run Analysis**. Images are aligned automatically using `astroalign` before per-pixel comparisons. A progress bar and per-metric timer are shown during analysis.
+9. **Review report** — The HTML report and an interactive Report Inspector window both open automatically when analysis completes.
+
+The **Tools** menu also has three standalone utilities not part of this linear workflow: a Synthetic Data Generator and a Spatial Target Generator for producing test images, and an interactive Halo Analyzer for click-a-star PSF/halo inspection. See [QuickStart.md](QuickStart.md#additional-tools) for details.
---
## Output Report
-The report is saved to your chosen output directory as a self-contained HTML file (all plots embedded as base64 PNG) or as a PDF if a renderer is installed. HTML is the default and requires no additional packages. It contains ten sections:
+The report is saved to your chosen output directory as a single self-contained HTML file (all plots embedded as base64 PNG). HTML is the only output format and requires no additional packages. It contains nine sections:
1. **Image metadata** — Side-by-side header info for both filters; bandwidth warning banner if bandwidths differ
2. **Observation context** — Seeing warning if FWHM > 3″; notes on valid comparison conditions
-3. **PSF / MTF** — FWHM, Moffat β, ellipticity, MTF50, MTF at Nyquist; overlaid MTF curves; ePSF images
-4. **Halo analysis** — Halo-to-core ratio, halo radius; side-by-side semi-log radial profiles
-5. **Ghost detection** — Candidate table (separation, intensity ratio, classification); annotated image
+3. **Signal / Noise (SNR)** — Global sky-σ SNR, median star SNR ± IQR, per-pixel SNR map (side-by-side, plasma colourmap), and a pixel percentile table showing what fraction of the field exceeds 3σ / 5σ / 10σ / 20σ
+4. **PSF / MTF** — FWHM, Moffat β, ellipticity, MTF50, MTF at Nyquist; overlaid MTF curves; ePSF images; field aberration scoring (coma, collimation, field curvature)
+5. **Halo analysis** — Halo-to-core ratio, halo radius; side-by-side semi-log radial profiles
6. **Edge analysis** — 10–90% edge width in pixels and arcseconds; ESF and LSF plots; edge contrast ratio (flagged ⚠ if bandwidths differ); cross-section profile overlay if a line was drawn
-7. **Power spectrum** — Signal-normalised 2D power spectrum; radial power comparison; mid/high ratio
-8. **Spatial detail** — Local σ maps (3 scales), |LoG| maps (3 scales), wavelet detail images and SNR bar chart; cross-section profile overlays if a line was drawn
-9. **Signal / Noise (SNR)** — Global sky-σ SNR, median star SNR ± IQR, per-pixel SNR map (side-by-side, plasma colourmap), and a pixel percentile table showing what fraction of the field exceeds 3σ / 5σ / 10σ / 20σ
-10. **Summary table** — All scalar metrics side by side; better value highlighted green, worse value highlighted red
+7. **Power spectrum** — Signal-normalised 2D power spectrum; radial power comparison; mid/high ratio and dB ratio curve
+8. **Spatial detail** — Local σ, Laplacian of Gaussian, wavelet, gradient magnitude, and local entropy maps at multiple scales; log-ratio comparison and correlation scatter plots per family; noise-corrected cross-method overview; scale-adaptive local-maxima masked metrics; cross-section profile overlays if a line was drawn
+9. **Summary table** — All scalar metrics side by side; better value highlighted green, worse value highlighted red
---
@@ -163,6 +159,7 @@ The report is saved to your chosen output directory as a self-contained HTML fil
|--------|-----------|-------|
| FITS | `.fits` `.fit` `.fts` | Standard calibrated output from all major acquisition software |
| XISF | `.xisf` | PixInsight native format; requires `pip install xisf` |
+| TIFF | `.tiff` `.tif` | 16-/32-bit calibrated TIFF stacks |
---
@@ -172,7 +169,7 @@ This tool is designed for **on-sky images**, not optical bench tests. Several im
- **Seeing is the dominant PSF contribution** on most nights. PSF/MTF comparisons between filters are most meaningful when both images were captured on the same night under similar atmospheric conditions.
- The app flags `seeing_dominated = True` and adds a warning in the report when FWHM exceeds 3″.
-- **Halo, ghost, edge width, and spatial detail metrics** are less sensitive to seeing and are more reliably attributable to filter differences.
+- **Halo, edge width, and spatial detail metrics** are less sensitive to seeing and are more reliably attributable to filter differences.
- **Astroalign** is used to register Image A onto the coordinate frame of Image B before any per-pixel comparison metrics are computed.
---
@@ -183,9 +180,9 @@ Filters with different bandwidths (e.g., 3 nm vs 7 nm) produce different absolut
**Bandwidth-independent metrics** (ratio or normalised — valid as-is):
- PSF FWHM and MTF (normalised PSF shape)
-- Halo-to-core ratio and ghost-to-parent ratio
+- Halo-to-core ratio
- Edge 10–90% width (normalised ESF)
-- Local std contrast ratio, LoG maps, wavelet SNR (all mean-signal normalised)
+- Spatial detail log-ratio maps and local-maxima masked metrics (all mean-signal normalised)
- Power spectrum mid/high ratio (mean-signal normalised before FFT)
- SNR metrics (all expressed as signal / noise ratios, independent of absolute flux)
@@ -198,28 +195,43 @@ When filter bandwidths differ, a banner appears at the top of the report, and ea
## Project Structure
-```
-AstroImageLab.py # Entry point
-environment.yml # Conda environment specification
+```text
+AstroImageLab.py # Entry point
+environment.yml # Conda environment specification
core/
- models.py # Constants, AnalysisResult dataclass
- astro_image.py # FITS/XISF loading, background estimation, statistical stretch
+ models.py # Constants, AnalysisResult dataclass
+ astro_image.py # FITS/XISF/TIFF loading, background estimation
+ fig_utils.py # fig_to_b64(), finalize_layout() — thread-safe figure rendering
+ stretch.py # STF stretch + normalize_for_display()
+ stats_utils.py # mannwhitney_effect() — shared significance testing
+ update_checker.py # GitHub-release update check
analysis/
- star_catalog.py # DAOStarFinder + isolation filtering
- psf_analyzer.py # Moffat fitting, ePSF builder, MTF via FFT
- halo_analyzer.py # Radial profile extraction, two-component Moffat fit
- ghost_detector.py # Secondary source search in annular regions
- edge_analyzer.py # Sobel edge detection, ESF/LSF extraction
- power_spectrum.py # Signal-normalised 2D FFT and radial average
- image_filters.py # Local std maps, LoG maps, wavelet decomposition
- snr_analyzer.py # Global SNR, per-star SNR, local SNR map, percentile table
+ star_catalog.py # DAOStarFinder + isolation filtering
+ psf_analyzer.py # Moffat fitting, ePSF builder, MTF via FFT
+ moffat_fit.py # Shared Moffat-fitting helpers
+ halo_analyzer.py # Radial profile extraction, two-component Moffat fit
+ edge_analyzer.py # Sobel edge detection, ESF/LSF extraction
+ power_spectrum.py # Signal-normalised 2D FFT and radial average
+ image_filters.py # Local σ, LoG, wavelet, gradient, entropy, local-maxima
+ snr_analyzer.py # Global SNR, per-star SNR, local SNR map, percentile table
report/
- report_builder.py # Self-contained HTML report generator
+ report_builder.py # Self-contained HTML report generator
gui/
- image_panel.py # PyQt6 image display with ROI rubber-band and line selection
- control_panel.py # Metric checkboxes, parameters, output directory
- analysis_thread.py # QThread orchestrator; runs all engines off the main thread
- main_window.py # QMainWindow; assembles panels, menu, signal wiring
+ image_panel.py # PyQt6 image display with ROI rubber-band and line selection
+ control_panel.py # Metric checkboxes, parameters, output directory
+ analysis_thread.py # QThread orchestrator; runs all engines off the main thread
+ main_window.py # QMainWindow; assembles panels, toolbar, menu, signal wiring
+ report_inspector.py # Interactive side-by-side figure viewer
+ synthetic_dialog.py # Synthetic Data Generator dialog
+ spatial_target_dialog.py # Spatial Target Generator dialog
+ halo_dialog.py # Halo Analyzer interactive tool
+synthetic/
+ cameras.py # Camera database (24 models)
+ generator.py # Synthetic star-field image generation engine
+ target_generator.py # Spatial calibration target generation engine
+tools/
+ generate_icon.py # Regenerates resources/icon.ico
+ generate_screenshots.py # Regenerates resources/*.png doc screenshots
```
---
@@ -237,8 +249,6 @@ gui/
| [xisf](https://pypi.org/project/xisf/) | PixInsight XISF format support |
| [PyQt6](https://riverbankcomputing.com/software/pyqt/) | GUI framework |
| [matplotlib](https://matplotlib.org/) | All plots and figures |
-| [WeasyPrint](https://weasyprint.org/) *(optional)* | High-fidelity HTML→PDF rendering |
-| [xhtml2pdf](https://xhtml2pdf.readthedocs.io/) *(optional)* | Pure-Python HTML→PDF fallback |
Wavelet noise estimation uses the robust MAD estimator from Donoho & Johnstone (1994).
SNR background estimation uses photutils `Background2D` with `MADStdBackgroundRMS`.
diff --git a/analysis/image_filters.py b/analysis/image_filters.py
index 4d3af43..4c1c12a 100644
--- a/analysis/image_filters.py
+++ b/analysis/image_filters.py
@@ -1,6 +1,7 @@
from __future__ import annotations
import concurrent.futures
+import time
import numpy as np
try:
import bottleneck as bn
@@ -11,11 +12,11 @@
matplotlib.use("Agg")
import matplotlib.pyplot as plt
from matplotlib.patches import Patch
-from scipy.ndimage import generic_filter, uniform_filter, gaussian_filter, gaussian_laplace, gaussian_gradient_magnitude, map_coordinates, zoom, maximum_filter, binary_dilation, binary_fill_holes, label
+from scipy.ndimage import uniform_filter, gaussian_filter, gaussian_laplace, gaussian_gradient_magnitude, map_coordinates, zoom, maximum_filter, binary_dilation, binary_fill_holes, label
import pywt
from core.astro_image import AstroImage
-from core.fig_utils import fig_to_b64, figs_to_b64, finalize_layout
+from core.fig_utils import fig_to_b64, figs_to_b64, finalize_layout, locked_draw_call
from core.models import (STD_KERNEL_SIZES, LOG_SIGMAS, WAVELET_NAME, WAVELET_LEVELS,
ENTROPY_KERNEL_SIZES,
XS_LINE_ALPHA, SECTION8_BORDER_CROP_FRACTION, SECTION8_ANALYSIS_CMAP,
@@ -28,7 +29,7 @@
SECTION8_LOCALMAX_DIST_MAX_SAMPLES, SECTION8_LOCALMAX_TOP_PERCENT,
SECTION8_ENTROPY_N_BINS, SECTION8_ENTROPY_CLIP_PERCENTILE)
-MAX_DIM_FOR_STD = 2048 # downsample to this before generic_filter (performance)
+MAX_DIM_FOR_STD = 2048 # downsample to this before the local-std/entropy windowed filters (performance)
_DISPLAY_SMOOTH_SIGMA = 1.0 # applied to maps before plotting; does NOT affect metrics
@@ -212,7 +213,7 @@ def _clip01(v): return max(0.0, min(1.0, v))
f"Original (normalised) — {image_a.label}",
f"Original (normalised) — {_label_b}",
diff_title="Log ratio (A/B), original image",
- cmap=SECTION8_ANALYSIS_CMAP,
+ cmap="gray", # source image is shown as-is, not a derived metric — keep it greyscale
display_roi=None,
xs_data=xs_raw_orig,
xs_line=xs_line_orig,
@@ -221,7 +222,7 @@ def _clip01(v): return max(0.0, min(1.0, v))
orig_fig = self._plot_single(
self._crop_border(analysis_a, SECTION8_BORDER_CROP_FRACTION),
f"Original (normalised) — {image_a.label}",
- cmap=SECTION8_ANALYSIS_CMAP,
+ cmap="gray", # source image is shown as-is, not a derived metric — keep it greyscale
)
figures["original"] = fig_to_b64(orig_fig, dpi=150)
@@ -307,11 +308,25 @@ def _clip01(v): return max(0.0, min(1.0, v))
localmax_presmooth_fraction=localmax_presmooth_fraction,
localmax_top_percent=localmax_top_percent,
)
- std_b64, std_partial = _f_std.result()
- log_b64, log_partial = _f_log.result()
- wav_b64, wav_partial = _f_wav.result()
- ent_b64, ent_partial = _f_ent.result()
- grad_b64, grad_partial = _f_grad.result()
+ # Retrieve in actual-completion order (not submission order) so that,
+ # if a run ever stalls again, the printed timings show exactly which
+ # of the 5 families finished and which never did -- fixed-order
+ # .result() calls would block on an early name even if later ones
+ # had secretly already finished.
+ _futures = {"std": _f_std, "log": _f_log, "wavelet": _f_wav,
+ "entropy": _f_ent, "gradient": _f_grad}
+ _fut_to_name = {v: k for k, v in _futures.items()}
+ _t0 = time.perf_counter()
+ _results = {}
+ for _fut in concurrent.futures.as_completed(_futures.values()):
+ _name = _fut_to_name[_fut]
+ _results[_name] = _fut.result()
+ print(f"[SpatialDetail] {_name} finished in {time.perf_counter() - _t0:.1f}s")
+ std_b64, std_partial = _results["std"]
+ log_b64, log_partial = _results["log"]
+ wav_b64, wav_partial = _results["wavelet"]
+ ent_b64, ent_partial = _results["entropy"]
+ grad_b64, grad_partial = _results["gradient"]
figures.update(std_b64)
figures.update(log_b64)
@@ -863,7 +878,16 @@ def _compute_std_map(self, norm: np.ndarray, kernel_size: int) -> np.ndarray:
data = zoom(norm, (new_h / norm.shape[0], new_w / norm.shape[1]), order=1)
kernel_size = max(3, int(kernel_size * factor) | 1)
- std_map = generic_filter(data, np.std, size=kernel_size)
+ # Vectorized box-filter variance (mirrors _compute_entropy_map's migration
+ # off generic_filter -- a per-pixel Python callback benchmarked at
+ # ~10-50x slower than this uniform_filter-based approach at the same
+ # array size). float64 accumulation avoids cancellation error in
+ # mean_sq - mean**2; clip guards residual float noise before sqrt.
+ data64 = data.astype(np.float64)
+ mean = uniform_filter(data64, size=kernel_size, mode="reflect")
+ mean_sq = uniform_filter(data64 * data64, size=kernel_size, mode="reflect")
+ var = np.clip(mean_sq - mean * mean, 0.0, None)
+ std_map = np.sqrt(var).astype(np.float32)
if factor < 1.0:
std_map = zoom(std_map,
@@ -1572,7 +1596,8 @@ def _compute_entropy_map(self, norm: np.ndarray, kernel_size: int,
of bin b within each window -- summing -p_b*log2(p_b) across bins then
gives the entropy map with no per-pixel Python-level histogram ever
built. A per-pixel generic_filter callback (the initial approach here,
- mirroring _compute_std_map) measured ~10-50x slower than Weber's fully
+ mirroring _compute_std_map's original implementation before it was
+ likewise vectorized) measured ~10-50x slower than Weber's fully
vectorized maximum/minimum/median_filter maps at the same array size;
this reuses that same vectorization principle for entropy.
"""
@@ -1687,14 +1712,14 @@ def _plot_side_by_side(self, arr_a: np.ndarray, arr_b: np.ndarray,
linewidth=1.5, alpha=XS_LINE_ALPHA, zorder=5)
ax.set_title(title, fontsize=10)
ax.axis("off")
- fig.colorbar(im, ax=ax, fraction=0.046, pad=0.04)
+ locked_draw_call(fig.colorbar, im, ax=ax, fraction=0.046, pad=0.04)
im_diff = ax_diff.imshow(diff, origin="upper", cmap="bwr",
vmin=dvmin, vmax=dvmax,
interpolation="nearest", aspect="equal")
ax_diff.set_title(diff_title, fontsize=10)
ax_diff.axis("off")
- fig.colorbar(im_diff, ax=ax_diff, fraction=0.046, pad=0.04)
+ locked_draw_call(fig.colorbar, im_diff, ax=ax_diff, fraction=0.046, pad=0.04)
if xs_data is not None:
pos, prof_a, prof_b, xs_label_a, xs_label_b, xs_title = xs_data
@@ -1748,7 +1773,7 @@ def _plot_single(self, arr_a: np.ndarray, title_a: str,
interpolation="nearest", aspect="equal")
ax.set_title(title_a, fontsize=10)
ax.axis("off")
- fig.colorbar(im, ax=ax, fraction=0.046, pad=0.04)
+ locked_draw_call(fig.colorbar, im, ax=ax, fraction=0.046, pad=0.04)
return fig
def _plot_mask_illustration(self, base: np.ndarray, mask_neb: np.ndarray,
@@ -1790,7 +1815,7 @@ def _plot_mask_illustration(self, base: np.ndarray, mask_neb: np.ndarray,
Patch(facecolor="tomato", edgecolor="none", alpha=0.7, label="Background"),
Patch(facecolor="0.5", edgecolor="none", label="Unclassified"),
]
- ax.legend(handles=legend_handles, loc="lower right", fontsize=8, framealpha=0.8)
+ locked_draw_call(ax.legend, handles=legend_handles, loc="lower right", fontsize=8, framealpha=0.8)
finalize_layout(fig)
return fig
@@ -1855,7 +1880,7 @@ def _plot_snr_bars(self, snr_a: dict, snr_b: dict,
ax.set_ylabel("Signal energy / Noise energy")
ax.set_xticks(x)
ax.set_xticklabels([f"Level {i}" for i in x])
- ax.legend(fontsize=8)
+ locked_draw_call(ax.legend, fontsize=8)
ax.grid(True, axis="y", alpha=0.3)
finalize_layout(fig)
return fig
@@ -1919,7 +1944,7 @@ def _plot_nc_ratio_overview(self, ratios_by_method: dict,
ax.set_xlabel("Approximate spatial scale (px)")
ax.set_ylabel("Noise-corrected score ratio (A / B)")
ax.set_title("Noise-corrected local contrast — cross-method overview")
- ax.legend(fontsize=8)
+ locked_draw_call(ax.legend, fontsize=8)
ax.grid(True, alpha=0.3)
finalize_layout(fig)
return fig
@@ -1967,7 +1992,7 @@ def _plot_localmax_ratio_overview(self, log_ratios_by_method: dict,
ax.set_xlabel("Approximate spatial scale (px)")
ax.set_ylabel("Local-maxima masked log₁₀(A / B) (geometric mean ± SD)")
ax.set_title("Local-maxima masked contrast — cross-method overview (log ratio)")
- ax.legend(fontsize=8)
+ locked_draw_call(ax.legend, fontsize=8)
ax.grid(True, alpha=0.3)
finalize_layout(fig)
return fig
@@ -2003,7 +2028,7 @@ def _draw_cross_section(ax, pos: np.ndarray, prof_a: np.ndarray, prof_b: np.ndar
ax.set_xlabel("Position along line (px)", fontsize=8)
ax.set_ylabel("Map value", fontsize=8)
ax.tick_params(labelsize=7)
- ax.legend(loc="upper left", fontsize=6.5, labelspacing=0.3)
+ locked_draw_call(ax.legend, loc="upper left", fontsize=6.5, labelspacing=0.3)
ax.grid(True, alpha=0.3)
ax.set_title(title, fontsize=9)
@@ -2074,7 +2099,7 @@ def _plot_metric_correlation(map_a: np.ndarray, map_b: np.ndarray,
alpha=0.55, s=8, zorder=3, edgecolors="none", rasterized=True)
ax.plot([lo, hi], [lo, hi], color=orig_color, linestyle="--",
linewidth=1.2, zorder=4, label="Slope = 1 (A = B)")
- fig.colorbar(sc, ax=ax, fraction=0.046, pad=0.04, label="log10(|A|/|B|)")
+ locked_draw_call(fig.colorbar, sc, ax=ax, fraction=0.046, pad=0.04, label="log10(|A|/|B|)")
ax.set_xlim(lo, hi)
ax.set_ylim(lo, hi)
ax.set_aspect("equal")
@@ -2082,7 +2107,7 @@ def _plot_metric_correlation(map_a: np.ndarray, map_b: np.ndarray,
ax.set_ylabel(f"{metric_title} — {label_a} (y)", fontsize=8)
ax.set_title(f"{region_name} (n={n})", fontsize=9)
ax.tick_params(labelsize=7)
- ax.legend(fontsize=6.5, loc="best", labelspacing=0.3)
+ locked_draw_call(ax.legend, fontsize=6.5, loc="best", labelspacing=0.3)
ax.grid(True, alpha=0.3)
if not any_data:
@@ -2175,7 +2200,7 @@ def _plot_image_profile(pos_a: np.ndarray, prof_a: np.ndarray,
ax.set_xlabel("Position along line (px)")
ax.set_ylabel(ylabel)
ax.set_title(title)
- ax.legend(fontsize=9)
+ locked_draw_call(ax.legend, fontsize=9)
ax.grid(True, alpha=0.3)
return fig
@@ -2190,7 +2215,7 @@ def _plot_image_profile_single(pos: np.ndarray, prof: np.ndarray,
ax.set_xlabel("Position along line (px)")
ax.set_ylabel(ylabel)
ax.set_title(title)
- ax.legend(fontsize=9)
+ locked_draw_call(ax.legend, fontsize=9)
ax.grid(True, alpha=0.3)
return fig
@@ -2266,7 +2291,7 @@ def _snr(prof: np.ndarray) -> float:
ax.set_xlabel("Distance (px)")
ax.set_ylabel("Pixel value (ADU)")
ax.set_title("Cross-section SNR — bright/dark sample regions")
- ax.legend(fontsize=8)
+ locked_draw_call(ax.legend, fontsize=8)
ax.grid(True, alpha=0.3)
return {
diff --git a/analysis/psf_analyzer.py b/analysis/psf_analyzer.py
index fb60a73..6515b68 100644
--- a/analysis/psf_analyzer.py
+++ b/analysis/psf_analyzer.py
@@ -408,9 +408,13 @@ def _compute_mtf(self, epsf: np.ndarray) -> tuple[np.ndarray, np.ndarray]:
y_idx, x_idx = np.mgrid[0:n, 0:n]
r = np.sqrt((x_idx - cx) ** 2 + (y_idx - cy) ** 2)
- # Frequency in cycles/native-pixel (account for oversampling)
+ # Frequency in cycles/native-pixel (account for oversampling). The ePSF is
+ # sampled EPSF_OVERSAMPLING x finer than native pixels, so the FFT's own
+ # Nyquist bin (r = max_r) corresponds to EPSF_OVERSAMPLING x the native-pixel
+ # Nyquist, not a fraction of it — native Nyquist (0.5 cyc/px) falls at the
+ # midpoint of the array (r = max_r / EPSF_OVERSAMPLING), not at its edge.
max_r = n / 2.0
- freq_max = 0.5 / EPSF_OVERSAMPLING # Nyquist of native pixels
+ freq_max = 0.5 * EPSF_OVERSAMPLING # true freq at r = max_r, in cycles/native-px
nbins = n // 2
freq_edges = np.linspace(0, max_r, nbins + 1)
diff --git a/core/fig_utils.py b/core/fig_utils.py
index c8c46de..f16d1ce 100644
--- a/core/fig_utils.py
+++ b/core/fig_utils.py
@@ -25,6 +25,13 @@
# only savefig() leaves tight_layout() free to race and reproduces the same
# ParseException. Use finalize_layout() below instead of calling
# fig.tight_layout() directly.
+#
+# Nor is tight_layout() the last of it -- fig.colorbar() and ax.legend()
+# (particularly loc="best"/auto-placement, which needs text-extent
+# measurement to find a non-overlapping spot) can also hit the same cache
+# during figure *construction*, before savefig() is ever called. Use
+# locked_draw_call() below to wrap any such call in code that can run
+# concurrently with other figure-building code.
_MPL_DRAW_LOCK = threading.Lock()
@@ -39,6 +46,18 @@ def finalize_layout(fig: plt.Figure, **kwargs) -> None:
fig.tight_layout(**kwargs)
+def locked_draw_call(fn, *args, **kwargs):
+ """Run a matplotlib call that can trigger draw-adjacent text/layout
+ measurement (colorbar placement, legend auto-placement, etc.) under the
+ same process-wide lock as finalize_layout()/fig_to_b64(). Call this
+ instead of calling fig.colorbar(...)/ax.legend(...) directly in any
+ analyzer/report figure builder that can execute concurrently with other
+ figure-building code (see the _MPL_DRAW_LOCK comment above for why).
+ """
+ with _MPL_DRAW_LOCK:
+ return fn(*args, **kwargs)
+
+
def fig_to_b64(fig: plt.Figure, dpi: int = 120) -> str:
"""Render a matplotlib figure to a base64 PNG string and immediately close it.
diff --git a/core/models.py b/core/models.py
index 6c4aa72..34e3c40 100644
--- a/core/models.py
+++ b/core/models.py
@@ -7,7 +7,7 @@
import matplotlib.figure
-APP_VERSION = "0.0.9" # semver string; bump on each GitHub release tag
+APP_VERSION = "0.0.10" # semver string; bump on each GitHub release tag
# === CONSTANTS ===
diff --git a/gui/control_panel.py b/gui/control_panel.py
index 01ffe01..ecac073 100644
--- a/gui/control_panel.py
+++ b/gui/control_panel.py
@@ -310,7 +310,7 @@ def _build_ui(self) -> None:
left_col.addLayout(align_row)
self._parallel_cb = QCheckBox("Run metrics in parallel (faster, uses more RAM)")
- self._parallel_cb.setChecked(False)
+ self._parallel_cb.setChecked(True)
self._parallel_cb.setToolTip(
"When checked, all selected analysis metrics run concurrently in separate\n"
"threads, which can significantly reduce total run time on multi-core CPUs.\n"
diff --git a/gui/main_window.py b/gui/main_window.py
index 8f1d1f2..caf374a 100644
--- a/gui/main_window.py
+++ b/gui/main_window.py
@@ -1,6 +1,6 @@
from __future__ import annotations
-from PyQt6.QtCore import Qt, QUrl
+from PyQt6.QtCore import Qt, QUrl, QSettings
from PyQt6.QtGui import QAction, QDesktopServices
from PyQt6.QtWidgets import (
QMainWindow, QWidget, QHBoxLayout, QVBoxLayout,
@@ -123,6 +123,11 @@ def _build_menu(self) -> None:
act_halo.triggered.connect(self._open_halo_dialog)
tools_menu.addAction(act_halo)
+ tools_menu.addSeparator()
+ act_extract = QAction("&Extract Images from HTML…", self)
+ act_extract.triggered.connect(self._extract_images_from_html)
+ tools_menu.addAction(act_extract)
+
help_menu = mb.addMenu("&Help")
act_quickstart = QAction("&Quick Start Guide", self)
act_quickstart.triggered.connect(self._open_quickstart)
@@ -449,6 +454,39 @@ def _open_inspector(self) -> None:
self._inspector = ReportInspector(npz_path, parent=self)
self._inspector.show()
+ def _extract_images_from_html(self) -> None:
+ from pathlib import Path as _Path
+ from tools.extract_images import extract_images
+
+ settings = QSettings("FilterImageComparator", "FilterImageComparator")
+ start_dir = settings.value("last_extract_html_dir", "")
+ path, _ = QFileDialog.getOpenFileName(
+ self,
+ "Select HTML Report to Extract Images From",
+ start_dir,
+ "HTML files (*.html *.htm);;All files (*)",
+ )
+ if not path:
+ return
+ input_path = _Path(path)
+ settings.setValue("last_extract_html_dir", str(input_path.parent))
+
+ try:
+ output_html, image_dir, count = extract_images(input_path)
+ except Exception as exc:
+ QMessageBox.warning(
+ self, "Extract Images Failed",
+ f"Could not extract images from this file:\n\n{exc}",
+ )
+ return
+
+ QMessageBox.information(
+ self, "Extract Images",
+ f"Extracted {count} image(s).\n\n"
+ f"Report: {output_html.name}\n"
+ f"Images folder: {image_dir.name}",
+ )
+
def _reset_zoom(self) -> None:
self._panel_a._img_label.reset_zoom()
self._panel_b._img_label.reset_zoom()
diff --git a/report/report_builder.py b/report/report_builder.py
index 4ff3cc8..f10a877 100644
--- a/report/report_builder.py
+++ b/report/report_builder.py
@@ -550,19 +550,14 @@ def _draw_boxwhisker(ax, vals_list):
caption_html = (
' ' "Masked-region pixel value distributions (Image A vs Image B). " + "Magnitudes are the absolute values of the masked pixels within each row's local-maxima mask. " + "In most cases, larger magnitudes indicate sharper local detail. " "Each row is one Section 8j metric/scale, shown as a " "violin plot (kernel density estimate, randomly subsampled for " "display) with an IQR box-plot overlay: " "a cyan box spanning Q1–Q3, " "a magenta centre line at the " - "median. These are the raw masked-pixel magnitude populations the " - "Mean/Std/Ratio/Significance columns above are computed from — see the " - "table for exact values (computed from the full, unsampled population, not " - "this figure's subsampled copy). " - "Each row's x-axis is independently clipped to its own 1st–99th " - "percentile range so the IQR box stays visible; rows with rare " - "extreme-outlier magnitudes may have a small fraction of the violin's tail " - "extend beyond the visible axis." + "median. " " " ) return img_html, caption_html @@ -592,6 +587,9 @@ def _localmax_log_ratio_distribution_figure(localmax: dict) -> tuple[str, str]: if not rows or not has_data: return "", "" + _is_dark = matplotlib.rcParams.get("figure.facecolor", "white") not in ("white", "#ffffff", 1.0) + orig_color = "white" if _is_dark else "black" + fig, axes = plt.subplots(len(rows), 1, figsize=(7, 1.1 * len(rows) + 1)) fig.subplots_adjust(hspace=0.65, left=0.22, right=0.97, top=0.97, bottom=0.04) @@ -617,7 +615,7 @@ def _draw_boxwhisker(ax, vals_list): sns.violinplot(x=vlr, orient="h", color="steelblue", inner=None, linewidth=0.8, ax=ax) _draw_boxwhisker(ax, [vlr]) - ax.axvline(0.0, color="black", linestyle="--", linewidth=0.8, zorder=4) + ax.axvline(0.0, color=orig_color, linestyle="--", linewidth=0.8, zorder=4) lo, hi = np.percentile(vlr, [1.0, 99.0]) if hi > lo: @@ -636,6 +634,8 @@ def _draw_boxwhisker(ax, vals_list): caption_html = ( '' "Masked-region log ratio distributions (log₁₀(A / B)). " + "Values greater than 0 indicate that Image A's pixel magnitudes are larger than Image B's, " + "and values less than 0 indicate the opposite. " "Each row is one Section 8j metric/scale, shown as a " "violin plot (kernel density estimate, randomly subsampled for " "display) of the per-pixel log10(|A|/|B|) population within that row's " @@ -941,6 +941,28 @@ def _power_ratio_db(freq_a, rp_a, freq_b, rp_b) -> tuple[np.ndarray, np.ndarray] return freq_a, ratio_db +def _mtf_ratio_db(freq_a, mtf_a, freq_b, mtf_b) -> tuple[np.ndarray, np.ndarray] | None: + """20*log10(MTF_A/MTF_B) resampled onto a common frequency grid. MTF is an + amplitude/modulation ratio, not power, hence 20x rather than _power_ratio_db's 10x. + Resampling (rather than requiring exact bin alignment like _power_ratio_db) is needed + because each image's MTF bin count depends on its own median star FWHM even though + both curves share the same fixed domain (EPSF_OVERSAMPLING is a global constant).""" + if freq_a is None or mtf_a is None or freq_b is None or mtf_b is None: + return None + freq_a = np.asarray(freq_a, dtype=float); mtf_a = np.asarray(mtf_a, dtype=float) + freq_b = np.asarray(freq_b, dtype=float); mtf_b = np.asarray(mtf_b, dtype=float) + if freq_a.size == 0 or freq_b.size == 0: + return None + freq_common = np.linspace(0, min(freq_a.max(), freq_b.max()), + max(len(freq_a), len(freq_b))) + a_i = np.interp(freq_common, freq_a, mtf_a) + b_i = np.interp(freq_common, freq_b, mtf_b) + positive = np.concatenate([a_i[a_i > 0], b_i[b_i > 0]]) + eps = float(positive.min()) * 0.01 if positive.size > 0 else 1e-12 + ratio_db = 20.0 * np.log10(np.clip(a_i, eps, None) / np.clip(b_i, eps, None)) + return freq_common, ratio_db + + def _focal_ratio(img: AstroImage) -> float | None: hdr = img.header if hdr is None: @@ -1137,9 +1159,14 @@ def _add_options_entry(section: str, name: str, options: dict, _add("epsf_b", np.log1p(eb - eb.min()).astype(np.float32)) ref_fwhm = _ref_fwhm_px(pa, pb, self._ref_seeing_arcsec) if ref_fwhm is not None: - # Match the measured ePSF size so cross-sections sample the same pixel grid + # Match the measured ePSF's oversampled grid so cross-sections sample the + # same pixel scale. ref_fwhm is in native-pixel units (from _ref_fwhm_px), + # but epsf_size is the oversampled array size — scale the FWHM by the + # oversampling factor before building the kernel, or it renders artificially + # narrow relative to the measured (oversampled) ePSF images beside it. + oversampling = pa.get("epsf_oversampling") or pb.get("epsf_oversampling") or 1 epsf_size = int(ea.shape[0]) - kern_ref = _make_moffat_kernel(ref_fwhm, size=epsf_size) + kern_ref = _make_moffat_kernel(ref_fwhm * oversampling, size=epsf_size) kr = np.log1p(kern_ref - kern_ref.min()).astype(np.float32) _add("epsf_ref", kr) epsf_opts: dict[str, str] = {"Image A": "epsf_a", "Image B": "epsf_b"} @@ -1540,6 +1567,16 @@ def _section_psf(self, ra: AnalysisResult, rb: AnalysisResult, freq_ref, mtf_ref, ref_label) img_mtf = _img_tag(fig_mtf, "MTF comparison") + img_mtf_ratio = _img_tag( + self._plot_mtf_ratio_db(freq_a, mtf_a, freq_b, mtf_b, ra.label, rb.label), + "MTF ratio (dB)", + ) + mtf_ratio_html = ( + f"{img_mtf_ratio}\n" + ' Ratio of MTF curves in decibels (20·log10). Positive values ' + f'indicate {ra.label} has higher modulation transfer (better contrast) at that ' + f'frequency, negative values mean {rb.label} does. ' + ) if img_mtf_ratio else "" img_epsf_a = _img_tag((pa.get("figures") or {}).get("epsf"), f"ePSF {ra.label}") img_epsf_b = _img_tag((pb.get("figures") or {}).get("epsf"), f"ePSF {rb.label}") img_scatter = _img_tag(self._plot_fwhm_scatter(ra, rb), "FWHM scatter") @@ -1784,6 +1821,8 @@ def _sig(key): 'optical aberrations in the filter glass.', title="How the MTF is derived")} +{mtf_ratio_html} + {self._psf_simulation_html(ra, rb)}""" + self._section_psf_aberration(ra, rb, img_a, img_b) def _section_psf_aberration(self, ra: AnalysisResult, rb: AnalysisResult, @@ -2332,13 +2371,42 @@ def _overlay_mtf(self, ax.set_xlabel("Spatial frequency (cycles/pixel)") ax.set_ylabel("MTF") ax.set_xlim(0, 0.5) - ax.set_ylim(0, 1.05) + ax.set_yscale("log") + ax.set_ylim(1e-3, 1.05) ax.set_title("MTF comparison") ax.legend(fontsize=9) ax.grid(True, alpha=0.3) fig.tight_layout() return fig + def _plot_mtf_ratio_db(self, freq_a, mtf_a, freq_b, mtf_b, + label_a: str, label_b: str) -> plt.Figure | None: + """dB ratio of A's to B's MTF curve. Amplitude quantity — 20*log10 convention.""" + result = _mtf_ratio_db(freq_a, mtf_a, freq_b, mtf_b) + if result is None: + return None + freq, ratio_db = result + + import matplotlib + _is_dark = matplotlib.rcParams.get("figure.facecolor", "white") not in ("white", "#ffffff", 1.0) + orig_color = "white" if _is_dark else "black" + + fig, ax = plt.subplots(figsize=(7, 4)) + ax.plot(freq, ratio_db, color="mediumpurple", linewidth=2) + ax.axhline(0.0, color=orig_color, linestyle="--", linewidth=0.8, label="0 dB (A = B)") + ax.axvline(0.5, color="red", linestyle=":", linewidth=0.8, label="Nyquist") + ax.set_xlabel("Spatial frequency (cycles/pixel)") + ax.set_ylabel("Ratio (dB) = 20·log10(MTF_A / MTF_B)") + ax.set_title(f"MTF ratio (dB): {label_a} / {label_b}") + ax.set_xlim(0, 0.5) + peak = float(np.max(np.abs(ratio_db))) if ratio_db.size else 3.0 + ylim = max(3.0, peak * 1.1) + ax.set_ylim(-ylim, ylim) + ax.legend(fontsize=9) + ax.grid(True, alpha=0.3) + fig.tight_layout() + return fig + def _plot_psf_simulation(self, ra: AnalysisResult, rb: AnalysisResult) -> dict | None: """Convolve the test chart with each filter's ePSF. @@ -3760,6 +3828,12 @@ def _section_edge(self, ra: AnalysisResult, rb: AnalysisResult, ca, cb = _better_worse_class(ea.get("edge_width_10_90_px"), eb.get("edge_width_10_90_px"), higher_is_better=False) + eca, ecb = _better_worse_class(ea.get("edge_contrast_ratio"), + eb.get("edge_contrast_ratio"), + higher_is_better=True) + gma, gmb = _better_worse_class(ea.get("gradient_magnitude"), + eb.get("gradient_magnitude"), + higher_is_better=True) ecr_warn = (' ⚠ bandwidth-sensitive' if bw_differ else "") @@ -3902,8 +3976,8 @@ def _section_edge(self, ra: AnalysisResult, rb: AnalysisResult, | ||
| Metric | {ra.label} | {rb.label} |
|---|---|---|
| Edge width 10–90% (px) ✓ | {_val(ea.get("edge_width_10_90_px"))} | {_val(eb.get("edge_width_10_90_px"))} |
| Edge width 10–90% (arcsec) ✓ | {_val(ea.get("edge_width_10_90_arcsec"))} | {_val(eb.get("edge_width_10_90_arcsec"))} |
| Edge contrast ratio{ecr_warn} | {_val(ea.get("edge_contrast_ratio"))} | {_val(eb.get("edge_contrast_ratio"))} |
| Gradient magnitude | {_val(ea.get("gradient_magnitude"), ".2f")} | {_val(eb.get("gradient_magnitude"), ".2f")} |
| Edge contrast ratio{ecr_warn} | {_val(ea.get("edge_contrast_ratio"))} | {_val(eb.get("edge_contrast_ratio"))} |
| Gradient magnitude | {_val(ea.get("gradient_magnitude"), ".2e")} | {_val(eb.get("gradient_magnitude"), ".2e")} |
| {label} | " f"{_val(val_a, fmt)} | " f"{_val(val_b, fmt)} |
| {label} | " f"{_val_pm(val_a, spread_a, fmt)} | " f"{_val_pm(val_b, spread_b, fmt)} |