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 `` wrapper for any Mann-Whitney significance column — used by both Section 4's PSF table and Section 8j's local-maxima table | | `SpatialDetailAnalyzer._ratio_series_with_errors(ratios_by_method, errors_by_method=None)` | `analysis/image_filters.py` | `{method: {scale: value}}` (+ optional matching errors) → sorted `{method: [(x_px, value, error_or_None), ...]}` point lists for a cross-method overview line plot; shared by `_plot_nc_ratio_overview` (8i) and `_plot_localmax_ratio_overview` (8j) | | `_nc_ratio_rows(score_a, score_b, ratio, scale_label, val_fmt=".3f", method_label=None)` | `report_builder.py` | `` rows for a noise-corrected score table (Scale \| A \| B \| Ratio A/B). Pass `method_label` to prepend a Method-name `` when consolidating several per-family tables that share this schema into one combined table — precedent: Section 8j's cross-method NC table, which concatenates the row-strings from all five `_nc_ratio_rows` calls (LoG/Wavelet/Gradient/Std/Entropy) into a single `` | +| `_better_worse_class(val_a, val_b, higher_is_better=True)` | `report_builder.py` | Returns a `("better", "worse")` (or reversed) CSS class-name pair for a comparison table's ` - - + +
` — 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 and output settings](resources/11_metrics_output.png) *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. + +![Synthetic Data Generator dialog](resources/15_synthetic_generator.png) +*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. + +![Spatial Target Generator dialog](resources/16_spatial_target_generator.png) +*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. + +![Halo Analyzer dialog with a star selected](resources/17_halo_analyzer.png) +*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")}
{edge_figures_html} @@ -4110,11 +4184,13 @@ def _section_power(self, ra: AnalysisResult, rb: AnalysisResult) -> str: 'bandwidths; mean subtraction and windowing suppress DC leakage from the image edges. ' 'Residual power at the lowest frequencies reflects genuine large-scale nebula structure ' 'rather than a DC artifact. The mid/high-frequency ratio (0.1–0.5 cyc/px vs 0–0.1 cyc/px) ' - 'measures fine detail content relative to coarse structure.
' + 'measures mid/high-frequency energy relative to coarse structure; that energy can include ' + 'real detail, residual noise, registration texture, and star-removal artifacts.
' 'Note: This comparison is only meaningful when both images cover ' 'the same target region. The ratio curve below expresses this directly in decibels ' '(10·log10(PA / PB)): positive values mean Image A has more ' - 'power (finer detail) at that frequency, negative values mean Image B does.', + 'mean-normalised power at that frequency, negative values mean Image B does. Read this ' + 'alongside the noise-corrected spatial-detail metrics before calling the difference real detail.', title="About the power spectrum")} @@ -4124,15 +4200,19 @@ def _section_power(self, ra: AnalysisResult, rb: AnalysisResult) -> str: {img_overlay}

Radial power spectra overlaid (log scale). Solid curves = starless; dashed curves = with stars (when starless images were provided). Curves that diverge at -high frequencies indicate one filter preserves more fine-scale spatial detail. The +high frequencies indicate a difference in mid/high-frequency energy, which may be real detail or residual noise/artifact power. The dashed vertical line marks the boundary between low (coarse structure) and mid/high frequencies.

{img_ratio} -

Ratio of radial power spectra in decibels. Solid = starless-pair +

Ratio of radial power spectra in decibels. Values greater than 0 indicate +that Image A has more mean-normalised power at that frequency, negative values mean Image B does. +Solid = starless-pair ratio; dashed = with-stars-pair ratio (only when both images used a starless primary input). Shown only where both images' frequency bins are exactly aligned bin-for-bin — always true when an analysis ROI is set, true in auto-ROI mode only when both images' -analysed regions share the same pixel dimensions. If only one pair aligns, only that +analysed regions share the same pixel dimensions. FFT power is not noise-corrected, so +small positive or negative offsets should be interpreted as spectral-energy differences, +not by themselves as proof of better preserved structure. If only one pair aligns, only that curve is shown.

