diff --git a/.claude/settings.json b/.claude/settings.json index ff6952b..c6b3e98 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -7,7 +7,21 @@ "WebSearch", "PowerShell(conda activate astrolab)", "PowerShell(python -m pytest tests/ -m \"not slow\" -q 2>&1)", - "PowerShell(Get-ChildItem -Path \"$env:USERPROFILE\" -Filter \"condabin\" -Directory -ErrorAction SilentlyContinue -Recurse -Depth 2 | Select-Object -First 5 FullName; Get-ChildItem -Path \"C:\\\\ProgramData\" -Filter \"condabin\" -Directory -ErrorAction SilentlyContinue | Select-Object FullName)" + "PowerShell(Get-ChildItem -Path \"$env:USERPROFILE\" -Filter \"condabin\" -Directory -ErrorAction SilentlyContinue -Recurse -Depth 2 | Select-Object -First 5 FullName; Get-ChildItem -Path \"C:\\\\ProgramData\" -Filter \"condabin\" -Directory -ErrorAction SilentlyContinue | Select-Object FullName)", + "PowerShell(Start-Process -FilePath \"python\" -ArgumentList \"AstroImageLab.py\" -WorkingDirectory \"d:\\\\GitHub\\\\AstroImageLab\" -PassThru)", + "PowerShell(Stop-Process -Id 25432 -Force -ErrorAction SilentlyContinue)", + "Bash(\"/c/Users/bmant/anaconda3/envs/astrolab/python.exe\" \"/c/Users/bmant/AppData/Local/Temp/claude/d--GitHub-AstroImageLab/689ff469-8361-4066-b1c5-da15c208080d/scratchpad/measure_control_panel.py\")", + "Bash(\"/c/Users/bmant/anaconda3/envs/astrolab/python.exe\" \"/c/Users/bmant/AppData/Local/Temp/claude/d--GitHub-AstroImageLab/689ff469-8361-4066-b1c5-da15c208080d/scratchpad/render_control_panel.py\")", + "Read(//c/Users/bmant/AppData/Local/Temp/claude/d--GitHub-AstroImageLab/689ff469-8361-4066-b1c5-da15c208080d/scratchpad/**)", + "Bash(python -c ' *)", + "Bash(\"/c/Users/bmant/anaconda3/envs/astrolab/python.exe\" \"/c/Users/bmant/AppData/Local/Temp/claude/d--GitHub-AstroImageLab/689ff469-8361-4066-b1c5-da15c208080d/scratchpad/crop_image.py\")", + "Bash(\"/c/Users/bmant/anaconda3/envs/astrolab/python.exe\" \"/c/Users/bmant/AppData/Local/Temp/claude/d--GitHub-AstroImageLab/689ff469-8361-4066-b1c5-da15c208080d/scratchpad/render_main_window.py\")", + "Bash(\"/c/Users/bmant/anaconda3/envs/astrolab/python.exe\" \"/c/Users/bmant/AppData/Local/Temp/claude/d--GitHub-AstroImageLab/689ff469-8361-4066-b1c5-da15c208080d/scratchpad/test_roi_reset.py\")", + "Bash(PYTHONIOENCODING=utf-8 \"/c/Users/bmant/anaconda3/envs/astrolab/python.exe\" \"/c/Users/bmant/AppData/Local/Temp/claude/d--GitHub-AstroImageLab/689ff469-8361-4066-b1c5-da15c208080d/scratchpad/test_roi_reset.py\")", + "Bash(PYTHONIOENCODING=utf-8 \"/c/Users/bmant/anaconda3/envs/astrolab/python.exe\" \"/c/Users/bmant/AppData/Local/Temp/claude/d--GitHub-AstroImageLab/689ff469-8361-4066-b1c5-da15c208080d/scratchpad/test_toolbar_sync.py\")", + "Bash(python -m py_compile gui/control_panel.py gui/main_window.py gui/image_panel.py)", + "Bash(/c/Users/bmant/anaconda3/envs/astrolab/python.exe -c ' *)", + "PowerShell(& \"$env:USERPROFILE\\\\miniconda3\\\\Scripts\\\\conda.exe\" --version 2>$null; if \\(-not $?\\) { & \"$env:USERPROFILE\\\\anaconda3\\\\Scripts\\\\conda.exe\" --version 2>$null })" ] } } diff --git a/AstroImageLab.py b/AstroImageLab.py index 85ad46e..e6354f0 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.8 -# git push origin v0.0.8 +# git tag v0.0.9 +# git push origin v0.0.9 import sys import os @@ -23,64 +23,18 @@ import time from PyQt6.QtWidgets import QApplication, QSplashScreen -from PyQt6.QtGui import QIcon, QPixmap, QPainter, QColor, QFont, QRadialGradient +from PyQt6.QtGui import QIcon, QPixmap from PyQt6.QtCore import Qt, QTimer from gui.main_window import MainWindow from core.models import SPLASH_DURATION_MS -def _make_splash_pixmap() -> QPixmap: - W, H = 600, 300 - pix = QPixmap(W, H) - pix.fill(QColor(13, 17, 23)) - p = QPainter(pix) - p.setRenderHint(QPainter.RenderHint.Antialiasing) - - # Dividing line - p.setPen(QColor(200, 200, 200)) - p.drawLine(W // 2, 20, W // 2, H - 20) - - # Left panel label (A) - p.setFont(QFont("Segoe UI", 20, QFont.Weight.Bold)) - p.setPen(QColor(139, 191, 255)) - p.drawText(18, 44, "A") - - # Right panel label (B) - p.setPen(QColor(255, 200, 120)) - p.drawText(W - 34, 44, "B") - - # Left star — broad blurry radial glow (Image A, muted blue-white) - grad_a = QRadialGradient(W // 4, H // 2, H // 3) - grad_a.setColorAt(0.0, QColor(139, 191, 255, 160)) - grad_a.setColorAt(0.4, QColor(100, 140, 200, 60)) - grad_a.setColorAt(1.0, QColor(13, 17, 23, 0)) - p.setBrush(grad_a) - p.setPen(Qt.PenStyle.NoPen) - p.drawEllipse(W // 4 - H // 3, H // 2 - H // 3, H // 3 * 2, H // 3 * 2) - - # Right star — tight bright radial glow (Image B, warm white) - grad_b = QRadialGradient(3 * W // 4, H // 2, H // 8) - grad_b.setColorAt(0.0, QColor(255, 252, 224, 255)) - grad_b.setColorAt(0.3, QColor(255, 220, 120, 120)) - grad_b.setColorAt(1.0, QColor(13, 17, 23, 0)) - p.setBrush(grad_b) - p.drawEllipse(3 * W // 4 - H // 8, H // 2 - H // 8, H // 4, H // 4) - - # Title - p.setPen(QColor(240, 240, 240)) - title_font = QFont("Segoe UI", 26, QFont.Weight.Bold) - p.setFont(title_font) - p.drawText(pix.rect(), Qt.AlignmentFlag.AlignHCenter | Qt.AlignmentFlag.AlignVCenter, - "Astro Image Lab") - - # Subtitle - p.setPen(QColor(160, 170, 185)) - p.setFont(QFont("Segoe UI", 12)) - sub_rect = pix.rect().adjusted(0, H // 2 + 44, 0, 0) - p.drawText(sub_rect, Qt.AlignmentFlag.AlignHCenter | Qt.AlignmentFlag.AlignTop, - "Astronomical Image Comparison & Analysis") - - p.end() +def _load_splash_pixmap() -> QPixmap: + splash_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), + "resources", "AstroImageLabSplash.png") + pix = QPixmap(splash_path) + if not pix.isNull(): + pix = pix.scaledToWidth(640, Qt.TransformationMode.SmoothTransformation) return pix @@ -95,7 +49,7 @@ def _make_splash_pixmap() -> QPixmap: app.setWindowIcon(QIcon(_icon_path)) # Splash screen - splash = QSplashScreen(_make_splash_pixmap(), + splash = QSplashScreen(_load_splash_pixmap(), Qt.WindowType.WindowStaysOnTopHint) splash.show() app.processEvents() diff --git a/CLAUDE.md b/CLAUDE.md index 81743bf..5c0b849 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -15,7 +15,7 @@ images. It produces a self-contained HTML report with embedded matplotlib figure ## Architecture ```text -AstroImageLab.py PyQt6 app + animated splash screen +AstroImageLab.py PyQt6 app; splash screen loads resources/AstroImageLabSplash.png analysis/ Metric engines — each returns a plain dict psf_analyzer.py Moffat/ePSF fitting, MTF via FFT halo_analyzer.py Radial halo profiles, two-component Moffat fit @@ -29,6 +29,7 @@ core/ models.py 40+ constants + AnalysisResult dataclass fig_utils.py fig_to_b64() — embeds matplotlib figure as base64 PNG stretch.py STF stretch + normalize_for_display() for 8-bit display output + stats_utils.py mannwhitney_effect() — Mann-Whitney U + Cliff's delta, shared by analysis/ and report/ gui/ analysis_thread.py QThread orchestrator; dark-mode rcParams save/restore lives here control_panel.py Settings UI; settings() returns dict consumed by the thread @@ -49,9 +50,10 @@ synthetic/ | Utility | Location | Purpose | | --- | --- | --- | -| `_info_box(body, title, open=False, style="")` | `report_builder.py` | Collapsible `
/` HTML panel | +| `_info_box(body, title, open=False, style="")` | `report_builder.py:197` | Collapsible `
/` HTML panel — `body` is raw HTML, so it also wraps whole figure-heavy blocks (not just prose) closed by default to keep the report compact; see "Collapsible figure blocks" below | | `_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 | | `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 | @@ -64,6 +66,14 @@ synthetic/ | `_plot_mask_illustration(base, mask_neb, mask_bg)` | `analysis/image_filters.py` | Translucent steelblue/tomato mask overlay on a grayscale base image | | `_plot_metric_correlation(map_a, map_b, log_ratio, mask_neb, mask_bg, ...)` | `analysis/image_filters.py` | 1×2 masked-region scatter (A vs B) with a 1:1 line; each point colored by its pixel's log-ratio value using the same `bwr` scale as the adjacent map figure | | `_family_figs_with_corr(rows, map_key_fn)` | `report_builder.py` | Emits a Section 8 family's map figure immediately followed by its `corr_*` correlation scatter, one scale at a time, in numeric order (`_SPATIAL_CORR_ROWS`) — the pattern to follow when adding any new per-scale Section 8 figure pair | +| `_family_nrm_figs(rows)` | `report_builder.py` | Sibling of `_family_figs_with_corr` for the noise-normalised (`nrm_*`) trailer figures — same `_SPATIAL_CORR_ROWS`-ordered iteration, no `map_key_fn` needed since the nrm key is always `"nrm_" + row_key`. Always use this (never a raw `sorted(figs)` scan) for any new per-scale trailer block — `sorted()` on figure-key strings is lexicographic and puts `nrm_std_10px` before `nrm_std_3px`/`5px` | +| `SpatialDetailAnalyzer._crosshair_to_cropped_px(crosshair, shape, crop_fraction)` | `image_filters.py` | Converts a normalised `[0,1]` crosshair dict into pixel coords in the frame `_crop_border(arr, crop_fraction)` produces — the pattern for overlaying the user's cross-section line directly onto a Section 8 map panel (`_plot_side_by_side`'s `xs_line` param), as opposed to `xs_data`'s separate line-chart profile panel | +| `SpatialDetailAnalyzer._local_maxima_mask(data, footprint_px, prominence_percentile, region_px, presmooth_sigma)` | `analysis/image_filters.py` | Scale-adaptive peak mask: `maximum_filter` non-max suppression + percentile prominence threshold + optional pre-smoothing (suppresses noise-driven false peaks) + `binary_dilation` region growth. All params are relative to the caller's own characteristic scale, not fixed pixel counts — see the Local-maxima detection convention below | +| `SpatialDetailAnalyzer._combined_localmax_mask(abs_a, abs_b, footprint_px, prominence_percentile, region_px, presmooth_sigma, top_percent)` / `_top_percent_mask(abs_a, abs_b, top_percent)` | `analysis/image_filters.py` | `_local_maxima_mask` unioned (OR) with a top-`top_percent`% brightness mask — catches broad bright plateaus a sharp-peak detector alone would miss. Used identically by `_localmax_entry` (Section 8j table stats) and the mask-grid figure builder in `analyze()`, so the displayed mask always matches the mask backing that row's numbers | +| `mannwhitney_effect(va, vb)` | `core/stats_utils.py` | Mann-Whitney U p-value + Cliff's delta in O(n log n) via `delta = 2·U/(n1·n2) − 1`; shared by `_psf_stat_test` (Section 4) and `SpatialDetailAnalyzer._localmax_stats` (Section 8j) — never reimplement Cliff's delta via a pairwise `arr_a[:, None] - arr_b[None, :]` matrix, it's O(n·m) memory | +| `_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 `` | --- @@ -130,6 +140,21 @@ Pre-compute any Python variable **before** a `return f"""..."""` block. Do not n `{f"...{var}..."}` substitutions — they cause confusing `UnboundLocalError` and syntax errors at runtime. +### Collapsible figure blocks — reuse `_info_box` for images, not just text + +`_info_box(body, title, open=False, style="")` (`report_builder.py:197`) only ever +wrapped prose/methodology text until Section 8's families got collapsed — its `body` +parameter is raw HTML, so it works unchanged for a block of embedded `` tags. +To collapse a figure-heavy block (precedent: Section 8's five metric families 8d–8h, +and Section 4's PSF test-chart image sequence in `_psf_simulation_html`), **pre-compute +the image HTML as its own variable before the enclosing `return f"""..."""`** — same +rule as "Long f-string HTML blocks" above, since an f-string expression can't contain +statements — then interpolate `{_info_box(images_html, title="Show ...", open=False)}` +in place of the raw figure calls. Keep the section heading, methodology `_info_box`, +and any data table **outside** the collapsible: those are what a reader needs even with +the images hidden, and default-closed means the collapsible content shouldn't be load- +bearing for understanding the section at a glance. + ### PyQt6 signal arity — declaration must match every emit() `pyqtSignal(str, str)` declared but `.emit(a, b, c)` called raises a `TypeError` at @@ -278,6 +303,124 @@ analyzer that needs background stats, just call `image.estimate_background()` as normal at the top of `analyze()` — do not add another pre-pass call site; the existing one in `_execute()` already covers every image object the thread constructs. +### Local-maxima / peak detection — scale-relative parameters, not fixed pixel values + +`SpatialDetailAnalyzer._local_maxima_mask` (Section 8j) detects peaks via +`data == maximum_filter(data, size=footprint_px)` AND `data > percentile(data, prominence_percentile)`, +then grows survivors with `binary_dilation(mask, iterations=region_px)`. All four +tunables are expressed **relative to the caller's own characteristic scale** +(`footprint_px = footprint_mult * scale_px`, `region_px = region_fraction * footprint_px`, +`presmooth_sigma = presmooth_fraction * scale_px`), never as fixed absolute pixel +counts — a std-3px kernel and a wavelet level-3 (~8px) feature need genuinely +different "how local" and "how tall" thresholds, and one fixed setting either misses +fine structure or over-merges coarse structure. Pre-smooth the peak-source array +(`gaussian_filter`, sigma also scale-relative) **before** non-max suppression to +prevent single-pixel noise from registering as spurious peaks — reuse this whenever +applying the pattern to new noisy per-pixel data; skipping the pre-smooth step lets +shot noise dominate the detected-peak count. See `core/models.py`'s five +`SECTION8_LOCALMAX_*` constants for the current default multipliers/fractions. + +A pure peak detector still undersamples broad bright plateaus that never register as a +sharp local maximum (every pixel ties for "the max of its own neighbourhood"). +`_combined_localmax_mask` unions the peak mask with `_top_percent_mask` — pixels in the +top `SECTION8_LOCALMAX_TOP_PERCENT`% of Image A's or Image B's own value distribution — +so broad-but-real bright regions are still captured. Both the table's per-row +statistics (`_localmax_entry`) and the mask-grid display figure (built inside +`analyze()`) call this **one shared method**; never let the mask backing a statistic +and the mask drawn in its illustrative figure be two independent implementations of +the same idea — they will silently drift apart the next time either one gets a +formula change. + +### Ratio uncertainty / error bars — exact when pixel-paired, approximate (CV-propagated) otherwise + +When adding an error bar to a ratio-vs-scale plot (precedent: Section 8i/8j's +cross-method overview figures, `_plot_nc_ratio_overview` / `_plot_localmax_ratio_overview` +in `image_filters.py`), first check whether the two populations behind the ratio are +**pixel-paired** (same pixel coordinates in both images): + +- **Pixel-paired** (Section 8j: `diff[mask]` is a genuine per-pixel `log10(|A|/|B|)` + population) → take `std(diff[mask])` directly and delta-method-propagate it into + linear ratio units (`ratio * ln(10) * log_std`) — an exact spread measure. +- **Not pixel-paired** (Section 8i: nebula vs. background populations, computed + independently per image) → there is no per-pixel ratio distribution to take a std + of. Use a standard relative-uncertainty (coefficient-of-variation) propagation + instead: `err = |ratio| * sqrt((std_a/median_a)² + (std_b/median_b)²)`. **This is + an approximation, not a formal confidence interval** — caption it as such in the + report (see 8i's methodology caption) rather than presenting it as exact. + +Both plot functions share `_ratio_series_with_errors` for the point-list-building +loop; only the upstream computation of the error value differs by data source. + +**Prefer presenting a log-space quantity in log space throughout, rather than +converting back to linear for display.** Section 8j's ratio column and cross-method +overview originally stored `ratio = 10**mean(diff[mask])` and converted `log_ratio_std` +into a linear error bar via the delta method (`ratio * ln(10) * log_std`) — correct, +but an avoidable approximation-flavoured extra step. Since `diff[mask]` *is* a log10 +population, carrying `log_ratio_mean`/`log_ratio_std` straight through to the table +(`_val_pm`) and the overview plot's y-axis removes the conversion entirely — the error +bar becomes exact by construction instead of merely well-approximated, and the table +header should say so (`"log ratio A/B (geo. mean ± SD)"`, not `"Ratio A/B"`). Shade +this kind of column a fixed neutral color (not `_better_worse_class` red/green) when +it isn't a value judgement between A and B, just a measured quantity. + +### Toolbar actions that mirror control-panel widgets — share QActions, proxy clicks, sync reactively + +`gui/main_window.py::_build_toolbar()` is the app's first `QToolBar`, sitting above the +image-panel splitter to give the load → select-region → run workflow a visible +left-to-right order (Open Image A / Open Image B │ Select ROI / Select Line │ Run +Analysis). It never introduces a second state machine alongside `AnalysisControlPanel`'s +existing widgets — extend this pattern for any future toolbar addition: + +- **Reuse the same `QAction` object in both the menu and the toolbar** when one already + exists (`self._act_open_a`, `self._act_open_b`, `self._act_run` — promoted from local + variables in `_build_menu` to instance attributes specifically so `_build_toolbar` can + add them a second time). A `QAction` can live in multiple containers simultaneously — + this is what it's for, not a hack. +- **For a checkable control-panel `QPushButton` with no existing menu equivalent** (ROI, + Line), add a plain **non-checkable** `QAction` whose `triggered` handler calls + `.click()` on the real button (`self._control._roi_btn.click()`) — this drives the + entire existing toggle/signal chain unmodified instead of maintaining a second + checked-state to keep in sync. Reflect the active/cancel state by setting the + toolbar action's text inside the *existing* mode-toggled slot + (`_on_roi_mode_toggled`/`_on_line_mode_toggled`), which already fires on every path + that changes mode (control-panel click, toolbar click, and post-selection auto-reset). +- **For enable/disable state** that must track a control-panel widget (Run), add one + wrapper method (`_set_run_enabled`) that updates both the button and the action, and + route every call site through it — do not leave the toolbar/menu action's enabled + state to drift independently from the button's. +- **Do not use a `QToolBar.addWidget(spacer)` with an `Expanding` `QSizePolicy` to + right-align a trailing action.** In this environment it made every action added after + the spacer disappear entirely — not merely misplaced — regardless of whether the + spacer's vertical policy was `Preferred` or `Expanding`. Removing the spacer (`Run + Analysis` simply follows the last separator, left-aligned like everything else) fixed + it immediately. If a right-aligned toolbar action is ever genuinely needed, verify the + spacer approach renders before relying on it — don't assume the common Qt idiom is + safe in this codebase's environment without checking. + +### Fixed-size markers in report figures — matplotlib `markersize`, not a data-space patch + +When a marker must stay a constant *visual* size regardless of the image's zoom or +pixel scale (precedent: the red "scan start" square in Section 6's edge ROI figure and +its ESF/LSF profile chart, `EdgeAnalyzer._plot_results` / `_plot_esf_lsf_pair`), use +`ax.plot(x, y, marker='s', markersize=N, color=...)` — `markersize` is in points +(1/72 inch), independent of the axes' data-coordinate scaling. Do **not** use a +`Rectangle` patch sized in data coordinates for this (e.g. `Rectangle((x, y), 5, 5)`) +— its apparent size changes with the image's pixel scale/zoom, the opposite of what +"fixed size" means here. + +### Locating a point across `scipy.ndimage.rotate()` without hand-deriving its angle-sign convention + +`EdgeAnalyzer._extract_esf` rotates an ROI so the edge runs vertical, then (to place +the "scan start" marker) needs to know which point in the *original*, unrotated frame +corresponds to a specific column of the *rotated* frame. Hand-deriving `rotate()`'s +counter-clockwise-vs-clockwise / y-down handedness risks a silently mirrored result +with no exception raised. Instead, use `rotate()` itself in both directions: place a +single-pixel impulse at the target column in a same-shaped zero array, apply the +**inverse** rotation (`rotate(impulse, -angle, reshape=False, order=1)`), and take +`argmax` of the result. This is correct by construction — the same function, forward +then backward — regardless of the library's sign convention, and generalizes to any +"where did this rotated-frame coordinate come from" problem. + --- ## Collaboration Rules @@ -321,7 +464,7 @@ sudo apt-get install -y libgl1 libegl1 libxcb-cursor0 libxkbcommon-x11-0 ```bash conda activate astrolab pip install pytest pytest-cov pytest-timeout # one-time setup; not in environment.yml -pytest tests/ -m "not slow" # fast suite (~120 s, 205 tests) +pytest tests/ -m "not slow" # fast suite (~11 min, 430 tests) pytest tests/ -m slow # slow/integration tests (full FITS generation) pytest tests/ --cov=analysis,core,synthetic,report --cov-report=html ``` @@ -347,7 +490,7 @@ pytest tests/ --cov=analysis,core,synthetic,report --cov-report=html | Inline FITS too small to load | `_load_fits` skips HDUs where `max(shape) <= 100`. Test FITS must be at least 101×101; use 128×128 for safety. | | `float(mtf(array, m))` raises TypeError | `mtf()` returns a same-shape array, not a scalar. Index with `[0]` or pass a scalar input. | | `PSFAnalyzer.analyze()["figures"]` KeyError | `figures` is only added when `n_stars_used > 0`. Guard with `if result["n_stars_used"] > 0`. | -| `contrast_ratios_b` / `weber_contrast_b` always present | `SpatialDetailAnalyzer.analyze()` always includes `contrast_ratios_b: {}` and `weber_contrast_b: {}` even in single-image mode. Neither is ever `None` or absent — check `not b_ratios` / `not wc_b` instead. | +| `contrast_ratios_b` / `entropy_contrast_ratio_b` always present | `SpatialDetailAnalyzer.analyze()` always includes `contrast_ratios_b: {}` and `entropy_contrast_ratio_b: {}` even in single-image mode. Neither is ever `None` or absent — check `not b_ratios` / `not ecr_b` instead. | | Background2D fails on tiny images | `estimate_background()` with default `box_size=64` needs the image to be larger than the box. Any image used in analysis tests should be at least 128×128. | --- @@ -375,9 +518,23 @@ pytest tests/ --cov=analysis,core,synthetic,report --cov-report=html | Adding a float64 cast in analysis code | Don't. All image data is float32 after `AstroImage.load()`. The only float64 exception is `astroalign` in `gui/analysis_thread.py`. Redundant float64 casts waste memory and defeat the float32 performance gains. | | Mixed float32/float64 arithmetic silently widens to float64 | NumPy upcasts when operands differ (e.g. `float32_array - float64_scalar`). If photutils ever returns a float64 background model, `background_subtracted()` will silently return float64. Guard by adding `.astype(np.float32)` at the end of `background_subtracted()` in `astro_image.py` if this is observed. | | `_section_snr` crashed when SNR metric is unchecked | `_plot_snr_pair` (`report_builder.py`) built a `panels` list filtered to non-`None` entries but never checked whether it was empty before calling `plt.subplots(1, len(panels), ...)` — 0 columns raised `ValueError: Number of columns must be a positive integer, not 0`. Hit whenever SNR is unchecked while another metric (e.g. Power Spectrum) is run. Fixed with an early `if not panels: return None` guard, matching `_plot_radial_overlay`/`_plot_radial_ratio_db`; both call sites already pipe the result through `_img_tag`, which turns `None` into `""`. | -| New Section 8 panel key doesn't need Report Inspector code changes | `gui/report_inspector.py` is fully generic — driven entirely by a companion `_inspector.npz` (raw float32/uint8 arrays) plus an embedded `catalog_json` built in `report_builder.py::_write_inspector_file`. `_panel_display_name`/`_panel_concept` dynamically parse any `panels` dict key prefix, so a new `SpatialDetailAnalyzer` panel family auto-appears in the inspector with zero inspector-side changes. A genuinely new *visual type* is a different story: the inspector only knows how to `imshow` 2D/RGB arrays (side-by-side or slider-reveal), so scatter-style plots (Section 8's `corr_*` correlation figures, interleaved into 8b–8f right after each map figure via `_family_figs_with_corr`) must stay static-HTML-only unless new inspector canvas code is written. | -| Renumbering a Section 8 subsection misses caption cross-references | Section 8's sub-heading letters (8a–8g) are referenced by literal string in caption/info-box text scattered throughout `_section_spatial` — not just in the `

` tags (e.g. "see 8g for…", "(8b–8f, 8g)"). After adding, removing, or renumbering a subsection, `grep` the function for every old *and* new heading letter — HTML renders a stale cross-reference without error, it just silently misdirects the reader to the wrong subsection. | -| Stale ROI crashes Section 8 with "index -1 is out of bounds for axis 0 with size 0" | `MainWindow._roi` (`gui/main_window.py`) is never cleared when a new image is loaded into either panel. If the user draws an ROI on one image pair, then loads a smaller replacement pair without clearing it, the stale coordinates go out of bounds for the new image. NumPy doesn't raise on an out-of-range slice — `norm_a[ry0:ry1, rx0:rx1]` silently returns a zero-size array — so the crash surfaces much later and far from the real cause: `SpatialDetailAnalyzer._plot_mask_illustration → _stretch_for_display → np.percentile(empty_array, ...)`. The same unguarded `bgsub[y0:y1, x0:x1]` pattern exists in `power_spectrum.py::_extract_roi` and `edge_analyzer.py::analyze`, so a stale ROI can corrupt those sections too (with different, equally misleading errors) if they happen to run. Fixed at the single real boundary — `MainWindow._on_run()`, which is the only path that constructs `AnalysisThread` — by validating `self._roi` against every loaded image's `data.shape` right before `settings["roi"]` is set; an out-of-bounds ROI is cleared (falls back to auto-detect/full-image) with a `QMessageBox` explaining why, rather than patching each analyzer's slice individually. | +| New Section 8 panel key doesn't need Report Inspector code changes | `gui/report_inspector.py` is fully generic — driven entirely by a companion `_inspector.npz` (raw float32/uint8 arrays) plus an embedded `catalog_json` built in `report_builder.py::_write_inspector_file`. `_panel_display_name`/`_panel_concept` dynamically parse any `panels` dict key prefix, so a new `SpatialDetailAnalyzer` panel family auto-appears in the inspector with zero inspector-side changes. A genuinely new *visual type* is a different story: the inspector only knows how to `imshow` 2D/RGB arrays (side-by-side or slider-reveal), so scatter-style plots (Section 8's `corr_*` correlation figures, interleaved into 8d–8h right after each map figure via `_family_figs_with_corr`) must stay static-HTML-only unless new inspector canvas code is written. | +| Renumbering a Section 8 subsection misses caption cross-references | Section 8's sub-heading letters (currently 8a–8j: 8a Background/Key Terms, 8b Original Image, 8c Mask Overview, 8d–8f detail-based families [LoG, Wavelet, Gradient], 8g–8h contrast/texture-based families [Local σ, Local Entropy], 8i Noise-Corrected Cross-Method Overview, 8j Local-Maxima Masked Metrics) are referenced by literal string in caption/info-box text scattered throughout `_section_spatial` — not just in the `

` tags (e.g. "see 8i for…", "(8d–8h, 8i)"). After adding, removing, or renumbering a subsection, `grep` the function (and `_SPATIAL_GLOSSARY_HTML`) for every old *and* new heading letter — HTML renders a stale cross-reference without error, it just silently misdirects the reader to the wrong subsection. A self-reference inside a family's own info box (e.g. Gradient's "same framework as the other N families") must enumerate the other letters explicitly rather than use a dash range — under a non-contiguous lettering (Gradient kept letter `8f` while Std/Entropy moved past it into the Contrast group), a `8d–8h` range would wrongly include Gradient's own letter. Precedent: when Weber contrast (formerly 8h) was replaced by Local Entropy, keeping the same letter for the new family avoided a renumbering pass entirely — every existing `8h`/`8d–8h` cross-reference stayed numerically valid, only the prose describing 8h's content changed. | +| Stale ROI crashes Section 8 with "index -1 is out of bounds for axis 0 with size 0" | `MainWindow._on_image_loaded()` (`gui/main_window.py`) now resets `self._roi`/`self._crosshair` to `None` (plus both panels' visual overlays, via `ImagePanel.clear_roi_overlay()`/`clear_line_overlay()`) on every new main-image load — the choke point is `ImagePanel.image_loaded`, emitted only from `_open_file`/`load_path`, never from `set_starless_path`, so attaching a starless companion correctly does *not* wipe an existing ROI/line. Before this proactive reset existed, a stale ROI drawn against a previous, larger image pair silently went out of bounds for a smaller replacement: NumPy doesn't raise on an out-of-range slice — `norm_a[ry0:ry1, rx0:rx1]` silently returns a zero-size array — so the crash surfaced much later and far from the real cause: `SpatialDetailAnalyzer._plot_mask_illustration → _stretch_for_display → np.percentile(empty_array, ...)`. The same unguarded `bgsub[y0:y1, x0:x1]` pattern exists in `power_spectrum.py::_extract_roi` and `edge_analyzer.py::analyze`, so a stale ROI could corrupt those sections too. `MainWindow._on_run()`'s validation (checking `self._roi` against every loaded image's `data.shape` right before `settings["roi"]` is set, clearing it with a `QMessageBox` if it no longer fits) is kept as defense-in-depth for any future code path that changes loaded-image dimensions without going through `_on_image_loaded`, but the normal load→run flow now clears stale state at the source instead of catching it reactively at Run time. | +| `binary_dilation` on a loose sigma-threshold mask amplifies noise, not signal | Growing a boolean mask straight from a threshold cut (e.g. Section 8's nebula mask at 1.7σ) dilates *every* True pixel, including scattered single/few-pixel noise-driven false positives — expected in bulk at a loose sigma cut (~4.5% of pixels at 1.7σ one-sided). Each isolated speck balloons into a `~(2·dilation_px+1)²`-px blob, inflating the mask area by 4x+ and diluting any signal-vs-background metric computed over it. Fix: strip small isolated connected components (`scipy.ndimage.label` + `np.bincount` size filter, same size threshold used for hole-filling) *before* calling `binary_dilation` — see `SpatialDetailAnalyzer._remove_small_objects` / `_fill_small_holes` in `image_filters.py`. Caught by comparing mask pixel counts with dilation on vs off on real (noisy) fixture data — a clean synthetic square mask (no noise) will not reveal this bug. | +| Adding GUI parameter rows clips existing text in the Parameters group | `gui/main_window.py`'s `AnalysisControlPanel.setMaximumHeight(...)` caps the whole control panel's height. Metrics / Parameters / Region & Run are laid out side-by-side (`QHBoxLayout` in `control_panel.py::_build_ui`), so the cap must fit the *tallest* group box's natural content height. Parameters (`control_panel.py`, "2. Parameters") is itself split into two side-by-side `QFormLayout`s — "General / PSF" (`form1`) and "Nebula & Local-Maxima" (`form2`) — so its own height is driven by `max(form1_rows, form2_rows)`, not the total row count across both. When adding a new parameter row, add it to whichever column keeps the two roughly balanced, then re-measure: construct `AnalysisControlPanel` headlessly and read `QGroupBox.sizeHint()` for all three boxes (see the measurement approach used when this split was introduced — a small script that imports the widget, calls `.adjustSize()`, and prints each `findChildren(QGroupBox)` entry's `sizeHint()` — is far more precise than eyeballing a screenshot, and sidesteps OS/DPI screenshot-scaling inconsistencies entirely) rather than assuming a fixed per-row pixel cost. Set `setMaximumHeight(...)` to comfortably cover the tallest of the three measured heights. | +| Cliff's delta via pairwise sign matrix doesn't scale past ~100s of samples | `arr_a[:, None] - arr_b[None, :]` is O(n1·n2) memory — fine for PSF's per-star counts, a multi-gigabyte blowup for per-pixel populations (Section 8j masks can hold 10⁴–10⁵+ pixels). Use the exact O(n log n) identity instead: `delta = 2·U/(n1·n2) − 1`, where `U` is the Mann-Whitney U statistic `scipy.stats.mannwhitneyu` already computes. See `core/stats_utils.py::mannwhitney_effect`. | +| Section HTML block gated on the wrong figure's output | A conditional HTML block that wraps *multiple* pieces of content but gates on only *one* figure's presence (e.g. `dist_html = ("

...

" + mask_html + dist_img + ...) if dist_img else ""`) will silently delete the other content too if that one figure is ever removed or becomes empty — this hit Section 8c, whose Nebula/Background mask illustration disappeared along with the (later-removed) log-ratio violin figure it happened to share a block with. Gate on the actual content being wrapped (e.g. `mask_fig`, if that's what must be present for the block to be worth showing), not on a sibling figure that currently always co-occurs with it. | +| Extending an analyzer method's return-tuple arity misses a call site | `_nc_score` gained a 3rd return value (`neb_std`) to support Section 8i's error bars; it has 10 call sites (2 per metric family × 5 families), each needing `nc_a, noise_a = ...` changed to `nc_a, noise_a, neb_std_a = ...`. `grep` for every call site before changing a shared method's return signature — a missed site raises `TypeError: cannot unpack non-sequence`, or worse, silently mis-assigns if old and new arity both happen to unpack without error. | +| `np.percentile` threshold on a flat/constant array selects everything | A percentile-based `>=`-threshold mask (e.g. `_top_percent_mask`) degenerates when the source array has little/no spread: if every value is equal, every percentile equals that one value, so `arr >= threshold` matches 100% of pixels, not the intended top N%. Hit writing a unit test for `_top_percent_mask` with an all-zero second operand — its own 90th-percentile threshold was also `0`, making `arr_b >= 0` trivially true everywhere and swamping the assertion. When constructing a synthetic array to exercise percentile-threshold logic, give it genuine spread (or reuse the same non-flat array for both operands) rather than an all-zero/constant placeholder. | +| A cached render-to-attribute call duplicates report content | `_psf_simulation_html` called `self._psf_retention_table(sim)` twice with identical arguments — once inline into its own returned HTML, once purely to populate `self._cached_retention_html` so `_section_summary` could re-splice the same table into Section 9 later. Both calls are pure functions of `sim`, so the two copies were byte-identical, and the report silently showed the same table twice. If a value needs to reach a second section, thread the already-computed *result* through (return value, parameter, or a plain instance attribute set once) rather than re-invoking the render function a second time purely as a caching side-effect. | +| `QGroupBox` title containing a bare `&` silently swallows it (mnemonic) | `QGroupBox("3. Region & Run")` rendered as "3. Region Run" — Qt interprets `&` in a group box title as a mnemonic prefix for the next character, same as `QAction`/`QPushButton` text. Escape a literal ampersand as `&&` (`QGroupBox("3. Region && Run")`). Plain `QLabel` text does **not** have this problem (no mnemonic support unless `setBuddy()` is used), so this is specific to titles/text that Qt treats as mnemonic-aware (menus, actions, buttons, group boxes). | +| `QToolBar.addWidget(spacer)` with an `Expanding` size policy can make every action after it vanish | Adding a bare `QWidget` spacer (`QSizePolicy.Policy.Expanding` horizontal, tried both `Preferred` and `Expanding` vertical) to right-align a trailing toolbar action made that action disappear from the rendered toolbar entirely — not shifted, not overflowed into a `»` chevron, just absent — confirmed by re-rendering with the spacer removed (the action reappeared immediately, left-aligned after the preceding separator). Root cause not fully isolated; treat any `addWidget(spacer)`-for-right-alignment idiom in a `QToolBar` in this codebase as unverified until the rendered result is actually checked (see the next pitfall for how to check it without a real display). | +| 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. | +| 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(...)`. | --- diff --git a/analysis/edge_analyzer.py b/analysis/edge_analyzer.py index e24b58b..a689c74 100644 --- a/analysis/edge_analyzer.py +++ b/analysis/edge_analyzer.py @@ -10,13 +10,13 @@ from scipy.interpolate import interp1d from core.astro_image import AstroImage -from core.fig_utils import figs_to_b64 +from core.fig_utils import figs_to_b64, finalize_layout from core.models import (EDGE_ROI_HALF_WIDTH, EDGE_ROI_MAP_INDICATOR_PX, - SECTION8_BORDER_CROP_FRACTION, EDGE_ESF_MIN_MONOTONICITY) + SECTION8_BORDER_CROP_FRACTION, EDGE_ESF_MIN_MONOTONICITY, + EDGE_N_TOP_EDGES) EDGE_DISPLAY_HALF_WIDTH = 250 # half-side of the context window shown in the report figure -N_TOP_EDGES = 3 # number of gradient peaks to auto-detect -N_CANDIDATE_EDGES = N_TOP_EDGES * 3 # extra auto-detect candidates so low-quality ones can be skipped +N_CANDIDATE_EDGES = EDGE_N_TOP_EDGES * 3 # extra auto-detect candidates so low-quality ones can be skipped _ESF_DISC_MARGIN_PX = 2.0 # safety margin (px) subtracted from the inscribed-circle radius _ESF_QUALITY_SMOOTH_FRAC = 0.20 # smoothing window as a fraction of ESF length, for the quality metric only @@ -35,7 +35,7 @@ class EdgeAnalyzer: """Extract edge spread function (ESF) and line spread function (LSF) from nebula edges to measure local contrast and resolution. - When roi is None, the top N_TOP_EDGES well-separated gradient peaks are + When roi is None, the top EDGE_N_TOP_EDGES well-separated gradient peaks are located automatically. For each, a separate ESF/LSF measurement is run. The strongest edge's metrics are promoted to top-level keys for the summary table; all per-edge results are available in result["edges"]. @@ -61,7 +61,7 @@ def analyze(self, image: AstroImage, bgsub = image.background_subtracted() # Build list of (roi_data_array, roi_tuple) pairs. Auto-detect mode - # searches more candidates than N_TOP_EDGES so low-quality ones (ESF + # searches more candidates than EDGE_N_TOP_EDGES so low-quality ones (ESF # crossed more than one physical edge) can be skipped in favour of the # next-strongest gradient peak; user-drawn/A-matched ROIs are fixed # and can't be swapped for an alternative, so quality is only flagged @@ -88,7 +88,7 @@ def analyze(self, image: AstroImage, edges: list[dict] = [] rejected: list[dict] = [] # low-quality auto-detect candidates, kept only as a fallback for i, (roi_data, roi_tuple) in enumerate(roi_pairs): - if allow_skip and len(edges) >= N_TOP_EDGES: + if allow_skip and len(edges) >= EDGE_N_TOP_EDGES: break if roi_data is None or roi_data.size == 0: continue @@ -101,7 +101,7 @@ def analyze(self, image: AstroImage, edge_info = dict(edge_info) edge_info["angle_rad"] = forced_angles[i] - positions, esf = self._extract_esf(roi_data, edge_info) + positions, esf, start_xy = self._extract_esf(roi_data, edge_info) if esf is None or len(esf) < 5: continue @@ -109,7 +109,7 @@ def analyze(self, image: AstroImage, low_confidence = quality < EDGE_ESF_MIN_MONOTONICITY entry = self._build_edge_entry( image, bgsub, roi_data, roi_tuple, edge_info, - positions, esf, i, quality, low_confidence) + positions, esf, start_xy, i, quality, low_confidence) if allow_skip and low_confidence: rejected.append(entry) @@ -122,6 +122,11 @@ def analyze(self, image: AstroImage, rejected.sort(key=lambda e: e["esf_quality"], reverse=True) edges.append(rejected[0]) + # Overview map should only mark the edges actually analyzed, not every + # searched candidate (auto-detect searches N_CANDIDATE_EDGES > len(edges) + # so low-quality candidates can be skipped in favour of the next peak). + rois_used = [e["roi_used"] for e in edges] + # Full-image gradient map for report visualisation from core.stretch import stf_stretch sigma = _gradient_sigma(image.pixel_scale) @@ -172,6 +177,7 @@ def analyze(self, image: AstroImage, def _build_edge_entry(self, image: AstroImage, bgsub: np.ndarray, roi_data: np.ndarray, roi_tuple: tuple, edge_info: dict, positions: np.ndarray, esf: np.ndarray, + start_xy: tuple | None, edge_num: int, quality: float, low_confidence: bool) -> dict: lsf = self._compute_lsf(positions, esf) width = self._measure_edge_width(positions, esf) @@ -188,9 +194,29 @@ def _build_edge_entry(self, image: AstroImage, bgsub: np.ndarray, dy1 = min(bgsub.shape[0], yc_full + dw) display_roi = bgsub[dy0:dy1, dx0:dx1] analysis_rect = (x0 - dx0, y0 - dy0, x1 - dx0, y1 - dy0) + + # The ESF scan/edge-orientation guide lines must pivot on the ROI's own + # geometric center, not the Sobel-detected gradient peak (edge_info's + # center_x/center_y) -- scipy.ndimage.rotate() in _extract_esf always + # rotates roi_data about its own array center ((h-1)/2, (w-1)/2), and + # start_xy (the "Scan start" marker) is derived by inverse-rotating a + # point on that same pivot. Drawing the guide lines through the + # gradient-peak point instead left the red marker floating off the + # cyan line whenever the peak wasn't exactly at the ROI's center. + h_roi, w_roi = roi_data.shape + roi_cx_full = x0 + (w_roi - 1) / 2.0 + roi_cy_full = y0 + (h_roi - 1) / 2.0 edge_info_display = dict(edge_info) - edge_info_display["center_x"] = xc_full - dx0 - edge_info_display["center_y"] = yc_full - dy0 + edge_info_display["center_x"] = roi_cx_full - dx0 + edge_info_display["center_y"] = roi_cy_full - dy0 + + # ESF start point (position index 0), in the same display-frame + # coordinates as edge_info_display, for the directional marker. + start_xy_display = None + if start_xy is not None: + start_x_full = x0 + start_xy[0] + start_y_full = y0 + start_xy[1] + start_xy_display = (start_x_full - dx0, start_y_full - dy0) return { "roi_used": roi_tuple, @@ -211,6 +237,7 @@ def _build_edge_entry(self, image: AstroImage, bgsub: np.ndarray, positions, esf, lsf, width, image.label, edge_info_display, edge_num=edge_num + 1, low_confidence=low_confidence, + start_xy_display=start_xy_display, ) }), } @@ -220,7 +247,7 @@ def _build_edge_entry(self, image: AstroImage, bgsub: np.ndarray, # ------------------------------------------------------------------ def _auto_detect_top_rois(self, bgsub: np.ndarray, image: AstroImage, - n: int = N_TOP_EDGES + n: int = EDGE_N_TOP_EDGES ) -> list[tuple[np.ndarray, tuple]]: """Find n well-separated patches centred on the strongest gradients.""" from core.stretch import stf_stretch @@ -281,9 +308,23 @@ def _detect_strongest_edge(self, roi_data: np.ndarray) -> dict | None: # ------------------------------------------------------------------ def _extract_esf(self, roi_data: np.ndarray, - edge_info: dict) -> tuple[np.ndarray, np.ndarray | None]: + edge_info: dict) -> tuple[np.ndarray, np.ndarray | None, tuple | None]: angle_deg = np.degrees(edge_info["angle_rad"]) - rotation_angle = -(90.0 - angle_deg) + # Empirically verified (not hand-derived -- see the impulse-trick + # comment below for why that matters with rotate()'s sign/handedness): + # rendering rotate(roi_data, angle, ...) for known synthetic edge + # angles and inspecting the result directly shows rotation_angle = + # angle_deg aligns the edge vertically, exactly as this function + # needs (esf_raw below averages DOWN COLUMNS, so the edge must run + # vertically for that average to stay on one side of the transition + # per column). A previous formula, -(90.0 - angle_deg), looked like + # a more "natural" complement but actually aligned the edge + # HORIZONTALLY instead -- averaging down columns then integrated + # straight across the transition, canceling out nearly all of the + # real signal and leaving only a disc-boundary/interpolation + # artifact (still smooth and monotonic, so _esf_quality's gate never + # caught it). + rotation_angle = angle_deg # cval=nan (not the default 0.0) marks pixels that rotate() had to # invent because the source square doesn't cover that output pixel at # this angle -- see the disc-mask comment below for why this matters. @@ -318,11 +359,11 @@ def _extract_esf(self, roi_data: np.ndarray, valid_idx = np.where(~np.isnan(esf_raw))[0] if len(valid_idx) < 5: - return positions, None + return positions, None, None lo = float(np.nanmin(esf_raw)) hi = float(np.nanmax(esf_raw)) if hi - lo < 1e-12: - return positions, None + return positions, None, None esf = (esf_raw - lo) / (hi - lo) if esf[valid_idx[0]] > esf[valid_idx[-1]]: @@ -332,7 +373,19 @@ def _extract_esf(self, roi_data: np.ndarray, # near the two far ends can be NaN) so downstream LSF/width code never # has to handle missing values. i0, i1 = valid_idx[0], valid_idx[-1] + 1 - return positions[i0:i1] - positions[i0], esf[i0:i1] + + # Locate the ROI-local (pre-rotation) pixel corresponding to the ESF's + # start (position index 0), for a directional marker. Uses rotate() + # itself in reverse -- an impulse at the same rotated-frame column, + # inverse-rotated back -- rather than hand-deriving rotate()'s angle- + # sign convention, so this stays correct regardless of its handedness. + impulse = np.zeros((h, w), dtype=float) + impulse[int(round(cy)), i0] = 1.0 + impulse_orig = rotate(impulse, -rotation_angle, reshape=False, order=1, cval=0.0) + start_ry, start_rx = np.unravel_index(np.argmax(impulse_orig), impulse_orig.shape) + start_xy = (int(start_rx), int(start_ry)) + + return positions[i0:i1] - positions[i0], esf[i0:i1], start_xy @staticmethod def _esf_quality(esf: np.ndarray) -> float: @@ -441,7 +494,7 @@ def _plot_gradient_map(self, gm: np.ndarray, label: str, ax.set_title(f"Gradient magnitude — {label}") ax.set_xlabel("X (px)") ax.set_ylabel("Y (px)") - fig.tight_layout() + finalize_layout(fig) return fig # ------------------------------------------------------------------ @@ -456,7 +509,8 @@ def _plot_results(self, roi_data: np.ndarray, label: str, edge_info: dict | None = None, edge_num: int = 1, - low_confidence: bool = False) -> plt.Figure: + low_confidence: bool = False, + start_xy_display: tuple | None = None) -> plt.Figure: from matplotlib.patches import Rectangle fig, ax = plt.subplots(figsize=(5, 5)) @@ -505,12 +559,17 @@ def _clipped_line(cx, cy, ang): ax.plot(xs, ys, color="yellow", linewidth=1.2, linestyle="--", alpha=0.75, label="Edge orientation") + if start_xy_display is not None: + ax.plot(start_xy_display[0], start_xy_display[1], marker="s", + markersize=5, color="red", zorder=6, + label="Scan start") + ax.legend(fontsize=7, loc="lower right") ax.set_xlim(0, w_disp) ax.set_ylim(0, h_disp) - fig.tight_layout() + finalize_layout(fig) return fig # ------------------------------------------------------------------ diff --git a/analysis/halo_analyzer.py b/analysis/halo_analyzer.py index 48995dc..59366de 100644 --- a/analysis/halo_analyzer.py +++ b/analysis/halo_analyzer.py @@ -14,7 +14,7 @@ from scipy.optimize import curve_fit from core.astro_image import AstroImage -from core.fig_utils import figs_to_b64 +from core.fig_utils import figs_to_b64, finalize_layout from core.models import HALO_FIT_RADIUS_PX, HALO_MIN_STAR_SNR, RDF_BIN_WIDTH from analysis.star_catalog import StarCatalogBuilder @@ -423,5 +423,5 @@ def _plot_profile(self, r: np.ndarray, I_norm: np.ndarray, f"R_halo = {halo_r_str}") ax.legend(fontsize=8) ax.grid(True, alpha=0.3) - fig.tight_layout() + finalize_layout(fig) return fig diff --git a/analysis/image_filters.py b/analysis/image_filters.py index 2d52d87..4d3af43 100644 --- a/analysis/image_filters.py +++ b/analysis/image_filters.py @@ -11,16 +11,22 @@ matplotlib.use("Agg") import matplotlib.pyplot as plt from matplotlib.patches import Patch -from scipy.ndimage import generic_filter, gaussian_filter, gaussian_laplace, gaussian_gradient_magnitude, map_coordinates, zoom, maximum_filter, minimum_filter, median_filter +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 import pywt from core.astro_image import AstroImage -from core.fig_utils import fig_to_b64, figs_to_b64 +from core.fig_utils import fig_to_b64, figs_to_b64, finalize_layout from core.models import (STD_KERNEL_SIZES, LOG_SIGMAS, WAVELET_NAME, WAVELET_LEVELS, - WEBER_KERNEL_SIZES, + ENTROPY_KERNEL_SIZES, XS_LINE_ALPHA, SECTION8_BORDER_CROP_FRACTION, SECTION8_ANALYSIS_CMAP, - XS_SNR_REGION_WIDTH, SECTION8_DIFF_DIST_MAX_SAMPLES, - SECTION8_LOGRATIO_EPS_PERCENTILE, SECTION8_SCATTER_MAX_SAMPLES) + XS_SNR_REGION_WIDTH, + SECTION8_LOGRATIO_EPS_PERCENTILE, SECTION8_SCATTER_MAX_SAMPLES, + SECTION8_NEBULA_MASK_SIGMA, SECTION8_NEBULA_MASK_DILATION_PX, + SECTION8_NEBULA_MASK_MAX_HOLE_PX, + SECTION8_LOCALMAX_FOOTPRINT_MULT, SECTION8_LOCALMAX_PROMINENCE_PERCENTILE, + SECTION8_LOCALMAX_PRESMOOTH_FRACTION, SECTION8_LOCALMAX_REGION_FRACTION, + 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) _DISPLAY_SMOOTH_SIGMA = 1.0 # applied to maps before plotting; does NOT affect metrics @@ -38,10 +44,18 @@ def analyze(self, image_a: AstroImage, image_b: AstroImage | None = None, log_sigmas: tuple = LOG_SIGMAS, wavelet: str = WAVELET_NAME, levels: int = WAVELET_LEVELS, - weber_kernel_sizes: tuple = WEBER_KERNEL_SIZES, + entropy_kernel_sizes: tuple = ENTROPY_KERNEL_SIZES, crosshair: dict | None = None, roi: tuple | None = None, - xs_snr_width: int | None = None) -> dict: + xs_snr_width: int | None = None, + nebula_sigma: float = SECTION8_NEBULA_MASK_SIGMA, + nebula_dilation_px: int = SECTION8_NEBULA_MASK_DILATION_PX, + nebula_max_hole_px: int = SECTION8_NEBULA_MASK_MAX_HOLE_PX, + localmax_footprint_mult: float = SECTION8_LOCALMAX_FOOTPRINT_MULT, + localmax_prominence_percentile: float = SECTION8_LOCALMAX_PROMINENCE_PERCENTILE, + localmax_region_fraction: float = SECTION8_LOCALMAX_REGION_FRACTION, + localmax_presmooth_fraction: float = SECTION8_LOCALMAX_PRESMOOTH_FRACTION, + localmax_top_percent: float = SECTION8_LOCALMAX_TOP_PERCENT) -> dict: image_a.estimate_background() if image_b is not None: @@ -57,21 +71,34 @@ def analyze(self, image_a: AstroImage, image_b: AstroImage | None = None, "wavelet_snr_b": {}, "sigma_noise_a": None, "sigma_noise_b": None, - "weber_contrast_a": {}, - "weber_contrast_b": {}, + "nebula_sigma": nebula_sigma, + "nebula_dilation_px": nebula_dilation_px, + "nebula_max_hole_px": nebula_max_hole_px, + "localmax_footprint_mult": localmax_footprint_mult, + "localmax_prominence_percentile": localmax_prominence_percentile, + "localmax_region_fraction": localmax_region_fraction, + "localmax_presmooth_fraction": localmax_presmooth_fraction, + "localmax_top_percent": localmax_top_percent, + "entropy_contrast_ratio_a": {}, + "entropy_contrast_ratio_b": {}, "panels": {}, - "diff_dist": {}, + "localmax": {}, "nc_shared_nebula_pixels": 0, "std_nc_score_a": {}, "std_nc_score_b": {}, "std_nc_noise_a": {}, "std_nc_noise_b": {}, "std_nc_ratio": {}, + "std_nc_neb_std_a": {}, "std_nc_neb_std_b": {}, "std_nc_ratio_err": {}, "log_nc_score_a": {}, "log_nc_score_b": {}, "log_nc_noise_a": {}, "log_nc_noise_b": {}, "log_nc_ratio": {}, + "log_nc_neb_std_a": {}, "log_nc_neb_std_b": {}, "log_nc_ratio_err": {}, "wavelet_nc_score_a": {}, "wavelet_nc_score_b": {}, "wavelet_nc_noise_a": {}, "wavelet_nc_noise_b": {}, "wavelet_nc_ratio": {}, - "weber_nc_score_a": {}, "weber_nc_score_b": {}, - "weber_nc_noise_a": {}, "weber_nc_noise_b": {}, "weber_nc_ratio": {}, + "wavelet_nc_neb_std_a": {}, "wavelet_nc_neb_std_b": {}, "wavelet_nc_ratio_err": {}, + "entropy_nc_score_a": {}, "entropy_nc_score_b": {}, + "entropy_nc_noise_a": {}, "entropy_nc_noise_b": {}, "entropy_nc_ratio": {}, + "entropy_nc_neb_std_a": {}, "entropy_nc_neb_std_b": {}, "entropy_nc_ratio_err": {}, "gm_nc_score_a": {}, "gm_nc_score_b": {}, "gm_nc_noise_a": {}, "gm_nc_noise_b": {}, "gm_nc_ratio": {}, + "gm_nc_neb_std_a": {}, "gm_nc_neb_std_b": {}, "gm_nc_ratio_err": {}, } figures: dict = {} @@ -80,10 +107,10 @@ def analyze(self, image_a: AstroImage, image_b: AstroImage | None = None, return result # Nebula / background masks (used for contrast ratio) - mask_neb_a, mask_bg_a = self._make_masks(image_a) + mask_neb_a, mask_bg_a = self._make_masks(image_a, nebula_sigma, nebula_dilation_px, nebula_max_hole_px) mask_neb_b = mask_bg_b = None if image_b is not None: - mask_neb_b, mask_bg_b = self._make_masks(image_b) + mask_neb_b, mask_bg_b = self._make_masks(image_b, nebula_sigma, nebula_dilation_px, nebula_max_hole_px) # When a user ROI is provided, crop the analysis arrays to that region after # normalisation so the global signal mean is used, not the ROI's local mean. @@ -124,23 +151,30 @@ def _clip01(v): return max(0.0, min(1.0, v)) result["display_roi"] = display_roi # Shared nebula/background regions for noise-corrected A/B scoring and diff - # distributions: pixels BOTH images independently classify the same way. + # distributions. Nebula is the two-image UNION: a pixel counts as Nebula if + # either image independently classifies it that way, so nebula signal that's + # marginal in one image (registration offset, PSF, local noise) still counts. + # Background stays the two-image INTERSECTION: a pixel counts as Background + # only if both images agree, keeping the noise-floor reference population + # clean. Per-image nebula-dominance in _make_masks (bg_mask excludes + # nebula_mask) guarantees these two combinations never overlap. # None in single-image mode. mask_neb_shared = mask_bg_shared = None if mask_neb_b is not None: h_s = min(mask_neb_a.shape[0], mask_neb_b.shape[0]) w_s = min(mask_neb_a.shape[1], mask_neb_b.shape[1]) - mask_neb_shared = mask_neb_a[:h_s, :w_s] & mask_neb_b[:h_s, :w_s] + mask_neb_shared = mask_neb_a[:h_s, :w_s] | mask_neb_b[:h_s, :w_s] mask_bg_shared = mask_bg_a[:h_s, :w_s] & mask_bg_b[:h_s, :w_s] result["nc_shared_nebula_pixels"] = ( int(np.count_nonzero(mask_neb_shared)) if mask_neb_shared is not None else 0 ) - # Fixed seed so the diff-distribution subsampling below is reproducible - # across report generations for the same input images. - diff_dist_rng = np.random.default_rng(42) + # Fixed seed so the correlation-scatter and local-maxima-distribution + # subsampling below is reproducible across report generations for the + # same input images. + rng = np.random.default_rng(42) - # Export the exact preprocessed array every std/LoG/wavelet/Weber/gradient + # Export the exact preprocessed array every std/LoG/wavelet/entropy/gradient # map is computed from (mean-normalised, ROI-cropped if a ROI was given) as # its own panel, so the Report Inspector can show the source image content # for a map alongside the map itself, pixel-aligned to the same crop. @@ -150,12 +184,7 @@ def _clip01(v): return max(0.0, min(1.0, v)) "b": analysis_b.astype(np.float32) if analysis_b is not None else None, "diff": original_diff, } - if original_diff is not None: - result["diff_dist"]["original"] = self._diff_distribution( - original_diff, mask_neb_shared, mask_bg_shared, diff_dist_rng) - - # Mask illustration: only meaningful in two-image mode, mirroring the - # violin plots' own empty-in-single-image-mode behaviour. + # Mask illustration: only meaningful in two-image mode. if mask_neb_shared is not None: mask_fig = self._plot_mask_illustration( result["panels"]["original"]["a"], mask_neb_shared, mask_bg_shared) @@ -163,7 +192,47 @@ def _clip01(v): return max(0.0, min(1.0, v)) _label_b = image_b.label if image_b is not None else None - # 1-5. Local std, LoG, wavelet, Weber, gradient — all read norm_a/norm_b with no + # Original image family: the raw source data itself, analysed with the same + # map-pair layout (A|B, log-ratio, cross-section, histogram, correlation) as + # every derived metric below — not a filter, just the input they all share. + xs_raw_orig = None + xs_line_orig = None + if crosshair_roi is not None and analysis_b is not None: + pos, pa = self._sample_line(analysis_a, **crosshair_roi) + _, pb = self._sample_line(analysis_b, **crosshair_roi) + xs_raw_orig = (pos, pa, pb, image_a.label, _label_b, + "Cross-section — Original (normalised image)") + xs_line_orig = self._crosshair_to_cropped_px( + crosshair_roi, analysis_a.shape, SECTION8_BORDER_CROP_FRACTION) + + if analysis_b is not None: + orig_fig = self._plot_side_by_side( + self._crop_border(analysis_a, SECTION8_BORDER_CROP_FRACTION), + self._crop_border(analysis_b, SECTION8_BORDER_CROP_FRACTION), + f"Original (normalised) — {image_a.label}", + f"Original (normalised) — {_label_b}", + diff_title="Log ratio (A/B), original image", + cmap=SECTION8_ANALYSIS_CMAP, + display_roi=None, + xs_data=xs_raw_orig, + xs_line=xs_line_orig, + ) + else: + orig_fig = self._plot_single( + self._crop_border(analysis_a, SECTION8_BORDER_CROP_FRACTION), + f"Original (normalised) — {image_a.label}", + cmap=SECTION8_ANALYSIS_CMAP, + ) + figures["original"] = fig_to_b64(orig_fig, dpi=150) + + if mask_neb_shared is not None: + orig_corr_fig = self._plot_metric_correlation( + analysis_a, analysis_b, original_diff, mask_neb_shared, mask_bg_shared, + image_a.label, _label_b, "Original (normalised image)", rng) + if orig_corr_fig is not None: + figures["corr_original"] = fig_to_b64(orig_corr_fig, dpi=150) + + # 1-5. Local std, LoG, wavelet, entropy, gradient — all read norm_a/norm_b with no # shared mutable state, so they run concurrently. Each method returns # (b64_figs, partial_result). with concurrent.futures.ThreadPoolExecutor(max_workers=5) as _ex: @@ -176,7 +245,12 @@ def _clip01(v): return max(0.0, min(1.0, v)) display_roi=display_roi, crosshair=crosshair_roi, mask_neb_shared=mask_neb_shared, - mask_bg_shared=mask_bg_shared, diff_dist_rng=diff_dist_rng, + mask_bg_shared=mask_bg_shared, rng=rng, + localmax_footprint_mult=localmax_footprint_mult, + localmax_prominence_percentile=localmax_prominence_percentile, + localmax_region_fraction=localmax_region_fraction, + localmax_presmooth_fraction=localmax_presmooth_fraction, + localmax_top_percent=localmax_top_percent, ) _f_log = _ex.submit(self._log_analysis, analysis_a, analysis_b, log_sigmas, @@ -184,7 +258,12 @@ def _clip01(v): return max(0.0, min(1.0, v)) display_roi=display_roi, crosshair=crosshair_roi, mask_neb_shared=mask_neb_shared, mask_bg_a=mask_bg_a, mask_bg_b=mask_bg_b, - mask_bg_shared=mask_bg_shared, diff_dist_rng=diff_dist_rng, + mask_bg_shared=mask_bg_shared, rng=rng, + localmax_footprint_mult=localmax_footprint_mult, + localmax_prominence_percentile=localmax_prominence_percentile, + localmax_region_fraction=localmax_region_fraction, + localmax_presmooth_fraction=localmax_presmooth_fraction, + localmax_top_percent=localmax_top_percent, ) _f_wav = _ex.submit(self._wavelet_analysis, analysis_a, analysis_b, wavelet, levels, @@ -192,16 +271,28 @@ def _clip01(v): return max(0.0, min(1.0, v)) display_roi=display_roi, crosshair=crosshair_roi, mask_neb_shared=mask_neb_shared, mask_bg_a=mask_bg_a, mask_bg_b=mask_bg_b, - mask_bg_shared=mask_bg_shared, diff_dist_rng=diff_dist_rng, + mask_bg_shared=mask_bg_shared, rng=rng, + localmax_footprint_mult=localmax_footprint_mult, + localmax_prominence_percentile=localmax_prominence_percentile, + localmax_region_fraction=localmax_region_fraction, + localmax_presmooth_fraction=localmax_presmooth_fraction, + localmax_top_percent=localmax_top_percent, ) - _f_web = _ex.submit(self._weber_analysis, + _f_ent = _ex.submit(self._entropy_analysis, analysis_a, analysis_b, - weber_kernel_sizes, + mask_neb_a, mask_bg_a, + mask_neb_b, mask_bg_b, + entropy_kernel_sizes, image_a.label, _label_b, display_roi=display_roi, crosshair=crosshair_roi, - mask_neb_shared=mask_neb_shared, mask_bg_a=mask_bg_a, mask_bg_b=mask_bg_b, - mask_bg_shared=mask_bg_shared, diff_dist_rng=diff_dist_rng, + mask_neb_shared=mask_neb_shared, + mask_bg_shared=mask_bg_shared, rng=rng, + localmax_footprint_mult=localmax_footprint_mult, + localmax_prominence_percentile=localmax_prominence_percentile, + localmax_region_fraction=localmax_region_fraction, + localmax_presmooth_fraction=localmax_presmooth_fraction, + localmax_top_percent=localmax_top_percent, ) _f_grad = _ex.submit(self._gradient_analysis, analysis_a, analysis_b, log_sigmas, @@ -209,18 +300,23 @@ def _clip01(v): return max(0.0, min(1.0, v)) display_roi=display_roi, crosshair=crosshair_roi, mask_neb_shared=mask_neb_shared, mask_bg_a=mask_bg_a, mask_bg_b=mask_bg_b, - mask_bg_shared=mask_bg_shared, diff_dist_rng=diff_dist_rng, + mask_bg_shared=mask_bg_shared, rng=rng, + localmax_footprint_mult=localmax_footprint_mult, + localmax_prominence_percentile=localmax_prominence_percentile, + localmax_region_fraction=localmax_region_fraction, + 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() - web_b64, web_partial = _f_web.result() + ent_b64, ent_partial = _f_ent.result() grad_b64, grad_partial = _f_grad.result() figures.update(std_b64) figures.update(log_b64) figures.update(wav_b64) - figures.update(web_b64) + figures.update(ent_b64) figures.update(grad_b64) result["contrast_ratios_a"].update(std_partial["contrast_ratios_a"]) result["contrast_ratios_b"].update(std_partial["contrast_ratios_b"]) @@ -228,39 +324,105 @@ def _clip01(v): return max(0.0, min(1.0, v)) result["sigma_noise_b"] = wav_partial["sigma_noise_b"] result["wavelet_snr_a"].update(wav_partial["wavelet_snr_a"]) result["wavelet_snr_b"].update(wav_partial["wavelet_snr_b"]) - result["weber_contrast_a"].update(web_partial["weber_contrast_a"]) - result["weber_contrast_b"].update(web_partial["weber_contrast_b"]) + result["entropy_contrast_ratio_a"].update(ent_partial["entropy_contrast_ratio_a"]) + result["entropy_contrast_ratio_b"].update(ent_partial["entropy_contrast_ratio_b"]) result["panels"].update(std_partial["panels"]) result["panels"].update(log_partial["panels"]) result["panels"].update(wav_partial["panels"]) - result["panels"].update(web_partial["panels"]) + result["panels"].update(ent_partial["panels"]) result["panels"].update(grad_partial["panels"]) - result["diff_dist"].update(std_partial["diff_dist"]) - result["diff_dist"].update(log_partial["diff_dist"]) - result["diff_dist"].update(wav_partial["diff_dist"]) - result["diff_dist"].update(web_partial["diff_dist"]) - result["diff_dist"].update(grad_partial["diff_dist"]) + result["localmax"].update(std_partial["localmax"]) + result["localmax"].update(log_partial["localmax"]) + result["localmax"].update(wav_partial["localmax"]) + result["localmax"].update(ent_partial["localmax"]) + result["localmax"].update(grad_partial["localmax"]) # Merge noise-corrected scores/noise-floors and compute A/B ratios centrally. for prefix, partial in (("std", std_partial), ("log", log_partial), - ("wavelet", wav_partial), ("weber", web_partial), + ("wavelet", wav_partial), ("entropy", ent_partial), ("gm", grad_partial)): - for suffix in ("nc_score_a", "nc_score_b", "nc_noise_a", "nc_noise_b"): + for suffix in ("nc_score_a", "nc_score_b", "nc_noise_a", "nc_noise_b", + "nc_neb_std_a", "nc_neb_std_b"): result[f"{prefix}_{suffix}"].update(partial[f"{prefix}_{suffix}"]) result[f"{prefix}_nc_ratio"] = self._compute_nc_ratios( result[f"{prefix}_nc_score_a"], result[f"{prefix}_nc_score_b"]) + result[f"{prefix}_nc_ratio_err"] = self._compute_nc_ratio_errors( + result[f"{prefix}_nc_ratio"], result[f"{prefix}_nc_score_a"], result[f"{prefix}_nc_score_b"], + result[f"{prefix}_nc_noise_a"], result[f"{prefix}_nc_noise_b"], + result[f"{prefix}_nc_neb_std_a"], result[f"{prefix}_nc_neb_std_b"]) if image_b is not None: + nc_errors_by_method = { + "std": result["std_nc_ratio_err"], "log": result["log_nc_ratio_err"], + "wavelet": result["wavelet_nc_ratio_err"], "entropy": result["entropy_nc_ratio_err"], + "gradient": result["gm_nc_ratio_err"], + } nc_fig = self._plot_nc_ratio_overview({ "std": result["std_nc_ratio"], "log": result["log_nc_ratio"], "wavelet": result["wavelet_nc_ratio"], - "weber": result["weber_nc_ratio"], + "entropy": result["entropy_nc_ratio"], "gradient": result["gm_nc_ratio"], - }) + }, nc_errors_by_method) if nc_fig is not None: figures["nc_ratio_overview"] = fig_to_b64(nc_fig, dpi=150) + localmax_log_ratios_by_method = { + "std": std_partial["localmax_log_ratio"], + "log": log_partial["localmax_log_ratio"], + "wavelet": wav_partial["localmax_log_ratio"], + "entropy": ent_partial["localmax_log_ratio"], + "gradient": grad_partial["localmax_log_ratio"], + } + localmax_log_ratio_errors_by_method = { + "std": std_partial["localmax_log_ratio_err"], + "log": log_partial["localmax_log_ratio_err"], + "wavelet": wav_partial["localmax_log_ratio_err"], + "entropy": ent_partial["localmax_log_ratio_err"], + "gradient": grad_partial["localmax_log_ratio_err"], + } + lm_ratio_fig = self._plot_localmax_ratio_overview( + localmax_log_ratios_by_method, localmax_log_ratio_errors_by_method) + if lm_ratio_fig is not None: + figures["localmax_ratio_overview"] = fig_to_b64(lm_ratio_fig, dpi=150) + + # Local-maxima mask grid: one row per metric family, columns = kernel/scale + # sizes smallest -> largest. Every panel's mask is computed with the same + # _combined_localmax_mask formula _localmax_entry uses for that row's own + # table statistics -- this reuses panels already cached in + # result["panels"], no new map computation. Wavelet has only 2 display + # scales, so its 3rd column is left blank. + _grid_families = [ + ("Local σ", [(f"std_{ks}px", float(ks), f"Local σ — {ks} px") for ks in kernel_sizes]), + ("|LoG|", [(f"log_{s}", float(s), f"|LoG| — σ={s} px") for s in log_sigmas]), + ("Gradient |G|", [(f"gradient_{s}", float(s), f"Gradient |G| — σ={s} px") for s in log_sigmas]), + ("Wavelet", [(f"wavelet_{lvl}", float(2 ** lvl), f"Wavelet — level {lvl}") for lvl in (2, 3)]), + ("Local entropy", [(f"entropy_{ks}px", float(ks), f"Entropy — {ks} px") for ks in entropy_kernel_sizes]), + ] + grid_rows = [] + for family_label, entries in _grid_families: + cells = [] + for key, scale_px, panel_title in entries: + panel = result["panels"].get(key) + if panel is None or panel["a"] is None or panel["b"] is None: + continue + h_g = min(panel["a"].shape[0], panel["b"].shape[0]) + w_g = min(panel["a"].shape[1], panel["b"].shape[1]) + abs_a = np.abs(panel["a"][:h_g, :w_g]) + abs_b = np.abs(panel["b"][:h_g, :w_g]) + footprint_px = max(3, int(round(localmax_footprint_mult * scale_px)) | 1) + region_px = max(1, int(round(localmax_region_fraction * footprint_px))) + presmooth_sigma = max(0.5, localmax_presmooth_fraction * scale_px) + mask = self._combined_localmax_mask(abs_a, abs_b, footprint_px, + localmax_prominence_percentile, + region_px, presmooth_sigma, + localmax_top_percent) + cells.append((abs_a, mask, panel_title)) + grid_rows.append((family_label, cells)) + grid_fig = self._plot_localmax_mask_grid(grid_rows) + if grid_fig is not None: + figures["localmax_mask_illustration"] = fig_to_b64(grid_fig, dpi=150) + if crosshair is not None: pos_a, prof_a = self._sample_line(norm_a, **crosshair) pos_a_raw, prof_a_raw = self._sample_line(image_a.data, **crosshair) @@ -313,7 +475,10 @@ def _normalise(self, image: AstroImage) -> np.ndarray | None: return None return bgsub / mean_signal - def _make_masks(self, image: AstroImage) -> tuple[np.ndarray, np.ndarray]: + def _make_masks(self, image: AstroImage, + nebula_sigma: float = SECTION8_NEBULA_MASK_SIGMA, + dilation_px: int = SECTION8_NEBULA_MASK_DILATION_PX, + max_hole_px: int = SECTION8_NEBULA_MASK_MAX_HOLE_PX) -> tuple[np.ndarray, np.ndarray]: rms = image.background_rms if rms is None: rms_val = float(np.std(image.background_subtracted())) @@ -321,7 +486,7 @@ def _make_masks(self, image: AstroImage) -> tuple[np.ndarray, np.ndarray]: rms_val = float(np.median(rms)) bgsub = image.background_subtracted() - nebula_mask = bgsub > 2.0 * rms_val + nebula_mask = bgsub > nebula_sigma * rms_val bg_mask = bgsub < 0.5 * rms_val # Fallback: use top-5% as nebula if no pixels pass threshold @@ -329,8 +494,206 @@ def _make_masks(self, image: AstroImage) -> tuple[np.ndarray, np.ndarray]: threshold = np.percentile(bgsub, 95) nebula_mask = bgsub >= threshold + # Fill small enclosed background gaps, strip small isolated noise-driven + # specks (same size threshold — a scattered 1-2px false positive at this + # sigma level would otherwise balloon into a ~(2*dilation_px+1)^2 blob per + # speck once dilated, polluting blank-sky area far from any real nebula), + # then grow into adjacent dim/dark transition regions at nebula edges. + # All three are applied before the bg_mask exclusion below, since any of + # them can pull previously bg-classified pixels into the nebula mask. + nebula_mask = self._fill_small_holes(nebula_mask, max_hole_px) + nebula_mask = self._remove_small_objects(nebula_mask, max_hole_px) + if dilation_px > 0: + nebula_mask = binary_dilation(nebula_mask, iterations=dilation_px) + + # Nebula dominates: keep the two masks mutually exclusive after growth. + bg_mask = bg_mask & ~nebula_mask + return nebula_mask, bg_mask + def _fill_small_holes(self, mask: np.ndarray, max_hole_px: int) -> np.ndarray: + """Fill enclosed background gaps inside mask up to (max_hole_px)**2 area.""" + if max_hole_px <= 0: + return mask + filled = binary_fill_holes(mask) + holes = filled & ~mask + labeled, n_holes = label(holes) + if n_holes == 0: + return mask + sizes = np.bincount(labeled.ravel()) + max_area = max_hole_px * max_hole_px + keep = np.zeros(sizes.size, dtype=bool) + keep[1:] = sizes[1:] <= max_area # label 0 is background, not a hole + return mask | keep[labeled] + + def _remove_small_objects(self, mask: np.ndarray, max_size_px: int) -> np.ndarray: + """Strip isolated foreground specks up to (max_size_px)**2 area. + + Scattered single/few-pixel noise excursions above the nebula sigma + threshold are common at a loose (~1.7 sigma) cut. Left in place, dilation + would inflate each one into a much larger blob far from any real nebula + structure, so small islands are dropped before growth is applied. + """ + if max_size_px <= 0: + return mask + labeled, n_objects = label(mask) + if n_objects == 0: + return mask + sizes = np.bincount(labeled.ravel()) + max_area = max_size_px * max_size_px + keep = np.zeros(sizes.size, dtype=bool) + keep[1:] = sizes[1:] > max_area # label 0 is background; drop small islands + return keep[labeled] + + @staticmethod + def _local_maxima_mask(data: np.ndarray, footprint_px: int, + prominence_percentile: float, + region_px: int, + presmooth_sigma: float = 0.0) -> np.ndarray: + """Binary mask marking the local region of pixels around each detected + peak in `data` — not just the single maximal pixel. + + Detection runs on a lightly Gaussian-smoothed copy of `data` + (presmooth_sigma) to suppress single-pixel noise-driven false maxima; + the mask itself is built from that smoothed detection pass, but + callers should measure statistics from the raw (unsmoothed) data + within the returned mask, not from the smoothed copy. A pixel is a + peak if it equals the max of its own footprint_px neighbourhood + (scipy.ndimage.maximum_filter) AND exceeds the + prominence_percentile-th percentile of the smoothed data — the + percentile floor is relative to the data's own distribution (mirrors + the SECTION8_LOGRATIO_EPS_PERCENTILE precedent in _log_ratio_map), so + peak "height" auto-scales per metric instead of needing an absolute + cutoff. Each surviving peak pixel is then grown by region_px + (binary_dilation) to cover the local neighbourhood around it — sized + proportionally to footprint_px by the caller — rather than a single + pixel. + """ + if data.size == 0: + return np.zeros_like(data, dtype=bool) + smoothed = gaussian_filter(data, sigma=presmooth_sigma) if presmooth_sigma > 0 else data + footprint = max(3, int(footprint_px) | 1) + local_max = smoothed == maximum_filter(smoothed, size=footprint) + threshold = np.percentile(smoothed, prominence_percentile) + mask = local_max & (smoothed > threshold) + if region_px > 0 and np.any(mask): + mask = binary_dilation(mask, iterations=region_px) + return mask + + @staticmethod + def _top_percent_mask(abs_a: np.ndarray, abs_b: np.ndarray, top_percent: float) -> np.ndarray: + """Boolean mask of pixels in the top `top_percent`% of Image A's OR Image B's + own value distribution -- catches broad bright regions that local-maxima peak + detection alone would miss (e.g. an extended plateau, not a sharp point peak).""" + thresh_a = np.percentile(abs_a, 100.0 - top_percent) + thresh_b = np.percentile(abs_b, 100.0 - top_percent) + return (abs_a >= thresh_a) | (abs_b >= thresh_b) + + def _combined_localmax_mask(self, abs_a: np.ndarray, abs_b: np.ndarray, + footprint_px: int, prominence_percentile: float, + region_px: int, presmooth_sigma: float, + top_percent: float) -> np.ndarray: + """Local-maxima peak mask (_local_maxima_mask) unioned with a top-percent + brightness mask (_top_percent_mask). Used identically by _localmax_entry + (Section 8j table/figure stats) and the mask-grid figure builder in analyze(), + so every displayed panel matches the mask actually backing that row's numbers.""" + peak_source = np.maximum(abs_a, abs_b) + mask = self._local_maxima_mask(peak_source, footprint_px, prominence_percentile, + region_px, presmooth_sigma) + mask |= self._top_percent_mask(abs_a, abs_b, top_percent) + return mask + + @staticmethod + def _localmax_stats(abs_a: np.ndarray, abs_b: np.ndarray, + diff: np.ndarray, mask: np.ndarray, + rng: np.random.Generator) -> dict: + """Masked summary stats for one metric/scale's local-maxima mask. + + abs_a/abs_b must already be magnitude (|.|) arrays so wavelet's signed + reconstructions don't cancel when averaged (a no-op for the other four + families, which are already >= 0). ratio = 10**mean(diff[mask]) — the + geometric mean of the per-pixel A/B ratio at the masked pixels, + expressed as a plain ×-factor; diff is the already-computed + log10(|A|/|B|) map every family builds via _log_ratio_map, not + recomputed here. std_a/std_b are the sample standard deviation of the + masked magnitudes. p_value/cliffs_delta are a Mann-Whitney U test + (two-sided) + Cliff's delta comparing the full masked-pixel + populations of A vs B (core.stats_utils.mannwhitney_effect) — delta + > 0 means A tends higher. log_ratio_mean/log_ratio_std are the mean/standard + deviation of the per-pixel log10(|A|/|B|) population at the masked pixels + (masked_diff itself, a genuinely pixel-paired quantity) — log_ratio_mean is + the same quantity `ratio` is exponentiated from (ratio = 10**log_ratio_mean), + kept unexponentiated for the Section 8j table/overview plot, which present + this column directly in log10 space rather than converting back to a linear + ×-factor. vals_a/vals_b/vals_log_ratio are each a + SECTION8_LOCALMAX_DIST_MAX_SAMPLES-capped random subsample of the + corresponding masked population, retained only for the Section 8j + distribution figures; every other returned value is computed from the + FULL population. Returns None values / n_px=0 when the mask selects + no pixels. + """ + from core.stats_utils import mannwhitney_effect + h = min(abs_a.shape[0], abs_b.shape[0], diff.shape[0], mask.shape[0]) + w = min(abs_a.shape[1], abs_b.shape[1], diff.shape[1], mask.shape[1]) + m = mask[:h, :w] + n_px = int(np.count_nonzero(m)) + if n_px == 0: + return {"mean_a": None, "mean_b": None, "std_a": None, "std_b": None, + "ratio": None, "log_ratio_mean": None, "log_ratio_std": None, + "p_value": None, "cliffs_delta": None, + "n_px": 0, "pct_area": 0.0, + "vals_a": np.empty(0, dtype=np.float32), "vals_b": np.empty(0, dtype=np.float32), + "vals_log_ratio": np.empty(0, dtype=np.float32)} + vals_a = abs_a[:h, :w][m] + vals_b = abs_b[:h, :w][m] + mean_a, std_a = float(np.mean(vals_a)), float(np.std(vals_a)) + mean_b, std_b = float(np.mean(vals_b)), float(np.std(vals_b)) + masked_diff = diff[:h, :w][m] + log_ratio_mean = float(np.mean(masked_diff)) + log_ratio_std = float(np.std(masked_diff)) + ratio = float(10.0 ** log_ratio_mean) + p_value, delta = mannwhitney_effect(vals_a, vals_b) + sub_a, sub_b, sub_log_ratio = vals_a, vals_b, masked_diff + if vals_a.size > SECTION8_LOCALMAX_DIST_MAX_SAMPLES: + sub_a = vals_a[rng.choice(vals_a.size, SECTION8_LOCALMAX_DIST_MAX_SAMPLES, replace=False)] + if vals_b.size > SECTION8_LOCALMAX_DIST_MAX_SAMPLES: + sub_b = vals_b[rng.choice(vals_b.size, SECTION8_LOCALMAX_DIST_MAX_SAMPLES, replace=False)] + if masked_diff.size > SECTION8_LOCALMAX_DIST_MAX_SAMPLES: + sub_log_ratio = masked_diff[rng.choice(masked_diff.size, SECTION8_LOCALMAX_DIST_MAX_SAMPLES, replace=False)] + return {"mean_a": mean_a, "mean_b": mean_b, "std_a": std_a, "std_b": std_b, + "ratio": ratio, "log_ratio_mean": log_ratio_mean, "log_ratio_std": log_ratio_std, + "p_value": p_value, "cliffs_delta": delta, + "n_px": n_px, "pct_area": 100.0 * n_px / m.size, + "vals_a": sub_a.astype(np.float32), "vals_b": sub_b.astype(np.float32), + "vals_log_ratio": sub_log_ratio.astype(np.float32)} + + def _localmax_entry(self, map_a: np.ndarray, map_b: np.ndarray, + diff: np.ndarray, scale_px: float, + footprint_mult: float, prominence_percentile: float, + region_fraction: float, presmooth_fraction: float, + top_percent: float, + rng: np.random.Generator) -> dict: + """One partial['localmax'][key] entry. Builds the local-maxima mask via + _combined_localmax_mask (peaks over np.maximum(|A|, |B|) — a peak strong in + either image counts, mirroring the existing Nebula-mask union rationale — + unioned with the top-percent brightness mask) then returns its masked stats + measured from the raw (unsmoothed) magnitude maps. footprint_px, region_px + (the neighbourhood grown around each peak), and presmooth_sigma + (detection-only smoothing) all scale with this call's own scale_px, so both + "how local" and "how tall" a peak must be, plus how much denoising happens + before detection, auto-adapt per metric/scale instead of using one fixed + setting everywhere. + """ + h = min(map_a.shape[0], map_b.shape[0]) + w = min(map_a.shape[1], map_b.shape[1]) + abs_a, abs_b = np.abs(map_a[:h, :w]), np.abs(map_b[:h, :w]) + footprint_px = max(3, int(round(footprint_mult * scale_px)) | 1) + region_px = max(1, int(round(region_fraction * footprint_px))) + presmooth_sigma = max(0.5, presmooth_fraction * scale_px) + mask = self._combined_localmax_mask(abs_a, abs_b, footprint_px, prominence_percentile, + region_px, presmooth_sigma, top_percent) + return self._localmax_stats(abs_a, abs_b, diff, mask, rng) + def _nebula_bounding_box(self, mask: np.ndarray, shape: tuple) -> tuple[int, int, int, int] | None: """Return (r0, r1, c0, c1) bounding box of the nebula mask with 5% padding. @@ -369,14 +732,22 @@ def _std_analysis(self, norm_a, norm_b, display_roi=None, crosshair=None, mask_neb_shared=None, - mask_bg_shared=None, diff_dist_rng=None) -> tuple[dict, dict]: + mask_bg_shared=None, rng=None, + localmax_footprint_mult=SECTION8_LOCALMAX_FOOTPRINT_MULT, + localmax_prominence_percentile=SECTION8_LOCALMAX_PROMINENCE_PERCENTILE, + localmax_region_fraction=SECTION8_LOCALMAX_REGION_FRACTION, + localmax_presmooth_fraction=SECTION8_LOCALMAX_PRESMOOTH_FRACTION, + localmax_top_percent=SECTION8_LOCALMAX_TOP_PERCENT) -> tuple[dict, dict]: figures = {} partial: dict = { "contrast_ratios_a": {}, "contrast_ratios_b": {}, "std_nc_score_a": {}, "std_nc_score_b": {}, "std_nc_noise_a": {}, "std_nc_noise_b": {}, + "std_nc_neb_std_a": {}, "std_nc_neb_std_b": {}, "panels": {}, - "diff_dist": {}, + "localmax": {}, + "localmax_log_ratio": {}, + "localmax_log_ratio_err": {}, } single = norm_b is None for ks in kernel_sizes: @@ -392,12 +763,14 @@ def _std_analysis(self, norm_a, norm_b, noise_a = noise_b = None if not single: - nc_a, noise_a = self._nc_score(std_a, mask_neb_shared, mask_bg_a) + nc_a, noise_a, neb_std_a = self._nc_score(std_a, mask_neb_shared, mask_bg_a) partial["std_nc_score_a"][ks] = nc_a partial["std_nc_noise_a"][ks] = noise_a - nc_b, noise_b = self._nc_score(std_b, mask_neb_shared, mask_bg_b) + partial["std_nc_neb_std_a"][ks] = neb_std_a + nc_b, noise_b, neb_std_b = self._nc_score(std_b, mask_neb_shared, mask_bg_b) partial["std_nc_score_b"][ks] = nc_b partial["std_nc_noise_b"][ks] = noise_b + partial["std_nc_neb_std_b"][ks] = neb_std_b diff = self._log_ratio_map(std_a, std_b) if not single else None partial["panels"][f"std_{ks}px"] = { @@ -406,12 +779,18 @@ def _std_analysis(self, norm_a, norm_b, "diff": diff, } if diff is not None: - partial["diff_dist"][f"std_{ks}px"] = self._diff_distribution( - diff, mask_neb_shared, mask_bg_shared, diff_dist_rng) + lm_entry = self._localmax_entry( + std_a, std_b, diff, ks, + localmax_footprint_mult, localmax_prominence_percentile, + localmax_region_fraction, localmax_presmooth_fraction, + localmax_top_percent, rng) + partial["localmax"][f"std_{ks}px"] = lm_entry + partial["localmax_log_ratio"][ks] = lm_entry["log_ratio_mean"] + partial["localmax_log_ratio_err"][ks] = lm_entry["log_ratio_std"] if not single: corr_fig = self._plot_metric_correlation( std_a, std_b, diff, mask_neb_shared, mask_bg_shared, - label_a, label_b, f"Local σ (kernel {ks}px)", diff_dist_rng) + label_a, label_b, f"Local σ (kernel {ks}px)", rng) if corr_fig is not None: figures[f"corr_std_{ks}px"] = corr_fig if not single and noise_a and noise_b: @@ -422,11 +801,13 @@ def _std_analysis(self, norm_a, norm_b, } xs_raw = None + xs_line = None if crosshair is not None and not single: pos, pa = self._sample_line(std_a, **crosshair) _, pb = self._sample_line(std_b, **crosshair) xs_raw = (pos, pa, pb, label_a, label_b, f"Cross-section — Local σ, kernel {ks}px") + xs_line = self._crosshair_to_cropped_px(crosshair, std_a.shape, SECTION8_BORDER_CROP_FRACTION) if not single: fig = self._plot_side_by_side( @@ -439,6 +820,7 @@ def _std_analysis(self, norm_a, norm_b, nonlinear_norm=True, display_roi=None, xs_data=xs_raw, + xs_line=xs_line, ) else: fig = self._plot_single( @@ -465,6 +847,7 @@ def _std_analysis(self, norm_a, norm_b, cmap=SECTION8_ANALYSIS_CMAP, display_roi=None, xs_data=xs_nrm, + xs_line=xs_line, ) return figs_to_b64(figures, dpi=150), partial @@ -503,28 +886,33 @@ def _contrast_ratio(self, std_map: np.ndarray, def _nc_score(self, detail_map: np.ndarray, mask_neb_shared: np.ndarray | None, - mask_bg: np.ndarray) -> tuple[float | None, float | None]: + mask_bg: np.ndarray) -> tuple[float | None, float | None, float | None]: """Noise-corrected local-contrast score for one detail map at one scale. - score = median(|detail|) over the pixels BOTH images classify as nebula + score = median(|detail|) over the pixels either image classifies as nebula (mask_neb_shared), divided by median(|detail|) over THIS image's own background mask — its empirical per-scale noise floor for this operator. - Returns (score, noise_floor); either is None if a mask selects zero pixels, - mask_neb_shared is unavailable (single-image mode), or noise_floor <= 0. + Returns (score, noise_floor, neb_std); all None if a mask selects zero + pixels, mask_neb_shared is unavailable (single-image mode), or + noise_floor <= 0. neb_std is the sample standard deviation of the same + nebula-region |detail| population the median score is computed from — + used to propagate an approximate uncertainty onto the 8i cross-method + overview's ratio-of-scores error bars (see _compute_nc_ratio_errors). """ if mask_neb_shared is None: - return None, None + return None, None, None h = min(detail_map.shape[0], mask_neb_shared.shape[0], mask_bg.shape[0]) w = min(detail_map.shape[1], mask_neb_shared.shape[1], mask_bg.shape[1]) absmap = np.abs(detail_map[:h, :w]) neb_vals = absmap[mask_neb_shared[:h, :w]] bg_vals = absmap[mask_bg[:h, :w]] if neb_vals.size == 0 or bg_vals.size == 0: - return None, None + return None, None, None noise_floor = float(bn.median(bg_vals)) if noise_floor <= 0: - return None, None - return float(bn.median(neb_vals)) / noise_floor, noise_floor + return None, None, None + neb_std = float(np.std(neb_vals)) + return float(bn.median(neb_vals)) / noise_floor, noise_floor, neb_std @staticmethod def _log_ratio_map(a: np.ndarray, b: np.ndarray) -> np.ndarray: @@ -535,7 +923,7 @@ def _log_ratio_map(a: np.ndarray, b: np.ndarray) -> np.ndarray: background-subtracted flux, wavelet band-pass reconstructions) — this turns the comparison into "which image shows more structure/contrast at this scale", not "which image is signed-brighter". Non-negative families - (std, |LoG|, gradient magnitude, Weber contrast) are unaffected by the + (std, |LoG|, gradient magnitude, local entropy) are unaffected by the abs() since they're already >= 0. Epsilon-floors both operands using a low percentile (not a raw minimum — @@ -567,34 +955,6 @@ def _log_ratio_color_range(diff: np.ndarray) -> tuple[float, float]: d_max = float(np.percentile(np.abs(diff), 99.5)) or 1.0 return -d_max, d_max - @staticmethod - def _diff_distribution(diff_map: np.ndarray, - mask_neb_shared: np.ndarray | None, - mask_bg_shared: np.ndarray | None, - rng: np.random.Generator) -> dict: - """Random-subsampled log10(|A|/|B|) ratio pixel populations for nebula vs - background. - - Returns {"nebula": ndarray, "background": ndarray} (float32, signed - log-ratio values — 0 means A=B — up to SECTION8_DIFF_DIST_MAX_SAMPLES - each). Either array is empty if the corresponding mask is unavailable - (single-image mode) or selects zero pixels. - """ - out = {"nebula": np.empty(0, dtype=np.float32), - "background": np.empty(0, dtype=np.float32)} - if mask_neb_shared is None or mask_bg_shared is None: - return out - h = min(diff_map.shape[0], mask_neb_shared.shape[0], mask_bg_shared.shape[0]) - w = min(diff_map.shape[1], mask_neb_shared.shape[1], mask_bg_shared.shape[1]) - cropped = diff_map[:h, :w] - for key, mask in (("nebula", mask_neb_shared), ("background", mask_bg_shared)): - vals = cropped[mask[:h, :w]] - if vals.size > SECTION8_DIFF_DIST_MAX_SAMPLES: - idx = rng.choice(vals.size, SECTION8_DIFF_DIST_MAX_SAMPLES, replace=False) - vals = vals[idx] - out[key] = vals.astype(np.float32) - return out - @staticmethod def _compute_nc_ratios(score_a: dict, score_b: dict) -> dict: """Per-scale A/B ratio of noise-corrected scores; {} if either side is empty @@ -607,6 +967,35 @@ def _compute_nc_ratios(score_a: dict, score_b: dict) -> dict: out[scale] = None if (va is None or vb is None or vb == 0) else va / vb return out + @staticmethod + def _compute_nc_ratio_errors(ratio: dict, score_a: dict, score_b: dict, + noise_a: dict, noise_b: dict, + neb_std_a: dict, neb_std_b: dict) -> dict: + """Approximate symmetric uncertainty on each scale's nc_ratio, propagated + from the coefficient of variation (std/median) of each image's own + nebula-region pixel population -- a standard relative-uncertainty + propagation for a ratio of two independent quantities. This is NOT a + formal confidence interval on the median; it is an approximate indicator + of spread, captioned as such in the report (see 8i methodology). + median_neb is recovered as score * noise_floor (score = median_neb / + noise_floor by construction in _nc_score) rather than recomputed. + """ + out = {} + for scale, r in ratio.items(): + sa, sb = score_a.get(scale), score_b.get(scale) + na, nb = noise_a.get(scale), noise_b.get(scale) + sda, sdb = neb_std_a.get(scale), neb_std_b.get(scale) + if None in (r, sa, sb, na, nb, sda, sdb): + out[scale] = None + continue + median_neb_a, median_neb_b = sa * na, sb * nb + if median_neb_a == 0 or median_neb_b == 0: + out[scale] = None + continue + cv_a, cv_b = sda / median_neb_a, sdb / median_neb_b + out[scale] = abs(r) * (cv_a ** 2 + cv_b ** 2) ** 0.5 + return out + # ------------------------------------------------------------------ # Laplacian of Gaussian maps # ------------------------------------------------------------------ @@ -616,13 +1005,21 @@ def _log_analysis(self, norm_a, norm_b, sigmas, display_roi=None, crosshair=None, mask_neb_shared=None, mask_bg_a=None, mask_bg_b=None, - mask_bg_shared=None, diff_dist_rng=None) -> tuple[dict, dict]: + mask_bg_shared=None, rng=None, + localmax_footprint_mult=SECTION8_LOCALMAX_FOOTPRINT_MULT, + localmax_prominence_percentile=SECTION8_LOCALMAX_PROMINENCE_PERCENTILE, + localmax_region_fraction=SECTION8_LOCALMAX_REGION_FRACTION, + localmax_presmooth_fraction=SECTION8_LOCALMAX_PRESMOOTH_FRACTION, + localmax_top_percent=SECTION8_LOCALMAX_TOP_PERCENT) -> tuple[dict, dict]: figures = {} partial: dict = { "log_nc_score_a": {}, "log_nc_score_b": {}, "log_nc_noise_a": {}, "log_nc_noise_b": {}, + "log_nc_neb_std_a": {}, "log_nc_neb_std_b": {}, "panels": {}, - "diff_dist": {}, + "localmax": {}, + "localmax_log_ratio": {}, + "localmax_log_ratio_err": {}, } single = norm_b is None for sigma in sigmas: @@ -631,12 +1028,14 @@ def _log_analysis(self, norm_a, norm_b, sigmas, noise_a = noise_b = None if not single: - nc_a, noise_a = self._nc_score(log_a, mask_neb_shared, mask_bg_a) + nc_a, noise_a, neb_std_a = self._nc_score(log_a, mask_neb_shared, mask_bg_a) partial["log_nc_score_a"][sigma] = nc_a partial["log_nc_noise_a"][sigma] = noise_a - nc_b, noise_b = self._nc_score(log_b, mask_neb_shared, mask_bg_b) + partial["log_nc_neb_std_a"][sigma] = neb_std_a + nc_b, noise_b, neb_std_b = self._nc_score(log_b, mask_neb_shared, mask_bg_b) partial["log_nc_score_b"][sigma] = nc_b partial["log_nc_noise_b"][sigma] = noise_b + partial["log_nc_neb_std_b"][sigma] = neb_std_b diff = self._log_ratio_map(log_a, log_b) if log_b is not None else None partial["panels"][f"log_{sigma}"] = { @@ -645,12 +1044,18 @@ def _log_analysis(self, norm_a, norm_b, sigmas, "diff": diff, } if diff is not None: - partial["diff_dist"][f"log_{sigma}"] = self._diff_distribution( - diff, mask_neb_shared, mask_bg_shared, diff_dist_rng) + lm_entry = self._localmax_entry( + log_a, log_b, diff, sigma, + localmax_footprint_mult, localmax_prominence_percentile, + localmax_region_fraction, localmax_presmooth_fraction, + localmax_top_percent, rng) + partial["localmax"][f"log_{sigma}"] = lm_entry + partial["localmax_log_ratio"][sigma] = lm_entry["log_ratio_mean"] + partial["localmax_log_ratio_err"][sigma] = lm_entry["log_ratio_std"] if not single: corr_fig = self._plot_metric_correlation( log_a, log_b, diff, mask_neb_shared, mask_bg_shared, - label_a, label_b, f"|LoG| (σ={sigma}px)", diff_dist_rng) + label_a, label_b, f"|LoG| (σ={sigma}px)", rng) if corr_fig is not None: figures[f"corr_log_{sigma}"] = corr_fig if not single and noise_a and noise_b: @@ -661,11 +1066,13 @@ def _log_analysis(self, norm_a, norm_b, sigmas, } xs_raw = None + xs_line = None if crosshair is not None and not single: pos, pa = self._sample_line(log_a, **crosshair) _, pb = self._sample_line(log_b, **crosshair) xs_raw = (pos, pa, pb, label_a, label_b, f"Cross-section — |LoG|, σ={sigma}px") + xs_line = self._crosshair_to_cropped_px(crosshair, log_a.shape, SECTION8_BORDER_CROP_FRACTION) if not single: fig = self._plot_side_by_side( @@ -678,6 +1085,7 @@ def _log_analysis(self, norm_a, norm_b, sigmas, nonlinear_norm=True, display_roi=None, xs_data=xs_raw, + xs_line=xs_line, ) else: fig = self._plot_single( @@ -704,6 +1112,7 @@ def _log_analysis(self, norm_a, norm_b, sigmas, cmap=SECTION8_ANALYSIS_CMAP, display_roi=None, xs_data=xs_nrm, + xs_line=xs_line, ) return figs_to_b64(figures, dpi=150), partial @@ -716,7 +1125,12 @@ def _gradient_analysis(self, norm_a, norm_b, sigmas, display_roi=None, crosshair=None, mask_neb_shared=None, mask_bg_a=None, mask_bg_b=None, - mask_bg_shared=None, diff_dist_rng=None) -> tuple[dict, dict]: + mask_bg_shared=None, rng=None, + localmax_footprint_mult=SECTION8_LOCALMAX_FOOTPRINT_MULT, + localmax_prominence_percentile=SECTION8_LOCALMAX_PROMINENCE_PERCENTILE, + localmax_region_fraction=SECTION8_LOCALMAX_REGION_FRACTION, + localmax_presmooth_fraction=SECTION8_LOCALMAX_PRESMOOTH_FRACTION, + localmax_top_percent=SECTION8_LOCALMAX_TOP_PERCENT) -> tuple[dict, dict]: """G = |gradient| at Gaussian scale sigma (first spatial derivative magnitude). Reuses the LOG_SIGMAS scale set so gradient and |LoG| are directly comparable at identical spatial scales. Structured identically to _log_analysis.""" @@ -724,8 +1138,11 @@ def _gradient_analysis(self, norm_a, norm_b, sigmas, partial: dict = { "gm_nc_score_a": {}, "gm_nc_score_b": {}, "gm_nc_noise_a": {}, "gm_nc_noise_b": {}, + "gm_nc_neb_std_a": {}, "gm_nc_neb_std_b": {}, "panels": {}, - "diff_dist": {}, + "localmax": {}, + "localmax_log_ratio": {}, + "localmax_log_ratio_err": {}, } single = norm_b is None for sigma in sigmas: @@ -734,12 +1151,14 @@ def _gradient_analysis(self, norm_a, norm_b, sigmas, noise_a = noise_b = None if not single: - nc_a, noise_a = self._nc_score(gm_a, mask_neb_shared, mask_bg_a) + nc_a, noise_a, neb_std_a = self._nc_score(gm_a, mask_neb_shared, mask_bg_a) partial["gm_nc_score_a"][sigma] = nc_a partial["gm_nc_noise_a"][sigma] = noise_a - nc_b, noise_b = self._nc_score(gm_b, mask_neb_shared, mask_bg_b) + partial["gm_nc_neb_std_a"][sigma] = neb_std_a + nc_b, noise_b, neb_std_b = self._nc_score(gm_b, mask_neb_shared, mask_bg_b) partial["gm_nc_score_b"][sigma] = nc_b partial["gm_nc_noise_b"][sigma] = noise_b + partial["gm_nc_neb_std_b"][sigma] = neb_std_b diff = self._log_ratio_map(gm_a, gm_b) if gm_b is not None else None partial["panels"][f"gradient_{sigma}"] = { @@ -748,12 +1167,18 @@ def _gradient_analysis(self, norm_a, norm_b, sigmas, "diff": diff, } if diff is not None: - partial["diff_dist"][f"gradient_{sigma}"] = self._diff_distribution( - diff, mask_neb_shared, mask_bg_shared, diff_dist_rng) + lm_entry = self._localmax_entry( + gm_a, gm_b, diff, sigma, + localmax_footprint_mult, localmax_prominence_percentile, + localmax_region_fraction, localmax_presmooth_fraction, + localmax_top_percent, rng) + partial["localmax"][f"gradient_{sigma}"] = lm_entry + partial["localmax_log_ratio"][sigma] = lm_entry["log_ratio_mean"] + partial["localmax_log_ratio_err"][sigma] = lm_entry["log_ratio_std"] if not single: corr_fig = self._plot_metric_correlation( gm_a, gm_b, diff, mask_neb_shared, mask_bg_shared, - label_a, label_b, f"Gradient |G| (σ={sigma}px)", diff_dist_rng) + label_a, label_b, f"Gradient |G| (σ={sigma}px)", rng) if corr_fig is not None: figures[f"corr_gradient_{sigma}"] = corr_fig if not single and noise_a and noise_b: @@ -764,11 +1189,13 @@ def _gradient_analysis(self, norm_a, norm_b, sigmas, } xs_raw = None + xs_line = None if crosshair is not None and not single: pos, pa = self._sample_line(gm_a, **crosshair) _, pb = self._sample_line(gm_b, **crosshair) xs_raw = (pos, pa, pb, label_a, label_b, f"Cross-section — Gradient, σ={sigma}px") + xs_line = self._crosshair_to_cropped_px(crosshair, gm_a.shape, SECTION8_BORDER_CROP_FRACTION) if not single: fig = self._plot_side_by_side( @@ -781,6 +1208,7 @@ def _gradient_analysis(self, norm_a, norm_b, sigmas, nonlinear_norm=True, display_roi=None, xs_data=xs_raw, + xs_line=xs_line, ) else: fig = self._plot_single( @@ -807,6 +1235,7 @@ def _gradient_analysis(self, norm_a, norm_b, sigmas, cmap=SECTION8_ANALYSIS_CMAP, display_roi=None, xs_data=xs_nrm, + xs_line=xs_line, ) return figs_to_b64(figures, dpi=150), partial @@ -819,15 +1248,23 @@ def _wavelet_analysis(self, norm_a, norm_b, wavelet, levels, display_roi=None, crosshair=None, mask_neb_shared=None, mask_bg_a=None, mask_bg_b=None, - mask_bg_shared=None, diff_dist_rng=None) -> tuple[dict, dict]: + mask_bg_shared=None, rng=None, + localmax_footprint_mult=SECTION8_LOCALMAX_FOOTPRINT_MULT, + localmax_prominence_percentile=SECTION8_LOCALMAX_PROMINENCE_PERCENTILE, + localmax_region_fraction=SECTION8_LOCALMAX_REGION_FRACTION, + localmax_presmooth_fraction=SECTION8_LOCALMAX_PRESMOOTH_FRACTION, + localmax_top_percent=SECTION8_LOCALMAX_TOP_PERCENT) -> tuple[dict, dict]: figures = {} partial: dict = { "sigma_noise_a": None, "sigma_noise_b": None, "wavelet_snr_a": {}, "wavelet_snr_b": {}, "wavelet_nc_score_a": {}, "wavelet_nc_score_b": {}, "wavelet_nc_noise_a": {}, "wavelet_nc_noise_b": {}, + "wavelet_nc_neb_std_a": {}, "wavelet_nc_neb_std_b": {}, "panels": {}, - "diff_dist": {}, + "localmax": {}, + "localmax_log_ratio": {}, + "localmax_log_ratio_err": {}, } single = norm_b is None @@ -862,12 +1299,14 @@ def _wavelet_analysis(self, norm_a, norm_b, wavelet, levels, noise_a = noise_b = None if not single: - nc_a, noise_a = self._nc_score(rec_a, mask_neb_shared, mask_bg_a) + nc_a, noise_a, neb_std_a = self._nc_score(rec_a, mask_neb_shared, mask_bg_a) partial["wavelet_nc_score_a"][human_level] = nc_a partial["wavelet_nc_noise_a"][human_level] = noise_a - nc_b, noise_b = self._nc_score(rec_b, mask_neb_shared, mask_bg_b) + partial["wavelet_nc_neb_std_a"][human_level] = neb_std_a + nc_b, noise_b, neb_std_b = self._nc_score(rec_b, mask_neb_shared, mask_bg_b) partial["wavelet_nc_score_b"][human_level] = nc_b partial["wavelet_nc_noise_b"][human_level] = noise_b + partial["wavelet_nc_neb_std_b"][human_level] = neb_std_b if human_level not in (2, 3): continue # display/panels only for levels 2-3, unchanged from prior behaviour @@ -880,15 +1319,21 @@ def _wavelet_analysis(self, norm_a, norm_b, wavelet, levels, "diff": diff, } if diff is not None: - partial["diff_dist"][f"wavelet_{display_level}"] = self._diff_distribution( - diff, mask_neb_shared, mask_bg_shared, diff_dist_rng) + lm_entry = self._localmax_entry( + rec_a, rec_b, diff, 2 ** display_level, + localmax_footprint_mult, localmax_prominence_percentile, + localmax_region_fraction, localmax_presmooth_fraction, + localmax_top_percent, rng) + partial["localmax"][f"wavelet_{display_level}"] = lm_entry + partial["localmax_log_ratio"][display_level] = lm_entry["log_ratio_mean"] + partial["localmax_log_ratio_err"][display_level] = lm_entry["log_ratio_std"] if not single: # Raw signed reconstructions (not abs()) — complementary to the # sign-discarding log-ratio map, shows whether band-pass detail # flips sign between the two filters at a given pixel. corr_fig = self._plot_metric_correlation( rec_a, rec_b, diff, mask_neb_shared, mask_bg_shared, - label_a, label_b, f"Wavelet level {display_level}", diff_dist_rng) + label_a, label_b, f"Wavelet level {display_level}", rng) if corr_fig is not None: figures[f"corr_wavelet_{display_level}"] = corr_fig if not single and noise_a and noise_b: @@ -898,11 +1343,13 @@ def _wavelet_analysis(self, norm_a, norm_b, wavelet, levels, "diff": None, } xs_raw = None + xs_line = None if crosshair is not None and not single: pos, pa = self._sample_line(rec_a, **crosshair) _, pb = self._sample_line(rec_b, **crosshair) xs_raw = (pos, pa, pb, label_a, label_b, f"Cross-section — Wavelet level {display_level}") + xs_line = self._crosshair_to_cropped_px(crosshair, rec_a.shape, SECTION8_BORDER_CROP_FRACTION) if not single: fig = self._plot_side_by_side( @@ -914,6 +1361,7 @@ def _wavelet_analysis(self, norm_a, norm_b, wavelet, levels, cmap=SECTION8_ANALYSIS_CMAP, display_roi=None, xs_data=xs_raw, + xs_line=xs_line, ) else: fig = self._plot_single( @@ -939,6 +1387,7 @@ def _wavelet_analysis(self, norm_a, norm_b, wavelet, levels, cmap=SECTION8_ANALYSIS_CMAP, display_roi=None, xs_data=xs_nrm, + xs_line=xs_line, ) return figs_to_b64(figures, dpi=150), partial @@ -976,119 +1425,157 @@ def _reconstruct_level(self, coeffs, target_coeff_idx: int, # ------------------------------------------------------------------ # ------------------------------------------------------------------ - # Weber fraction contrast maps + # Local entropy maps # ------------------------------------------------------------------ - def _weber_analysis(self, norm_a, norm_b, kernel_sizes, - label_a, label_b, + def _entropy_analysis(self, norm_a, norm_b, + mask_neb_a, mask_bg_a, + mask_neb_b, mask_bg_b, + kernel_sizes, label_a, label_b, display_roi=None, crosshair=None, - mask_neb_shared=None, mask_bg_a=None, mask_bg_b=None, - mask_bg_shared=None, diff_dist_rng=None) -> tuple[dict, dict]: + mask_neb_shared=None, + mask_bg_shared=None, rng=None, + localmax_footprint_mult=SECTION8_LOCALMAX_FOOTPRINT_MULT, + localmax_prominence_percentile=SECTION8_LOCALMAX_PROMINENCE_PERCENTILE, + localmax_region_fraction=SECTION8_LOCALMAX_REGION_FRACTION, + localmax_presmooth_fraction=SECTION8_LOCALMAX_PRESMOOTH_FRACTION, + localmax_top_percent=SECTION8_LOCALMAX_TOP_PERCENT) -> tuple[dict, dict]: figures = {} partial: dict = { - "weber_contrast_a": {}, - "weber_contrast_b": {}, - "weber_nc_score_a": {}, "weber_nc_score_b": {}, - "weber_nc_noise_a": {}, "weber_nc_noise_b": {}, + "entropy_contrast_ratio_a": {}, "entropy_contrast_ratio_b": {}, + "entropy_nc_score_a": {}, "entropy_nc_score_b": {}, + "entropy_nc_noise_a": {}, "entropy_nc_noise_b": {}, + "entropy_nc_neb_std_a": {}, "entropy_nc_neb_std_b": {}, "panels": {}, - "diff_dist": {}, + "localmax": {}, + "localmax_log_ratio": {}, + "localmax_log_ratio_err": {}, } single = norm_b is None for ks in kernel_sizes: - wc_a = self._compute_weber_map(norm_a, ks) - wc_b = self._compute_weber_map(norm_b, ks) if not single else None + ent_a = self._compute_entropy_map(norm_a, ks) + ent_b = self._compute_entropy_map(norm_b, ks) if not single else None - # 99th percentile avoids dark-sky floor driving the scalar metric to extremes - partial["weber_contrast_a"][ks] = float(np.percentile(wc_a, 99)) + # Contrast ratios (computed on unsmoothed maps) + cr_a = self._contrast_ratio(ent_a, mask_neb_a, mask_bg_a) + partial["entropy_contrast_ratio_a"][ks] = cr_a if not single: - partial["weber_contrast_b"][ks] = float(np.percentile(wc_b, 99)) + cr_b = self._contrast_ratio(ent_b, mask_neb_b, mask_bg_b) + partial["entropy_contrast_ratio_b"][ks] = cr_b noise_a = noise_b = None if not single: - nc_a, noise_a = self._nc_score(wc_a, mask_neb_shared, mask_bg_a) - partial["weber_nc_score_a"][ks] = nc_a - partial["weber_nc_noise_a"][ks] = noise_a - nc_b, noise_b = self._nc_score(wc_b, mask_neb_shared, mask_bg_b) - partial["weber_nc_score_b"][ks] = nc_b - partial["weber_nc_noise_b"][ks] = noise_b - - diff = self._log_ratio_map(wc_a, wc_b) if wc_b is not None else None - partial["panels"][f"weber_{ks}px"] = { - "a": wc_a.astype(np.float32), - "b": wc_b.astype(np.float32) if wc_b is not None else None, + nc_a, noise_a, neb_std_a = self._nc_score(ent_a, mask_neb_shared, mask_bg_a) + partial["entropy_nc_score_a"][ks] = nc_a + partial["entropy_nc_noise_a"][ks] = noise_a + partial["entropy_nc_neb_std_a"][ks] = neb_std_a + nc_b, noise_b, neb_std_b = self._nc_score(ent_b, mask_neb_shared, mask_bg_b) + partial["entropy_nc_score_b"][ks] = nc_b + partial["entropy_nc_noise_b"][ks] = noise_b + partial["entropy_nc_neb_std_b"][ks] = neb_std_b + + diff = self._log_ratio_map(ent_a, ent_b) if not single else None + partial["panels"][f"entropy_{ks}px"] = { + "a": ent_a.astype(np.float32), + "b": ent_b.astype(np.float32) if ent_b is not None else None, "diff": diff, } if diff is not None: - partial["diff_dist"][f"weber_{ks}px"] = self._diff_distribution( - diff, mask_neb_shared, mask_bg_shared, diff_dist_rng) + lm_entry = self._localmax_entry( + ent_a, ent_b, diff, ks, + localmax_footprint_mult, localmax_prominence_percentile, + localmax_region_fraction, localmax_presmooth_fraction, + localmax_top_percent, rng) + partial["localmax"][f"entropy_{ks}px"] = lm_entry + partial["localmax_log_ratio"][ks] = lm_entry["log_ratio_mean"] + partial["localmax_log_ratio_err"][ks] = lm_entry["log_ratio_std"] if not single: corr_fig = self._plot_metric_correlation( - wc_a, wc_b, diff, mask_neb_shared, mask_bg_shared, - label_a, label_b, f"Weber contrast (kernel {ks}px)", diff_dist_rng) + ent_a, ent_b, diff, mask_neb_shared, mask_bg_shared, + label_a, label_b, f"Local entropy (kernel {ks}px)", rng) if corr_fig is not None: - figures[f"corr_weber_{ks}px"] = corr_fig + figures[f"corr_entropy_{ks}px"] = corr_fig if not single and noise_a and noise_b: - partial["panels"][f"nrm_weber_{ks}px"] = { - "a": (wc_a / noise_a).astype(np.float32), - "b": (wc_b / noise_b).astype(np.float32), + partial["panels"][f"nrm_entropy_{ks}px"] = { + "a": (ent_a / noise_a).astype(np.float32), + "b": (ent_b / noise_b).astype(np.float32), "diff": None, } xs_raw = None + xs_line = None if crosshair is not None and not single: - pos, pa = self._sample_line(wc_a, **crosshair) - _, pb = self._sample_line(wc_b, **crosshair) + pos, pa = self._sample_line(ent_a, **crosshair) + _, pb = self._sample_line(ent_b, **crosshair) xs_raw = (pos, pa, pb, label_a, label_b, - f"Cross-section — Weber contrast, kernel {ks}px") + f"Cross-section — Local entropy, kernel {ks}px") + xs_line = self._crosshair_to_cropped_px(crosshair, ent_a.shape, SECTION8_BORDER_CROP_FRACTION) if not single: fig = self._plot_side_by_side( - self._crop_border(wc_a, SECTION8_BORDER_CROP_FRACTION), - self._crop_border(wc_b, SECTION8_BORDER_CROP_FRACTION), - f"Weber contrast — kernel {ks}px — {label_a}", - f"Weber contrast — kernel {ks}px — {label_b}", - diff_title=f"Weber log-ratio (A/B), kernel {ks}px", + self._crop_border(ent_a, SECTION8_BORDER_CROP_FRACTION), + self._crop_border(ent_b, SECTION8_BORDER_CROP_FRACTION), + f"Local entropy — kernel {ks}px — {label_a}", + f"Local entropy — kernel {ks}px — {label_b}", + diff_title=f"Log ratio (A/B), kernel {ks}px", cmap=SECTION8_ANALYSIS_CMAP, - nonlinear_norm=True, # unbounded output; PowerNorm compresses dynamic range + nonlinear_norm=True, display_roi=None, # _crop_border already applied xs_data=xs_raw, + xs_line=xs_line, ) else: fig = self._plot_single( - self._crop_border(wc_a, SECTION8_BORDER_CROP_FRACTION), - f"Weber contrast — kernel {ks}px — {label_a}", + self._crop_border(ent_a, SECTION8_BORDER_CROP_FRACTION), + f"Local entropy — kernel {ks}px — {label_a}", cmap=SECTION8_ANALYSIS_CMAP, nonlinear_norm=True, ) - figures[f"weber_{ks}px"] = fig + figures[f"entropy_{ks}px"] = fig if not single and noise_a and noise_b: xs_nrm = None if crosshair is not None: - pos_n, pa_n = self._sample_line(wc_a / noise_a, **crosshair) - _, pb_n = self._sample_line(wc_b / noise_b, **crosshair) + pos_n, pa_n = self._sample_line(ent_a / noise_a, **crosshair) + _, pb_n = self._sample_line(ent_b / noise_b, **crosshair) xs_nrm = (pos_n, pa_n, pb_n, label_a, label_b, - f"Cross-section — Weber contrast (× noise floor), kernel {ks}px") - figures[f"nrm_weber_{ks}px"] = self._plot_side_by_side( - self._crop_border(wc_a / noise_a, SECTION8_BORDER_CROP_FRACTION), - self._crop_border(wc_b / noise_b, SECTION8_BORDER_CROP_FRACTION), - f"Weber (× noise floor) — kernel {ks}px — {label_a}", - f"Weber (× noise floor) — kernel {ks}px — {label_b}", + f"Cross-section — Local entropy (× noise floor), kernel {ks}px") + figures[f"nrm_entropy_{ks}px"] = self._plot_side_by_side( + self._crop_border(ent_a / noise_a, SECTION8_BORDER_CROP_FRACTION), + self._crop_border(ent_b / noise_b, SECTION8_BORDER_CROP_FRACTION), + f"Local entropy (× noise floor) — kernel {ks}px — {label_a}", + f"Local entropy (× noise floor) — kernel {ks}px — {label_b}", diff_title=f"Log ratio (A/B), noise-normalised, kernel {ks}px", cmap=SECTION8_ANALYSIS_CMAP, display_roi=None, xs_data=xs_nrm, + xs_line=xs_line, ) return figs_to_b64(figures, dpi=150), partial - def _compute_weber_map(self, norm: np.ndarray, kernel_size: int) -> np.ndarray: - """Weber fraction contrast c = ΔL / L where ΔL = max−min and L = median of kernel. - L uses median (not mean) — robust background luminance unaffected by bright filaments. - Output is unbounded >= 0; nonlinear_norm=True is used for display. + def _compute_entropy_map(self, norm: np.ndarray, kernel_size: int, + n_bins: int = SECTION8_ENTROPY_N_BINS) -> np.ndarray: + """Local Shannon entropy (bits, log2) of the gray-level histogram within a + square window. norm is deliberately quantized into n_bins integer levels + from a percentile-clipped range *before* filtering -- computing entropy + directly on continuous float32 data returns ~log2(window_area) almost + everywhere (every pixel value in a window is unique), measuring float + precision rather than genuine tonal diversity. The clip/bin range is + computed independently per image (matches the existing per-image + convention used by every other Section 8 map). + + Vectorized via n_bins uniform_filter passes (one per gray level) rather + than a per-pixel generic_filter callback: uniform_filter(mask_b) is a + box filter of a 0/1 mask, which *is* exactly the local proportion p_b + 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 + vectorized maximum/minimum/median_filter maps at the same array size; + this reuses that same vectorization principle for entropy. """ - _EPS = 1e-6 # L (median) can be very small over dark sky; larger EPS prevents extremes factor = 1.0 data = norm if max(norm.shape) > MAX_DIM_FOR_STD: @@ -1098,20 +1585,24 @@ def _compute_weber_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) - i_max = maximum_filter(data, size=kernel_size) - i_min = minimum_filter(data, size=kernel_size) - i_med = median_filter(data, size=kernel_size) - - delta_L = i_max - i_min # always >= 0 - L = np.maximum(i_med, 0.0) # bg-subtracted images can have negative medians - weber = delta_L / (L + _EPS) + lo, hi = np.percentile(data, [SECTION8_ENTROPY_CLIP_PERCENTILE, + 100 - SECTION8_ENTROPY_CLIP_PERCENTILE]) + if hi <= lo: + entropy_map = np.zeros_like(data, dtype=np.float64) + else: + binned = np.clip(((data - lo) / (hi - lo) * n_bins), 0, n_bins - 1).astype(np.intp) + entropy_map = np.zeros(data.shape, dtype=np.float64) + for b in range(n_bins): + p_b = uniform_filter((binned == b).astype(np.float64), size=kernel_size, mode="reflect") + with np.errstate(divide="ignore", invalid="ignore"): + entropy_map -= np.where(p_b > 0, p_b * np.log2(p_b), 0.0) if factor < 1.0: - weber = zoom(weber, - (norm.shape[0] / weber.shape[0], - norm.shape[1] / weber.shape[1]), - order=1) - return np.maximum(weber, 0.0) + entropy_map = zoom(entropy_map, + (norm.shape[0] / entropy_map.shape[0], + norm.shape[1] / entropy_map.shape[1]), + order=1) + return entropy_map.astype(np.float32) def _plot_side_by_side(self, arr_a: np.ndarray, arr_b: np.ndarray, title_a: str, title_b: str, @@ -1120,9 +1611,13 @@ def _plot_side_by_side(self, arr_a: np.ndarray, arr_b: np.ndarray, nonlinear_norm: bool = False, display_roi=None, smooth_display: bool = True, - xs_data: tuple | None = None) -> plt.Figure: + xs_data: tuple | None = None, + xs_line: tuple | None = None) -> plt.Figure: """xs_data, if given, is (pos, prof_a, prof_b, label_a, label_b, xs_title) - for the embedded cross-section panel; None leaves that panel blank.""" + for the embedded cross-section panel; None leaves that panel blank. + xs_line, if given, is (x0, y0, x1, y1) pixel coords (in arr_a/arr_b's own + frame, post any cropping already applied by the caller) of the user's + cross-section line, overlaid directly on the Image A/B panels above.""" # Crop to bright-feature ROI if available if display_roi is not None: r0, r1, c0, c1 = display_roi @@ -1181,6 +1676,15 @@ def _plot_side_by_side(self, arr_a: np.ndarray, arr_b: np.ndarray, vmin=None if norm is not None else vmin, vmax=None if norm is not None else vmax, interpolation="nearest", aspect="equal") + if xs_line is not None: + # Lock the view before plotting — otherwise matplotlib autoscales to + # include line endpoints outside the (already-cropped) array, adding + # unwanted blank padding around the image. + ax.set_xlim(-0.5, arr.shape[1] - 0.5) + ax.set_ylim(arr.shape[0] - 0.5, -0.5) # origin="upper" + lx0, ly0, lx1, ly1 = xs_line + ax.plot([lx0, lx1], [ly0, ly1], color="#ff7f0e", + 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) @@ -1256,7 +1760,8 @@ def _plot_mask_illustration(self, base: np.ndarray, mask_neb: np.ndarray, "Background": "tomato"}) so the two figures read as one visual language. Unclassified pixels (neither mask) are left plain grayscale. mask_neb/ mask_bg are expected to already be the two-image shared classification - (mask_neb_a & mask_neb_b) — the exact masks that feed the violin plots + (mask_neb_a | mask_neb_b for Nebula, mask_bg_a & mask_bg_b for Background) + — the exact masks that feed the violin plots and correlation scatter plots — so this figure depicts what those plots are actually gated on. """ @@ -1286,7 +1791,48 @@ def _plot_mask_illustration(self, base: np.ndarray, mask_neb: np.ndarray, Patch(facecolor="0.5", edgecolor="none", label="Unclassified"), ] ax.legend(handles=legend_handles, loc="lower right", fontsize=8, framealpha=0.8) - fig.tight_layout() + finalize_layout(fig) + return fig + + def _plot_localmax_mask_grid(self, grid_rows: list, + color: str = "magenta", alpha: float = 0.55) -> plt.Figure | None: + """Grid figure: one row per metric family, columns = kernel/scale sizes + smallest to largest. Each panel shows that exact scale's local-maxima + mask (same computation _localmax_entry uses for the Section 8j table) + overlaid on that metric's own |A| magnitude map. Families with fewer + scales than the widest row (Wavelet: 2 vs. 3) leave trailing panels + blank. grid_rows: list of (family_label, [(base_img, mask, panel_title), ...]). + Returns None if no row has any panel (e.g. single-image mode never calls this). + """ + n_rows = len(grid_rows) + n_cols = max((len(cells) for _, cells in grid_rows), default=0) + if n_cols == 0 or not any(cells for _, cells in grid_rows): + return None + fig, axes = plt.subplots(n_rows, n_cols, figsize=(3.6 * n_cols, 3.6 * n_rows)) + axes = np.atleast_2d(axes) + col_rgb = np.array(mcolors.to_rgb(color)) + for r, (family_label, cells) in enumerate(grid_rows): + for c in range(n_cols): + ax = axes[r, c] + if c >= len(cells): + ax.axis("off") + continue + base, mask, panel_title = cells[c] + gray = self._stretch_for_display(base) + h = min(gray.shape[0], mask.shape[0]) + w = min(gray.shape[1], mask.shape[1]) + gray, m = gray[:h, :w], mask[:h, :w] + rgb = np.stack([gray, gray, gray], axis=-1) + rgb[m] = (1 - alpha) * rgb[m] + alpha * col_rgb + ax.imshow(rgb, origin="upper", interpolation="nearest", aspect="equal") + ax.axis("off") + ax.set_title(panel_title, fontsize=8) + axes[r, 0].text(-0.08, 0.5, family_label, transform=axes[r, 0].transAxes, + fontsize=9, fontweight="bold", ha="right", va="center", rotation=90) + legend_handle = Patch(facecolor=color, edgecolor="none", alpha=0.8, label="Local maxima (dilated) ∪ top-N% bright") + fig.legend(handles=[legend_handle], loc="lower center", fontsize=9, bbox_to_anchor=(0.5, -0.01)) + fig.suptitle("Local-maxima masks by metric (rows) and scale, smallest → largest (columns)", fontsize=11) + finalize_layout(fig, rect=[0.03, 0.02, 1, 0.96]) return fig def _plot_snr_bars(self, snr_a: dict, snr_b: dict, @@ -1311,32 +1857,47 @@ def _plot_snr_bars(self, snr_a: dict, snr_b: dict, ax.set_xticklabels([f"Level {i}" for i in x]) ax.legend(fontsize=8) ax.grid(True, axis="y", alpha=0.3) - fig.tight_layout() + finalize_layout(fig) return fig - def _plot_nc_ratio_overview(self, ratios_by_method: dict) -> plt.Figure | None: - """One line per method: noise-corrected A/B ratio vs. approximate spatial - scale (px, log-x). None if no method has any usable (non-None) value.""" - _SCALE_LABEL = { - "std": "px", "weber": "px", "log": "σ px", - "gradient": "σ px", "wavelet": "level (≈px)", - } - _COLORS = { - "std": "steelblue", "log": "tomato", "wavelet": "mediumpurple", - "weber": "seagreen", "gradient": "goldenrod", - } + @staticmethod + def _ratio_series_with_errors(ratios_by_method: dict, errors_by_method: dict | None = None) -> dict: + """{method: {scale: value}} (+ optional matching {method: {scale: error}}) -> + {method: [(x_px, value, error_or_None), ...]} sorted by x. Wavelet scale + keys are human levels, converted to approximate px via 2**level. Shared + by _plot_nc_ratio_overview (8i) and _plot_localmax_ratio_overview (8j).""" series = {} for method, ratios in ratios_by_method.items(): if not ratios: continue - if method == "wavelet": - pts = [(2 ** scale, v) for scale, v in ratios.items() if v is not None] - else: - pts = [(float(scale), v) for scale, v in ratios.items() if v is not None] + errs = (errors_by_method or {}).get(method, {}) + pts = [] + for scale, v in ratios.items(): + if v is None: + continue + x = 2 ** scale if method == "wavelet" else float(scale) + pts.append((x, v, errs.get(scale))) if pts: pts.sort(key=lambda p: p[0]) series[method] = pts + return series + def _plot_nc_ratio_overview(self, ratios_by_method: dict, + errors_by_method: dict | None = None) -> plt.Figure | None: + """One line per method: noise-corrected A/B ratio vs. approximate spatial + scale (px, log-x). None if no method has any usable (non-None) value. + errors_by_method (optional): matching {method: {scale: error}} — an + approximate symmetric uncertainty (see _compute_nc_ratio_errors), + rendered as error bars when present for a given point.""" + _SCALE_LABEL = { + "std": "px", "entropy": "px", "log": "σ px", + "gradient": "σ px", "wavelet": "level (≈px)", + } + _COLORS = { + "std": "steelblue", "log": "tomato", "wavelet": "mediumpurple", + "entropy": "seagreen", "gradient": "goldenrod", + } + series = self._ratio_series_with_errors(ratios_by_method, errors_by_method) if not series: return None @@ -1344,8 +1905,14 @@ def _plot_nc_ratio_overview(self, ratios_by_method: dict) -> plt.Figure | None: for method, pts in series.items(): xs = [p[0] for p in pts] ys = [p[1] for p in pts] - ax.plot(xs, ys, marker="o", label=f"{method} ({_SCALE_LABEL.get(method, 'px')})", - color=_COLORS.get(method)) + es = [p[2] for p in pts] + label = f"{method} ({_SCALE_LABEL.get(method, 'px')})" + if any(e is not None for e in es): + yerr = [e if e is not None else 0.0 for e in es] + ax.errorbar(xs, ys, yerr=yerr, marker="o", capsize=3, linestyle="-", + label=label, color=_COLORS.get(method)) + else: + ax.plot(xs, ys, marker="o", label=label, color=_COLORS.get(method)) ax.axhline(1.0, color="black", linestyle="--", linewidth=0.8, label="Ratio = 1 (A = B)") ax.set_xscale("log") @@ -1354,7 +1921,55 @@ def _plot_nc_ratio_overview(self, ratios_by_method: dict) -> plt.Figure | None: ax.set_title("Noise-corrected local contrast — cross-method overview") ax.legend(fontsize=8) ax.grid(True, alpha=0.3) - fig.tight_layout() + finalize_layout(fig) + return fig + + def _plot_localmax_ratio_overview(self, log_ratios_by_method: dict, + errors_by_method: dict | None = None) -> plt.Figure | None: + """One line per method: local-maxima masked log10(A/B) geometric-mean vs. + approximate spatial scale (px, log-x). Structurally identical to + _plot_nc_ratio_overview (Section 8i); y-axis is the local-maxima-masked + log-ratio (Section 8j) instead of the whole-nebula noise-corrected score + ratio. None if no method has any usable (non-None) value. + errors_by_method (optional): matching {method: {scale: error}} — ±1 + standard deviation of the per-pixel log10(A/B) population within each + scale's mask, plotted directly with no unit conversion (both the value + and its error already live in log10 space, so this is an exact spread + measure, not an approximation), rendered as error bars when present for + a given point.""" + _SCALE_LABEL = { + "std": "px", "entropy": "px", "log": "σ px", + "gradient": "σ px", "wavelet": "level (≈px)", + } + _COLORS = { + "std": "steelblue", "log": "tomato", "wavelet": "mediumpurple", + "entropy": "seagreen", "gradient": "goldenrod", + } + series = self._ratio_series_with_errors(log_ratios_by_method, errors_by_method) + if not series: + return None + + fig, ax = plt.subplots(figsize=(7, 4.5)) + for method, pts in series.items(): + xs = [p[0] for p in pts] + ys = [p[1] for p in pts] + es = [p[2] for p in pts] + label = f"{method} ({_SCALE_LABEL.get(method, 'px')})" + if any(e is not None for e in es): + yerr = [e if e is not None else 0.0 for e in es] + ax.errorbar(xs, ys, yerr=yerr, marker="o", capsize=3, linestyle="-", + label=label, color=_COLORS.get(method)) + else: + ax.plot(xs, ys, marker="o", label=label, color=_COLORS.get(method)) + ax.axhline(0.0, color="black", linestyle="--", linewidth=0.8, + label="log ratio = 0 (A = B)") + ax.set_xscale("log") + 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) + ax.grid(True, alpha=0.3) + finalize_layout(fig) return fig @staticmethod @@ -1473,7 +2088,7 @@ def _plot_metric_correlation(map_a: np.ndarray, map_b: np.ndarray, if not any_data: plt.close(fig) return None - fig.tight_layout() + finalize_layout(fig) return fig @staticmethod @@ -1483,6 +2098,21 @@ def _crop_border(arr: np.ndarray, fraction: float) -> np.ndarray: return arr[n:-n, n:-n] return arr + @staticmethod + def _crosshair_to_cropped_px(crosshair: dict | None, shape: tuple, + crop_fraction: float) -> tuple[float, float, float, float] | None: + """Convert a normalised [0,1] crosshair dict (in the coordinate frame of an + array with `shape`) to pixel coords in the frame _crop_border(arr, crop_fraction) + produces for that array — mirrors _crop_border's own offset math exactly, so the + overlay lines up pixel-for-pixel with the already-cropped display arrays.""" + if crosshair is None: + return None + H, W = shape[:2] + n = max(1, int(min(H, W) * crop_fraction)) + off_y, off_x = (n, n) if (H > 2 * n and W > 2 * n) else (0, 0) + return (crosshair["x0"] * W - off_x, crosshair["y0"] * H - off_y, + crosshair["x1"] * W - off_x, crosshair["y1"] * H - off_y) + @staticmethod def _stretch_for_display(arr: np.ndarray) -> np.ndarray: lo, hi = np.percentile(arr, [0.5, 99.9]) diff --git a/analysis/moffat_fit.py b/analysis/moffat_fit.py new file mode 100644 index 0000000..d24de51 --- /dev/null +++ b/analysis/moffat_fit.py @@ -0,0 +1,59 @@ +from __future__ import annotations + +import warnings + +import numpy as np +from astropy.modeling import fitting +from astropy.modeling.models import Moffat2D +from astropy.utils.exceptions import AstropyUserWarning + + +def moffat_fwhm(gamma: float, alpha: float) -> float: + """FWHM from astropy Moffat2D gamma/alpha parameters.""" + return 2.0 * gamma * np.sqrt(2.0 ** (1.0 / alpha) - 1.0) + + +def fit_moffat2d_core( + cutout: np.ndarray, + *, + alpha_bounds: tuple[float, float], + gamma_min: float, + fwhm_bounds: tuple[float, float], +) -> dict | None: + """Fit a 2D Moffat profile to a star cutout centred in the frame. + + `alpha_bounds`/`gamma_min` constrain the fit itself (TRFLSQFitter supports + bounded parameters, unlike the legacy LevMarLSQFitter); `fwhm_bounds` is an + additional post-fit plausibility check on the derived FWHM. Returns + `{"fwhm", "alpha", "gamma", "peak"}` in pixel units, or `None` if the cutout + is empty, the fit raises, or the fitted parameters fall outside the + caller-supplied bounds. + """ + if cutout.size == 0: + return None + + cy, cx = np.mgrid[0:cutout.shape[0], 0:cutout.shape[1]] + amp = float(np.max(cutout)) + cx0 = cutout.shape[1] / 2.0 + cy0 = cutout.shape[0] / 2.0 + + model = Moffat2D(amplitude=amp, x_0=cx0, y_0=cy0, gamma=2.0, alpha=2.5) + model.gamma.bounds = (gamma_min, None) + model.alpha.bounds = alpha_bounds + + fitter = fitting.TRFLSQFitter() + with warnings.catch_warnings(): + warnings.filterwarnings("ignore", category=AstropyUserWarning) + try: + fitted = fitter(model, cx, cy, cutout) + except Exception: + return None + + gamma = abs(fitted.gamma.value) + alpha = abs(fitted.alpha.value) + if not (alpha_bounds[0] <= alpha <= alpha_bounds[1]) or gamma < gamma_min: + return None + fwhm = moffat_fwhm(gamma, alpha) + if not (fwhm_bounds[0] <= fwhm <= fwhm_bounds[1]): + return None + return {"fwhm": fwhm, "alpha": alpha, "gamma": gamma, "peak": amp} diff --git a/analysis/power_spectrum.py b/analysis/power_spectrum.py index d563350..bae0162 100644 --- a/analysis/power_spectrum.py +++ b/analysis/power_spectrum.py @@ -7,7 +7,7 @@ from astropy.stats import sigma_clip from core.astro_image import AstroImage -from core.fig_utils import figs_to_b64 +from core.fig_utils import figs_to_b64, finalize_layout from core.models import POWER_SPECTRUM_NPIX LOW_FREQ_MAX = 0.10 # cycles/px boundary between low and mid+high @@ -217,5 +217,5 @@ def _plot_results(self, ps2d: np.ndarray, axes[1].legend(fontsize=8) axes[1].grid(True, alpha=0.3) - fig.tight_layout() + finalize_layout(fig) return fig diff --git a/analysis/psf_analyzer.py b/analysis/psf_analyzer.py index 4f85fcc..fb60a73 100644 --- a/analysis/psf_analyzer.py +++ b/analysis/psf_analyzer.py @@ -6,16 +6,15 @@ import matplotlib matplotlib.use("Agg") import matplotlib.pyplot as plt -from astropy.modeling import fitting -from astropy.modeling.models import Moffat2D -from astropy.stats import median_absolute_deviation as mad +from astropy.stats import median_absolute_deviation as mad, mad_std from astropy.table import Table from photutils.psf import EPSFBuilder, extract_stars from core.astro_image import AstroImage -from core.fig_utils import figs_to_b64 +from core.fig_utils import figs_to_b64, finalize_layout from core.models import (SEEING_WARN_FWHM_ARCS, PSF_BETA_MIN, PSF_BETA_MAX, PSF_FWHM_CLIP_NSIGMA, ABERRATION_MIN_STARS, ABERRATION_OUTER_RADIUS_FRAC, EPSF_MAX_STARS) +from analysis.moffat_fit import fit_moffat2d_core from analysis.star_catalog import StarCatalogBuilder CUTOUT_SIZE = 25 # pixels per side for per-star cutouts @@ -23,11 +22,6 @@ EPSF_MAXITERS = 15 -def _moffat_fwhm(gamma: float, alpha: float) -> float: - """FWHM from astropy Moffat2D gamma/alpha parameters.""" - return 2.0 * gamma * np.sqrt(2.0 ** (1.0 / alpha) - 1.0) - - class PSFAnalyzer: """Fit Moffat PSF to stars, build empirical PSF, compute MTF.""" @@ -167,7 +161,6 @@ def analyze(self, image: AstroImage) -> dict: # ------------------------------------------------------------------ def _fit_moffat_all(self, bgsub: np.ndarray, stars: Table) -> list[dict]: - fitter = fitting.LevMarLSQFitter() results = [] h, w = bgsub.shape half = CUTOUT_SIZE // 2 @@ -180,45 +173,33 @@ def _fit_moffat_all(self, bgsub: np.ndarray, stars: Table) -> list[dict]: x1 = min(w, xc + half + 1) y1 = min(h, yc + half + 1) cutout = bgsub[y0:y1, x0:x1].copy() - if cutout.size == 0: - continue - - cy, cx = np.mgrid[0:cutout.shape[0], 0:cutout.shape[1]] - amp = float(np.max(cutout)) - cx0 = cutout.shape[1] / 2.0 - cy0 = cutout.shape[0] / 2.0 - model = Moffat2D(amplitude=amp, x_0=cx0, y_0=cy0, gamma=2.0, alpha=2.5) - with warnings.catch_warnings(): - warnings.simplefilter("ignore") - try: - fitted = fitter(model, cx, cy, cutout) - except Exception: - continue - - gamma = abs(fitted.gamma.value) - alpha = abs(fitted.alpha.value) - if not (PSF_BETA_MIN <= alpha <= PSF_BETA_MAX) or gamma < 0.1: - continue - fwhm = _moffat_fwhm(gamma, alpha) - if fwhm < 0.5 or fwhm > CUTOUT_SIZE: + fit = fit_moffat2d_core( + cutout, + alpha_bounds=(PSF_BETA_MIN, PSF_BETA_MAX), + gamma_min=0.1, + fwhm_bounds=(0.5, CUTOUT_SIZE), + ) + if fit is None: continue - results.append({"x": xc, "y": yc, "fwhm": fwhm, "alpha": alpha, "gamma": gamma, - "peak": float(amp)}) + results.append({"x": xc, "y": yc, **fit}) return results @staticmethod def _sigma_clip_fwhm(fits: list[dict]) -> list[dict]: - """Remove fits whose FWHM deviates more than PSF_FWHM_CLIP_NSIGMA*MAD from median. + """Remove fits whose FWHM deviates more than PSF_FWHM_CLIP_NSIGMA*sigma from median. - Requires at least 5 fits; returns the list unchanged if MAD is zero. + Uses mad_std (the sigma-scaled MAD, ~1.4826x raw MAD) rather than the raw + median_absolute_deviation used for the reported *_mad dispersion fields, so + PSF_FWHM_CLIP_NSIGMA is an actual sigma multiple rather than a raw-MAD multiple. + Requires at least 5 fits; returns the list unchanged if the scaled MAD is zero. """ if len(fits) < 5: return fits fwhms = np.array([f["fwhm"] for f in fits]) med = float(np.median(fwhms)) - m = float(mad(fwhms)) + m = float(mad_std(fwhms)) if m == 0: return fits return [f for f in fits if abs(f["fwhm"] - med) <= PSF_FWHM_CLIP_NSIGMA * m] @@ -472,7 +453,7 @@ def _plot_mtf(self, freq: np.ndarray, mtf: np.ndarray, ax.set_ylim(0, 1.05) ax.legend(fontsize=8) ax.grid(True, alpha=0.3) - fig.tight_layout() + finalize_layout(fig) return fig def _plot_epsf(self, epsf: np.ndarray, label: str) -> plt.Figure: @@ -481,5 +462,5 @@ def _plot_epsf(self, epsf: np.ndarray, label: str) -> plt.Figure: origin="upper", cmap="viridis", interpolation="nearest") ax.set_title(f"ePSF — {label}") plt.colorbar(im, ax=ax, fraction=0.046, pad=0.04) - fig.tight_layout() + finalize_layout(fig) return fig diff --git a/analysis/snr_analyzer.py b/analysis/snr_analyzer.py index 1a2d79a..e2a40f4 100644 --- a/analysis/snr_analyzer.py +++ b/analysis/snr_analyzer.py @@ -10,8 +10,8 @@ except ImportError: bn = np -from core.astro_image import AstroImage -from core.fig_utils import fig_to_b64 +from core.astro_image import AstroImage, _resolve_gain +from core.fig_utils import fig_to_b64, finalize_layout class SNRAnalyzer: @@ -87,25 +87,9 @@ def analyze(self, image: AstroImage) -> dict: if image.background is not None else None) # --- Camera gain from FITS header -------------------------------- - # Try common keyword variants used by capture software (NINA, SGP, etc.). - # EGAIN is preferred: it is the FITS standard for the actual e⁻/ADU conversion - # factor. GAIN is ambiguous — many cameras write the gain mode index (0, 100, - # 200 …) there, not the physical conversion factor. Values ≤ 0 are always - # invalid for e⁻/ADU and are skipped so a zero gain mode index doesn't - # produce bogus 0.0 electron values in the SNR table. - gain_e_per_adu: float | None = None - hdr = getattr(image, 'header', None) - if hdr is not None: - for kw in ("EGAIN", "GAIN", "CCDGAIN", "GAINDB"): - v = hdr.get(kw) - if v is not None: - try: - g = float(v) - if g > 0: - gain_e_per_adu = g - break - except (TypeError, ValueError): - pass + # See core.astro_image._resolve_gain for the EGAIN-priority rationale + # (GAIN alone is often an ambiguous camera mode index, not e⁻/ADU). + gain_e_per_adu: float | None = _resolve_gain(getattr(image, 'header', None)) # --- Noise factor: σ_sky / √μ_sky -------------------------------- # 1.0 = pure sky shot noise (Poisson); >1.0 = read noise / thermal contributions. @@ -163,5 +147,5 @@ def _plot_snr_map(self, snr_map: np.ndarray, label: str) -> str: ax.set_title(f"SNR map — {label}", fontsize=10) ax.set_xlabel("x (px)") ax.set_ylabel("y (px)") - fig.tight_layout() + finalize_layout(fig) return fig_to_b64(fig, dpi=120) diff --git a/core/astro_image.py b/core/astro_image.py index e293209..fdb5784 100644 --- a/core/astro_image.py +++ b/core/astro_image.py @@ -6,13 +6,46 @@ import numpy as np from astropy.io import fits from astropy.nddata import NDData, StdDevUncertainty +from astropy.stats import SigmaClip +from astropy.utils.exceptions import AstropyUserWarning from photutils.background import Background2D, SExtractorBackground, MADStdBackgroundRMS from core.models import DEFAULT_PIXEL_SCALE, FILTER_THICKNESS_MM -# FITS keywords tried in priority order for pixel scale derivation -_PIXEL_SCALE_KEYWORDS = ["CDELT1", "CD1_1", "PIXSCALE", "SCALE"] +# FITS keywords tried in priority order for pixel scale derivation. +# (keyword, multiplier to arcsec/px, apply_abs) — CDELT1/CD1_1 are in degrees/px +# (abs() strips the sign some WCS conventions use to indicate axis-flip direction); +# PIXSCALE/SCALE are already arcsec/px. +_PIXEL_SCALE_KEYWORDS: list[tuple[str, float, bool]] = [ + ("CDELT1", 3600.0, True), + ("CD1_1", 3600.0, True), + ("PIXSCALE", 1.0, False), + ("SCALE", 1.0, False), +] + + +def _resolve_gain(header: fits.Header | None) -> float | None: + """Resolve the physical e-/ADU gain from FITS header keywords. + + EGAIN is preferred: it is the FITS standard for the actual e⁻/ADU conversion + factor. GAIN is ambiguous — many cameras write the gain mode index (0, 100, + 200 …) there, not the physical conversion factor. Values <= 0 are always + invalid for e⁻/ADU and are skipped so a zero gain mode index doesn't + produce bogus 0.0 electron values. + """ + if header is None: + return None + for kw in ("EGAIN", "GAIN", "CCDGAIN", "GAINDB"): + v = header.get(kw) + if v is not None: + try: + g = float(v) + if g > 0: + return g + except (TypeError, ValueError): + pass + return None _DTYPE_LABELS: dict[str, str] = { "uint8": "8-bit unsigned int", @@ -45,7 +78,6 @@ def __init__(self, path: str, label: str = ""): self.original_dtype: np.dtype | None = None # dtype before float32 conversion self.background: Background2D | None = None self.background_rms: np.ndarray | None = None - self._load_error: str | None = None self.is_color: bool = False # True when RGB file was converted to luminance self.starless_image: AstroImage | None = None # Set by ImagePanel when starless is loaded @@ -125,7 +157,7 @@ def _load_xisf(self) -> None: self.is_color = True else: img = img[:, :, 0] - self.data = img # float64 conversion happens in load() after dtype is captured + self.data = img # float32 conversion happens in load() after dtype is captured # Build a minimal header-like dict from XISF metadata if meta_list: self.header = fits.Header() @@ -158,25 +190,23 @@ def _extract_pixel_scale(self) -> float: self.pixel_scale_is_estimated = True return DEFAULT_PIXEL_SCALE - # CDELT1 in degrees/px - if "CDELT1" in self.header: - return abs(float(self.header["CDELT1"])) * 3600.0 - - # CD matrix - if "CD1_1" in self.header: - return abs(float(self.header["CD1_1"])) * 3600.0 - - # Direct arcsec/px keywords - for kw in ("PIXSCALE", "SCALE"): + for kw, factor, use_abs in _PIXEL_SCALE_KEYWORDS: if kw in self.header: - return float(self.header[kw]) + try: + v = float(self.header[kw]) + return (abs(v) if use_abs else v) * factor + except (ValueError, TypeError): + pass # Derive from focal length + pixel size if "FOCALLEN" in self.header and "XPIXSZ" in self.header: - focallen_mm = float(self.header["FOCALLEN"]) - xpixsz_um = float(self.header["XPIXSZ"]) - if focallen_mm > 0: - return (xpixsz_um / focallen_mm) * 206.265 + try: + focallen_mm = float(self.header["FOCALLEN"]) + xpixsz_um = float(self.header["XPIXSZ"]) + if focallen_mm > 0: + return (xpixsz_um / focallen_mm) * 206.265 + except (ValueError, TypeError): + pass self.pixel_scale_is_estimated = True return DEFAULT_PIXEL_SCALE @@ -215,7 +245,6 @@ def _extract_metadata(self) -> None: "Pixel size": ["XPIXSZ"], "Exposure": ["EXPTIME", "EXPOSURE"], "Date": ["DATE-OBS"], - "Gain": ["GAIN"], "Binning": ["XBINNING"], "Bandwidth": ["BANDWID", "FWHM", "BANDWIDTH"], } @@ -225,6 +254,16 @@ def _extract_metadata(self) -> None: self.meta[display_key] = str(self.header[kw]).strip() break + # Gain — prefer the resolved physical e-/ADU value (EGAIN priority order, + # see _resolve_gain); fall back to the raw GAIN string (often a camera mode + # index) only when no keyword resolves to a valid value, so the display + # still shows *something* for files with only an ambiguous GAIN keyword. + gain = _resolve_gain(self.header) + if gain is not None: + self.meta["Gain"] = f"{gain:.3g} e⁻/ADU" + elif "GAIN" in self.header: + self.meta["Gain"] = str(self.header["GAIN"]).strip() + # Focal ratio — prefer explicit keyword; fall back to FOCALLEN / APTDIA fr: float | None = None for kw in ("FOCRATIO", "FRATIO", "FNUMBER"): @@ -275,13 +314,14 @@ def estimate_background(self, box_size: int = 64) -> None: if self.background is not None: return # already computed for this instance's data; self.data never changes post-load with warnings.catch_warnings(): - warnings.simplefilter("ignore") + warnings.filterwarnings("ignore", category=AstropyUserWarning) self.background = Background2D( self.data, box_size=box_size, filter_size=3, + sigma_clip=SigmaClip(sigma=3.0, maxiters=10), bkg_estimator=SExtractorBackground(), - bkgrms_estimator=MADStdBackgroundRMS(), + bkg_rms_estimator=MADStdBackgroundRMS(), ) self.background_rms = self.background.background_rms @@ -290,7 +330,7 @@ def background_subtracted(self) -> np.ndarray: raise RuntimeError("Image not loaded") if self.background is None: return self.data.copy() - return self.data - self.background.background + return (self.data - self.background.background).astype(np.float32) def saturation_threshold(self) -> float: if self.data is None: diff --git a/core/fig_utils.py b/core/fig_utils.py index 520d646..c8c46de 100644 --- a/core/fig_utils.py +++ b/core/fig_utils.py @@ -2,9 +2,42 @@ import base64 import io +import threading import matplotlib.pyplot as plt +# matplotlib's mathtext grammar (matplotlib/_mathtext.py) calls pyparsing's +# ParserElement.enable_packrat() at import time, turning on a process-wide, +# non-thread-safe memoization cache used by every mathtext parse (tick labels, +# titles, legends -- anything rendered through a draw pass). Multiple +# analyzers render figures concurrently (SpatialDetailAnalyzer's own 5-way +# ThreadPoolExecutor, PowerSpectrumAnalyzer's per-image ThreadPoolExecutor, and +# analysis_thread.py's cross-analyzer parallel mode), so unsynchronized calls +# can corrupt that shared cache and raise a spurious mathtext ParseException +# on completely valid text (reproduced directly against +# matplotlib.mathtext.MathTextParser.parse() under thread concurrency). +# +# savefig() is not the only trigger -- fig.tight_layout() also runs a full +# draw pass to measure text extents (titles, tick labels, legends), so it +# hits the same shared cache. Any figure-building code that can run +# concurrently with another figure-building call must route *both* +# tight_layout() and savefig() through this one process-wide lock; locking +# only savefig() leaves tight_layout() free to race and reproduces the same +# ParseException. Use finalize_layout() below instead of calling +# fig.tight_layout() directly. +_MPL_DRAW_LOCK = threading.Lock() + + +def finalize_layout(fig: plt.Figure, **kwargs) -> None: + """Run fig.tight_layout() under the same lock as fig_to_b64()'s savefig(). + + Call this instead of fig.tight_layout() 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: + fig.tight_layout(**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. @@ -13,7 +46,8 @@ def fig_to_b64(fig: plt.Figure, dpi: int = 120) -> str: are released as soon as their pixels are captured. """ buf = io.BytesIO() - fig.savefig(buf, format="png", dpi=dpi, bbox_inches="tight") + with _MPL_DRAW_LOCK: + fig.savefig(buf, format="png", dpi=dpi, bbox_inches="tight") buf.seek(0) data = base64.b64encode(buf.read()).decode() plt.close(fig) diff --git a/core/models.py b/core/models.py index f413f67..6c4aa72 100644 --- a/core/models.py +++ b/core/models.py @@ -7,7 +7,7 @@ import matplotlib.figure -APP_VERSION = "0.0.8" # semver string; bump on each GitHub release tag +APP_VERSION = "0.0.9" # semver string; bump on each GitHub release tag # === CONSTANTS === @@ -24,6 +24,7 @@ EDGE_ROI_HALF_WIDTH = 30 EDGE_ROI_MAP_INDICATOR_PX = 500 # px; full width of the ROI indicator box drawn on the gradient magnitude map EDGE_ESF_MIN_MONOTONICITY = 0.3 # min net/total variation ratio; below this the ESF likely crossed >1 edge (corner/filament) +EDGE_N_TOP_EDGES = 3 # number of auto-detected gradient-peak edges (line extractions) analyzed per image FILTER_THICKNESS_MM = 1.0 # narrowband filter substrate thickness (mm); default for UI GLASS_REFRACTIVE_INDEX = 1.9 # dichroic filter substrate refractive index RDF_BIN_WIDTH = 1.0 # px; annular bin width for RDF mean/std computation @@ -31,7 +32,7 @@ STD_KERNEL_SIZES = (3, 5, 10) #originally (5, 10, 15) # px; Gaussian kernel sizes for std dev maps LOG_SIGMAS = (1.5, 3.0, 6.0) -WEBER_KERNEL_SIZES = (3, 5, 9) # px; local kernel for Weber fraction contrast c = ΔL/L (odd values required) +ENTROPY_KERNEL_SIZES = (5, 9, 17) # px; local window for Shannon entropy — deliberately larger than STD_KERNEL_SIZES since small windows (<25 samples) give unstable histogram estimates WAVELET_NAME = "db4" WAVELET_LEVELS = 4 @@ -40,9 +41,19 @@ EPSF_MAX_STARS = 600 # maximum candidate stars passed to EPSFBuilder; limits computation time SECTION8_BORDER_CROP_FRACTION = 0.05 # fraction of each image dimension cropped from perimeter in Section 8 display maps SECTION8_ANALYSIS_CMAP = "viridis" # colormap for Section 8 A/B analysis map panels (std, LoG, wavelet) -SECTION8_DIFF_DIST_MAX_SAMPLES = 1000000 # per masked population, per scale — caps violin/KDE cost on full-res diff maps SECTION8_LOGRATIO_EPS_PERCENTILE = 1.0 # percentile of pooled positive |A|,|B| values used as the epsilon floor in log10(|A|/|B|) SECTION8_SCATTER_MAX_SAMPLES = 50000 # per masked population, per scale — caps render cost of Section 8g correlation scatter plots +SECTION8_ENTROPY_N_BINS = 32 # gray-level bins for local entropy histograms; max possible entropy = log2(32) = 5 bits +SECTION8_ENTROPY_CLIP_PERCENTILE = 0.5 # symmetric percentile clip (0.5-99.5) applied to each image's own normalised data before binning, so a few outlier pixels don't blow out the bin range +SECTION8_NEBULA_MASK_SIGMA = 1.7 # ×RMS above background = "Nebula" pixel classification (Section 8 masks); background cut stays fixed at 0.5×RMS +SECTION8_NEBULA_MASK_DILATION_PX = 3 # px; scipy.ndimage.binary_dilation iterations to grow the nebula mask into adjacent dim/dark nebula regions +SECTION8_NEBULA_MASK_MAX_HOLE_PX = 5 # px; enclosed background gaps up to this many pixels per side (area ≤ N²) inside the nebula mask are filled before dilation +SECTION8_LOCALMAX_FOOTPRINT_MULT = 2.0 # multiplier on each metric's own characteristic scale (kernel px / sigma px / 2**level) used as the maximum_filter footprint (peak/non-max-suppression neighbourhood) for Section 8j local-maxima detection +SECTION8_LOCALMAX_PROMINENCE_PERCENTILE = 99.0 # percentile of the (smoothed) per-scale |A|,|B|-combined peak-source array used as the minimum local-maximum height (Section 8j) +SECTION8_LOCALMAX_PRESMOOTH_FRACTION = 0.5 # fraction of each metric's own scale used as the Gaussian pre-smoothing sigma applied to the peak-source array before maximum_filter detection, to suppress single-pixel noise-driven false maxima (Section 8j); detection only — masked-region statistics are still measured on the raw, unsmoothed maps +SECTION8_LOCALMAX_REGION_FRACTION = 0.5 # fraction of the maximum_filter footprint used as the binary_dilation radius (px) grown around each detected peak pixel, so the mask covers the local neighbourhood around a peak rather than a single pixel (Section 8j) +SECTION8_LOCALMAX_DIST_MAX_SAMPLES = 1000000 # per masked population, per scale — caps render cost of the Section 8j A/B distribution violin+box figure; mean/std/significance-test stats are computed from the full population, not this capped copy +SECTION8_LOCALMAX_TOP_PERCENT = 5.0 # percentile of Image A's or Image B's own pixel-value distribution unioned (OR) into the local-maxima mask, so broad bright plateaus are captured even when they never register as an isolated local-maximum peak (Section 8j). E.g. 5.0 -> top 5% of values in A OR top 5% in B are included regardless of the peak-detection result PSF_SPATIAL_MAP_SIZE = 150 # px; long-axis resolution of FWHM / eccentricity spatial maps PSF_SPATIAL_MAP_SMOOTH_SIGMA = 5.0 # Gaussian smoothing sigma (px) applied to spatial maps before display diff --git a/core/stats_utils.py b/core/stats_utils.py new file mode 100644 index 0000000..bbaec49 --- /dev/null +++ b/core/stats_utils.py @@ -0,0 +1,21 @@ +"""Shared statistical-comparison utilities used by both analysis/ and report/.""" +from __future__ import annotations + + +def mannwhitney_effect(va, vb) -> tuple[float | None, float | None]: + """Mann-Whitney U p-value and Cliff's delta for two independent samples. + + Returns (p_value, delta), or (None, None) if either sample has fewer than + 3 values. delta > 0 means va's values tend to be higher than vb's; |delta| + is in [0, 1]. Uses the exact identity delta = 2*U/(n1*n2) - 1 (U = the + Mann-Whitney U statistic for va) rather than an O(n1*n2) pairwise sign + matrix, so this scales to large samples (tens of thousands of pixel + values) as well as small ones (dozens of per-star measurements). + """ + from scipy.stats import mannwhitneyu + n1, n2 = len(va), len(vb) + if n1 < 3 or n2 < 3: + return None, None + u_stat, p = mannwhitneyu(va, vb, alternative="two-sided") + delta = 2.0 * float(u_stat) / (n1 * n2) - 1.0 + return float(p), delta diff --git a/gui/analysis_thread.py b/gui/analysis_thread.py index c8f9c56..9788ec9 100644 --- a/gui/analysis_thread.py +++ b/gui/analysis_thread.py @@ -11,7 +11,10 @@ from PyQt6.QtCore import QThread, pyqtSignal from core.astro_image import AstroImage -from core.models import AnalysisResult, LABEL_MAX_LEN +from core.models import (AnalysisResult, LABEL_MAX_LEN, SECTION8_NEBULA_MASK_SIGMA, + SECTION8_NEBULA_MASK_DILATION_PX, SECTION8_NEBULA_MASK_MAX_HOLE_PX, + SECTION8_LOCALMAX_FOOTPRINT_MULT, SECTION8_LOCALMAX_PROMINENCE_PERCENTILE, + SECTION8_LOCALMAX_TOP_PERCENT) from analysis.psf_analyzer import PSFAnalyzer from analysis.halo_analyzer import HaloAnalyzer from analysis.edge_analyzer import EdgeAnalyzer @@ -245,13 +248,24 @@ def _power(ps_a=self._starless_a or img_a, ps_b=_ps_b_src): wavelet_levels = s.get("wavelet_levels", 4) crosshair = s.get("crosshair") xs_snr_width = s.get("xs_snr_width") + nebula_sigma = s.get("nebula_sigma", SECTION8_NEBULA_MASK_SIGMA) + nebula_dilation_px = s.get("nebula_dilation_px", SECTION8_NEBULA_MASK_DILATION_PX) + nebula_max_hole_px = s.get("nebula_max_hole_px", SECTION8_NEBULA_MASK_MAX_HOLE_PX) + localmax_footprint_mult = s.get("localmax_footprint_mult", SECTION8_LOCALMAX_FOOTPRINT_MULT) + localmax_prominence_percentile = s.get("localmax_prominence_percentile", SECTION8_LOCALMAX_PROMINENCE_PERCENTILE) + localmax_top_percent = s.get("localmax_top_percent", SECTION8_LOCALMAX_TOP_PERCENT) _sd_b_src = (self._starless_b or img_b) if img_b is not None else None def _spatial(sd_a=self._starless_a or img_a, sd_b=_sd_b_src, _ch=crosshair, _roi=roi, _xs_snr_width=xs_snr_width): sda = SpatialDetailAnalyzer() spatial = sda.analyze(sd_a, sd_b, levels=wavelet_levels, crosshair=_ch, roi=_roi, - xs_snr_width=_xs_snr_width) + xs_snr_width=_xs_snr_width, nebula_sigma=nebula_sigma, + nebula_dilation_px=nebula_dilation_px, + nebula_max_hole_px=nebula_max_hole_px, + localmax_footprint_mult=localmax_footprint_mult, + localmax_prominence_percentile=localmax_prominence_percentile, + localmax_top_percent=localmax_top_percent) spatial["used_starless_a"] = self._starless_a is not None spatial["used_starless_b"] = self._starless_b is not None result_a.spatial_metrics = spatial diff --git a/gui/control_panel.py b/gui/control_panel.py index 6535d9f..01ffe01 100644 --- a/gui/control_panel.py +++ b/gui/control_panel.py @@ -14,7 +14,10 @@ from core.models import ( STD_KERNEL_SIZES, LOG_SIGMAS, WAVELET_LEVELS, DEFAULT_PIXEL_SCALE, MIN_STAR_SNR, SEEING_WARN_FWHM_ARCS, REF_SEEING_ARCSEC, XS_SNR_REGION_WIDTH, - EPSF_MAX_STARS, + EPSF_MAX_STARS, SECTION8_NEBULA_MASK_SIGMA, SECTION8_NEBULA_MASK_DILATION_PX, + SECTION8_NEBULA_MASK_MAX_HOLE_PX, + SECTION8_LOCALMAX_FOOTPRINT_MULT, SECTION8_LOCALMAX_PROMINENCE_PERCENTILE, + SECTION8_LOCALMAX_TOP_PERCENT, ) @@ -49,7 +52,7 @@ def _build_ui(self) -> None: root.setContentsMargins(6, 6, 6, 6) # ── Metrics group ────────────────────────────────────────────── - metrics_box = QGroupBox("Metrics") + metrics_box = QGroupBox("1. Metrics") metrics_layout = QGridLayout(metrics_box) metrics_layout.setColumnStretch(0, 1) metrics_layout.setColumnMinimumWidth(1, 46) # Export column @@ -126,21 +129,40 @@ def _build_ui(self) -> None: root.addWidget(metrics_box) # ── Parameters group ─────────────────────────────────────────── - params_box = QGroupBox("Parameters") - params_layout = QFormLayout(params_box) + params_box = QGroupBox("2. Parameters") + params_outer = QVBoxLayout(params_box) + columns_row = QHBoxLayout() + + col1_box = QVBoxLayout() + col1_hdr = QLabel("General / PSF") + col1_hdr.setStyleSheet("font-weight: bold;") + col1_box.addWidget(col1_hdr) + form1 = QFormLayout() + col1_box.addLayout(form1) + + col2_box = QVBoxLayout() + col2_hdr = QLabel("Nebula & Local-Maxima") + col2_hdr.setStyleSheet("font-weight: bold;") + col2_box.addWidget(col2_hdr) + form2 = QFormLayout() + col2_box.addLayout(form2) + + columns_row.addLayout(col1_box, stretch=1) + columns_row.addLayout(col2_box, stretch=1) + params_outer.addLayout(columns_row) self._min_snr = QDoubleSpinBox() self._min_snr.setRange(5.0, 500.0) self._min_snr.setValue(MIN_STAR_SNR) self._min_snr.setToolTip("Threshold signal-to-noise ratio for star inclusion in ePSF calculations.") - params_layout.addRow("Min star S/N:", self._min_snr) + form1.addRow("Min star S/N:", self._min_snr) self._epsf_max_stars = QSpinBox() self._epsf_max_stars.setRange(10, 2000) self._epsf_max_stars.setValue(EPSF_MAX_STARS) self._epsf_max_stars.setToolTip("Maximum number of stars used for ePSF estimation.\n" "Stars are ranked by peak flux; brightest N are used.") - params_layout.addRow("ePSF max stars:", self._epsf_max_stars) + form1.addRow("ePSF max stars:", self._epsf_max_stars) self._ref_seeing_arcsec = QDoubleSpinBox() self._ref_seeing_arcsec.setRange(0.5, 10.0) @@ -150,7 +172,7 @@ def _build_ui(self) -> None: self._ref_seeing_arcsec.setSuffix(" \"") self._ref_seeing_arcsec.setToolTip("Seeing distortion reference FWHM for ePSF analysis.\n" "Sets the benchmark Moffat PSF shown in PSF/MTF reports.") - params_layout.addRow("PSF reference seeing (arcsec):", self._ref_seeing_arcsec) + form1.addRow("PSF reference seeing (arcsec):", self._ref_seeing_arcsec) self._seeing_thresh = QDoubleSpinBox() self._seeing_thresh.setRange(0.5, 10.0) @@ -159,20 +181,75 @@ def _build_ui(self) -> None: self._seeing_thresh.setSuffix(" \"") self._seeing_thresh.setToolTip("Warning indicator threshold based on measured FWHM of stars.\n" "Analysis flags sessions exceeding this seeing limit.") - params_layout.addRow("Seeing warn threshold:", self._seeing_thresh) + form1.addRow("Seeing warn threshold:", self._seeing_thresh) self._xs_snr_width = QSpinBox() self._xs_snr_width.setRange(3, 100) self._xs_snr_width.setValue(XS_SNR_REGION_WIDTH) self._xs_snr_width.setToolTip("Number of pixels to sample for cross-section SNR calculation.\n" "Defines the bright and dark region window widths along the profile.") - params_layout.addRow("XS SNR region width (px):", self._xs_snr_width) + form1.addRow("XS SNR region width (px):", self._xs_snr_width) self._wavelet_levels = QSpinBox() self._wavelet_levels.setRange(2, 6) self._wavelet_levels.setValue(WAVELET_LEVELS) self._wavelet_levels.setToolTip("Number of wavelet layers used in spatial detail analysis.") - params_layout.addRow("Wavelet levels:", self._wavelet_levels) + form1.addRow("Wavelet levels:", self._wavelet_levels) + + self._nebula_sigma = QDoubleSpinBox() + self._nebula_sigma.setRange(0.5, 5.0) + self._nebula_sigma.setSingleStep(0.1) + self._nebula_sigma.setDecimals(2) + self._nebula_sigma.setValue(SECTION8_NEBULA_MASK_SIGMA) + self._nebula_sigma.setToolTip("Nebula mask threshold for Section 8 spatial detail analysis.\n" + "Pixels above this many RMS units over background are classified as Nebula.") + form2.addRow("Nebula mask threshold (× RMS):", self._nebula_sigma) + + self._nebula_dilation_px = QSpinBox() + self._nebula_dilation_px.setRange(0, 20) + self._nebula_dilation_px.setValue(SECTION8_NEBULA_MASK_DILATION_PX) + self._nebula_dilation_px.setToolTip("Grows the Section 8 nebula mask outward by this many pixels\n" + "to capture dim/dark transition regions at nebula edges.") + form2.addRow("Nebula mask dilation (px):", self._nebula_dilation_px) + + self._nebula_max_hole_px = QSpinBox() + self._nebula_max_hole_px.setRange(0, 20) + self._nebula_max_hole_px.setValue(SECTION8_NEBULA_MASK_MAX_HOLE_PX) + self._nebula_max_hole_px.setToolTip("Fills enclosed background gaps up to this size (px per side)\n" + "inside the Section 8 nebula mask, before dilation.") + form2.addRow("Nebula mask hole-fill (px):", self._nebula_max_hole_px) + + self._localmax_footprint_mult = QDoubleSpinBox() + self._localmax_footprint_mult.setRange(1.0, 6.0) + self._localmax_footprint_mult.setSingleStep(0.5) + self._localmax_footprint_mult.setDecimals(2) + self._localmax_footprint_mult.setValue(SECTION8_LOCALMAX_FOOTPRINT_MULT) + self._localmax_footprint_mult.setToolTip( + "Section 8j local-maxima mask: neighbourhood size, as a multiple of\n" + "each metric's own spatial scale, used to test whether a pixel is a local maximum.") + form2.addRow("Local-maxima footprint (× scale):", self._localmax_footprint_mult) + + self._localmax_prominence_pctl = QDoubleSpinBox() + self._localmax_prominence_pctl.setRange(50.0, 99.9) + self._localmax_prominence_pctl.setSingleStep(1.0) + self._localmax_prominence_pctl.setDecimals(1) + self._localmax_prominence_pctl.setValue(SECTION8_LOCALMAX_PROMINENCE_PERCENTILE) + self._localmax_prominence_pctl.setToolTip( + "Section 8j local-maxima mask: minimum peak height, as a percentile of\n" + "each scale's own combined |A|,|B| peak-source values.") + form2.addRow("Local-maxima prominence (pctl):", self._localmax_prominence_pctl) + + self._localmax_top_percent = QDoubleSpinBox() + self._localmax_top_percent.setRange(0.5, 25.0) + self._localmax_top_percent.setSingleStep(0.5) + self._localmax_top_percent.setDecimals(1) + self._localmax_top_percent.setSuffix(" %") + self._localmax_top_percent.setValue(SECTION8_LOCALMAX_TOP_PERCENT) + self._localmax_top_percent.setToolTip( + "Section 8j local-maxima mask: pixels in the top N% of Image A's or Image B's\n" + "own value distribution are unioned (OR) into the mask, so broad bright plateaus\n" + "are captured even when they never register as an isolated local-maximum peak.") + form2.addRow("Local-maxima top-bright (%):", self._localmax_top_percent) self._pixel_scale_override = QDoubleSpinBox() self._pixel_scale_override.setRange(0.0, 20.0) @@ -180,12 +257,12 @@ def _build_ui(self) -> None: self._pixel_scale_override.setValue(0.0) self._pixel_scale_override.setSuffix(" \"/px") self._pixel_scale_override.setSpecialValueText("(from header)") - params_layout.addRow("Pixel scale override:", self._pixel_scale_override) + form1.addRow("Pixel scale override:", self._pixel_scale_override) root.addWidget(params_box) # ── Output + ROI + Run ───────────────────────────────────────── - run_box = QGroupBox("Output & Run") + run_box = QGroupBox("3. Region && Run") run_layout = QHBoxLayout(run_box) # ── Left column: all selection / status controls ──────────────── @@ -373,6 +450,12 @@ def settings(self) -> dict: "pixel_scale_override": pso if pso > 0 else None, "wavelet_levels": self._wavelet_levels.value(), "xs_snr_width": self._xs_snr_width.value(), + "nebula_sigma": self._nebula_sigma.value(), + "nebula_dilation_px": self._nebula_dilation_px.value(), + "nebula_max_hole_px": self._nebula_max_hole_px.value(), + "localmax_footprint_mult": self._localmax_footprint_mult.value(), + "localmax_prominence_percentile": self._localmax_prominence_pctl.value(), + "localmax_top_percent": self._localmax_top_percent.value(), "ref_seeing_arcsec": self._ref_seeing_arcsec.value(), "epsf_max_stars": self._epsf_max_stars.value(), "roi": self._roi, diff --git a/gui/halo_dialog.py b/gui/halo_dialog.py index 33e4340..d2c10be 100644 --- a/gui/halo_dialog.py +++ b/gui/halo_dialog.py @@ -1,12 +1,9 @@ from __future__ import annotations -import math import warnings from pathlib import Path import numpy as np -from astropy.modeling import fitting -from astropy.modeling.models import Moffat2D import matplotlib import matplotlib.patches from matplotlib.backends.backend_qtagg import FigureCanvasQTAgg @@ -19,7 +16,9 @@ QTableWidgetItem, QHeaderView, QTextEdit, QWidget, ) +from analysis.moffat_fit import fit_moffat2d_core from core.astro_image import AstroImage +from core.fig_utils import finalize_layout from core.stretch import normalize_for_display from gui.image_panel import ZoomableImageLabel @@ -39,10 +38,6 @@ """ -def _moffat_fwhm(gamma: float, alpha: float) -> float: - return 2.0 * gamma * math.sqrt(2.0 ** (1.0 / alpha) - 1.0) - - # Metrics table rows: (display label, result key A, result key B, number format) _METRIC_ROWS = [ ("Peak (ADU)", "peak_a", "peak_b", ".3g"), @@ -526,25 +521,12 @@ def _fit_moffat(self, bgsub: np.ndarray, xc: float, yc: float) -> dict | None: max(0, cx_c - r_i):cx_c + r_i + 1] if inner.size > 0 and float(np.sum(inner >= 0.98 * amp)) / inner.size > 0.25: return {"saturated": True} - cy, cx = np.mgrid[0:cut.shape[0], 0:cut.shape[1]] - model = Moffat2D(amplitude=amp, - x_0=cut.shape[1] / 2.0, y_0=cut.shape[0] / 2.0, - gamma=2.0, alpha=2.5) - fitter = fitting.LevMarLSQFitter() - with warnings.catch_warnings(): - warnings.simplefilter("ignore") - try: - fitted = fitter(model, cx, cy, cut) - except Exception: - return None - gamma = abs(fitted.gamma.value) - alpha = abs(fitted.alpha.value) - if not (0.1 <= alpha <= 50.0) or gamma < 0.05: - return None - fwhm = _moffat_fwhm(gamma, alpha) - if not (0.2 <= fwhm <= 100.0): - return None - return {"fwhm": fwhm, "alpha": alpha, "gamma": gamma} + return fit_moffat2d_core( + cut, + alpha_bounds=(0.1, 50.0), + gamma_min=0.05, + fwhm_bounds=(0.2, 100.0), + ) def _shape_metrics(self, bgsub: np.ndarray, xc: float, yc: float) -> dict | None: """Eccentricity, ellipticity, orientation via photutils data_properties. @@ -1042,7 +1024,7 @@ def _update_figure(self, result: dict) -> None: ax3_top.set_xlabel("Radius (arcsec)", fontsize=7, color=orig_color) ax3_top.tick_params(labelsize=6, colors=orig_color) - self._fig.tight_layout(pad=1.0) + finalize_layout(self._fig, pad=1.0) self._canvas.draw_idle() finally: matplotlib.rcParams.update(_saved_params) diff --git a/gui/image_panel.py b/gui/image_panel.py index 0ff64b5..e5c9e21 100644 --- a/gui/image_panel.py +++ b/gui/image_panel.py @@ -116,6 +116,12 @@ def clear_roi_overlay(self) -> None: self._roi_norm = None self.update() + def clear_line_overlay(self) -> None: + self._line_n0 = None + self._line_n1 = None + self._line_state = "idle" + self.update() + def set_roi_mode(self, enabled: bool) -> None: self._roi_mode = enabled self.setCursor(Qt.CursorShape.CrossCursor if (enabled or self._line_mode) @@ -466,6 +472,9 @@ def set_roi_mode(self, enabled: bool) -> None: def clear_roi_overlay(self) -> None: self._img_label.clear_roi_overlay() + def clear_line_overlay(self) -> None: + self._img_label.clear_line_overlay() + def set_line_mode(self, enabled: bool) -> None: self._img_label.set_line_mode(enabled) diff --git a/gui/main_window.py b/gui/main_window.py index 5b5cf7f..8f1d1f2 100644 --- a/gui/main_window.py +++ b/gui/main_window.py @@ -5,6 +5,7 @@ from PyQt6.QtWidgets import ( QMainWindow, QWidget, QHBoxLayout, QVBoxLayout, QSplitter, QMessageBox, QFileDialog, QPushButton, + QToolBar, ) from gui.image_panel import ImagePanel @@ -18,7 +19,7 @@ class MainWindow(QMainWindow): def __init__(self, parent=None): super().__init__(parent) self.setWindowTitle("Astro Image Lab") - self.resize(1400, 900) + self.resize(1600, 900) # control panel's 2-col Parameters needs ~1473px natural width self._thread: AnalysisThread | None = None self._roi: tuple | None = None @@ -26,6 +27,8 @@ def __init__(self, parent=None): self._build_ui() self._build_menu() + # Needs self._control (from _build_ui) and self._act_* (from _build_menu) + self._build_toolbar() self._start_update_check() # ------------------------------------------------------------------ @@ -49,7 +52,10 @@ def _build_ui(self) -> None: # Control panel below images self._control = AnalysisControlPanel() - self._control.setMaximumHeight(240) + self._control.setMaximumHeight(300) # 2-col Parameters (General/PSF | Nebula & Local-Maxima) + # is tallest at ~278px natural height (measured via + # QGroupBox.sizeHint()); Metrics ~234px, Region & Run + # ~232px. Re-measure and adjust if a column gains rows. main_layout.addWidget(self._control) # Wire signals @@ -80,13 +86,13 @@ def _build_menu(self) -> None: mb = self.menuBar() file_menu = mb.addMenu("&File") - act_open_a = QAction("Open Image &A…", self) - act_open_a.triggered.connect(self._panel_a._open_file) - file_menu.addAction(act_open_a) + self._act_open_a = QAction("Open Image &A…", self) + self._act_open_a.triggered.connect(self._panel_a._open_file) + file_menu.addAction(self._act_open_a) - act_open_b = QAction("Open Image &B…", self) - act_open_b.triggered.connect(self._panel_b._open_file) - file_menu.addAction(act_open_b) + self._act_open_b = QAction("Open Image &B…", self) + self._act_open_b.triggered.connect(self._panel_b._open_file) + file_menu.addAction(self._act_open_b) file_menu.addSeparator() act_open_inspector = QAction("Open Report &Inspector…", self) @@ -99,9 +105,9 @@ def _build_menu(self) -> None: file_menu.addAction(act_quit) analysis_menu = mb.addMenu("&Analysis") - act_run = QAction("&Run Analysis", self) - act_run.triggered.connect(lambda: self._control._on_run()) - analysis_menu.addAction(act_run) + self._act_run = QAction("&Run Analysis", self) + self._act_run.triggered.connect(lambda: self._control._on_run()) + analysis_menu.addAction(self._act_run) tools_menu = mb.addMenu("&Tools") act_synth = QAction("Synthetic Star &Data…", self) @@ -126,6 +132,29 @@ def _build_menu(self) -> None: act_about.triggered.connect(self._show_about) help_menu.addAction(act_about) + def _build_toolbar(self) -> None: + tb = QToolBar("Main", self) + tb.setMovable(False) + tb.setFloatable(False) + tb.setToolButtonStyle(Qt.ToolButtonStyle.ToolButtonTextOnly) + self.addToolBar(tb) + + tb.addAction(self._act_open_a) + tb.addAction(self._act_open_b) + tb.addSeparator() + + self._act_toolbar_roi = QAction("Select ROI…", self) + self._act_toolbar_roi.triggered.connect(lambda: self._control._roi_btn.click()) + tb.addAction(self._act_toolbar_roi) + + self._act_toolbar_line = QAction("Select Line…", self) + self._act_toolbar_line.triggered.connect(lambda: self._control._line_btn.click()) + tb.addAction(self._act_toolbar_line) + tb.addSeparator() + + tb.addAction(self._act_run) + self._act_run.setEnabled(False) # matches Run button's initial disabled state + # ------------------------------------------------------------------ # Slots # ------------------------------------------------------------------ @@ -173,10 +202,27 @@ def _on_target_generated(self, clean_path: str, degraded_path: str, elif mode == "deg_b": self._panel_b.load_path(degraded_path) + def _set_run_enabled(self, enabled: bool) -> None: + self._control.set_run_enabled(enabled) + self._act_run.setEnabled(enabled) + def _on_image_loaded(self, img) -> None: + # A newly loaded image may not match the dimensions of whatever ROI/cross-section + # was drawn against the previous image pair — reset both outright rather than + # rely solely on _on_run's reactive out-of-bounds check (see CLAUDE.md's + # "Stale ROI crashes Section 8..." pitfall). + self._roi = None + self._crosshair = None + self._control.set_roi(None) + self._control.set_line(None) + self._panel_a.clear_roi_overlay() + self._panel_b.clear_roi_overlay() + self._panel_a.clear_line_overlay() + self._panel_b.clear_line_overlay() + either_loaded = (self._panel_a.image is not None or self._panel_b.image is not None) - self._control.set_run_enabled(either_loaded) + self._set_run_enabled(either_loaded) if self._panel_a.image is not None and self._panel_b.image is not None: self._control.set_alignment_status("Waiting for analysis…", ok=True) elif either_loaded: @@ -185,6 +231,7 @@ def _on_image_loaded(self, img) -> None: def _on_roi_mode_toggled(self, enabled: bool) -> None: self._panel_a.set_roi_mode(enabled) self._panel_b.set_roi_mode(enabled) + self._act_toolbar_roi.setText("Cancel ROI" if enabled else "Select ROI…") if not enabled: self._control.set_roi(self._roi) @@ -198,6 +245,7 @@ def _on_roi_selected(self, x0: int, y0: int, x1: int, y1: int) -> None: def _on_line_mode_toggled(self, enabled: bool) -> None: self._panel_a.set_line_mode(enabled) self._panel_b.set_line_mode(enabled) + self._act_toolbar_line.setText("Cancel Line" if enabled else "Select Line…") def _on_line_selected(self, x0n: float, y0n: float, x1n: float, y1n: float) -> None: @@ -234,7 +282,7 @@ def _on_run(self, settings: dict) -> None: if img_a is None and img_b is None: QMessageBox.warning(self, "Missing images", "Please load at least one image before running.") - self._control.set_run_enabled(True) + self._set_run_enabled(True) return # If only one image is loaded, confirm single-image mode @@ -249,7 +297,7 @@ def _on_run(self, settings: dict) -> None: QMessageBox.StandardButton.Yes, ) if answer != QMessageBox.StandardButton.Yes: - self._control.set_run_enabled(True) + self._set_run_enabled(True) return # Ensure img_a is always the loaded image so downstream code is uniform if img_a is None: @@ -277,7 +325,7 @@ def _on_run(self, settings: dict) -> None: QMessageBox.StandardButton.No, ) if answer == QMessageBox.StandardButton.No: - self._control.set_run_enabled(True) + self._set_run_enabled(True) return # Warn if no cross-section line has been drawn @@ -293,7 +341,7 @@ def _on_run(self, settings: dict) -> None: QMessageBox.StandardButton.Yes, ) if answer != QMessageBox.StandardButton.Yes: - self._control.set_run_enabled(True) + self._set_run_enabled(True) return # A previously-drawn ROI is never auto-cleared when a new image is loaded, so it @@ -355,7 +403,7 @@ def _on_progress(self, pct: int, msg: str) -> None: def _on_finished(self, result_a, result_b, report_path: str) -> None: self._control.reset_progress() - self._control.set_run_enabled(True) + self._set_run_enabled(True) msg = "Analysis complete." if report_path: msg += f"\nReport saved to:\n{report_path}" @@ -407,7 +455,7 @@ def _reset_zoom(self) -> None: def _on_error(self, msg: str) -> None: self._control.reset_progress() - self._control.set_run_enabled(True) + self._set_run_enabled(True) QMessageBox.critical(self, "Analysis error", msg) def _start_update_check(self) -> None: diff --git a/report/report_builder.py b/report/report_builder.py index b6d9c91..4ff3cc8 100644 --- a/report/report_builder.py +++ b/report/report_builder.py @@ -2,6 +2,7 @@ import base64 import io +import math import webbrowser from datetime import datetime from pathlib import Path @@ -16,10 +17,18 @@ from scipy.interpolate import griddata as _griddata from PIL import Image as _PILImage +from core.fig_utils import fig_to_b64 as _fig_to_b64 from core.models import (AnalysisResult, HALO_FIT_RADIUS_PX, XS_LINE_ALPHA, GLASS_REFRACTIVE_INDEX, PSF_SPATIAL_MAP_SIZE, PSF_SPATIAL_MAP_SMOOTH_SIGMA, EDGE_ROI_MAP_INDICATOR_PX, + EDGE_N_TOP_EDGES, LABEL_MAX_LEN, REF_SEEING_ARCSEC, REF_SEEING_BETA, - ABERRATION_MIN_STARS, ABERRATION_OUTER_RADIUS_FRAC) + ABERRATION_MIN_STARS, ABERRATION_OUTER_RADIUS_FRAC, + SECTION8_NEBULA_MASK_SIGMA, SECTION8_NEBULA_MASK_DILATION_PX, + SECTION8_NEBULA_MASK_MAX_HOLE_PX, + SECTION8_LOCALMAX_FOOTPRINT_MULT, SECTION8_LOCALMAX_PROMINENCE_PERCENTILE, + SECTION8_LOCALMAX_PRESMOOTH_FRACTION, SECTION8_LOCALMAX_REGION_FRACTION, + SECTION8_LOCALMAX_TOP_PERCENT, + SECTION8_ENTROPY_N_BINS, SECTION8_ENTROPY_CLIP_PERCENTILE) from core.astro_image import AstroImage _TEST_IMAGE_PATH = Path(__file__).parent.parent / "resources" / "ContrastTestImage.png" @@ -153,15 +162,6 @@ def _psf_make_map(pts: list, img_h: int, img_w: int) -> "np.ndarray | None": # ── Helpers ─────────────────────────────────────────────────────────────────── -def _fig_to_b64(fig: plt.Figure, dpi: int = 120) -> str: - buf = io.BytesIO() - fig.savefig(buf, format="png", dpi=dpi, bbox_inches="tight") - buf.seek(0) - data = base64.b64encode(buf.read()).decode() - plt.close(fig) - return data - - def _img_tag(fig: "plt.Figure | str | None", alt: str = "") -> str: if fig is None: return "" @@ -222,25 +222,12 @@ def _val_pm(v, spread, fmt=".3f", fallback="—") -> str: return s -def _psf_stat_test(va: list, vb: list) -> tuple[str, float | None]: - """Mann-Whitney U + Cliff's delta for two per-star metric distributions. - - Returns (html, p_value). html is a compact two-line string: effect rating + stars - on line 1, p-value and delta on line 2. Returns ("", None) if either list < 3 values. - d > 0 means A values tend to be higher than B. - """ - from scipy.stats import mannwhitneyu - - if len(va) < 3 or len(vb) < 3: - return "", None - - _, p = mannwhitneyu(va, vb, alternative="two-sided") - - arr_a = np.array(va) - arr_b = np.array(vb) - delta = float(np.sign(arr_a[:, None] - arr_b[None, :]).sum()) / (len(va) * len(vb)) +def _format_significance_html(p: float, delta: float) -> str: + """Compact two-line significance cell: effect rating + stars on line 1, + p-value and Cliff's delta on line 2. Shared by _psf_stat_test (Section 4) + and the Section 8j local-maxima table. delta > 0 means the first sample's + values tend to be higher than the second's.""" abs_d = abs(delta) - if p >= 0.05: rating, stars = "n.s.", "~" elif abs_d >= 0.474: @@ -253,7 +240,31 @@ def _psf_stat_test(va: list, vb: list) -> tuple[str, float | None]: rating, stars = "trivial", "~" p_str = "p<0.001" if p < 0.001 else f"p={p:.3f}" - return f"{stars} {rating}
{p_str}, δ={delta:+.2f}", float(p) + return f"{stars} {rating}
{p_str}, δ={delta:+.2f}" + + +def _sig_td(html: str, p: float | None) -> str: + """Table cell for a significance-test result: light blue if p<0.05, grey + otherwise, plain

" + style = 'style="background:#b3e5fc"' if p < 0.05 else 'style="background:#e0e0e0"' + return f"" + + +def _psf_stat_test(va: list, vb: list) -> tuple[str, float | None]: + """Mann-Whitney U + Cliff's delta for two per-star metric distributions. + + Returns (html, p_value). html is a compact two-line string: effect rating + stars + on line 1, p-value and delta on line 2. Returns ("", None) if either list < 3 values. + d > 0 means A values tend to be higher than B. + """ + from core.stats_utils import mannwhitney_effect + + p, delta = mannwhitney_effect(va, vb) + if p is None: + return "", None + return _format_significance_html(p, delta), p def _psf_distributions_figure(sd_a: list, sd_b: list, @@ -428,9 +439,11 @@ def _draw_boxwhisker(ax, vals_list): return img_html, caption_html -# Display order and labels for _spatial_diff_distributions_figure. Keys must match -# analysis/image_filters.py's partial["diff_dist"] keys exactly (same keys used for -# partial["panels"], see SpatialDetailAnalyzer._std_analysis/_log_analysis/etc.). +# Shared display order and labels for Section 8's per-scale figures/tables. Keys +# must match analysis/image_filters.py's partial["panels"]/partial["localmax"] keys +# exactly (see SpatialDetailAnalyzer._std_analysis/_log_analysis/etc.). Used to order +# the correlation scatter plots interleaved into 8d-8h and the Section 8j +# table/distribution figure. _SPATIAL_DIFF_DIST_ROWS = [ ("original", "Original (normalised image)"), ("std_3px", "Local σ — 3 px"), @@ -444,33 +457,38 @@ def _draw_boxwhisker(ax, vals_list): ("gradient_6.0", "Gradient |G| — σ=6 px"), ("wavelet_2", "Wavelet — level 2"), ("wavelet_3", "Wavelet — level 3"), - ("weber_3px", "Weber contrast — 3 px"), - ("weber_5px", "Weber contrast — 5 px"), - ("weber_9px", "Weber contrast — 9 px"), + ("entropy_5px", "Local entropy — 5 px"), + ("entropy_9px", "Local entropy — 9 px"), + ("entropy_17px", "Local entropy — 17 px"), ] # Same order/labels as above minus "original" (no kernel scale) — used to order the -# per-scale correlation scatter plots interleaved into Section 8b-8f, each one placed +# per-scale correlation scatter plots interleaved into Section 8d-8h, each one placed # immediately after its corresponding map figure. _SPATIAL_CORR_ROWS = [(k, label) for k, label in _SPATIAL_DIFF_DIST_ROWS if k != "original"] -def _spatial_diff_distributions_figure(diff_dist: dict) -> tuple[str, str]: - """Combined figure: one row per Section 8 calculation, each row showing a - nebula-region violin and a background-region violin of the A-B diff pixel - values, with an IQR box-plot overlay (median magenta, IQR cyan — same styling - as the ePSF section's _psf_distributions_figure). Always violin+box, never - strip/swarm — diff populations here are always high-N. +def _localmax_distributions_figure(localmax: dict) -> tuple[str, str]: + """Combined figure: one row per Section 8j metric/scale, each row showing an + Image-A violin and an Image-B violin of the raw masked pixel magnitudes + (|A|, |B| within that row's local-maxima mask), with an IQR box-plot overlay + (median magenta, IQR cyan — same styling as the ePSF section's + _psf_distributions_figure). Reads the pre-subsampled + localmax[key]["vals_a"/"vals_b"] arrays (SECTION8_LOCALMAX_DIST_MAX_SAMPLES + cap, from image_filters.py) — the table's own mean/std/ratio/significance + values are computed from the full population upstream and are unaffected by + this figure's subsampling. Returns (img_html, caption_html), or ("", "") if no row has enough data - (including single-image mode, where diff_dist is empty). + (including single-image mode, where localmax is empty). """ import seaborn as sns import pandas as pd - rows = [(k, label) for k, label in _SPATIAL_DIFF_DIST_ROWS if k in diff_dist] + rows = [(k, label) for k, label in _SPATIAL_CORR_ROWS if k in localmax] has_data = any( - diff_dist[k]["nebula"].size >= 3 and diff_dist[k]["background"].size >= 3 + localmax[k].get("vals_a") is not None and localmax[k]["vals_a"].size >= 3 + and localmax[k]["vals_b"].size >= 3 for k, _ in rows ) if not rows or not has_data: @@ -478,8 +496,8 @@ def _spatial_diff_distributions_figure(diff_dist: dict) -> tuple[str, str]: fig, axes = plt.subplots(len(rows), 1, figsize=(7, 1.3 * len(rows) + 1)) fig.subplots_adjust(hspace=0.65, left=0.22, right=0.97, top=0.97, bottom=0.04) - palette = {"Nebula": "steelblue", "Background": "tomato"} - order = ["Nebula", "Background"] + palette = {"Image A": "steelblue", "Image B": "tomato"} + order = ["Image A", "Image B"] def _draw_boxwhisker(ax, vals_list): for i, vals in enumerate(vals_list): @@ -496,23 +514,22 @@ def _draw_boxwhisker(ax, vals_list): ) for ax, (key, title) in zip(np.atleast_1d(axes), rows): - neb = diff_dist[key]["nebula"] - bg = diff_dist[key]["background"] - if neb.size < 3 or bg.size < 3: + va = localmax[key].get("vals_a") + vb = localmax[key].get("vals_b") + if va is None or va.size < 3 or vb.size < 3: ax.set_visible(False) continue - combined = np.concatenate([neb, bg]) + combined = np.concatenate([va, vb]) df = pd.DataFrame({ "value": combined, - "region": (["Nebula"] * neb.size) + (["Background"] * bg.size), + "region": (["Image A"] * va.size) + (["Image B"] * vb.size), }) sns.violinplot(data=df, x="value", y="region", order=order, palette=palette, inner=None, linewidth=0.8, ax=ax) - _draw_boxwhisker(ax, [neb, bg]) + _draw_boxwhisker(ax, [va, vb]) - # Some detail maps (e.g. Weber contrast, unbounded near dark-sky pixels — - # see 8e methodology) have rare extreme-outlier log-ratios that stretch the + # Some detail maps have rare extreme-outlier magnitudes that stretch the # axis so far the IQR box becomes an invisible sliver. Clip the *view* to the # 1st-99th percentile of this row's own pooled data; the boxplot/violin # values themselves are unaffected, only what's visible is cropped. @@ -521,7 +538,6 @@ def _draw_boxwhisker(ax, vals_list): pad = 0.05 * (hi - lo) ax.set_xlim(lo - pad, hi + pad) - ax.axvline(0.0, color="red", linestyle="--", linewidth=1.0, alpha=0.8, zorder=3) ax.set_title(title, fontsize=8, loc="left", pad=2) ax.set_xlabel("", fontsize=7) ax.set_ylabel("", fontsize=7) @@ -529,38 +545,110 @@ def _draw_boxwhisker(ax, vals_list): ax.tick_params(axis="y", labelsize=7, pad=1) ax.spines[["top", "right"]].set_visible(False) - img_html = _img_tag(fig, "spatial_diff_distributions") + img_html = _img_tag(fig, "localmax_distributions") caption_html = ( '

' - "Pixel-wise log₁₀(A / B) ratio distributions. " - "Each row is one Section 8 calculation, shown as a " - "violin plot (kernel density estimate of the log-ratio pixel values, " - "randomly subsampled for display) with an IQR box-plot overlay: " + "Masked-region pixel value distributions (Image A vs Image B). " + "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. Nebula = pixels both " - "images classify as nebula; Background " - "= pixels both images classify as background sky — the same shared masks " - "illustrated above and used for the noise-corrected scores in 8b–8f, here " - "shown as full distributions rather than a single median ratio. " - "A red dashed line marks zero " - "(A = B, equal). Units are log₁₀ — ±0.3 ≈ a " - "2× difference, ±1.0 ≈ a 10× difference. For the " - "Original and Wavelet rows specifically (the only two families that can go " - "negative), sign was discarded before the ratio — these rows compare the " - "magnitude of structure, not signed brightness. " - "How to read it: a Background row centred at zero with a narrow IQR is " - "the expected noise floor; a Nebula row with a similarly narrow, zero-centred " - "distribution means the two filters agree at that scale. A Nebula median shifted " - "away from zero, or an IQR visibly wider than the Background row's, indicates a " - "real structural difference between the filters at that scale rather than noise. " - "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 log-ratios (e.g. " - "Weber contrast near dark-sky pixels, see 8e) may have a small fraction of the " - "violin's tail extend beyond the visible axis — see the per-pixel correlation " - "scatter next to each map figure below (8b–8f) for the full, unclipped " - "upper-tail behaviour." + "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." + "

" + ) + return img_html, caption_html + + +def _localmax_log_ratio_distribution_figure(localmax: dict) -> tuple[str, str]: + """Combined figure: one row per Section 8j metric/scale, each row showing a + single violin of the per-pixel log10(|A|/|B|) population within that row's + local-maxima mask (vals_log_ratio), with an IQR box-plot overlay (median + magenta, IQR cyan — same styling as _localmax_distributions_figure) and a + dashed vertical reference line at 0 (A = B parity). This is the same masked + pixel population the "log ratio A/B (geo. mean ± SD)" table column and the + 8j cross-method overview plot's error bars are computed from — visualising + its shape helps judge whether a mean ± SD summary is a reasonable one (e.g. + whether the underlying A/B ratio is approximately log-normal). + + Returns (img_html, caption_html), or ("", "") if no row has enough data + (including single-image mode, where localmax is empty). + """ + import seaborn as sns + + rows = [(k, label) for k, label in _SPATIAL_CORR_ROWS if k in localmax] + has_data = any( + localmax[k].get("vals_log_ratio") is not None and localmax[k]["vals_log_ratio"].size >= 3 + for k, _ in rows + ) + if not rows or not has_data: + return "", "" + + 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) + + def _draw_boxwhisker(ax, vals_list): + for i, vals in enumerate(vals_list): + ax.boxplot( + [vals], positions=[i], vert=False, + widths=0.45, zorder=5, + patch_artist=True, + manage_ticks=False, + boxprops=dict(facecolor="none", edgecolor="#00e5ff", linewidth=1.5, alpha=0.9), + medianprops=dict(color="magenta", linewidth=2.0, alpha=0.9), + whiskerprops=dict(color="#00e5ff", linewidth=1.5, alpha=0.9), + capprops=dict(color="#00e5ff", linewidth=1.5, alpha=0.9), + flierprops=dict(marker="", visible=False), + ) + + for ax, (key, title) in zip(np.atleast_1d(axes), rows): + vlr = localmax[key].get("vals_log_ratio") + if vlr is None or vlr.size < 3: + ax.set_visible(False) + continue + + 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) + + lo, hi = np.percentile(vlr, [1.0, 99.0]) + if hi > lo: + pad = 0.05 * (hi - lo) + ax.set_xlim(lo - pad, hi + pad) + + ax.set_title(title, fontsize=8, loc="left", pad=2) + ax.set_xlabel("", fontsize=7) + ax.set_ylabel("", fontsize=7) + ax.set_yticks([]) + ax.tick_params(axis="x", labelsize=7) + ax.spines[["top", "right"]].set_visible(False) + + img_html = _img_tag(fig, "localmax_log_ratio_distributions") + + caption_html = ( + '

' + "Masked-region log ratio distributions (log₁₀(A / B)). " + "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 " + "local-maxima mask, with an IQR box-plot overlay: " + "a cyan box spanning Q1–Q3, " + "a magenta centre line at the " + "median. The dashed vertical line marks 0 (A = B). This is the same " + "masked pixel population the “log ratio A/B (geo. mean ± SD)” " + "table column and the cross-method overview plot's error bars are computed " + "from — a roughly symmetric, unimodal shape here supports summarising " + "it with a mean ± SD. " + "Each row's x-axis is independently clipped to its own 1st–99th " + "percentile range so the IQR box stays visible." "

" ) return img_html, caption_html @@ -599,22 +687,62 @@ def _better_worse_class(val_a, val_b, higher_is_better: bool = True) -> tuple[st return ("better", "worse") if val_a <= val_b else ("worse", "better") -def _nc_ratio_rows(score_a: dict, score_b: dict, ratio: dict, scale_label, val_fmt: str = ".3f") -> str: +def _nc_ratio_rows(score_a: dict, score_b: dict, ratio: dict, scale_label, + val_fmt: str = ".3f", method_label: str | None = None) -> str: """Build rows for a noise-corrected score table: scale | A | B | Ratio A/B. scale_label(scale) -> row label string. Ratio cell is colored relative to - 1.0 (parity), independently of the A/B columns' own coloring.""" + 1.0 (parity), independently of the A/B columns' own coloring. When + method_label is given, each row is prefixed with a " if method_label is not None else "" for scale in sorted(set(list(score_a.keys()) + list(score_b.keys()))): va, vb, vr = score_a.get(scale), score_b.get(scale), ratio.get(scale) ca, cb = _better_worse_class(va, vb) cr, _ = _better_worse_class(vr, 1.0) - rows += (f"" + rows += (f"{method_td}" f"" f"" f"") return rows +def _localmax_rows(localmax: dict, rows: list, val_fmt: str = ".3f") -> str: + """Build rows for the Section 8j local-maxima masked-region summary + table: Scale | Mean A ± SD | Mean B ± SD | log ratio A/B (geo. mean ± SD) | + Significance | N px / % area. `rows` is _SPATIAL_CORR_ROWS, reused + directly — partial['localmax'] keys are the exact same strings as + partial['panels']. Mean A/B colored against each other (same convention as + _nc_ratio_rows). The log-ratio column is sampled directly from the masked + log10(|A|/|B|) population (log_ratio_mean ± log_ratio_std, not derived from + Mean A/Mean B) and shaded a fixed neutral blue — it isn't an A-vs-B + comparison, so red/green better-worse coloring doesn't apply. Significance + is a Mann-Whitney U + Cliff's delta test (image_filters.py::_localmax_stats, + core.stats_utils.mannwhitney_effect) on the full masked population, + rendered with the same star-rating/coloring as Section 4's PSF table. + Pixel-count/%-area column is informational only (no coloring).""" + out = "" + for key, label in rows: + entry = localmax.get(key) + if entry is None: + continue + va, vb = entry.get("mean_a"), entry.get("mean_b") + sa, sb = entry.get("std_a"), entry.get("std_b") + log_mean, log_std = entry.get("log_ratio_mean"), entry.get("log_ratio_std") + n_px, pct = entry.get("n_px", 0), entry.get("pct_area", 0.0) + p, delta = entry.get("p_value"), entry.get("cliffs_delta") + ca, cb = _better_worse_class(va, vb) + sig_html = _format_significance_html(p, delta) if p is not None else "" + out += (f"" + f"" + f"" + f"" + f"{_sig_td(sig_html, p)}" + f"") + return out + + _SPATIAL_GLOSSARY_HTML = ( '

8a. Background — Key Terms

' '

Section 8 measures Detail — how much real, resolvable structure ' @@ -661,30 +789,57 @@ def _nc_ratio_rows(score_a: dict, score_b: dict, ratio: dict, scale_label, val_f + _info_box( '

if no test was run (p is None).""" + if p is None: + return f"{html}{html}
naming the metric + family (LoG/Wavelet/Gradient/etc.) -- used by the Section 8j combined + cross-method NC table.""" rows = "" + method_td = f"{method_label}
{scale_label(scale)}
{scale_label(scale)}{_val(va, val_fmt)}{_val(vb, val_fmt)}{_val(vr, val_fmt)}
{label}{_val_pm(va, sa, val_fmt)}{_val_pm(vb, sb, val_fmt)}{_val_pm(log_mean, log_std, val_fmt)}{n_px:,d} ({pct:.2f}%)
' ' ' - ' ' + ' ' " ' - ' ' + ' ' ' ' - ' ' + ' ' ' ' ' ' " ' - ' ' + ' ' ' ' ' ' - " " - " " - ' ' - ' ' + " " + " " + ' ' + ' ' + ' ' + ' ' + ' ' ' ' " ' '
MetricKernel / scalePrimarily measuresResponds to
Local σ map (8b)3, 5, 10 px windowDetail (texture / variability)
Local σ map (8g)3, 5, 10 px windowDetail (texture / variability)Any local brightness variation — filaments, halos, and noise (can't tell them " ' apart alone)
Contrast ratio (8b)same kernel sizesContrast, built from the σ map
Contrast ratio (8g)same kernel sizesContrast, built from the σ mapHow much more textured the nebula is than blank sky, at this scale
|LoG| map (8c)σ = 1.5, 3, 6 pxDetail (edge / curvature strength)
|LoG| map (8d)σ = 1.5, 3, 6 pxDetail (edge / curvature strength)Intensity boundaries — filament edges, shell rims — surviving smoothing at scale σ
Gradient magnitude (8f)same σ as LoGDetail (edge sharpness)How abrupt a boundary is at scale σ; complements Section 6's precise per-edge " ' resolution measurement
Wavelet decomposition (8d)levels ≈ 2, 4, 8, 16 px
Wavelet decomposition (8e)levels ≈ 2, 4, 8, 16 pxDetail by scale band, plus explicit SNRStructure whose size matches this scale band; level 1 is noise-only, used to ' ' calibrate the noise floor
Weber fraction contrast (8e)3, 5, 9 px windowContrast (Weber's law, ΔL/L)Local range vs. local median background — the most literal "Contrast" metric in ' - ' this section
Noise-corrected (NC) score (8b–8f, 8g)same scale as parent method
Local entropy map (8h)5, 9, 17 px windowDetail (texture complexity, bits, log₂)Rich/unpredictable local tonal structure — tangled nebulosity, mottled dust, ' + ' unresolved star fields — but noise raises this too, even more readily than σ
Entropy contrast ratio (8h)same kernel sizesContrast, built from the entropy mapWhether the nebula has richer tonal/textural complexity than blank sky, at this ' + ' scale
Noise-corrected (NC) score (8d–8h, 8i)same scale as parent methodSNR of DetailWhether a detail-map response in the nebula is real structure or just this image's " ' own noise floor at that scale
', title="Which concept each metric primarily measures") + + _info_box( + '' + ' ' + ' ' + ' ' + ' ' + ' ' + ' ' + ' ' + ' ' + ' ' + '
MetricHigh whenWeakness
Local σ (8g)Pixels differ strongly from the local meanResponds strongly to noise, halos, gradients, bright stars
|LoG| / Gradient (8d, 8f)There are edges, curvature, transitionsMore shape/edge-biased than texture-complexity-biased
Wavelets (8e)Structure exists in a specific spatial-frequency bandNeeds noise calibration (provided here via the explicit per-level SNR)
Local entropy (8h)The local intensity distribution is rich / unpredictableIgnores spatial arrangement entirely, and responds to noise even more readily ' + ' than σ
' + '

Local entropy is best read as a secondary texture-complexity signal, ' + 'not a replacement for σ/LoG/gradient/wavelet — it answers a different question ' + '("how unpredictable is the local tonal distribution?") rather than "how much does ' + 'brightness vary?" or "how sharp are the edges?". It is most informative when two images ' + 'score similarly on σ or wavelet power but still look visibly different in local texture ' + '(fine nebulosity, dust lanes, mottled galaxy structure) — entropy can surface that ' + 'difference where amplitude- and edge-based metrics do not. Because entropy alone cannot ' + 'distinguish real tonal richness from shot noise, always read it alongside its ' + 'noise-corrected score (8h, 8i), not in isolation.

', + title="Comparing the detail / texture-complexity metrics") ) @@ -702,8 +857,8 @@ def _panel_display_name(pkey: str) -> str: name = f"LoG σ {base[4:]} px" elif base.startswith("wavelet_"): name = f"Wavelet level {base[8:]}" - elif base.startswith("weber_") and base.endswith("px"): - name = f"Weber {base[6:-2]} px" + elif base.startswith("entropy_") and base.endswith("px"): + name = f"Entropy {base[8:-2]} px" elif base.startswith("gradient_"): name = f"Gradient σ {base[9:]} px" else: @@ -740,13 +895,15 @@ def _panel_concept(pkey: str) -> str: "shell edges at higher levels. Higher coefficient magnitude = more " "structure at this scale; see the wavelet SNR chart for whether it's " "signal- or noise-dominated.") - elif base.startswith("weber_") and base.endswith("px"): - concept = ("Measures Contrast (Weber's law, c = ΔL/L) within a square window — " - "local intensity range relative to local background luminance. Responds to " - "how strongly a feature stands out against its immediate surround. " - "Higher = feature stands out more strongly from its local background " - "(very high values over near-black sky can be an artifact of a small " - "denominator, not real contrast).") + elif base.startswith("entropy_") and base.endswith("px"): + concept = ("Measures Detail as texture complexity (Shannon entropy, bits, log₂) " + "of the local gray-level histogram within a square window, using data quantized " + "into a fixed number of levels beforehand (see 8h methodology). Responds to how " + "rich/unpredictable local tonal structure is — tangled nebulosity, mottled dust, " + "unresolved star fields — but also to noise, even more readily than Local σ. " + "Higher = richer/more unpredictable local tonal distribution (cannot " + "distinguish real structure from noise on its own — see the noise-corrected " + "score).") elif base.startswith("gradient_"): concept = ("Measures Detail as edge sharpness (1st derivative, slope magnitude) " "at Gaussian scale σ. Responds to how abrupt an intensity transition is at " @@ -1392,25 +1549,15 @@ def _section_psf(self, ra: AnalysisResult, rb: AnalysisResult, img_h_b, img_w_b = (img_b.data.shape[:2] if img_b is not None else (0, 0)) stars_a = pa.get("star_data", []) stars_b = pb.get("star_data", []) - fwhm_vals_a = [s["fwhm"] for s in stars_a if s.get("fwhm") is not None] - fwhm_vals_b = [s["fwhm"] for s in stars_b if s.get("fwhm") is not None] - ecc_vals_a = [s["eccentricity"] for s in stars_a if s.get("eccentricity") is not None] - ecc_vals_b = [s["eccentricity"] for s in stars_b if s.get("eccentricity") is not None] img_fwhm_map = _img_tag(self._plot_psf_spatial_map( stars_a, stars_b, "fwhm", ra.label, rb.label, img_h_a, img_w_a, img_h_b, img_w_b, "FWHM spatial map (px)", "viridis"), "FWHM spatial map") - img_fwhm_hist = _img_tag(self._plot_psf_histogram( - fwhm_vals_a, fwhm_vals_b, ra.label, rb.label, - "FWHM (px)", "FWHM distribution"), "FWHM histogram") img_ecc_map = _img_tag(self._plot_psf_spatial_map( stars_a, stars_b, "eccentricity", ra.label, rb.label, img_h_a, img_w_a, img_h_b, img_w_b, "Eccentricity spatial map", "plasma"), "Eccentricity spatial map") - img_ecc_hist = _img_tag(self._plot_psf_histogram( - ecc_vals_a, ecc_vals_b, ra.label, rb.label, - "Eccentricity", "Eccentricity distribution"), "Eccentricity histogram") dist_fig, dist_caption = _psf_distributions_figure(stars_a, stars_b, ra.label, rb.label) dist_html = ( @@ -1424,12 +1571,6 @@ def _sig(key): vb = [s[key] for s in stars_b if s.get(key) is not None] return _psf_stat_test(va, vb) # (html, p | None) - def _sig_td(html, p): - if p is None: - return f"{html}" - style = 'style="background:#b3e5fc"' if p < 0.05 else 'style="background:#e0e0e0"' - return f"{html}" - (sig_fwhm_px, p_fwhm_px) = _sig("fwhm") (sig_fwhm_arcsec, p_fwhm_arcsec) = _sig("fwhm_arcsec") (sig_beta, p_beta) = _sig("beta") @@ -1550,13 +1691,9 @@ def _sig_td(html, p): {img_fwhm_map}

Smoothed FWHM map (px) across the field. Shared colour scale between both images. Dots mark individual star measurements.

-{img_fwhm_hist} -

Distribution of per-star FWHM values.

{img_ecc_map}

Smoothed eccentricity map across the field. 0 = circular star, 1 = fully elongated.

-{img_ecc_hist} -

Distribution of per-star eccentricity values.

{img_scatter}

Per-star FWHM correlation. Points near the slope = 1 line indicate @@ -2102,29 +2239,6 @@ def _plot_psf_spatial_map( ax.set_ylabel("y (px)") return fig - @staticmethod - def _plot_psf_histogram( - vals_a: list, vals_b: list, - label_a: str, label_b: str, - xlabel: str, title: str) -> "plt.Figure | None": - if not vals_a and not vals_b: - return None - all_vals = vals_a + vals_b - rng = (float(min(all_vals)), float(max(all_vals))) - fig, ax = plt.subplots(figsize=(7, 4), constrained_layout=True) - if vals_a: - ax.hist(vals_a, bins=40, range=rng, alpha=XS_LINE_ALPHA, - color="#ff7f0e", label=label_a, edgecolor="none") - if vals_b: - ax.hist(vals_b, bins=40, range=rng, alpha=XS_LINE_ALPHA, - color="#1f77b4", label=label_b, edgecolor="none") - ax.set_xlabel(xlabel) - ax.set_ylabel("Count") - ax.set_title(title) - ax.legend(fontsize=9) - ax.grid(True, alpha=0.3) - return fig - def _plot_fwhm_scatter(self, ra: AnalysisResult, rb: AnalysisResult) -> plt.Figure | None: """Scatter plot of per-star FWHM_A vs FWHM_B for matched stars.""" data_a = (ra.psf_metrics or {}).get("star_data", []) @@ -2474,8 +2588,8 @@ def modulation(arr: np.ndarray) -> float: return fig def _plot_psf_band_modulation(self, sim: dict) -> "plt.Figure | None": - """Grouped bar chart: mean local contrast per spatial-frequency band, averaged - across the three contrast rows. Four bands span fine → coarse bar periods.""" + """Grouped bar chart: mean local contrast per spatial-frequency band, one 2x2 + panel per band, with grouped bars across the up-to-three contrast rows.""" xs_data = sim.get("xs_data", {}) if not xs_data: return None @@ -2490,10 +2604,10 @@ def _envelope(arr: np.ndarray, half: int = 5) -> np.ndarray: # Bands are index ranges into the *reversed* array (index 0 = finest bar). bands = [ - ("Fine\n(1–40 px)", slice(0, 40)), - ("Mid-fine\n(41–120 px)", slice(40, 120)), - ("Mid\n(121–300 px)", slice(120, 300)), - ("Coarse\n(301+ px)", slice(300, None)), + ("Fine (1–40 px)", slice(0, 40)), + ("Mid-fine (41–120 px)", slice(40, 120)), + ("Mid (121–300 px)", slice(120, 300)), + ("Coarse (301+ px)", slice(300, None)), ] level_info = [ ("high", "High contrast"), @@ -2505,46 +2619,52 @@ def _envelope(arr: np.ndarray, half: int = 5) -> np.ndarray: return None has_b = any(xs_data[lv].get("conv_b") is not None for lv in xs_data) - n_bands = len(bands) - x = np.arange(n_bands) - width = 0.25 if has_b else 0.3 + + # Precompute envelopes once per contrast level -- shared across all four band panels. + envelopes = {} + for level, _title in available: + d = xs_data[level] + env_orig = _envelope(d["original"][::-1]) + env_a = _envelope(d["conv_a"][::-1]) + env_b = (_envelope(d["conv_b"][::-1]) + if has_b and d.get("conv_b") is not None else None) + envelopes[level] = (env_orig, env_a, env_b) + + has_b_all = has_b and all(envelopes[lv][2] is not None for lv, _t in available) + n_levels = len(available) + x = np.arange(n_levels) + width = 0.25 if has_b_all else 0.3 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, axes = plt.subplots(len(available), 1, - figsize=(9, 4 * len(available)), squeeze=False) + fig, axes = plt.subplots(2, 2, figsize=(11, 8)) - for ax_row, (level, level_title) in zip(axes[:, 0], available): - d = xs_data[level] - env_orig = _envelope(d["original"][::-1]) - env_a = _envelope(d["conv_a"][::-1]) - orig_vals = [float(np.mean(env_orig[sl])) for _, sl in bands] - a_vals = [float(np.mean(env_a[sl])) for _, sl in bands] - - if has_b and d.get("conv_b") is not None: - env_b = _envelope(d["conv_b"][::-1]) - b_vals = [float(np.mean(env_b[sl])) for _, sl in bands] - ax_row.bar(x - width, orig_vals, width, label="Original", - color=orig_color, alpha=0.75) - ax_row.bar(x, a_vals, width, label=sim["label_a"], - color="steelblue", alpha=0.85) - ax_row.bar(x + width, b_vals, width, label=sim["label_b"], - color="tomato", alpha=0.85) + for ax, (band_label, sl) in zip(axes.flatten(), bands): + orig_vals = [float(np.mean(envelopes[lv][0][sl])) for lv, _t in available] + a_vals = [float(np.mean(envelopes[lv][1][sl])) for lv, _t in available] + + if has_b_all: + b_vals = [float(np.mean(envelopes[lv][2][sl])) for lv, _t in available] + ax.bar(x - width, orig_vals, width, label="Original", + color=orig_color, alpha=0.75) + ax.bar(x, a_vals, width, label=sim["label_a"], + color="steelblue", alpha=0.85) + ax.bar(x + width, b_vals, width, label=sim["label_b"], + color="tomato", alpha=0.85) else: - ax_row.bar(x - width / 2, orig_vals, width, label="Original", - color=orig_color, alpha=0.75) - ax_row.bar(x + width / 2, a_vals, width, label=sim["label_a"], - color="steelblue", alpha=0.85) - ax_row.set_xticks(x) - ax_row.set_xticklabels([b[0] for b in bands], fontsize=8) - ax_row.set_ylabel("Mean local contrast\n(peak − valley)", fontsize=8) - ax_row.set_title(f"Contrast retention — {level_title}", fontsize=9) - ax_row.legend(fontsize=8) - ax_row.grid(True, alpha=0.3, axis="y") - - axes[-1, 0].set_xlabel("Spatial-frequency band (bar period in pixels)", fontsize=8) + ax.bar(x - width / 2, orig_vals, width, label="Original", + color=orig_color, alpha=0.75) + ax.bar(x + width / 2, a_vals, width, label=sim["label_a"], + color="steelblue", alpha=0.85) + ax.set_xticks(x) + ax.set_xticklabels([t for _lv, t in available], fontsize=8) + ax.set_ylabel("Mean local contrast\n(peak − valley)", fontsize=8) + ax.set_title(band_label, fontsize=9) + ax.legend(fontsize=8) + ax.grid(True, alpha=0.3, axis="y") + fig.tight_layout() return fig @@ -2891,7 +3011,6 @@ def panel(arr, title, caption=""): lower bars = more blurring. Error bars show the standard deviation of the per-pixel ratios within each band. Colors identify contrast level (high / medium / low){retention_caption_b}

{self._psf_retention_table(sim)}""" - self._cached_retention_html = self._psf_retention_table(sim) diff_para = ("""

A pixel-level difference map (A − B) is computed and displayed with the RdBu_r @@ -2902,6 +3021,18 @@ def panel(arr, title, caption=""): peak-to-valley swing preserved by each PSF.

""" if has_b else "") + conv_a_title = f"Convolved — {sim['label_a']}" + conv_b_title = f"Convolved — {sim['label_b']}" + conv_ref_title = f"Convolved — {sim['label_ref']}" + _testchart_images_html = ( + panel(sim['original'], 'Original test chart') + + panel(sim['conv_a'], conv_a_title) + + (panel(sim['conv_b'], conv_b_title) if has_b else '') + + (panel(sim['conv_ref'], conv_ref_title) if sim.get('conv_ref') is not None else '') + + diff_panel + ) + _testchart_images_box = _info_box(_testchart_images_html, title="Show ePSF test chart convolution images", open=False) + return f"""

PSF Simulation — test chart convolved at native pixel resolution

@@ -2936,11 +3067,8 @@ def panel(arr, title, caption=""):

{diff_para}

Each image is rendered at 1 image-pixel : 1 screen-pixel.

-{panel(sim['original'], 'Original test chart')} -{panel(sim['conv_a'], f"Convolved — {sim['label_a']}")} -{panel(sim['conv_b'], f"Convolved — {sim['label_b']}") if has_b else ''} -{panel(sim['conv_ref'], f"Convolved — {sim['label_ref']}") if sim.get('conv_ref') is not None else ''} -{diff_panel}{xs_block}""" +{_testchart_images_box} +{xs_block}""" # ── Section 4: Halo ─────────────────────────────────────────────────────── @@ -3546,9 +3674,13 @@ def _plot_esf_lsf_pair(self, if esf_a.size: ax_esf.plot(pos_a, esf_a, color="steelblue", linewidth=1.5, alpha=XS_LINE_ALPHA, label=label_a) + ax_esf.plot(pos_a[0], esf_a[0], marker="s", markersize=5, + color="red", zorder=6) if esf_b.size: ax_esf.plot(pos_b, esf_b, color="tomato", linewidth=1.5, alpha=XS_LINE_ALPHA, label=label_b) + ax_esf.plot(pos_b[0], esf_b[0], marker="s", markersize=5, + color="red", zorder=6) ax_esf.axhline(0.10, color="gray", linestyle="--", linewidth=0.8) ax_esf.axhline(0.90, color="gray", linestyle="--", linewidth=0.8) w_label = "" @@ -3574,9 +3706,13 @@ def _plot_esf_lsf_pair(self, if lsf_a.size: ax_lsf.plot(pos_a, lsf_a, color="steelblue", linewidth=1.5, alpha=XS_LINE_ALPHA, label=label_a) + ax_lsf.plot(pos_a[0], lsf_a[0], marker="s", markersize=5, + color="red", zorder=6) if lsf_b.size: ax_lsf.plot(pos_b, lsf_b, color="tomato", linewidth=1.5, alpha=XS_LINE_ALPHA, label=label_b) + ax_lsf.plot(pos_b[0], lsf_b[0], marker="s", markersize=5, + color="red", zorder=6) ax_lsf.set_title(f"Edge #{edge_num} LSF (derivative of ESF)", fontsize=9) ax_lsf.set_xlabel("Position (px)") ax_lsf.set_ylabel("d(ESF)/dx") @@ -3693,7 +3829,8 @@ def _section_edge(self, ra: AnalysisResult, rb: AnalysisResult, '

Gradient magnitude (ROI auto-detection map)

' + _img_tag(pair_fig, "Gradient magnitude") + '

Gaussian gradient magnitude used to locate ' - 'the strongest edge regions. Cyan boxes show the three selected ' + 'the strongest edge regions. Cyan boxes show the ' + f'{EDGE_N_TOP_EDGES} selected ' 'analysis ROI regions. Both images share the same color scale (P99 of the brighter ' 'image) for direct comparison. Sigma is pixel-scale adaptive ' '(≈ 1.5 arcsec equivalent) so diffuse gradients in long-focal-length images ' @@ -3738,9 +3875,9 @@ def _section_edge(self, ra: AnalysisResult, rb: AnalysisResult, 'the whole frame. Using a Gaussian gradient rather than a fixed 3×3 Sobel kernel ' 'means that diffuse gradients in long-focal-length images are detected as reliably ' 'as sharp edges in short-focal-length data. ' - 'The three strongest, well-separated gradient peaks ' + f'The {EDGE_N_TOP_EDGES} strongest, well-separated gradient peaks ' 'are located automatically (peaks are suppressed within a 90 px radius after each ' - 'detection to ensure the three regions sample distinct features). A 500 × 500 px ' + f'detection to ensure the {EDGE_N_TOP_EDGES} regions sample distinct features). A 500 × 500 px ' 'context window is shown for each, centred on the gradient peak; the 60 × 60 px ' 'analysis region (cyan box) is highlighted within it. ' 'If a starless image was provided it is used in place of the stacked image, so the ' @@ -4012,7 +4149,9 @@ def _section_spatial(self, ra: AnalysisResult, rb: AnalysisResult) -> str: figs = sm.get("figures", {}) mask_fig = figs.get("mask_illustration") - dist_img, dist_caption = _spatial_diff_distributions_figure(sm.get("diff_dist", {})) + nebula_sigma = sm.get("nebula_sigma", SECTION8_NEBULA_MASK_SIGMA) + nebula_dilation_px = sm.get("nebula_dilation_px", SECTION8_NEBULA_MASK_DILATION_PX) + nebula_max_hole_px = sm.get("nebula_max_hole_px", SECTION8_NEBULA_MASK_MAX_HOLE_PX) mask_html = ( "

Nebula / Background Mask Regions

" + _hires_img_tag(mask_fig, "Mask illustration") + @@ -4020,82 +4159,52 @@ def _section_spatial(self, ra: AnalysisResult, rb: AnalysisResult) -> str: "How the masks are detected. Each image is independently classified " "per-pixel from its own background-subtracted flux and noise RMS: a pixel is " "Nebula if its background-subtracted " - "value exceeds 2× the image's RMS, and " + f"value exceeds {nebula_sigma:g}× the image's RMS, and " "Background if it's below 0.5× the " "RMS (a top-5% percentile fallback applies if no pixel clears the nebula threshold). " "Pixels between the two thresholds are left unclassified (plain grayscale). " - "How they're used below. The log-ratio distributions and the per-scale " - "correlation plots embedded in 8b–8f use the two-image intersection " - "of these masks — " - "a pixel counts as Nebula only if both images classify it as Nebula (same for " - "Background). This is deliberately conservative: it excludes pixels where one " - f"image's registration, PSF, or local noise disagrees with the other's. Shown on " + f"Enclosed background gaps up to {nebula_max_hole_px}×{nebula_max_hole_px} px " + "inside the nebula mask are filled, then the mask is grown outward by " + f"{nebula_dilation_px} px to capture dim transition regions at nebula edges, before " + "the two images' masks are combined below. " + "How they're used below. The per-scale correlation plots embedded in " + "8d–8h use the two-image union for Nebula " + "— a pixel counts as Nebula if either image classifies it that way, since " + "a pixel that's clearly nebula in one image but marginal in the other (e.g. due to " + "registration offset, PSF, or local noise) should still count as signal. Background " + "stays the two-image intersection — a pixel counts as Background only if " + "both images agree, keeping the noise-floor reference population clean. Shown on " f"{ra.label}, the same array as the "Original" row below." "

" if mask_fig else "" ) dist_html = ( - "

8a. Log-Ratio Distribution & Mask Overview

" + mask_html + dist_img + dist_caption - if dist_img else "" + "

8c. Mask Overview

" + mask_html + if mask_fig else "" ) - cr_a = sm.get("contrast_ratios_a", {}) - cr_b = sm.get("contrast_ratios_b", {}) - - # Contrast ratio table - cr_rows = "" - for ks in sorted(set(list(cr_a.keys()) + list(cr_b.keys()))): - va = cr_a.get(ks) - vb = cr_b.get(ks) - ca, cb = _better_worse_class(va, vb) - cr_rows += (f"{ks} px" - f"{_val(va)}" - f"{_val(vb)}") - - # Weber fraction contrast table - wc_a = sm.get("weber_contrast_a", {}) - wc_b = sm.get("weber_contrast_b", {}) - wc_rows = "" - for ks in sorted(set(list(wc_a.keys()) + list(wc_b.keys()))): - va = wc_a.get(ks) - vb = wc_b.get(ks) - ca, cb = _better_worse_class(va, vb) - wc_rows += (f"{ks} px" - f"{_val(va, '.4f')}" - f"{_val(vb, '.4f')}") - - # Wavelet SNR table - snr_a = sm.get("wavelet_snr_a", {}) - snr_b = sm.get("wavelet_snr_b", {}) - snr_rows = "" - for lvl in sorted(set(list(snr_a.keys()) + list(snr_b.keys()))): - va = snr_a.get(lvl) - vb = snr_b.get(lvl) - ca, cb = _better_worse_class(va, vb) - scale_approx = 2 ** lvl - snr_rows += (f"Level {lvl} (~{scale_approx}px scale)" - f"{_val(va)}" - f"{_val(vb)}") - sigma_a = _val(sm.get("sigma_noise_a"), ".5f") sigma_b = _val(sm.get("sigma_noise_b"), ".5f") - # Noise-corrected local contrast ratio tables (one per method) + # Noise-corrected local contrast ratio rows (one per method), consolidated + # into a single combined table in 8j rather than repeated per-family. std_nc_rows = _nc_ratio_rows( sm.get("std_nc_score_a", {}), sm.get("std_nc_score_b", {}), - sm.get("std_nc_ratio", {}), lambda ks: f"{ks} px") + sm.get("std_nc_ratio", {}), lambda ks: f"{ks} px", method_label="Local σ") log_nc_rows = _nc_ratio_rows( sm.get("log_nc_score_a", {}), sm.get("log_nc_score_b", {}), - sm.get("log_nc_ratio", {}), lambda s: f"σ = {s} px") + sm.get("log_nc_ratio", {}), lambda s: f"σ = {s} px", method_label="LoG") wavelet_nc_rows = _nc_ratio_rows( sm.get("wavelet_nc_score_a", {}), sm.get("wavelet_nc_score_b", {}), - sm.get("wavelet_nc_ratio", {}), lambda lvl: f"Level {lvl} (~{2 ** lvl}px scale)") - weber_nc_rows = _nc_ratio_rows( - sm.get("weber_nc_score_a", {}), sm.get("weber_nc_score_b", {}), - sm.get("weber_nc_ratio", {}), lambda ks: f"{ks} px") + sm.get("wavelet_nc_ratio", {}), lambda lvl: f"Level {lvl} (~{2 ** lvl}px scale)", + method_label="Wavelet") + entropy_nc_rows = _nc_ratio_rows( + sm.get("entropy_nc_score_a", {}), sm.get("entropy_nc_score_b", {}), + sm.get("entropy_nc_ratio", {}), lambda ks: f"{ks} px", method_label="Local entropy") gm_nc_rows = _nc_ratio_rows( sm.get("gm_nc_score_a", {}), sm.get("gm_nc_score_b", {}), - sm.get("gm_nc_ratio", {}), lambda s: f"σ = {s} px") + sm.get("gm_nc_ratio", {}), lambda s: f"σ = {s} px", method_label="Gradient") + combined_nc_rows = log_nc_rows + wavelet_nc_rows + gm_nc_rows + std_nc_rows + entropy_nc_rows nc_methodology_box = _info_box( 'Each detail map above additionally yields a noise-corrected local ' @@ -4106,9 +4215,9 @@ def _section_spatial(self, ra: AnalysisResult, rb: AnalysisResult) -> str: '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 ' - 'method (kernel px for std/Weber, Gaussian σ px for LoG/gradient, ≈2level ' + 'method (kernel px for std/entropy, Gaussian σ px for LoG/gradient, ≈2level ' 'px for wavelet) and ratios should not be compared numerically across methods — ' - 'see 8g for a cross-method overview. See also Section 7 for the frequency-domain ' + 'see 8i for a cross-method overview. See also Section 7 for the frequency-domain ' 'view of this same question. Maps below are also shown in noise-normalised form ' '(map ÷ noise floor) so that a shared colour scale is a fair visual comparison ' 'between A and B, even when their absolute noise levels differ.', @@ -4120,18 +4229,50 @@ def _section_spatial(self, ra: AnalysisResult, rb: AnalysisResult) -> str: 'scores below are unavailable (—) for every scale and method.', title="No shared nebula region", open=True) - def figs_for(prefix): - out = "" - for key in sorted(figs): - if key.startswith(prefix): - out += _hires_img_tag(figs[key], key) + "\n" - return out + localmax_rows_html = _localmax_rows(sm.get("localmax", {}), _SPATIAL_CORR_ROWS) + localmax_dist_img, localmax_dist_caption = _localmax_distributions_figure(sm.get("localmax", {})) + localmax_log_ratio_dist_img, localmax_log_ratio_dist_caption = _localmax_log_ratio_distribution_figure(sm.get("localmax", {})) + lm_footprint_mult = sm.get("localmax_footprint_mult", SECTION8_LOCALMAX_FOOTPRINT_MULT) + lm_prominence_pctl = sm.get("localmax_prominence_percentile", SECTION8_LOCALMAX_PROMINENCE_PERCENTILE) + lm_region_fraction = sm.get("localmax_region_fraction", SECTION8_LOCALMAX_REGION_FRACTION) + lm_presmooth_fraction = sm.get("localmax_presmooth_fraction", SECTION8_LOCALMAX_PRESMOOTH_FRACTION) + lm_top_percent = sm.get("localmax_top_percent", SECTION8_LOCALMAX_TOP_PERCENT) + localmax_methodology_box = _info_box( + 'For each metric/scale combination in 8d–8h, a local-maxima mask is ' + 'built independently: the combined |A|,|B| peak-source array is lightly ' + f'Gaussian-smoothed (sigma = {lm_presmooth_fraction:g} × the metric\'s own scale) to ' + 'suppress single-pixel noise-driven false peaks, then pixels that are the maximum of ' + f'their own {lm_footprint_mult:g}×(scale) neighbourhood AND exceed the ' + f'{lm_prominence_pctl:g}th percentile of the smoothed array are kept as peaks and grown ' + f'by {lm_region_fraction:g}× that same neighbourhood size, so the mask covers the local ' + 'region of pixels around each peak rather than a single pixel. This peak mask is then ' + f'unioned (OR) with a top-{lm_top_percent:g}% brightness mask — pixels in the ' + 'top percentile of Image A\'s or Image B\'s own value distribution — so broad bright plateaus ' + 'that never register as a sharp local maximum are still captured, not just isolated peaks. ' + 'Unlike the Nebula/Background masks in 8c, this mask is not restricted to the ' + 'nebula region — the prominence threshold already isolates prominent features on its own, and ' + 'intersecting with the coarser nebula mask could exclude legitimate sharp features (stars, ' + 'edges) outside it. ' + 'Mean A / Mean B are the average metric magnitude ± standard deviation ' + 'over the masked pixels in each image. log ratio A/B (geo. mean ± SD) ' + 'is sampled directly from the masked pixels\' per-pixel log10(|A|/|B|) population — not ' + 'derived from Mean A/Mean B — and reported in log10 units: 0 means A = B, positive means A is ' + 'brighter, negative means B is brighter, and the equivalent linear ×-factor is ' + '10value. It is shaded a neutral blue rather than red/green, since it is not an ' + 'A-vs-B comparison. Significance is a Mann-Whitney U test (two-sided) with ' + 'Cliff\'s delta effect size, comparing the full masked-pixel populations of A vs B for that ' + 'row — the same test and star-rating legend (★★★ large, ★★ ' + 'medium, ★ small, n.s. not significant; blue cell = p<0.05) already used for the ' + 'per-star PSF comparisons in Section 4. N px / % area shows how much of the ' + 'image the reported means/ratio/significance are drawn from — mean, standard deviation, and ' + 'significance are all computed from the full masked population, not a subsampled copy.', + title="Local-maxima masked metrics (methodology)") def _family_figs_with_corr(rows, map_key_fn) -> str: """Emit each row's raw map figure immediately followed by its per-pixel correlation scatter (when present), in _SPATIAL_CORR_ROWS - (numeric-scale) order — not figs_for's alphabetic key sort, which - would put e.g. std_10px before std_3px.""" + (numeric-scale) order — not an alphabetic key sort, which would put + e.g. std_10px before std_3px.""" out = "" for key, _label in rows: map_key = map_key_fn(key) @@ -4143,22 +4284,34 @@ def _family_figs_with_corr(rows, map_key_fn) -> str: out += _hires_img_tag(corr_fig, f"corr_{key}") + "\n" return out + def _family_nrm_figs(rows) -> str: + """Noise-normalised (nrm_) trailer figures in numeric scale order — nrm + keys are always 'nrm_' + the row's own key. Replaces the previous + figs_for()'s alphabetic sorted(figs), which put e.g. nrm_std_10px + before nrm_std_3px.""" + out = "" + for key, _label in rows: + fig = figs.get(f"nrm_{key}") + if fig: + out += _hires_img_tag(fig, f"nrm_{key}") + "\n" + return out + _std_rows = [(k, l) for k, l in _SPATIAL_CORR_ROWS if k.startswith("std_")] _log_rows = [(k, l) for k, l in _SPATIAL_CORR_ROWS if k.startswith("log_")] _gradient_rows = [(k, l) for k, l in _SPATIAL_CORR_ROWS if k.startswith("gradient_")] _wavelet_rows = [(k, l) for k, l in _SPATIAL_CORR_ROWS if k.startswith("wavelet_")] - _weber_rows = [(k, l) for k, l in _SPATIAL_CORR_ROWS if k.startswith("weber_")] + _entropy_rows = [(k, l) for k, l in _SPATIAL_CORR_ROWS if k.startswith("entropy_")] _has_any_corr = any(f"corr_{k}" in figs for k, _l in _SPATIAL_CORR_ROWS) corr_methodology_box = ( _info_box('Each per-pixel correlation scatter (embedded next to its map figure ' 'below) plots the raw (pre-ratio) metric value at every shared pixel: ' f'{ra.label} on the y-axis vs. {rb.label} on the x-axis, split into a ' - 'Nebula panel and a Background panel using the shared masks explained in 8a. ' + 'Nebula panel and a Background panel using the shared masks explained in 8c. ' 'The black dashed diagonal is the 1:1 line (perfect agreement, ' 'A = B). Axis limits span the full data range for that panel — ' - 'unlike the 8a violin plots, they are not clipped to a percentile — so ' - 'the behaviour of the upper tail is always visible. ' + 'unlike the 8j distribution violin plots, they are not clipped to a ' + 'percentile — so the behaviour of the upper tail is always visible. ' 'Each point is colored by that pixel\'s log-ratio value ' '(log10(|A|/|B|)), using the same bwr colour scale as the ' 'adjacent log-ratio map panel and its histogram — red points are pixels where A ' @@ -4175,9 +4328,10 @@ def _family_figs_with_corr(rows, map_key_fn) -> str: has_crosshair = sm.get("crosshair") is not None xs_note = _info_box( 'ℹ When a cross-section line is set in the viewer, its profile is embedded ' - 'as the middle-right panel of each map-pair figure below (8b–8f): Image A ' - '(top-left), Image B (top-right), log-ratio map (middle-left), cross-section ' - 'profile (middle-right, steelblue = A, tomato = B).', + 'as the middle-right panel of each map-pair figure below (8b, 8d–8h) — and, ' + 'in that same figure, overlaid directly on the Image A/B panels above it as a ' + 'semi-transparent line: Image A (top-left), Image B (top-right), log-ratio map ' + '(middle-left), cross-section profile (middle-right, steelblue = A, tomato = B).', title="Cross-section profiles", open=True, ) if has_crosshair else "" @@ -4236,6 +4390,111 @@ def _family_figs_with_corr(rows, map_key_fn) -> str: title="Wavelet decomposition", ) + orig_fig = figs.get("original") + orig_corr_fig = figs.get("corr_original") + original_html = ( + "

8b. Original Image

" + + _info_box( + 'This is the source image itself (mean-signal-normalised, and ' + 'restricted to the analysis ROI if one was set) — not a derived detail metric. ' + 'It is shown using the same map-pair layout as every metric family below (Image A, ' + 'Image B, log-ratio map, cross-section profile, pixel histogram, and per-pixel ' + 'correlation scatter), so the raw input content can be inspected directly before ' + 'looking at what each derived metric extracts from it.', + title='What "Original" shows') + + _hires_img_tag(orig_fig, "original") + + (_hires_img_tag(orig_corr_fig, "corr_original") if orig_corr_fig else "") + + '

The unmodified, mean-signal-normalised source image: Image A ' + '(top-left), Image B (top-right), log-ratio map (middle-left) showing where the raw ' + 'input itself differs pixel-by-pixel (sign discarded, since background-subtracted ' + 'flux can be negative — same convention as the Wavelet family), and — when a ' + 'cross-section line is set — its profile (middle-right, and overlaid directly on ' + 'Image A/B above), plus a bottom-row histogram of the log-ratio pixel values. ' + 'Immediately followed by the per-pixel correlation scatter (see methodology below).

' + if orig_fig else "" + ) + + # Section 8d-8h figure-heavy content, collapsed by default (closed
), + # so the report stays compact and navigable — headings/methodology/tables above + # each family stay visible; only the map/correlation/noise-normalised figures + # (the bulk of each family's page length) live inside the collapsible box. + _log_images_html = ( + _family_figs_with_corr(_log_rows, lambda k: "log_sigma" + k.split("_", 1)[1]) + + '

|LoG| maps at σ = 1.5, 3, and 6 px (shared colour scale per figure): ' + 'Image A (top-left), Image B (top-right), log-ratio map (middle-left), and — when a ' + 'cross-section line is set — its profile (middle-right), plus a bottom-row histogram of ' + 'the log-ratio map\'s pixel values (same colour scale). A filter preserving more fine ' + 'detail shows brighter, more defined boundaries at small σ. Each map is immediately ' + 'followed by its per-pixel correlation scatter (see methodology above).

' + '

Noise-normalised (× noise floor) — shared colour scale is a fair ' + 'A/B comparison.

' + + _family_nrm_figs(_log_rows) + ) + _log_images_box = _info_box(_log_images_html, title="Show LoG maps & figures", open=False) + + _wavelet_images_html = ( + _hires_img_tag(figs.get("wavelet_snr"), "Wavelet SNR") + + '

Per-level SNR for both filters. Level 1 SNR < 1 is expected ' + '(noise-dominated). A filter preserving more fine detail shows higher SNR at level 2.

' + + _family_figs_with_corr(_wavelet_rows, lambda k: "wavelet_level" + k.split("_", 1)[1]) + + '

Reconstructed detail images at levels 2 and 3 (shared colour scale, ' + 'diverging colourmap): Image A (top-left), Image B (top-right), log-ratio panel ' + '(middle-left) showing where fine structure differs between the two filters (sign ' + 'discarded — |A|/|B| — since wavelet reconstructions can be negative), and — when a ' + 'cross-section line is set — its profile (middle-right), plus a bottom-row histogram ' + 'of the log-ratio map\'s pixel values (same colour scale). Each map is immediately ' + 'followed by its per-pixel correlation scatter (see methodology above).

' + '

Noise-normalised (× noise floor) — shared colour scale is a fair ' + 'A/B comparison.

' + + _family_nrm_figs(_wavelet_rows) + ) + _wavelet_images_box = _info_box(_wavelet_images_html, title="Show Wavelet maps & figures", open=False) + + _gradient_images_html = ( + _family_figs_with_corr(_gradient_rows, lambda k: k) + + '

Gradient magnitude maps at σ = 1.5, 3, and 6 px (shared colour scale ' + 'per figure): Image A (top-left), Image B (top-right), log-ratio map (middle-left), and ' + '— when a cross-section line is set — its profile (middle-right), plus a bottom-row ' + 'histogram of the log-ratio map\'s pixel values (same colour scale). A filter preserving ' + 'sharper boundaries shows brighter, more defined gradient response. Each map is ' + 'immediately followed by its per-pixel correlation scatter (see methodology above).

' + '

Noise-normalised (× noise floor) — shared colour scale is a fair ' + 'A/B comparison.

' + + _family_nrm_figs(_gradient_rows) + ) + _gradient_images_box = _info_box(_gradient_images_html, title="Show Gradient maps & figures", open=False) + + _std_images_html = ( + _family_figs_with_corr(_std_rows, lambda k: k) + + '

Local σ maps at each kernel size (shared colour scale): Image A ' + '(top-left), Image B (top-right), log-ratio map (middle-left) highlighting where one ' + 'filter preserves more local variation, and — when a cross-section line is set — its ' + 'profile (middle-right), plus a bottom-row histogram of the log-ratio map\'s pixel ' + 'values (same colour scale). Each map is immediately followed by its per-pixel ' + 'correlation scatter (see methodology above).

' + '

Noise-normalised (× noise floor) — shared colour scale is a fair ' + 'A/B comparison.

' + + _family_nrm_figs(_std_rows) + ) + _std_images_box = _info_box(_std_images_html, title="Show Local σ maps & figures", open=False) + + _entropy_images_html = ( + _family_figs_with_corr(_entropy_rows, lambda k: k) + + '

Local Shannon entropy maps (bits, log₂, viridis colour ' + 'scale): Image A (top-left), Image B (top-right), log-ratio panel (middle-left) ' + 'showing where one image has richer local tonal structure, and — when a ' + 'cross-section line is set — its profile (middle-right), plus a bottom-row ' + 'histogram of the log-ratio map\'s pixel values (same colour scale). Brighter ' + 'regions have higher local entropy — a richer, less predictable gray-level ' + 'distribution within the window (nebulosity, mottled dust, unresolved stars — or ' + 'noise). Each map is immediately followed by its per-pixel correlation scatter ' + '(see methodology above).

' + '

Noise-normalised (× noise floor) — shared colour scale is a fair ' + 'A/B comparison.

' + + _family_nrm_figs(_entropy_rows) + ) + _entropy_images_box = _info_box(_entropy_images_html, title="Show Local entropy maps & figures", open=False) + return f"""

8. Spatial Detail Comparison  ✓ bandwidth-normalised

{err} @@ -4257,41 +4516,13 @@ def _family_figs_with_corr(rows, map_key_fn) -> str: 'log-ratio map\'s pixel values, coloured bin-by-bin with the same bwr scale and range as ' 'the log-ratio panel above it.', title="Spatial detail maps overview")} +{original_html} {dist_html} -{nc_methodology_box} {nc_empty_note} {corr_methodology_box} {xs_note} -

8b. Local Standard Deviation Maps

-{_info_box('Measures how much pixel values vary within a neighbourhood. ' - 'Higher values in nebula regions indicate more preserved local detail and contrast. ' - 'Contrast ratio = median(nebula std) / median(background std); ' - 'a higher ratio indicates better differentiation of nebula structure from background. ' - 'Each map pixel contains the standard deviation of surrounding pixels within a square ' - 'window. Brighter regions contain more local variation — typically nebula filaments, ' - 'star halos, or noise. A filter with higher std values in targeted emission regions ' - 'preserves more structure; higher std in blank sky regions indicates more photon noise. ' - 'When a cross-section line is set, its profile is embedded in the middle-right panel of ' - 'each map figure below, showing how local detail amplitude varies along the selected line.', - title="Local standard deviation")} - - - {cr_rows} -
Kernel size{ra.label}{rb.label}
- - - {std_nc_rows} -
Scale{ra.label} (NC score){rb.label} (NC score)Ratio A/B
-{_family_figs_with_corr(_std_rows, lambda k: k)} -

Local σ maps at each kernel size (shared colour scale): Image A (top-left), -Image B (top-right), log-ratio map (middle-left) highlighting where one filter preserves more -local variation, and — when a cross-section line is set — its profile (middle-right), plus a -bottom-row histogram of the log-ratio map's pixel values (same colour scale). Each map -is immediately followed by its per-pixel correlation scatter (see methodology above).

-

Noise-normalised (× noise floor) — shared colour scale is a fair A/B comparison.

-{figs_for("nrm_std_")} -

8c. Laplacian of Gaussian (LoG) Maps

+

8d. Laplacian of Gaussian (LoG) Maps

{_info_box('The Laplacian of Gaussian highlights regions of rapid intensity ' 'change at a specific spatial scale (controlled by σ). Brighter regions in |LoG| maps ' 'indicate stronger local curvature — sharper edges and finer nebula filaments. ' @@ -4303,123 +4534,122 @@ def _family_figs_with_corr(rows, map_key_fn) -> str: 'brighter LoG response at small σ values. When a cross-section line is set, its profile ' 'reveals subtle differences in edge sharpness along the selected line.', title="Laplacian of Gaussian (LoG)")} - - - {log_nc_rows} -
Scale{ra.label} (NC score){rb.label} (NC score)Ratio A/B
-{_family_figs_with_corr(_log_rows, lambda k: "log_sigma" + k.split("_", 1)[1])} -

|LoG| maps at σ = 1.5, 3, and 6 px (shared colour scale per figure): -Image A (top-left), Image B (top-right), log-ratio map (middle-left), and — when a -cross-section line is set — its profile (middle-right), plus a bottom-row histogram of -the log-ratio map's pixel values (same colour scale). A filter preserving more fine -detail shows brighter, more defined boundaries at small σ. Each map is immediately -followed by its per-pixel correlation scatter (see methodology above).

-

Noise-normalised (× noise floor) — shared colour scale is a fair A/B comparison.

-{figs_for("nrm_log_")} -

8d. Wavelet Decomposition

+{_log_images_box} +

8e. Wavelet Decomposition

{_wavelet_box} - -{_hires_img_tag(figs.get("wavelet_snr"), "Wavelet SNR")} -

Per-level SNR for both filters. Level 1 SNR < 1 is expected -(noise-dominated). A filter preserving more fine detail shows higher SNR at level 2.

- - - - {snr_rows} -
Wavelet level{ra.label} SNR{rb.label} SNR
- - - {wavelet_nc_rows} -
Scale{ra.label} (NC score){rb.label} (NC score)Ratio A/B
- -{_family_figs_with_corr(_wavelet_rows, lambda k: "wavelet_level" + k.split("_", 1)[1])} -

Reconstructed detail images at levels 2 and 3 (shared colour scale, -diverging colourmap): Image A (top-left), Image B (top-right), log-ratio panel (middle-left) -showing where fine structure differs between the two filters (sign discarded — |A|/|B| — -since wavelet reconstructions can be negative), and — when a cross-section line is set — -its profile (middle-right), plus a bottom-row histogram of the log-ratio map's pixel values -(same colour scale). Each map is immediately followed by its per-pixel correlation -scatter (see methodology above).

-

Noise-normalised (× noise floor) — shared colour scale is a fair A/B comparison.

-{figs_for("nrm_wavelet_")} - -

8e. Weber Fraction Contrast Maps

-{_info_box( - '

Formula: c = ΔL / L, ' - 'where ΔL = Imax − Imin (local range in the K × K kernel) ' - 'and L = median(kernel) (local background luminance). ' - 'Output is unbounded ≥ 0; a value of 1.0 means the local range equals the background ' - 'luminance, 2.0 means twice, and so on.

' - '

Why median for L: The median represents the background luminance ' - 'the feature is seen against, matching Weber\'s Law. Using the mean would inflate L ' - 'toward bright filaments within the kernel, artificially suppressing contrast values. ' - 'Median is also robust to hot pixels and residual star halos in starless images.

' - '

Scalar metric (table below): 99th percentile of the Weber map ' - 'within the analysis region. Near-zero median pixels over dark sky produce very large ' - 'Weber values; the 99th percentile captures peak structural contrast while ignoring ' - 'isolated dark-floor artefacts.

' - '

Kernel sizes: Small kernels (3 px) respond to sub-pixel-scale ' - 'transitions. Medium kernels (5 px) capture fine filaments. ' - 'Large kernels (9 px) reflect coarser structural contrast such as knots and shell edges.

' - '

Wide dynamic range: Weber contrast is intentionally unbounded. ' - 'Maps are displayed with a square-root colour scale (PowerNorm γ = 0.5) to compress ' - 'the bright end. Selecting a star-free nebula ROI avoids dark-sky pixels that drive ' - 'Weber values very high. Maps use a starless image when one is available.

', - title="Weber fraction contrast")} - - - {wc_rows} -
Kernel size{ra.label} (99th pct c){rb.label} (99th pct c)
- - - {weber_nc_rows} -
Scale{ra.label} (NC score){rb.label} (NC score)Ratio A/B
-{_family_figs_with_corr(_weber_rows, lambda k: k)} -

Per-pixel Weber fraction contrast maps (c = ΔL / L, square-root colour -scale, viridis): Image A (top-left), Image B (top-right), log-ratio panel (middle-left) -showing where one image achieves greater relative contrast, and — when a cross-section -line is set — its profile (middle-right), plus a bottom-row histogram of the log-ratio -map's pixel values (same colour scale). Brighter regions have higher Weber contrast — -the local intensity range is large relative to the local median luminance. High values -over dark-sky regions are expected; use a nebula ROI for meaningful filter comparison. -Each map is immediately followed by its per-pixel correlation scatter (see methodology above).

-

Noise-normalised (× noise floor) — shared colour scale is a fair A/B comparison.

-{figs_for("nrm_weber_")} +{_wavelet_images_box}

8f. Gradient Magnitude (Edge Sharpness)

{_info_box('The gradient magnitude G = |∇I| = sqrt((∂I/∂x)² + (∂I/∂y)²) highlights regions ' 'of rapid intensity change at a specific spatial scale (controlled by σ), computed ' 'as the first spatial derivative rather than the second derivative (curvature) LoG uses. ' 'Smaller σ highlights finer features; larger σ highlights broader structures. ' - 'Reuses the same σ scales as 8c so gradient and |LoG| are directly comparable at ' + 'Reuses the same σ scales as 8d so gradient and |LoG| are directly comparable at ' 'identical spatial frequencies. This measures how abrupt structure boundaries are — ' 'relevant when one filter renders stronger filament boundaries or shock fronts. ' 'Distinct from Section 6 (Edge Detection): Section 6 measures ' 'precise sub-pixel edge width and contrast at the 2–3 strongest individual detected ' 'edges. This section is the opposite granularity — an aggregate, whole-nebula, ' 'multi-scale sharpness score across the shared ROI, following the same ' - 'noise-corrected framework as 8b–8e. They are complementary, not redundant.', + 'noise-corrected framework as 8d, 8e, 8g, and 8h. They are complementary, not redundant.', title="Gradient magnitude / edge sharpness")} - - - {gm_nc_rows} -
Scale{ra.label} (NC score){rb.label} (NC score)Ratio A/B
-{_family_figs_with_corr(_gradient_rows, lambda k: k)} -

Gradient magnitude maps at σ = 1.5, 3, and 6 px (shared colour scale per -figure): Image A (top-left), Image B (top-right), log-ratio map (middle-left), and — when a -cross-section line is set — its profile (middle-right), plus a bottom-row histogram of the -log-ratio map's pixel values (same colour scale). A filter preserving sharper -boundaries shows brighter, more defined gradient response. Each map is immediately followed -by its per-pixel correlation scatter (see methodology above).

-

Noise-normalised (× noise floor) — shared colour scale is a fair A/B comparison.

-{figs_for("nrm_gradient_")} - -

8g. Noise-Corrected Contrast — Cross-Method Overview

+{_gradient_images_box} + +

8g. Local Standard Deviation Maps

+{_info_box('Measures how much pixel values vary within a neighbourhood. ' + 'Higher values in nebula regions indicate more preserved local detail and contrast. ' + 'Contrast ratio = median(nebula std) / median(background std); ' + 'a higher ratio indicates better differentiation of nebula structure from background. ' + 'Each map pixel contains the standard deviation of surrounding pixels within a square ' + 'window. Brighter regions contain more local variation — typically nebula filaments, ' + 'star halos, or noise. A filter with higher std values in targeted emission regions ' + 'preserves more structure; higher std in blank sky regions indicates more photon noise. ' + 'When a cross-section line is set, its profile is embedded in the middle-right panel of ' + 'each map figure below, showing how local detail amplitude varies along the selected line.', + title="Local standard deviation")} +{_std_images_box} +

8h. Local Entropy Maps

+{_info_box( + '

Formula: H = −Σ pi log₂(pi) ' + '(Shannon entropy, in bits), computed from the local gray-level histogram over ' + f'{SECTION8_ENTROPY_N_BINS} bins within the K × K kernel. Output is bounded ' + f'0 ≤ H ≤ log₂({SECTION8_ENTROPY_N_BINS}) ≈ {math.log2(SECTION8_ENTROPY_N_BINS):.1f} ' + 'bits; 0 means every pixel in the window is identical, the maximum means every gray ' + 'level is equally represented.

' + '

Why deliberate binning: computing entropy directly on continuous ' + 'float32 data would return a value close to the maximum almost everywhere, since every ' + 'pixel value in a small window is nearly unique — that measures floating-point ' + 'precision, not real tonal diversity. The map is instead computed on each image\'s own ' + f'data, independently quantized into {SECTION8_ENTROPY_N_BINS} levels from its own ' + f'{SECTION8_ENTROPY_CLIP_PERCENTILE:g}–{100 - SECTION8_ENTROPY_CLIP_PERCENTILE:g} ' + 'percentile range (not a joint A/B range, and not the display stretch — this is ' + 'done on linear, mean-signal-normalised data).

' + '

Interpretation: high entropy means rich, unpredictable local tonal ' + 'structure — tangled nebulosity, mottled dust, unresolved star fields — ' + 'but also shot noise; low entropy means a smooth or flat local region ' + '(uniform sky, a saturated core, a smooth gradient). Entropy alone cannot distinguish ' + 'real structure from noise — that is what the noise-corrected score (table below, ' + 'and 8i) is for.

' + '

How this differs from σ / LoG / gradient / wavelet: those ' + 'metrics respond to amplitude or edge sharpness; entropy responds to distributional ' + '"unpredictability" instead, and ignores spatial arrangement entirely — a smooth ' + 'gradient, scattered noise, and a small filament network can all share a similar local ' + 'histogram, and therefore similar entropy, even though they look nothing alike. It is ' + 'most useful as a secondary signal when two images score similarly on σ or wavelet ' + 'power but still show visibly different internal tonal texture (see the glossary ' + 'comparison table above).

' + '

Kernel sizes: 5, 9, and 17 px — deliberately larger than Local ' + 'σ\'s (8g), since a small window gives a statistically unstable histogram estimate ' + '(a 3 × 3 window has only 9 samples to build a histogram from).

' + '

Entropy contrast ratio (table below): median(nebula entropy) / ' + 'median(background entropy) — the same formula as Local σ\'s contrast ratio ' + '(8g), applied to the entropy map instead.

', + title="Local entropy")} +{_entropy_images_box} + +

8i. Noise-Corrected Contrast — Cross-Method Overview

{_hires_img_tag(figs.get("nc_ratio_overview"), "NC ratio overview")}

Ratio A/B for every noise-corrected method plotted against its -approximate spatial scale. Scale units differ by method (see 8b–8f methodology +approximate spatial scale. Scale units differ by method (see 8d–8h methodology boxes) — use this chart to spot which spatial-scale regime favours which filter, -not to compare absolute ratio values across methods.

""" +not to compare absolute ratio values across methods. Error bars show an +approximate relative uncertainty, propagated from the coefficient of +variation (standard deviation ÷ median) of each image's own nebula-region pixel +population — not a formal confidence interval on the median, since the nebula and +background populations behind each score are not pixel-paired between A and B.

+ +

8j. Local-Maxima Masked Metrics

+{localmax_methodology_box} + + + + {localmax_rows_html} +
Scale{ra.label} (mean ± SD){rb.label} (mean ± SD)log ratio A/B (geo. mean ± SD)SignificanceN px / % area
+ +

Noise-corrected contrast scores — all methods

+{nc_methodology_box} + + + {combined_nc_rows} +
MethodScale{ra.label} (NC score){rb.label} (NC score)Ratio A/B
+{_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 +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.

+{localmax_dist_img} +{localmax_dist_caption} +{localmax_log_ratio_dist_img} +{localmax_log_ratio_dist_caption} +{_hires_img_tag(figs.get("localmax_mask_illustration"), "Local-maxima mask grid")} +

Local-maxima masks for every metric/scale row in the table above — +one panel per combination, rows grouped by metric family, columns ordered +smallest→largest kernel/scale (Wavelet has only 2 display scales, so its third +column is blank). Each panel shows the exact mask used to compute that row's +statistics (isolated peaks unioned with the top-brightness mask — see the methodology +box above), overlaid on that metric's own |A| magnitude map.

""" # ── Section 9: Signal-to-Noise Ratio ───────────────────────────────────── @@ -4820,8 +5050,8 @@ def row(metric, val_a, val_b, fmt=".3f", cr_b = sm_b.get("contrast_ratios_b", {}) if sm_b else {} snr_wav_a = sm_a.get("wavelet_snr_a", {}) snr_wav_b = sm_b.get("wavelet_snr_b", {}) if sm_b else {} - wc_a_s = sm_a.get("weber_contrast_a", {}) - wc_b_s = sm_b.get("weber_contrast_b", {}) if sm_b else {} + ecr_a_s = sm_a.get("entropy_contrast_ratio_a", {}) + ecr_b_s = sm_b.get("entropy_contrast_ratio_b", {}) if sm_b else {} snr_ma = ra.snr_metrics or {} snr_mb = rb.snr_metrics or {} @@ -4879,7 +5109,7 @@ def row_pm(metric, val_a, val_b, spread_a, spread_b, fmt=".3f", row("Std contrast ratio (15px)", cr_a.get(15), cr_b.get(15)), row("Wavelet SNR level 2", snr_wav_a.get(2), snr_wav_b.get(2)), row("Wavelet SNR level 3", snr_wav_a.get(3), snr_wav_b.get(3)), - row("Weber contrast 99th pct (5px)", wc_a_s.get(5), wc_b_s.get(5), fmt=".4f"), + row("Entropy contrast ratio (9px)", ecr_a_s.get(9), ecr_b_s.get(9)), *([row( "Global SNR — starless (σ) ★", (snr_ma.get("starless") or {}).get("snr_global"), @@ -4915,13 +5145,6 @@ def row_pm(metric, val_a, val_b, spread_a, spread_b, fmt=".3f", ' = interpret with bandwidth ' 'context (filters had different bandwidths)

') - retention_block = getattr(self, "_cached_retention_html", "") - retention_section = ( - "

Contrast Retention Detail (convolved / original)

" - + retention_block - if retention_block else "" - ) - return f"""

9. Summary & Recommendations

{legend} @@ -4933,5 +5156,4 @@ def row_pm(metric, val_a, val_b, spread_a, spread_b, fmt=".3f", 'Red cells indicate the worse value. Metrics marked ⚠ may be influenced by the ' 'difference in filter bandwidth and should not be used as the sole basis for ' 'comparison.', - title="How to read this table")} -{retention_section}""" + title="How to read this table")}""" diff --git a/requirements-build.txt b/requirements-build.txt index be615f6..e8f5dde 100644 --- a/requirements-build.txt +++ b/requirements-build.txt @@ -4,8 +4,8 @@ pyinstaller>=6.0 numpy scipy matplotlib -astropy -photutils +astropy>=6.0 +photutils>=3.0 PyWavelets Pillow lz4 diff --git a/requirements-test.txt b/requirements-test.txt index 0025c90..9a39c32 100644 --- a/requirements-test.txt +++ b/requirements-test.txt @@ -5,8 +5,8 @@ pytest-timeout>=2.3 numpy scipy matplotlib -astropy -photutils +astropy>=6.0 +photutils>=3.0 PyWavelets Pillow lz4 diff --git a/requirements.txt b/requirements.txt index cfddd46..3d1ebe5 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,4 @@ -# conda install -c conda-forge pyqt6 astropy photutils scipy numpy matplotlib astroalign pillow pywavelets +# conda install -c conda-forge pyqt6 "astropy>=6.0" "photutils>=3.0" scipy numpy matplotlib astroalign pillow pywavelets # pip install xisf seaborn seaborn xisf diff --git a/resources/AstroImageLabSplash.png b/resources/AstroImageLabSplash.png new file mode 100644 index 0000000..ff6b3b2 Binary files /dev/null and b/resources/AstroImageLabSplash.png differ diff --git a/tests/test_analysis/test_edge_analyzer.py b/tests/test_analysis/test_edge_analyzer.py index bf3636a..2c6e15c 100644 --- a/tests/test_analysis/test_edge_analyzer.py +++ b/tests/test_analysis/test_edge_analyzer.py @@ -4,6 +4,7 @@ import numpy as np import pytest from scipy.ndimage import gaussian_filter +from scipy.special import erfinv from analysis.edge_analyzer import EdgeAnalyzer from core.models import EDGE_ESF_MIN_MONOTONICITY @@ -11,7 +12,8 @@ _RESULT_KEYS = {"edges", "n_edges", "rois_used"} -def _make_clean_edge_roi(angle_deg: float = 30.0, size: int = 60) -> np.ndarray: +def _make_clean_edge_roi(angle_deg: float = 30.0, size: int = 60, + sigma: float = 1.5) -> np.ndarray: """Single straight edge through the box center, background-subtracted semantics (background ~ 0, signal positive) -- matches real bgsub data.""" yy, xx = np.mgrid[0:size, 0:size] @@ -19,7 +21,7 @@ def _make_clean_edge_roi(angle_deg: float = 30.0, size: int = 60) -> np.ndarray: theta = np.radians(angle_deg) d = (xx - c) * np.cos(theta) + (yy - c) * np.sin(theta) roi = np.where(d > 0, 200.0, 0.0).astype(float) - return gaussian_filter(roi, sigma=1.5) + return gaussian_filter(roi, sigma=sigma) def _make_double_edge_roi(size: int = 60) -> np.ndarray: @@ -65,14 +67,14 @@ def test_clean_edge_scores_high(self): ea = EdgeAnalyzer() roi = _make_clean_edge_roi(angle_deg=30.0) edge_info = ea._detect_strongest_edge(roi) - _, esf = ea._extract_esf(roi, edge_info) + _, esf, _ = ea._extract_esf(roi, edge_info) assert ea._esf_quality(esf) > 0.8 def test_double_edge_scores_low(self): ea = EdgeAnalyzer() roi = _make_double_edge_roi() edge_info = ea._detect_strongest_edge(roi) - _, esf = ea._extract_esf(roi, edge_info) + _, esf, _ = ea._extract_esf(roi, edge_info) assert ea._esf_quality(esf) < EDGE_ESF_MIN_MONOTONICITY def test_perfectly_flat_scores_zero(self): @@ -93,7 +95,7 @@ def test_clean_edge_scores_high_at_various_angles(self, angle_deg): ea = EdgeAnalyzer() roi = _make_clean_edge_roi(angle_deg=angle_deg) edge_info = ea._detect_strongest_edge(roi) - _, esf = ea._extract_esf(roi, edge_info) + _, esf, _ = ea._extract_esf(roi, edge_info) assert ea._esf_quality(esf) > 0.8 @@ -104,7 +106,7 @@ def test_no_nan_in_returned_esf(self): ea = EdgeAnalyzer() roi = _make_clean_edge_roi(angle_deg=45.0) edge_info = ea._detect_strongest_edge(roi) - positions, esf = ea._extract_esf(roi, edge_info) + positions, esf, _ = ea._extract_esf(roi, edge_info) assert esf is not None assert not np.any(np.isnan(esf)) assert not np.any(np.isnan(positions)) @@ -113,18 +115,46 @@ def test_positions_start_at_zero(self): ea = EdgeAnalyzer() roi = _make_clean_edge_roi(angle_deg=45.0) edge_info = ea._detect_strongest_edge(roi) - positions, _ = ea._extract_esf(roi, edge_info) + positions, _, _ = ea._extract_esf(roi, edge_info) assert positions[0] == 0.0 def test_esf_normalised_to_unit_range(self): ea = EdgeAnalyzer() roi = _make_clean_edge_roi(angle_deg=30.0) edge_info = ea._detect_strongest_edge(roi) - _, esf = ea._extract_esf(roi, edge_info) + _, esf, _ = ea._extract_esf(roi, edge_info) assert esf.min() >= -1e-9 assert esf.max() <= 1.0 + 1e-9 +class TestEdgeWidthAccuracy: + """Regression guard for the rotation_angle sign bug in _extract_esf: a + step edge blurred by a known Gaussian sigma has an analytically known + 10-90% width (the edge spread function of a Gaussian-blurred step is an + erf profile), so the measured width can be checked against ground truth + instead of only the shape/monotonicity properties the rest of this file + tests. A prior formula (-(90.0 - angle_deg), which looked plausible but + aligned the edge horizontally instead of vertically) passed every + existing test in this file while over-measuring width by 7-14x, because + none of them compared against a known true width.""" + + @staticmethod + def _expected_width(sigma: float) -> float: + # ESF(x) = 0.5*(1+erf(x/(sigma*sqrt2))); solve for the 10%/90% crossings. + return 2.0 * sigma * np.sqrt(2.0) * erfinv(0.8) + + @pytest.mark.parametrize("angle_deg", [15.0, 30.0, 60.0, 75.0]) + @pytest.mark.parametrize("sigma", [1.5, 3.0]) + def test_measured_width_matches_known_sigma(self, angle_deg, sigma): + ea = EdgeAnalyzer() + roi = _make_clean_edge_roi(angle_deg=angle_deg, sigma=sigma) + edge_info = ea._detect_strongest_edge(roi) + positions, esf, _ = ea._extract_esf(roi, edge_info) + width = ea._measure_edge_width(positions, esf) + expected = self._expected_width(sigma) + assert width == pytest.approx(expected, rel=0.3) + + class TestQualityGateAutoDetect: """Directly control which candidate ROIs _auto_detect_top_rois returns so the skip/fallback control flow can be tested deterministically, without diff --git a/tests/test_analysis/test_spatial_detail.py b/tests/test_analysis/test_spatial_detail.py index b0fde89..616f386 100644 --- a/tests/test_analysis/test_spatial_detail.py +++ b/tests/test_analysis/test_spatial_detail.py @@ -7,7 +7,7 @@ from analysis.image_filters import SpatialDetailAnalyzer from core.astro_image import AstroImage -from core.models import STD_KERNEL_SIZES, LOG_SIGMAS, WEBER_KERNEL_SIZES, WAVELET_LEVELS +from core.models import STD_KERNEL_SIZES, LOG_SIGMAS, ENTROPY_KERNEL_SIZES, WAVELET_LEVELS class TestAnalyze: @@ -33,6 +33,8 @@ def test_original_panel_present_single_image(self, astro_image_a): assert original["a"] is not None assert original["b"] is None assert original["diff"] is None + assert "original" in result["figures"] + assert "corr_original" not in result["figures"] def test_contrast_ratios_are_positive(self, astro_image_a): result = SpatialDetailAnalyzer().analyze(astro_image_a) @@ -63,20 +65,21 @@ def test_minimal_image_no_crash(self, tmp_path): result = SpatialDetailAnalyzer().analyze(img) assert isinstance(result, dict) - def test_weber_contrast_a_present(self, astro_image_a): + def test_entropy_contrast_ratio_a_present(self, astro_image_a): result = SpatialDetailAnalyzer().analyze(astro_image_a) - assert "weber_contrast_a" in result + assert "entropy_contrast_ratio_a" in result - def test_single_image_weber_contrast_b_empty(self, astro_image_a): + def test_single_image_entropy_contrast_ratio_b_empty(self, astro_image_a): result = SpatialDetailAnalyzer().analyze(astro_image_a) - # Single-image mode: weber_contrast_b is present but empty (same pattern as contrast_ratios_b) - wc_b = result.get("weber_contrast_b") - assert wc_b is None or not wc_b + # Single-image mode: entropy_contrast_ratio_b is present but empty (same pattern as contrast_ratios_b) + ecr_b = result.get("entropy_contrast_ratio_b") + assert ecr_b is None or not ecr_b - def test_weber_contrast_a_positive(self, astro_image_a): + def test_entropy_contrast_ratio_a_positive(self, astro_image_a): result = SpatialDetailAnalyzer().analyze(astro_image_a) - for v in result.get("weber_contrast_a", {}).values(): - assert v >= 0.0 + for v in result.get("entropy_contrast_ratio_a", {}).values(): + if v is not None: + assert v >= 0.0 def test_with_roi(self, astro_image_a): result = SpatialDetailAnalyzer().analyze(astro_image_a, @@ -91,7 +94,7 @@ def test_with_roi(self, astro_image_a): def _make_nc_test_fits(path, add_texture: bool, seed: int) -> None: """256x256 FITS with a smooth nebula blob (sigma=25, well above 2*rms after background subtraction). When add_texture, a fine checkerboard (period 6px, - amplitude 8x sky noise) is added inside the blob so std/LoG/wavelet/Weber/ + amplitude 8x sky noise) is added inside the blob so std/LoG/wavelet/entropy/ gradient all detect meaningfully more local structure than the plain blob.""" rng = np.random.default_rng(seed) h, w = 256, 256 @@ -157,7 +160,7 @@ def test_shared_nebula_pixels_positive(self, nc_result): @pytest.mark.parametrize("prefix,scales", [ ("std", STD_KERNEL_SIZES), ("log", LOG_SIGMAS), - ("weber", WEBER_KERNEL_SIZES), + ("entropy", ENTROPY_KERNEL_SIZES), ("gm", LOG_SIGMAS), ]) def test_nc_score_dict_keys_match_scales(self, nc_result, prefix, scales): @@ -169,7 +172,7 @@ def test_wavelet_nc_score_keys_match_levels(self, nc_result): assert set(nc_result["wavelet_nc_score_a"].keys()) == expected assert set(nc_result["wavelet_nc_score_b"].keys()) == expected - @pytest.mark.parametrize("prefix", ["std", "log", "wavelet", "weber", "gm"]) + @pytest.mark.parametrize("prefix", ["std", "log", "wavelet", "entropy", "gm"]) def test_nc_noise_floor_positive_or_none(self, nc_result, prefix): for side in ("a", "b"): for v in nc_result[f"{prefix}_nc_noise_{side}"].values(): @@ -188,9 +191,9 @@ def test_wavelet_nc_ratio_captures_finer_detail_in_a(self, nc_result): ratio = nc_result["wavelet_nc_ratio"][2] assert ratio is not None and ratio > 1.05 - def test_weber_nc_ratio_captures_finer_detail_in_a(self, nc_result): - ratio = nc_result["weber_nc_ratio"][min(WEBER_KERNEL_SIZES)] - assert ratio is not None and ratio > 1.05 + def test_entropy_nc_ratio_captures_finer_detail_in_a(self, nc_result): + ratio = nc_result["entropy_nc_ratio"][min(ENTROPY_KERNEL_SIZES)] + assert ratio is not None and ratio > 1.0 def test_gradient_nc_ratio_captures_finer_detail_in_a(self, nc_result): # Gradient magnitude's peak response scale for a given texture need not be @@ -207,8 +210,8 @@ def test_normalized_panels_present_two_image(self, nc_result): for sigma in LOG_SIGMAS: assert f"nrm_log_{sigma}" in panels assert f"nrm_gradient_{sigma}" in panels - for ks in WEBER_KERNEL_SIZES: - assert f"nrm_weber_{ks}px" in panels + for ks in ENTROPY_KERNEL_SIZES: + assert f"nrm_entropy_{ks}px" in panels for lvl in (2, 3): assert f"nrm_wavelet_{lvl}" in panels @@ -218,6 +221,8 @@ def test_original_panel_present_two_image(self, nc_result): assert original["b"] is not None assert original["diff"] is not None assert original["a"].shape == original["b"].shape + assert "original" in nc_result["figures"] + assert "corr_original" in nc_result["figures"] def test_normalized_panel_values_differ_from_raw(self, nc_result): panels = nc_result["panels"] @@ -239,7 +244,7 @@ def test_with_roi_two_image_no_crash(self, nc_image_pair): class TestNoiseCorrectedContrastSingleImage: """Single-image mode: every new NC key must be empty, matching the existing - contrast_ratios_b / weber_contrast_b invariant (never None/absent, never + contrast_ratios_b / entropy_contrast_ratio_b invariant (never None/absent, never populated with placeholder values on the A side either).""" @pytest.fixture(scope="class") @@ -251,7 +256,7 @@ def single_result(cls, astro_image_a): "std_nc_score_a", "std_nc_score_b", "std_nc_ratio", "log_nc_score_a", "log_nc_score_b", "log_nc_ratio", "wavelet_nc_score_a", "wavelet_nc_score_b", "wavelet_nc_ratio", - "weber_nc_score_a", "weber_nc_score_b", "weber_nc_ratio", + "entropy_nc_score_a", "entropy_nc_score_b", "entropy_nc_ratio", "gm_nc_score_a", "gm_nc_score_b", "gm_nc_ratio", ]) def test_nc_key_empty_in_single_image_mode(self, single_result, key): @@ -273,24 +278,24 @@ def test_none_mask_neb_shared_returns_none(self): analyzer = SpatialDetailAnalyzer() detail = np.ones((20, 20), dtype=np.float32) bg_mask = np.ones((20, 20), dtype=bool) - score, noise = analyzer._nc_score(detail, None, bg_mask) - assert score is None and noise is None + score, noise, neb_std = analyzer._nc_score(detail, None, bg_mask) + assert score is None and noise is None and neb_std is None def test_empty_shared_nebula_mask_returns_none(self): analyzer = SpatialDetailAnalyzer() detail = np.ones((20, 20), dtype=np.float32) mask_neb_shared = np.zeros((20, 20), dtype=bool) # no shared nebula pixels bg_mask = np.ones((20, 20), dtype=bool) - score, noise = analyzer._nc_score(detail, mask_neb_shared, bg_mask) - assert score is None and noise is None + score, noise, neb_std = analyzer._nc_score(detail, mask_neb_shared, bg_mask) + assert score is None and noise is None and neb_std is None def test_empty_bg_mask_returns_none(self): analyzer = SpatialDetailAnalyzer() detail = np.ones((20, 20), dtype=np.float32) mask_neb_shared = np.ones((20, 20), dtype=bool) bg_mask = np.zeros((20, 20), dtype=bool) # no background pixels - score, noise = analyzer._nc_score(detail, mask_neb_shared, bg_mask) - assert score is None and noise is None + score, noise, neb_std = analyzer._nc_score(detail, mask_neb_shared, bg_mask) + assert score is None and noise is None and neb_std is None def test_zero_noise_floor_returns_none(self): analyzer = SpatialDetailAnalyzer() @@ -300,8 +305,8 @@ def test_zero_noise_floor_returns_none(self): mask_neb_shared[:10, :] = True bg_mask = np.zeros((20, 20), dtype=bool) bg_mask[10:, :] = True - score, noise = analyzer._nc_score(detail, mask_neb_shared, bg_mask) - assert score is None and noise is None + score, noise, neb_std = analyzer._nc_score(detail, mask_neb_shared, bg_mask) + assert score is None and noise is None and neb_std is None def test_valid_masks_return_ratio(self): analyzer = SpatialDetailAnalyzer() @@ -312,11 +317,50 @@ def test_valid_masks_return_ratio(self): mask_neb_shared[:10, :] = True bg_mask = np.zeros((20, 20), dtype=bool) bg_mask[10:, :] = True - score, noise = analyzer._nc_score(detail, mask_neb_shared, bg_mask) + score, noise, neb_std = analyzer._nc_score(detail, mask_neb_shared, bg_mask) assert score == pytest.approx(5.0) + assert neb_std == pytest.approx(0.0) # nebula region is a constant 10.0 block assert noise == pytest.approx(2.0) +class TestComputeNcRatioErrors: + """Direct unit tests of _compute_nc_ratio_errors's CV-propagation contract.""" + + def test_known_values_hand_computed(self): + # median_neb_a = score_a * noise_a = 5.0 * 2.0 = 10.0; cv_a = 1.0/10.0 = 0.1 + # median_neb_b = score_b * noise_b = 3.0 * 2.0 = 6.0; cv_b = 0.6/6.0 = 0.1 + ratio = {1: 5.0 / 3.0} + score_a, score_b = {1: 5.0}, {1: 3.0} + noise_a, noise_b = {1: 2.0}, {1: 2.0} + neb_std_a, neb_std_b = {1: 1.0}, {1: 0.6} + out = SpatialDetailAnalyzer._compute_nc_ratio_errors( + ratio, score_a, score_b, noise_a, noise_b, neb_std_a, neb_std_b) + expected = (5.0 / 3.0) * (0.1 ** 2 + 0.1 ** 2) ** 0.5 + assert out[1] == pytest.approx(expected, rel=1e-9) + + def test_missing_input_gives_none(self): + ratio = {1: 1.5} + score_a, score_b = {1: 5.0}, {} # score_b missing for scale 1 + noise_a, noise_b = {1: 2.0}, {1: 2.0} + neb_std_a, neb_std_b = {1: 1.0}, {1: 0.6} + out = SpatialDetailAnalyzer._compute_nc_ratio_errors( + ratio, score_a, score_b, noise_a, noise_b, neb_std_a, neb_std_b) + assert out[1] is None + + def test_zero_median_neb_guard(self): + ratio = {1: 0.0} + score_a, score_b = {1: 0.0}, {1: 3.0} # median_neb_a = 0*2.0 = 0 + noise_a, noise_b = {1: 2.0}, {1: 2.0} + neb_std_a, neb_std_b = {1: 1.0}, {1: 0.6} + out = SpatialDetailAnalyzer._compute_nc_ratio_errors( + ratio, score_a, score_b, noise_a, noise_b, neb_std_a, neb_std_b) + assert out[1] is None + + def test_empty_ratio_dict_returns_empty(self): + out = SpatialDetailAnalyzer._compute_nc_ratio_errors({}, {}, {}, {}, {}, {}, {}) + assert out == {} + + class TestLogRatioHelper: """Direct unit tests of _log_ratio_map's epsilon-floor and sign-discard contract.""" @@ -332,6 +376,413 @@ def test_double_ratio_gives_log10_two(self): result = SpatialDetailAnalyzer._log_ratio_map(a, b) assert np.allclose(result, np.log10(2.0), atol=1e-5) + +class TestFillSmallHoles: + """Direct unit tests of _fill_small_holes's area-limited hole-filling contract.""" + + def test_fills_hole_within_size_limit(self): + analyzer = SpatialDetailAnalyzer() + mask = np.ones((20, 20), dtype=bool) + mask[5:8, 5:8] = False # 3x3 hole, area 9 <= 5**2 + result = analyzer._fill_small_holes(mask, max_hole_px=5) + assert result[5:8, 5:8].all() + + def test_does_not_fill_hole_above_size_limit(self): + analyzer = SpatialDetailAnalyzer() + mask = np.ones((30, 30), dtype=bool) + mask[5:15, 5:15] = False # 10x10 hole, area 100 > 5**2 + result = analyzer._fill_small_holes(mask, max_hole_px=5) + assert not result[10, 10] + + def test_zero_max_hole_px_is_noop(self): + analyzer = SpatialDetailAnalyzer() + mask = np.ones((10, 10), dtype=bool) + mask[3:5, 3:5] = False + result = analyzer._fill_small_holes(mask, max_hole_px=0) + assert np.array_equal(result, mask) + + def test_hole_touching_border_is_not_filled(self): + analyzer = SpatialDetailAnalyzer() + mask = np.ones((20, 20), dtype=bool) + mask[0:3, 0:3] = False # touches the array border -> not an enclosed hole + result = analyzer._fill_small_holes(mask, max_hole_px=5) + assert not result[0:3, 0:3].any() + + +class TestRemoveSmallObjects: + """Direct unit tests of _remove_small_objects's area-limited speck-removal + contract — the fix for dilation amplifying isolated noise-driven pixels into + large blobs (see TestMakeMasksGrowth.test_dilation_does_not_amplify_noise_specks).""" + + def test_removes_object_within_size_limit(self): + analyzer = SpatialDetailAnalyzer() + mask = np.zeros((20, 20), dtype=bool) + mask[5:8, 5:8] = True # 3x3 speck, area 9 <= 5**2 + result = analyzer._remove_small_objects(mask, max_size_px=5) + assert not result.any() + + def test_keeps_object_above_size_limit(self): + analyzer = SpatialDetailAnalyzer() + mask = np.zeros((30, 30), dtype=bool) + mask[5:15, 5:15] = True # 10x10 object, area 100 > 5**2 + result = analyzer._remove_small_objects(mask, max_size_px=5) + assert result[10, 10] + + def test_zero_max_size_px_is_noop(self): + analyzer = SpatialDetailAnalyzer() + mask = np.zeros((10, 10), dtype=bool) + mask[3:5, 3:5] = True + result = analyzer._remove_small_objects(mask, max_size_px=0) + assert np.array_equal(result, mask) + + def test_mixed_sizes_keeps_only_large_object(self): + analyzer = SpatialDetailAnalyzer() + mask = np.zeros((30, 30), dtype=bool) + mask[2, 2] = True # isolated 1px speck + mask[20:28, 20:28] = True # 8x8 real object, area 64 > 25 + result = analyzer._remove_small_objects(mask, max_size_px=5) + assert not result[2, 2] + assert result[24, 24] + + +class TestLocalMaximaMask: + """Direct unit tests of _local_maxima_mask's scale-adaptive peak detection, + region growth, and noise-suppression contract.""" + + def test_finds_single_isolated_peak(self): + data = np.zeros((30, 30), dtype=np.float32) + data[15, 15] = 100.0 + mask = SpatialDetailAnalyzer._local_maxima_mask( + data, footprint_px=5, prominence_percentile=90.0, region_px=0) + assert mask[15, 15] + assert mask.sum() == 1 # no region growth requested + + def test_footprint_scaling_separates_vs_merges_nearby_peaks(self): + data = np.zeros((40, 40), dtype=np.float32) + data[15, 15] = 80.0 + data[15, 19] = 100.0 # 4 px away, slightly taller + small = SpatialDetailAnalyzer._local_maxima_mask( + data, footprint_px=3, prominence_percentile=10.0, region_px=0) + large = SpatialDetailAnalyzer._local_maxima_mask( + data, footprint_px=9, prominence_percentile=10.0, region_px=0) + assert small[15, 15] and small[15, 19] # both survive with a tight footprint + assert not large[15, 15] and large[15, 19] # large footprint suppresses the weaker peak + + def test_prominence_percentile_filters_low_peaks(self): + rng = np.random.default_rng(0) + data = rng.uniform(0, 10, size=(30, 30)).astype(np.float32) + data[15, 15] = 100.0 # one dominant spike + loose = SpatialDetailAnalyzer._local_maxima_mask( + data, footprint_px=3, prominence_percentile=50.0, region_px=0) + strict = SpatialDetailAnalyzer._local_maxima_mask( + data, footprint_px=3, prominence_percentile=99.5, region_px=0) + assert loose.sum() > strict.sum() + assert strict[15, 15] # the dominant spike always survives + + def test_region_px_grows_mask_around_peak(self): + data = np.zeros((30, 30), dtype=np.float32) + data[15, 15] = 100.0 + none = SpatialDetailAnalyzer._local_maxima_mask( + data, footprint_px=5, prominence_percentile=90.0, region_px=0) + grown = SpatialDetailAnalyzer._local_maxima_mask( + data, footprint_px=5, prominence_percentile=90.0, region_px=3) + assert np.count_nonzero(grown) > np.count_nonzero(none) + assert np.all(grown[none]) # superset of the ungrown mask + + def test_presmooth_suppresses_noise_driven_detections(self): + rng = np.random.default_rng(1) + data = rng.normal(0, 5.0, size=(60, 60)).astype(np.float32) + # Broad "real" feature: a Gaussian bump, amplitude well above the noise floor. + yy, xx = np.mgrid[0:60, 0:60] + bump = 60.0 * np.exp(-(((yy - 40) ** 2 + (xx - 40) ** 2) / (2 * 3.0 ** 2))) + data += bump.astype(np.float32) + unsmoothed = SpatialDetailAnalyzer._local_maxima_mask( + data, footprint_px=3, prominence_percentile=90.0, region_px=0, presmooth_sigma=0.0) + smoothed = SpatialDetailAnalyzer._local_maxima_mask( + data, footprint_px=3, prominence_percentile=90.0, region_px=0, presmooth_sigma=2.0) + assert smoothed.sum() < unsmoothed.sum() # fewer spurious noise-driven peaks + assert smoothed[40, 40] # the genuine broad feature still survives + + def test_flat_image_returns_empty_mask(self): + data = np.full((20, 20), 5.0, dtype=np.float32) + mask = SpatialDetailAnalyzer._local_maxima_mask( + data, footprint_px=5, prominence_percentile=90.0, region_px=2) + assert not mask.any() + + def test_empty_array_returns_empty_mask(self): + data = np.zeros((0, 0), dtype=np.float32) + mask = SpatialDetailAnalyzer._local_maxima_mask( + data, footprint_px=5, prominence_percentile=90.0, region_px=2) + assert mask.shape == (0, 0) + + def test_footprint_forced_odd_and_minimum_three(self): + data = np.zeros((20, 20), dtype=np.float32) + data[10, 10] = 50.0 + for fp in (0, 2, 4): + mask = SpatialDetailAnalyzer._local_maxima_mask( + data, footprint_px=fp, prominence_percentile=90.0, region_px=0) + assert mask[10, 10] # no crash, still detects the obvious peak + + +class TestLocalMaxStats: + """Direct unit tests of _localmax_stats's masked-region aggregation contract.""" + + def test_empty_mask_returns_none_stats(self): + abs_a = np.ones((10, 10), dtype=np.float32) + abs_b = np.ones((10, 10), dtype=np.float32) + diff = np.zeros((10, 10), dtype=np.float32) + mask = np.zeros((10, 10), dtype=bool) + rng = np.random.default_rng(0) + stats = SpatialDetailAnalyzer._localmax_stats(abs_a, abs_b, diff, mask, rng) + vals_a, vals_b = stats.pop("vals_a"), stats.pop("vals_b") + vals_log_ratio = stats.pop("vals_log_ratio") + assert stats == {"mean_a": None, "mean_b": None, "std_a": None, "std_b": None, + "ratio": None, "log_ratio_mean": None, "log_ratio_std": None, + "p_value": None, "cliffs_delta": None, + "n_px": 0, "pct_area": 0.0} + assert vals_a.size == 0 + assert vals_b.size == 0 + assert vals_log_ratio.size == 0 + + def test_known_values_give_expected_means_and_ratio(self): + abs_a = np.full((10, 10), 4.0, dtype=np.float32) + abs_b = np.full((10, 10), 2.0, dtype=np.float32) + diff = np.full((10, 10), np.log10(2.0), dtype=np.float32) # log10(4/2) + mask = np.zeros((10, 10), dtype=bool) + mask[2:5, 2:5] = True # 9 px + rng = np.random.default_rng(0) + stats = SpatialDetailAnalyzer._localmax_stats(abs_a, abs_b, diff, mask, rng) + assert stats["n_px"] == 9 + assert np.isclose(stats["mean_a"], 4.0) + assert np.isclose(stats["mean_b"], 2.0) + assert stats["std_a"] == 0.0 # constant array within the mask + assert stats["std_b"] == 0.0 + assert np.isclose(stats["ratio"], 2.0, rtol=1e-5) + assert stats["log_ratio_mean"] == pytest.approx(np.log10(2.0), rel=1e-5) + assert np.isclose(10.0 ** stats["log_ratio_mean"], stats["ratio"]) + assert stats["log_ratio_std"] == pytest.approx(0.0) # diff is constant within the mask + assert np.isclose(stats["pct_area"], 9.0) # 9 of 100 px + assert stats["vals_a"].size == 9 + assert stats["vals_b"].size == 9 + assert stats["vals_log_ratio"].size == 9 + # A is uniformly higher than B within the mask -> fully separable. + assert stats["p_value"] is not None and stats["p_value"] < 0.05 + assert stats["cliffs_delta"] is not None and stats["cliffs_delta"] > 0.9 + + def test_log_ratio_std_reflects_varying_diff_within_mask(self): + abs_a = np.full((10, 10), 4.0, dtype=np.float32) + abs_b = np.full((10, 10), 2.0, dtype=np.float32) + diff = np.zeros((10, 10), dtype=np.float32) + mask = np.zeros((10, 10), dtype=bool) + mask[2:5, 2:5] = True # 9 px + # Known, varying log-ratio values within the mask -> hand-computable std. + diff_vals = np.array([0.0, 0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8], dtype=np.float32) + diff[2:5, 2:5] = diff_vals.reshape(3, 3) + rng = np.random.default_rng(0) + stats = SpatialDetailAnalyzer._localmax_stats(abs_a, abs_b, diff, mask, rng) + assert stats["log_ratio_std"] == pytest.approx(float(np.std(diff_vals)), rel=1e-5) + assert stats["log_ratio_std"] > 0.0 + + def test_mismatched_shapes_crop_to_common_size(self): + abs_a = np.full((12, 12), 4.0, dtype=np.float32) + abs_b = np.full((10, 10), 2.0, dtype=np.float32) + diff = np.full((10, 10), np.log10(2.0), dtype=np.float32) + mask = np.ones((10, 10), dtype=bool) + rng = np.random.default_rng(0) + stats = SpatialDetailAnalyzer._localmax_stats(abs_a, abs_b, diff, mask, rng) + assert stats["n_px"] == 100 + + def test_vals_capped_at_dist_max_samples(self, monkeypatch): + import analysis.image_filters as image_filters_module + monkeypatch.setattr(image_filters_module, "SECTION8_LOCALMAX_DIST_MAX_SAMPLES", 10) + abs_a = np.full((20, 20), 4.0, dtype=np.float32) + abs_b = np.full((20, 20), 2.0, dtype=np.float32) + diff = np.full((20, 20), np.log10(2.0), dtype=np.float32) + mask = np.ones((20, 20), dtype=bool) # 400 px, well above the patched cap + rng = np.random.default_rng(0) + stats = SpatialDetailAnalyzer._localmax_stats(abs_a, abs_b, diff, mask, rng) + assert stats["n_px"] == 400 # true, uncapped count + assert stats["vals_a"].size == 10 + assert stats["vals_b"].size == 10 + assert stats["vals_log_ratio"].size == 10 + + +class TestTopPercentMask: + """Direct unit tests of _top_percent_mask's OR-brightness-threshold contract.""" + + def test_top_percent_selects_expected_count(self): + # abs_b == abs_a makes the OR a no-op, isolating abs_a's own contribution + # (a genuinely flat abs_b, e.g. all-zero, is a degenerate case: its own + # 90th-percentile threshold equals its only value, so "abs_b >= threshold" + # trivially matches every pixel and would swamp this assertion). + abs_a = np.arange(100, dtype=np.float32).reshape(10, 10) + abs_b = abs_a.copy() + mask = SpatialDetailAnalyzer._top_percent_mask(abs_a, abs_b, top_percent=10.0) + # Top 10% of a 0..99 uniform ramp is roughly the top 10 values. + assert mask.sum() == pytest.approx(10, abs=2) + assert mask[9, 9] # the single brightest pixel (value 99) is always included + + def test_or_across_both_images(self): + abs_a = np.zeros((10, 10), dtype=np.float32) + abs_b = np.zeros((10, 10), dtype=np.float32) + abs_a[0, 0] = 100.0 # bright only in A + abs_b[9, 9] = 100.0 # bright only in B + mask = SpatialDetailAnalyzer._top_percent_mask(abs_a, abs_b, top_percent=5.0) + assert mask[0, 0] and mask[9, 9] + + def test_larger_top_percent_selects_more_pixels(self): + rng = np.random.default_rng(0) + abs_a = rng.uniform(0, 1, size=(30, 30)).astype(np.float32) + abs_b = rng.uniform(0, 1, size=(30, 30)).astype(np.float32) + loose = SpatialDetailAnalyzer._top_percent_mask(abs_a, abs_b, top_percent=20.0) + strict = SpatialDetailAnalyzer._top_percent_mask(abs_a, abs_b, top_percent=2.0) + assert loose.sum() > strict.sum() + + +class TestCombinedLocalMaxMask: + """_combined_localmax_mask: local-maxima peak mask (_local_maxima_mask) unioned + (OR) with a top-percent brightness mask (_top_percent_mask) -- catches both + sharp isolated peaks and broad bright plateaus that peak detection alone + would miss. Used identically by _localmax_entry and the mask-grid builder.""" + + def test_union_includes_peak_only_pixel(self): + abs_a = np.zeros((30, 30), dtype=np.float32) + abs_a[15, 15] = 100.0 # isolated sharp peak + abs_b = np.zeros((30, 30), dtype=np.float32) + analyzer = SpatialDetailAnalyzer() + mask = analyzer._combined_localmax_mask( + abs_a, abs_b, footprint_px=5, prominence_percentile=90.0, + region_px=0, presmooth_sigma=0.0, top_percent=1.0) + assert mask[15, 15] + + def test_union_includes_top_percent_only_pixel(self): + # A broad plateau that never registers as an isolated local maximum + # (every pixel ties for "the max of its own neighbourhood"), but is + # well within the top-percent brightness threshold. + abs_a = np.zeros((30, 30), dtype=np.float32) + abs_a[10:20, 10:20] = 50.0 # flat plateau, no single dominant peak + abs_b = np.zeros((30, 30), dtype=np.float32) + analyzer = SpatialDetailAnalyzer() + mask = analyzer._combined_localmax_mask( + abs_a, abs_b, footprint_px=5, prominence_percentile=99.9, + region_px=0, presmooth_sigma=0.0, top_percent=50.0) + assert mask[15, 15] # inside the plateau, selected via top-percent only + + +class TestLocalMaxTopPercentWiring: + """Confirms localmax_top_percent is actually threaded from analyze() through + to each family's mask, not just accepted and discarded.""" + + def test_higher_top_percent_does_not_shrink_masked_pixel_count(self, nc_image_pair): + img_a, img_b = nc_image_pair + low = SpatialDetailAnalyzer().analyze(img_a, img_b, localmax_top_percent=1.0) + high = SpatialDetailAnalyzer().analyze(img_a, img_b, localmax_top_percent=40.0) + # A looser top-percent threshold can only add pixels to the OR'd mask, + # never remove any -- every scale/metric's n_px must be non-decreasing, + # and at least one must strictly grow (proves the parameter reaches + # the mask, not just accepted and ignored). + assert all( + high["localmax"][key]["n_px"] >= low["localmax"][key]["n_px"] + for key in low["localmax"] + ) + assert any( + high["localmax"][key]["n_px"] > low["localmax"][key]["n_px"] + for key in low["localmax"] + ) + + +class _FakeMaskImage: + """Minimal duck-typed stand-in for AstroImage, exposing only what + _make_masks reads (background_rms, background_subtracted()).""" + + def __init__(self, data: np.ndarray, rms: np.ndarray): + self._data = data + self.background_rms = rms + + def background_subtracted(self) -> np.ndarray: + return self._data + + +class TestMakeMasksGrowth: + """Direct unit tests of _make_masks's dilation and nebula-dominance contract.""" + + def _bright_square_image(self, size=40, lo=15, hi=25, value=100.0): + data = np.zeros((size, size), dtype=np.float32) + data[lo:hi, lo:hi] = value + rms = np.full((size, size), 1.0, dtype=np.float32) + return _FakeMaskImage(data, rms) + + def test_dilation_grows_nebula_mask(self): + analyzer = SpatialDetailAnalyzer() + image = self._bright_square_image() + neb_none, _ = analyzer._make_masks(image, nebula_sigma=1.7, dilation_px=0, max_hole_px=0) + neb_grown, _ = analyzer._make_masks(image, nebula_sigma=1.7, dilation_px=3, max_hole_px=0) + assert np.count_nonzero(neb_grown) > np.count_nonzero(neb_none) + assert np.all(neb_grown[neb_none]) # superset of the ungrown mask + + def test_background_excludes_dilated_nebula_pixels(self): + analyzer = SpatialDetailAnalyzer() + image = self._bright_square_image() + neb, bg = analyzer._make_masks(image, nebula_sigma=1.7, dilation_px=3, max_hole_px=0) + assert not np.any(neb & bg) # masks stay mutually exclusive after growth + assert neb[14, 20] # grown one row above the bright square's top edge + assert not bg[14, 20] # nebula dominates: excluded from background + + def test_zero_dilation_matches_undilated_threshold(self): + analyzer = SpatialDetailAnalyzer() + image = self._bright_square_image() + neb, _ = analyzer._make_masks(image, nebula_sigma=1.7, dilation_px=0, max_hole_px=0) + expected = image.background_subtracted() > 1.7 * 1.0 + assert np.array_equal(neb, expected) + + def test_dilation_does_not_amplify_isolated_noise_specks(self): + """Regression test: dilation must not inflate scattered single-pixel + threshold-crossings (expected at a loose ~1.7 sigma cut) into large blobs + far from any real nebula structure. _make_masks strips small islands via + _remove_small_objects before dilating, so an isolated speck should vanish + entirely rather than grow.""" + analyzer = SpatialDetailAnalyzer() + size = 60 + data = np.zeros((size, size), dtype=np.float32) + data[25:35, 25:35] = 100.0 # real nebula core + data[5, 5] = 100.0 # isolated single-pixel noise-like speck + rms = np.full((size, size), 1.0, dtype=np.float32) + image = _FakeMaskImage(data, rms) + neb, _ = analyzer._make_masks(image, nebula_sigma=1.7, dilation_px=3, max_hole_px=5) + assert not neb[2:9, 2:9].any() # speck stripped before it could be dilated + assert neb[30, 30] # the real core is still classified as nebula + + +class TestSharedMaskCombination: + """mask_neb_shared changed from two-image AND to OR; mask_bg_shared stays AND. + _make_masks is monkeypatched to return controlled, disjoint per-image masks so + the combination formula in analyze() is tested in isolation from noise/threshold + behaviour, following the _auto_detect_top_rois monkeypatch precedent in + test_edge_analyzer.py.""" + + def test_nebula_union_counts_pixels_unique_to_either_image(self, astro_image_a, monkeypatch): + shape = astro_image_a.background_subtracted().shape + + neb_a = np.zeros(shape, dtype=bool) + neb_a[0:10, 0:10] = True + bg_a = np.ones(shape, dtype=bool) + bg_a[0:10, 0:10] = False + + neb_b = np.zeros(shape, dtype=bool) + neb_b[20:30, 20:30] = True # disjoint from neb_a + bg_b = np.ones(shape, dtype=bool) + bg_b[20:30, 20:30] = False + + calls = iter([(neb_a, bg_a), (neb_b, bg_b)]) + monkeypatch.setattr( + SpatialDetailAnalyzer, "_make_masks", + lambda self, image, nebula_sigma=None, dilation_px=None, max_hole_px=None: next(calls)) + + result = SpatialDetailAnalyzer().analyze(astro_image_a, astro_image_a) + # Union of two disjoint 10x10 regions: under the old AND semantics this + # would be 0 (no true overlap); under OR it's the full 200-pixel footprint. + assert result["nc_shared_nebula_pixels"] == 200 + def test_opposite_sign_equal_magnitude_gives_zero(self): a = np.full((10, 10), -5.0, dtype=np.float32) b = np.full((10, 10), 5.0, dtype=np.float32) @@ -371,11 +822,12 @@ def test_present_in_two_image_mode(self, nc_result): class TestCorrelationScatterFigures: _CORR_KEYS = ( - [f"corr_std_{ks}px" for ks in STD_KERNEL_SIZES] + ["corr_original"] + + [f"corr_std_{ks}px" for ks in STD_KERNEL_SIZES] + [f"corr_log_{s}" for s in LOG_SIGMAS] + [f"corr_gradient_{s}" for s in LOG_SIGMAS] + ["corr_wavelet_2", "corr_wavelet_3"] - + [f"corr_weber_{ks}px" for ks in WEBER_KERNEL_SIZES] + + [f"corr_entropy_{ks}px" for ks in ENTROPY_KERNEL_SIZES] ) @pytest.mark.parametrize("key", _CORR_KEYS) @@ -390,6 +842,98 @@ def test_absent_in_single_image_mode(self, astro_image_a, key): assert key not in result["figures"] +class TestLocalMaxIntegration: + """Section 8j: result["localmax"] population across all metric/scale + combinations, mirroring the corr_* key set in TestCorrelationScatterFigures.""" + + _LOCALMAX_KEYS = ( + [f"std_{ks}px" for ks in STD_KERNEL_SIZES] + + [f"log_{s}" for s in LOG_SIGMAS] + + [f"gradient_{s}" for s in LOG_SIGMAS] + + ["wavelet_2", "wavelet_3"] + + [f"entropy_{ks}px" for ks in ENTROPY_KERNEL_SIZES] + ) + + @pytest.mark.parametrize("key", _LOCALMAX_KEYS) + def test_entry_present_with_valid_stats(self, nc_result, key): + entry = nc_result["localmax"][key] + assert entry["n_px"] >= 0 + assert 0.0 <= entry["pct_area"] <= 100.0 + assert entry["ratio"] is None or entry["ratio"] > 0 + assert entry["std_a"] is None or entry["std_a"] >= 0 + assert entry["std_b"] is None or entry["std_b"] >= 0 + assert entry["p_value"] is None or 0.0 <= entry["p_value"] <= 1.0 + assert entry["cliffs_delta"] is None or -1.0 <= entry["cliffs_delta"] <= 1.0 + assert entry["vals_a"].size <= entry["n_px"] + assert entry["vals_b"].size <= entry["n_px"] + assert entry["vals_log_ratio"].size <= entry["n_px"] + if entry["ratio"] is not None: + assert entry["log_ratio_mean"] is not None + assert np.isclose(10.0 ** entry["log_ratio_mean"], entry["ratio"]) + + def test_empty_in_single_image_mode(self, astro_image_a): + result = SpatialDetailAnalyzer().analyze(astro_image_a) + assert result["localmax"] == {} + + +class TestLocalMaxFigures: + def test_ratio_overview_present_in_two_image_mode(self, nc_result): + assert "localmax_ratio_overview" in nc_result["figures"] + + def test_ratio_overview_absent_in_single_image_mode(self, astro_image_a): + result = SpatialDetailAnalyzer().analyze(astro_image_a) + assert "localmax_ratio_overview" not in result["figures"] + + def test_mask_illustration_present_in_two_image_mode(self, nc_result): + assert "localmax_mask_illustration" in nc_result["figures"] + assert isinstance(nc_result["figures"]["localmax_mask_illustration"], str) + assert len(nc_result["figures"]["localmax_mask_illustration"]) > 0 + + def test_mask_illustration_absent_in_single_image_mode(self, astro_image_a): + result = SpatialDetailAnalyzer().analyze(astro_image_a) + assert "localmax_mask_illustration" not in result["figures"] + + def test_mask_illustration_is_a_multi_row_grid(self, nc_result): + # Regression guard: the old single-scale illustration was one ~9-inch-tall + # panel (~1350px at dpi=150); the grid is 5 rows tall (~2700px) -- decode + # the PNG and check its height reflects the multi-row layout, not the old + # single-panel figure. + import base64 + import io + from PIL import Image + png_bytes = base64.b64decode(nc_result["figures"]["localmax_mask_illustration"]) + img = Image.open(io.BytesIO(png_bytes)) + assert img.height > 2000 + + def test_localmax_entries_carry_vals_for_distribution_figure(self, nc_result): + # result["figures"] never stores the A/B distribution violin plot itself + # (that's rendered in report_builder.py from result["localmax"][key]["vals_a"/"vals_b"]), + # so verify those raw-value arrays are actually present in two-image mode. + any_present = any( + entry.get("vals_a") is not None and entry["vals_a"].size > 0 + for entry in nc_result["localmax"].values() + ) + assert any_present + + def test_localmax_entries_carry_vals_log_ratio_for_distribution_figure(self, nc_result): + # Same contract as vals_a/vals_b above, for the log-ratio distribution + # figure (report_builder.py::_localmax_log_ratio_distribution_figure), + # which reads result["localmax"][key]["vals_log_ratio"]. + any_present = any( + entry.get("vals_log_ratio") is not None and entry["vals_log_ratio"].size > 0 + for entry in nc_result["localmax"].values() + ) + assert any_present + + def test_mask_grid_default_color_is_high_contrast(self): + # Regression guard: the mask overlay used to default to "darkorange", + # which is hard to distinguish against bright nebula regions in the + # grayscale-stretched base image. Confirm the more contrasting default. + import inspect + sig = inspect.signature(SpatialDetailAnalyzer._plot_localmax_mask_grid) + assert sig.parameters["color"].default == "magenta" + + @pytest.fixture(scope="module") def nc_result_with_crosshair(nc_image_pair) -> dict: img_a, img_b = nc_image_pair @@ -400,8 +944,8 @@ def nc_result_with_crosshair(nc_image_pair) -> dict: class TestCrosshairEmbeddedCrossSections: """Section 8 cross-sections are embedded panels inside the 2x2 map figures, not separate figures — no standalone xs_* keys should ever appear, and all - 5 families (including Weber, newly crosshair-capable) must still produce - their usual figure keys with or without a crosshair, without crashing.""" + 5 families (including Local entropy) must still produce their usual figure + keys with or without a crosshair, without crashing.""" def test_no_crash_with_crosshair(self, nc_result_with_crosshair): assert isinstance(nc_result_with_crosshair, dict) @@ -409,15 +953,15 @@ def test_no_crash_with_crosshair(self, nc_result_with_crosshair): def test_no_standalone_xs_keys_with_crosshair(self, nc_result_with_crosshair): figs = nc_result_with_crosshair["figures"] assert not any(k.startswith(("xs_std_", "xs_log_", "xs_wavelet_", - "xs_gradient_", "xs_weber_")) for k in figs) + "xs_gradient_", "xs_entropy_")) for k in figs) def test_no_standalone_xs_keys_without_crosshair(self, nc_result): figs = nc_result["figures"] assert not any(k.startswith(("xs_std_", "xs_log_", "xs_wavelet_", - "xs_gradient_", "xs_weber_")) for k in figs) + "xs_gradient_", "xs_entropy_")) for k in figs) @pytest.mark.parametrize("key_prefix,scales", [ - ("std_", STD_KERNEL_SIZES), ("weber_", WEBER_KERNEL_SIZES), + ("std_", STD_KERNEL_SIZES), ("entropy_", ENTROPY_KERNEL_SIZES), ]) def test_family_figures_present_with_crosshair(self, nc_result_with_crosshair, key_prefix, scales): @@ -425,10 +969,163 @@ def test_family_figures_present_with_crosshair(self, nc_result_with_crosshair, for scale in scales: assert f"{key_prefix}{scale}px" in figs - def test_weber_no_crash_without_crosshair(self, nc_result): - # Regression: weber previously had no crosshair param at all. - assert all(f"weber_{ks}px" in nc_result["figures"] for ks in WEBER_KERNEL_SIZES) + def test_entropy_no_crash_without_crosshair(self, nc_result): + # Regression: weber (entropy's predecessor family) previously had no + # crosshair param at all. + assert all(f"entropy_{ks}px" in nc_result["figures"] for ks in ENTROPY_KERNEL_SIZES) - def test_weber_nrm_figures_present_with_crosshair(self, nc_result_with_crosshair): + def test_entropy_nrm_figures_present_with_crosshair(self, nc_result_with_crosshair): figs = nc_result_with_crosshair["figures"] - assert any(k.startswith("nrm_weber_") for k in figs) + assert any(k.startswith("nrm_entropy_") for k in figs) + + def test_original_present_with_crosshair(self, nc_result_with_crosshair): + assert "original" in nc_result_with_crosshair["figures"] + + +class TestCrosshairToCroppedPx: + """Unit tests for the helper that overlays the user's cross-section line + directly onto the Image A/B map panels — converts a normalised [0,1] + crosshair into pixel coords matching _crop_border's own offset math.""" + + def test_none_crosshair_returns_none(self): + assert SpatialDetailAnalyzer._crosshair_to_cropped_px(None, (200, 200), 0.05) is None + + def test_full_diagonal_line_cropped(self): + # 200x200 array, 5% crop -> n = 10 px removed from each edge. + crosshair = {"x0": 0.0, "y0": 0.0, "x1": 1.0, "y1": 1.0} + result = SpatialDetailAnalyzer._crosshair_to_cropped_px(crosshair, (200, 200), 0.05) + assert result == pytest.approx((-10.0, -10.0, 190.0, 190.0)) + + def test_center_point_unaffected_by_crop_offset(self): + crosshair = {"x0": 0.5, "y0": 0.5, "x1": 0.5, "y1": 0.5} + x0, y0, x1, y1 = SpatialDetailAnalyzer._crosshair_to_cropped_px( + crosshair, (200, 200), 0.05) + assert x0 == pytest.approx(90.0) + assert y0 == pytest.approx(90.0) + assert (x0, y0) == pytest.approx((x1, y1)) + + def test_small_array_below_crop_threshold_no_offset(self): + # _crop_border only crops when shape > 2n; a tiny array stays uncropped, + # so the returned pixel coords must have zero offset applied. + crosshair = {"x0": 0.0, "y0": 0.0, "x1": 1.0, "y1": 1.0} + result = SpatialDetailAnalyzer._crosshair_to_cropped_px(crosshair, (10, 10), 0.5) + assert result == pytest.approx((0.0, 0.0, 10.0, 10.0)) + + +class TestSectionSpatialReportOrder: + """Integration check on report/report_builder.py::_section_spatial's HTML + output: the reorganized subsection order (8a Background, 8b Original Image, + 8c Mask Overview, 8d LoG, 8e Wavelet, 8f Gradient, 8g Local Std, + 8h Local Entropy, 8i NC overview, 8j Local-Maxima Masked Metrics), and the + fix for the alphabetic-sort bug that put e.g. nrm_std_10px before nrm_std_3px/5px.""" + + @pytest.fixture(scope="class") + @classmethod + def section_html(cls, nc_result): + from core.models import AnalysisResult + from report.report_builder import ReportBuilder + + ra = AnalysisResult(label="A", spatial_metrics=nc_result) + rb = AnalysisResult(label="B", spatial_metrics=nc_result) + return ReportBuilder()._section_spatial(ra, rb) + + def test_headings_present_in_order(self, section_html): + import re + expected = ["8a", "8b", "8c", "8d", "8e", "8f", "8g", "8h", "8i", "8j"] + found = re.findall(r"

(8[a-j])\.", section_html) + assert found == expected + + def test_original_image_section_present(self, section_html): + assert "8b. Original Image" in section_html + assert 'alt="original"' in section_html or "original" in section_html + + def test_local_std_now_in_contrast_group_after_gradient(self, section_html): + assert section_html.index("8f. Gradient Magnitude") < section_html.index( + "8g. Local Standard Deviation Maps") + + def test_entropy_after_local_std(self, section_html): + assert section_html.index("8g. Local Standard Deviation Maps") < section_html.index( + "8h. Local Entropy Maps") + + def test_nrm_std_figures_in_ascending_kernel_order(self, section_html): + # Regression test: figs_for()'s old lexicographic sorted(figs) rendered + # nrm_std_10px before nrm_std_3px/5px. _family_nrm_figs must not. + positions = { + ks: section_html.find(f'"nrm_std_{ks}px"') + for ks in STD_KERNEL_SIZES + } + assert all(p >= 0 for p in positions.values()) + ordered = sorted(STD_KERNEL_SIZES) + for a, b in zip(ordered, ordered[1:]): + assert positions[a] < positions[b] + + def test_no_stale_letter_references(self, section_html): + import re + # Strip embedded base64 image data first — long random-looking base64 + # blobs incidentally contain "8" substrings that aren't section + # references at all. + prose = re.sub(r"data:image/png;base64,[A-Za-z0-9+/=]+", "", section_html) + # Every literal "8" reference must be one of the 10 valid + # letters — a stray old letter (e.g. a missed "8e" -> "8h" rename) + # would show up here. + for m in re.finditer(r"\b8([a-z])\b", prose): + assert m.group(1) in "abcdefghij", f"unexpected section letter: 8{m.group(1)}" + + def test_8c_mask_overview_survives_violin_removal(self, section_html): + # Regression test: 8c's HTML block used to be gated on the (now-removed) + # violin figure's own output, which meant the Nebula/Background mask + # illustration silently disappeared too if that figure was ever empty. + # The heading and mask illustration must render on their own gating. + assert "8c. Mask Overview" in section_html + assert "Nebula / Background Mask Regions" in section_html + + def test_old_violin_caption_removed(self, section_html): + # Direct regression test for the removal request: the old 8c + # Nebula-vs-Background log-ratio violin figure's distinctive caption + # text must no longer appear anywhere in the section. "ratio + # distributions" alone is no longer a safe substring to check on its + # own -- the (legitimate, newer) 8j log-ratio distribution figure's + # caption also contains that phrase -- so match the old figure's + # full distinctive title instead. + assert "Pixel-wise log" not in section_html + assert "Pixel-wise log₁₀(A / B) ratio distributions" not in section_html + + def test_localmax_distribution_figure_present(self, section_html): + assert 'alt="localmax_distributions"' in section_html + + def test_old_mask_grid_illustrative_caption_removed(self, section_html): + # Regression test: the old single-scale illustration's "not literally + # the source of every row's statistics" disclaimer no longer applies + # now that the grid shows every row's actual mask. + assert "Illustrative only" not in section_html + assert "single representative scale" not in section_html + assert "smallest" in section_html and "largest" in section_html + + @pytest.mark.parametrize("title", [ + "Show LoG maps & figures", + "Show Wavelet maps & figures", + "Show Gradient maps & figures", + "Show Local σ maps & figures", + "Show Local entropy maps & figures", + ]) + def test_family_images_are_collapsed_by_default(self, section_html, title): + # Each of the 5 metric families' map/correlation/noise-normalised figures + # (8d-8h) must be wrapped in a closed-by-default
box, keeping + # the report compact. The heading/methodology/table above each family + # stay outside the box (checked implicitly: the table for that family + # still appears earlier in section_html than this collapsible summary). + idx = section_html.find(f"{title}") + assert idx > 0, f"collapsible box with title {title!r} not found" + # The
tag immediately preceding this must not carry + # an "open" attribute -- default closed. + details_start = section_html.rfind(" 0 + details_tag = section_html[details_start:idx] + assert " open" not in details_tag + + def test_localmax_log_ratio_distribution_figure_present(self, section_html): + assert 'alt="localmax_log_ratio_distributions"' in section_html + + def test_log_ratio_table_column_present(self, section_html): + assert "log ratio A/B (geo. mean" in section_html + assert "Ratio A/B (geo. mean)" not in section_html diff --git a/tests/test_core/test_stats_utils.py b/tests/test_core/test_stats_utils.py new file mode 100644 index 0000000..cff0bca --- /dev/null +++ b/tests/test_core/test_stats_utils.py @@ -0,0 +1,47 @@ +"""Unit tests for core/stats_utils.py.""" +from __future__ import annotations + +import numpy as np + +from core.stats_utils import mannwhitney_effect + + +class TestMannwhitneyEffect: + def test_minimum_sample_guard(self): + assert mannwhitney_effect([1, 2], [1, 2, 3]) == (None, None) + assert mannwhitney_effect([1, 2, 3], [1, 2]) == (None, None) + + def test_separable_samples_give_expected_sign_and_significance(self): + va = [10.0, 11.0, 12.0, 13.0, 14.0] + vb = [1.0, 2.0, 3.0, 4.0, 5.0] + p, delta = mannwhitney_effect(va, vb) + assert p is not None and p < 0.05 + assert delta is not None and delta > 0.9 + + p2, delta2 = mannwhitney_effect(vb, va) + assert p2 is not None and p2 < 0.05 + assert delta2 is not None and delta2 < -0.9 + + def test_identical_distributions_not_significant(self): + vals = [5.0, 6.0, 7.0, 8.0, 9.0] + p, delta = mannwhitney_effect(vals, vals) + assert p is not None and p >= 0.05 + assert delta is not None and abs(delta) < 1e-9 + + def test_matches_pairwise_matrix_reference_on_small_sample(self): + rng = np.random.default_rng(7) + va = rng.normal(10, 2, size=17).tolist() + vb = rng.normal(9, 3, size=23).tolist() + + p, delta = mannwhitney_effect(va, vb) + + # Brute-force reference: Cliff's delta via the O(n*m) pairwise sign + # matrix, computed independently here (affordable at this small size) + # to prove the O(n log n) identity in mannwhitney_effect is exact. + arr_a = np.array(va) + arr_b = np.array(vb) + ref_delta = float(np.sign(arr_a[:, None] - arr_b[None, :]).sum()) / (len(va) * len(vb)) + + assert p is not None + assert delta is not None + assert abs(delta - ref_delta) < 1e-9 diff --git a/tests/test_report/test_inspector_catalog.py b/tests/test_report/test_inspector_catalog.py index 87f574b..1fd240d 100644 --- a/tests/test_report/test_inspector_catalog.py +++ b/tests/test_report/test_inspector_catalog.py @@ -1,9 +1,10 @@ """Regression tests for the Report Inspector's .npz panel catalog. Covers the bug where _write_inspector_file used a hardcoded, stale panel-name -map (std_15px/std_31px from an old STD_KERNEL_SIZES, no Weber entries at all) -that silently omitted panels SpatialDetailAnalyzer actually computes. The fix -derives the catalog dynamically from panels_a.keys() via _panel_display_name. +map (std_15px/std_31px from an old STD_KERNEL_SIZES, no Weber/entropy entries +at all) that silently omitted panels SpatialDetailAnalyzer actually computes. +The fix derives the catalog dynamically from panels_a.keys() via +_panel_display_name. """ from __future__ import annotations @@ -68,11 +69,13 @@ def test_every_computed_panel_is_cataloged(self, inspector_npz_path): from report.report_builder import _panel_display_name assert _panel_display_name(pkey) in cataloged_names - def test_weber_panels_present(self, inspector_npz_path): - # Regression: the old hardcoded _PANEL_IMAGE_SETS never included Weber at all. + def test_entropy_panels_present(self, inspector_npz_path): + # Regression: the old hardcoded _PANEL_IMAGE_SETS never included this + # family at all (Weber, entropy's predecessor family, was the original + # motivating example). _, spatial = inspector_npz_path - weber_keys = [k for k in spatial["panels"] if k.startswith("weber_")] - assert weber_keys, "fixture should have produced Weber panels" + entropy_keys = [k for k in spatial["panels"] if k.startswith("entropy_")] + assert entropy_keys, "fixture should have produced Local entropy panels" def test_all_std_kernel_sizes_present(self, inspector_npz_path): # Regression: the old map referenced std_15px/std_31px from a stale diff --git a/tests/test_report/test_report_helpers.py b/tests/test_report/test_report_helpers.py index 2e47abd..57cc9e7 100644 --- a/tests/test_report/test_report_helpers.py +++ b/tests/test_report/test_report_helpers.py @@ -16,6 +16,8 @@ _arr_to_b64_png, _fig_to_b64, _psf_stat_test, + _format_significance_html, + _sig_td, _power_ratio_db, _nc_ratio_rows, _panel_display_name, @@ -141,6 +143,50 @@ def test_identical_distributions_not_significant(self): assert p == pytest.approx(1.0, abs=0.01) or (p is not None and p > 0.05) +class TestFormatSignificanceHtml: + def test_not_significant(self): + html = _format_significance_html(0.5, 0.05) + assert "n.s." in html + assert "p=0.500" in html + + def test_trivial_effect(self): + html = _format_significance_html(0.01, 0.05) + assert "trivial" in html + + def test_small_effect(self): + html = _format_significance_html(0.01, 0.2) + assert "small" in html + + def test_medium_effect(self): + html = _format_significance_html(0.01, 0.4) + assert "medium" in html + + def test_large_effect(self): + html = _format_significance_html(0.01, 0.6) + assert "large" in html + + def test_p_below_threshold_formatted(self): + html = _format_significance_html(0.0001, 0.6) + assert "p<0.001" in html + + def test_delta_sign_shown(self): + html = _format_significance_html(0.01, -0.6) + assert "delta=-0.60" in html.replace("δ", "delta") + + +class TestSigTd: + def test_none_p_plain_cell(self): + assert _sig_td("hello", None) == "hello" + + def test_significant_p_blue_background(self): + td = _sig_td("hello", 0.01) + assert "background:#b3e5fc" in td + + def test_nonsignificant_p_grey_background(self): + td = _sig_td("hello", 0.5) + assert "background:#e0e0e0" in td + + class TestArrToB64Png: def test_grayscale_returns_string(self): arr = np.zeros((16, 16), dtype=np.uint8) @@ -276,7 +322,7 @@ class TestPanelDisplayName: @pytest.mark.parametrize("pkey,expected", [ ("std_3px", "Std Dev 3 px"), ("std_10px", "Std Dev 10 px"), - ("weber_9px", "Weber 9 px"), + ("entropy_9px", "Entropy 9 px"), ("log_1.5", "LoG σ 1.5 px"), ("wavelet_2", "Wavelet level 2"), ("gradient_3.0", "Gradient σ 3.0 px"),