@@ -4209,9 +4289,10 @@ def _section_spatial(self, ra: AnalysisResult, rb: AnalysisResult) -> str: nc_methodology_box = _info_box( 'Each detail map above additionally yields a noise-corrected local ' 'contrast score at every scale: score = median(|detail|) over the ' - 'region BOTH images classify as nebula (intersection of each image\'s own ' - 'nebula mask), divided by median(|detail|) over that image\'s OWN background ' - 'region (its empirical per-scale noise floor). Ratio A/B ' + 'shared nebula region (the union of each image\'s own nebula mask, so marginal ' + 'nebula signal in either image still counts as signal), divided by median(|detail|) ' + 'over that image\'s OWN background region (its empirical per-scale noise floor, ' + 'using only pixels both images classify as background). Ratio A/B ' 'divides Image A\'s score by Image B\'s score at each scale — greater than 1 ' 'means Image A shows relatively stronger detail than Image B at that scale, ' 'after accounting for each image\'s own noise level. Scale units differ by ' @@ -4635,7 +4716,9 @@ def _family_nrm_figs(rows) -> str: {_hires_img_tag(figs.get("localmax_ratio_overview"), "Local-maxima ratio overview")}

Local-maxima masked log₁₀(A/B) for every metric plotted against its approximate spatial scale (same scale convention as 8i). Compare within a -method's own line, not numerically across methods. Error bars show ±1 standard +method's own line, not numerically across methods. 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. +Error bars show ±1 standard deviation of the per-pixel log10(A/B) population within each scale's own mask, plotted directly with no unit conversion — an exact spread measure, since the masked pixels are genuinely paired between Image A and Image B at each scale.

@@ -5033,7 +5116,8 @@ def _section_summary(self, ra: AnalysisResult, rb: AnalysisResult, def row(metric, val_a, val_b, fmt=".3f", higher_is_better=True, bw_flag="✓"): ca, cb = _better_worse_class(val_a, val_b, higher_is_better) - label = f'{metric} {bw_flag}' + flag_class = "metric-label-warn" if bw_flag == "⚠" else "metric-label-ok" + label = f'{metric} {bw_flag}' return (f"
" f"" f"") @@ -5060,7 +5144,8 @@ def row(metric, val_a, val_b, fmt=".3f", def row_pm(metric, val_a, val_b, spread_a, spread_b, fmt=".3f", higher_is_better=True, bw_flag="✓"): ca, cb = _better_worse_class(val_a, val_b, higher_is_better) - label = f'{metric} {bw_flag}' + flag_class = "metric-label-warn" if bw_flag == "⚠" else "metric-label-ok" + label = f'{metric} {bw_flag}' return (f"" f"" f"") @@ -5088,8 +5173,7 @@ def row_pm(metric, val_a, val_b, spread_a, spread_b, fmt=".3f", psf_a.get("fwhm_arcsec_mad"), psf_b.get("fwhm_arcsec_mad"), higher_is_better=False), row_pm("Moffat β", psf_a.get("beta"), psf_b.get("beta"), - psf_a.get("beta_mad"), psf_b.get("beta_mad"), - higher_is_better=False), + psf_a.get("beta_mad"), psf_b.get("beta_mad")), row_pm("Ellipticity", psf_a.get("ellipticity"), psf_b.get("ellipticity"), psf_a.get("ellipticity_mad"), psf_b.get("ellipticity_mad"), higher_is_better=False), diff --git a/resources/01_main_window.png b/resources/01_main_window.png index fe8d937..3333ca8 100644 Binary files a/resources/01_main_window.png and b/resources/01_main_window.png differ diff --git a/resources/03_starless_prompt.png b/resources/03_starless_prompt.png index 3d7020d..0f567aa 100644 Binary files a/resources/03_starless_prompt.png and b/resources/03_starless_prompt.png differ diff --git a/resources/04_image_a_loaded.png b/resources/04_image_a_loaded.png index 0b0fc8d..7f8c43a 100644 Binary files a/resources/04_image_a_loaded.png and b/resources/04_image_a_loaded.png differ diff --git a/resources/06_drawing_line.png b/resources/06_drawing_line.png index f41c6f6..9a14a5b 100644 Binary files a/resources/06_drawing_line.png and b/resources/06_drawing_line.png differ diff --git a/resources/08_drawing_roi.png b/resources/08_drawing_roi.png index f768472..f67fd28 100644 Binary files a/resources/08_drawing_roi.png and b/resources/08_drawing_roi.png differ diff --git a/resources/10_parameters.png b/resources/10_parameters.png index 760e5c7..94c4a41 100644 Binary files a/resources/10_parameters.png and b/resources/10_parameters.png differ diff --git a/resources/11_metrics_output.png b/resources/11_metrics_output.png new file mode 100644 index 0000000..8fa2cc8 Binary files /dev/null and b/resources/11_metrics_output.png differ diff --git a/resources/12_running.png b/resources/12_running.png new file mode 100644 index 0000000..bf06785 Binary files /dev/null and b/resources/12_running.png differ diff --git a/resources/13_complete.png b/resources/13_complete.png new file mode 100644 index 0000000..6e33d7c Binary files /dev/null and b/resources/13_complete.png differ diff --git a/resources/14_inspector.png b/resources/14_inspector.png new file mode 100644 index 0000000..100e4bc Binary files /dev/null and b/resources/14_inspector.png differ diff --git a/resources/15_synthetic_generator.png b/resources/15_synthetic_generator.png new file mode 100644 index 0000000..b675ec0 Binary files /dev/null and b/resources/15_synthetic_generator.png differ diff --git a/resources/16_spatial_target_generator.png b/resources/16_spatial_target_generator.png new file mode 100644 index 0000000..78d74be Binary files /dev/null and b/resources/16_spatial_target_generator.png differ diff --git a/resources/17_halo_analyzer.png b/resources/17_halo_analyzer.png new file mode 100644 index 0000000..1b36552 Binary files /dev/null and b/resources/17_halo_analyzer.png differ diff --git a/resources/AstroImageLabMain.png b/resources/AstroImageLabMain.png index 83735e2..ee507eb 100644 Binary files a/resources/AstroImageLabMain.png and b/resources/AstroImageLabMain.png differ diff --git a/tools/__init__.py b/tools/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tools/extract_images.py b/tools/extract_images.py new file mode 100644 index 0000000..7e652a9 --- /dev/null +++ b/tools/extract_images.py @@ -0,0 +1,65 @@ +"""Extract base64-embedded images from an HTML report into a sibling folder. + +Rewrites every `data:image/...;base64,...` URI in the HTML with a relative +path to an extracted image file. Given `.html`, writes +`_Extracted.html` and `_images/figure_NNN.` next to it. + +Run standalone from the repo root: + python tools/extract_images.py path/to/report.html + +Also available from the GUI's Tools menu ("Extract Images from HTML…") via +`extract_images()`. +""" + +from __future__ import annotations + +import base64 +import re +import sys +from pathlib import Path + +_PATTERN = re.compile( + r'data:image/(?Ppng|jpeg|jpg|gif|webp);base64,(?P[A-Za-z0-9+/=\n\r]+)' +) + + +def extract_images(input_path: Path) -> tuple[Path, Path, int]: + """Extract embedded base64 images from `input_path` into a sibling folder. + + Returns (output_html_path, image_dir, image_count). + """ + input_path = Path(input_path) + output_html = input_path.with_name(f"{input_path.stem}_Extracted{input_path.suffix}") + image_dir = input_path.with_name(f"{input_path.stem}_images") + image_dir.mkdir(exist_ok=True) + + html = input_path.read_text(encoding="utf-8") + + count = 0 + + def replace(match: re.Match) -> str: + nonlocal count + ext = match.group("ext") + if ext == "jpeg": + ext = "jpg" + + count += 1 + filename = f"figure_{count:03d}.{ext}" + path = image_dir / filename + + image_bytes = base64.b64decode(match.group("data")) + path.write_bytes(image_bytes) + + return f"{image_dir.name}/{filename}" + + html = _PATTERN.sub(replace, html) + output_html.write_text(html, encoding="utf-8") + + return output_html, image_dir, count + + +if __name__ == "__main__": + target = Path(sys.argv[1]) if len(sys.argv) > 1 else Path("input.html") + out_path, img_dir, n = extract_images(target) + print(f"Extracted {n} images to {img_dir}") + print(f"Wrote {out_path}") diff --git a/tools/generate_screenshots.py b/tools/generate_screenshots.py new file mode 100644 index 0000000..c9b32fb --- /dev/null +++ b/tools/generate_screenshots.py @@ -0,0 +1,311 @@ +"""Regenerate resources/*.png screenshots used by README.md and QuickStart.md. + +Constructs the app's widgets directly (no user interaction, no mouse events) +and saves QWidget.grab() output to PNG. This is the same technique documented +in CLAUDE.md's "OS-level screenshots... unreliable" pitfall: .grab() renders +through Qt's own coordinate system, sidestepping OS/DPI virtualization, and +does not require a visible window. + +Must be run interactively on a real desktop session — the app follows the OS +theme (dark/light), and the existing screenshots were all captured in Windows +dark mode. Not exercised by CI. Takes a couple of minutes (two full-resolution +synthetic image generations plus one real six-metric analysis run). + +Run once from the repo root: + python tools/generate_screenshots.py +""" + +from __future__ import annotations + +import pathlib +import shutil +import sys +import tempfile +import time + +ROOT = pathlib.Path(__file__).parent.parent +sys.path.insert(0, str(ROOT)) +RESOURCES = ROOT / "resources" + +from PyQt6.QtCore import Qt, QTimer +from PyQt6.QtGui import QPainter, QPixmap +from PyQt6.QtWidgets import QApplication, QGroupBox, QMessageBox, QWidget + +from core.astro_image import AstroImage +from gui.halo_dialog import HaloAnalyzerDialog +from gui.image_panel import ImagePanel +from gui.main_window import MainWindow +from gui.spatial_target_dialog import SpatialTargetDialog +from gui.synthetic_dialog import SyntheticDialog +from synthetic.generator import SyntheticGenerator + +CAMERA = "Player One — Mercury-M" # smallest full camera in synthetic/cameras.py + +_BASE_PARAMS = { + "camera": CAMERA, + "focal_length_mm": 500.0, + "aperture_mm": 100.0, + "n_stars": 200, # shared -> star_rng(n_stars) gives A/B matching star positions + "exposure_s": 300.0, + "gain_e_per_adu": 1.0, + "bortle": 4, + "moffat_beta": 3.5, + "guiding": 0.3, + "coma": 0.0, + "astigmatism": 0.0, + "spherical": 0.0, + "collimation": 0.0, + "backfocus": 0.0, + "poor_focus": 0.0, + "field_curvature": 0.0, + "mag_min": 8.0, + "mag_max": 14.0, + "nebula_enabled": True, +} +PARAMS_A = {**_BASE_PARAMS, "fwhm_arcsec": 2.5, "halo": 0.05, "seed": 42} +PARAMS_B = {**_BASE_PARAMS, "fwhm_arcsec": 3.4, "halo": 0.12, "moffat_beta": 3.0, "seed": 43} + + +# --------------------------------------------------------------------------- +# Generic helpers +# --------------------------------------------------------------------------- + +def grab(widget: QWidget, path: pathlib.Path) -> None: + pixmap = widget.grab() + pixmap.save(str(path)) + print(f" wrote {path.relative_to(ROOT)} ({pixmap.width()}x{pixmap.height()})") + + +def pump(app: QApplication, condition, timeout_s: float = 180, interval_s: float = 0.02) -> None: + start = time.monotonic() + while not condition(): + app.processEvents() + time.sleep(interval_s) + if time.monotonic() - start > timeout_s: + raise TimeoutError(f"Timed out after {timeout_s}s waiting for condition") + + +def arm_modal_capture(save_path: pathlib.Path, + click_role=QMessageBox.StandardButton.Ok) -> dict: + """Arm a repeating QTimer that grabs+closes the next QMessageBox to appear. + + Must be armed BEFORE the action that raises the dialog: QMessageBox.information()/ + .question() block via a nested Qt event loop, and only a timer already running + keeps firing inside that nested loop -- code in our own outer processEvents() + loop does not resume until the dialog is dismissed. + """ + state = {"done": False} + timer = QTimer() + timer.setInterval(100) + + def _poll() -> None: + w = QApplication.activeModalWidget() + if isinstance(w, QMessageBox): + grab(w, save_path) + btn = w.button(click_role) + (btn.click() if btn is not None else w.close()) + state["done"] = True + timer.stop() + + timer.timeout.connect(_poll) + timer.start() + state["_timer"] = timer # keep a reference alive + return state + + +def find_groupbox(parent: QWidget, title: str) -> QGroupBox: + for box in parent.findChildren(QGroupBox): + if box.title() == title: + return box + raise RuntimeError(f"QGroupBox {title!r} not found") + + +def grab_side_by_side(widgets: list[QWidget], path: pathlib.Path, gap: int = 12) -> None: + pixmaps = [w.grab() for w in widgets] + total_w = sum(p.width() for p in pixmaps) + gap * (len(pixmaps) - 1) + max_h = max(p.height() for p in pixmaps) + canvas = QPixmap(total_w, max_h) + canvas.fill(Qt.GlobalColor.transparent) + painter = QPainter(canvas) + x = 0 + for p in pixmaps: + painter.drawPixmap(x, 0, p) + x += p.width() + gap + painter.end() + canvas.save(str(path)) + print(f" wrote {path.relative_to(ROOT)} ({canvas.width()}x{canvas.height()})") + + +# --------------------------------------------------------------------------- +# Sample data +# --------------------------------------------------------------------------- + +def build_sample_data() -> dict: + tmp_root = pathlib.Path(tempfile.mkdtemp(prefix="aillab_screenshots_")) + dir_a, dir_b, dir_lone, dir_report = (tmp_root / n for n in ("a", "b", "lone", "report")) + for d in (dir_a, dir_b, dir_lone, dir_report): + d.mkdir(parents=True, exist_ok=True) + + gen = SyntheticGenerator() + main_a, starless_a = gen.generate({**PARAMS_A, "output_dir": str(dir_a)}, preview=False) + main_b, starless_b = gen.generate({**PARAMS_B, "output_dir": str(dir_b)}, preview=False) + + # A copy with no "_starless" sibling on disk, to trigger ImagePanel's manual + # starless prompt (auto-detect only skips it when a "_starless." + # companion is found next to the loaded file). + main_lone = dir_lone / pathlib.Path(main_a).name + shutil.copy(main_a, main_lone) + + return { + "main_a": main_a, "starless_a": starless_a, + "main_b": main_b, "starless_b": starless_b, + "main_lone": str(main_lone), + "report_dir": str(dir_report), + } + + +# --------------------------------------------------------------------------- +# Static window/panel/dialog states +# --------------------------------------------------------------------------- + +def capture_static_states(app: QApplication, data: dict) -> MainWindow: + mw = MainWindow() + grab(mw, RESOURCES / "01_main_window.png") + + mw._panel_a.load_path(data["main_a"]) + mw._panel_a.set_starless_path(data["starless_a"]) + for _ in range(3): + app.processEvents() + grab(mw._panel_a, RESOURCES / "04_image_a_loaded.png") + + mw._panel_b.load_path(data["main_b"]) + mw._panel_b.set_starless_path(data["starless_b"]) + for _ in range(3): + app.processEvents() + grab(mw, RESOURCES / "AstroImageLabMain.png") + + # Cross-section line -- call the same handler the mouse-driven flow uses. + mw._on_line_selected(0.18, 0.72, 0.60, 0.28) + grab(mw, RESOURCES / "06_drawing_line.png") + + # ROI -- _on_roi_selected only stores state (gui/main_window.py); there is + # no public setter that draws the overlay, so poke the same private + # attribute ZoomableImageLabel.mouseReleaseEvent writes on a real drag. + h, w = mw._panel_a.image.data.shape[:2] + rx0, ry0, rx1, ry1 = int(0.15 * w), int(0.15 * h), int(0.85 * w), int(0.85 * h) + mw._on_roi_selected(rx0, ry0, rx1, ry1) + for panel in (mw._panel_a, mw._panel_b): + panel._img_label._roi_norm = (0.15, 0.15, 0.85, 0.85) + panel._img_label.update() + grab(mw, RESOURCES / "08_drawing_roi.png") + + grab(find_groupbox(mw._control, "2. Parameters"), RESOURCES / "10_parameters.png") + + mw._control._out_dir.setText(data["report_dir"]) + metrics_box = find_groupbox(mw._control, "1. Metrics") + region_box = find_groupbox(mw._control, "3. Region && Run") + grab_side_by_side([metrics_box, region_box], RESOURCES / "11_metrics_output.png") + + # Starless prompt -- needs an image with no "_starless" sibling on disk. + lone_panel = ImagePanel("Image A") + lone_panel.load_path(data["main_lone"]) + arm_modal_capture(RESOURCES / "03_starless_prompt.png", + click_role=QMessageBox.StandardButton.No) + lone_panel._ask_about_starless(lone_panel.image) # blocks until timer clicks No + + return mw + + +def capture_tool_dialogs(app: QApplication, data: dict) -> None: + dlg = SyntheticDialog() + dlg.show() + for _ in range(3): + app.processEvents() + grab(dlg, RESOURCES / "15_synthetic_generator.png") + dlg.close() + + dlg2 = SpatialTargetDialog() + dlg2.show() + for _ in range(3): + app.processEvents() + grab(dlg2, RESOURCES / "16_spatial_target_generator.png") + dlg2.close() + + img_a = AstroImage(data["main_a"], label="A") + img_a.load() + halo_dlg = HaloAnalyzerDialog(img_a, None, dark_mode=True) + halo_done = {"flag": False} + halo_dlg._detect_thread.finished.connect(lambda *a: halo_done.update(flag=True)) + pump(app, lambda: halo_done["flag"], timeout_s=60) + + # Click the brightest detected star so the screenshot shows a populated + # results table and PSF/RDF chart rather than the blank pre-selection state. + if halo_dlg._stars_a is not None and len(halo_dlg._stars_a) > 0: + brightest = halo_dlg._stars_a[halo_dlg._stars_a[:, 2].argmax()] + halo_dlg._on_star_clicked(float(brightest[0]), float(brightest[1])) + analyze_done = {"flag": False} + + def _mark_done(*_a): + analyze_done["flag"] = True + + pump(app, lambda: halo_dlg._analyze_thread is not None, timeout_s=10) + halo_dlg._analyze_thread.finished.connect(_mark_done) + pump(app, lambda: analyze_done["flag"], timeout_s=60) + + for _ in range(3): + app.processEvents() + grab(halo_dlg, RESOURCES / "17_halo_analyzer.png") + halo_dlg.close() + + +# --------------------------------------------------------------------------- +# Real analysis run -> running / complete / inspector +# --------------------------------------------------------------------------- + +def capture_running_and_report(app: QApplication, mw: MainWindow) -> None: + # Avoid every pre-flight confirmation dialog in MainWindow._on_run so the + # only modal that appears is the completion QMessageBox. + mw._panel_a._bw_edit.setText("6") + mw._panel_b._bw_edit.setText("12") + + mw._control._on_run() + thread = mw._thread + + capture_state = {"count": 0} + + def _on_metric_started(_key: str) -> None: + capture_state["count"] += 1 + if capture_state["count"] == 3: + QTimer.singleShot(150, lambda: grab(mw, RESOURCES / "12_running.png")) + + thread.metric_started.connect(_on_metric_started) + + modal_state = arm_modal_capture(RESOURCES / "13_complete.png") + pump(app, lambda: modal_state["done"], timeout_s=300) + + for _ in range(5): + app.processEvents() + time.sleep(0.05) + grab(mw._inspector, RESOURCES / "14_inspector.png") + + +def main() -> None: + app = QApplication(sys.argv) + + print("Generating synthetic sample data (two full-resolution images, ~1-2 min)...") + data = build_sample_data() + + print("Capturing static window/panel/parameter states...") + mw = capture_static_states(app, data) + + print("Capturing Tools-menu dialogs...") + capture_tool_dialogs(app, data) + + print("Running a full analysis for running/complete/inspector captures...") + capture_running_and_report(app, mw) + + print("Done. Screenshots regenerated in resources/.") + + +if __name__ == "__main__": + main()
{label}{_val(val_a, fmt)}{_val(val_b, fmt)}
{label}{_val_pm(val_a, spread_a, fmt)}{_val_pm(val_b, spread_b, fmt)}