From 7e5542734f878b4ed89936e9b1d41a553385ea57 Mon Sep 17 00:00:00 2001 From: Hans Davenport <35202271+hd152@users.noreply.github.com> Date: Wed, 23 Sep 2026 06:27:35 -0700 Subject: [PATCH 1/5] Add ZOGY real/bogus transient triage; fix cross-session shape mismatch --transient-triage scores each --transient-detect candidate with a small CNN (new/ref/diff stamp triplet, native tract inference, advisory only -- never drops a candidate) for a real_probability, the same role ZTF's BTSbot/Rubin's DIA triage play downstream of classical image differencing. No amateur stacking tool does this today. The bundled model is trained entirely on synthetic data (tools/gen_transient_triage_data.py + torch in tools/train_transient_triage.py); tools/mine_real_transient_data.py adds a real-data path -- stacks real multi-night sessions of the same target and mines real cross-session negatives plus real-epoch-injection positives. While validating the real-data path, found that two independently-stacked sessions of the same target routinely differ in pixel dimensions (different dither pattern, different Phase 3 crop), which run_transient_detection treated as a hard failure -- a real limitation of the shipped --transient-detect flag, not just the mining tool. Fixed by promoting merge.py's shape-reconciliation helper to src.utils.embed_to_shape (shared by both callers now) and carrying a real valid-data mask through the warp so a zero-padded border reads as uncovered, not real reference data. Co-Authored-By: Claude Sonnet 5 --- .gitignore | 6 + CLAUDE.md | 5 +- ext/astro_native/Cargo.lock | 2 +- ext/astro_native/Cargo.toml | 2 +- ext/astro_native/pyproject.toml | 2 +- ext/astro_native/src/lib.rs | 146 +++++++++++++++ src/cli.py | 20 ++ src/data/transient_triage.onnx | Bin 0 -> 96951 bytes src/difference_imaging.py | 200 +++++++++++++++----- src/merge.py | 17 +- src/pipeline.py | 4 +- src/transient_triage.py | 152 +++++++++++++++ src/utils.py | 25 +++ tests/test_difference_imaging.py | 89 ++++++++- tests/test_native.py | 36 ++++ tools/gen_transient_triage_data.py | 193 +++++++++++++++++++ tools/mine_real_transient_data.py | 290 +++++++++++++++++++++++++++++ tools/train_transient_triage.py | 159 ++++++++++++++++ 18 files changed, 1274 insertions(+), 74 deletions(-) create mode 100644 src/data/transient_triage.onnx create mode 100644 src/transient_triage.py create mode 100644 tools/gen_transient_triage_data.py create mode 100644 tools/mine_real_transient_data.py create mode 100644 tools/train_transient_triage.py diff --git a/.gitignore b/.gitignore index 669e751..225bbc5 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,12 @@ orion-live/ synthetic_data/ synthetic_data_mixed/ +# transient-triage training data (tools/gen_transient_triage_data.py, +# tools/mine_real_transient_data.py) -- generated, not committed +transient_triage_data.npz +transient_triage_real_data*.npz +transient_triage_real_work*/ + # Claude Code directories .claude/ **/claude/ diff --git a/CLAUDE.md b/CLAUDE.md index f02ceab..a0910d5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -97,7 +97,7 @@ The pipeline is split across `src/` modules. [originstack.py](originstack.py) is | [src/lightcurve_analysis.py](src/lightcurve_analysis.py) | `--lightcurve-analysis` on `--photometry-timeseries` CSVs: astropy `LombScargle` (periods 4 cadences to half the baseline, with FAP) and `BoxLeastSquares` to place a dip, then a trapezoid `least_squares` fit with Jacobian errors and a BIC test against flat. Uses `astropy.timeseries` (verify it survives a PyInstaller build with `packaging/verify_build.ps1` before relying on it in the packaged app) | | [src/frame_processor.py](src/frame_processor.py) | Parallel workers, `execute_frame_processing`, `quality_gate` | | [src/postprocess.py](src/postprocess.py) | Full post-processing chain: `postprocess_stack`. **Step 1 (hot pixel removal) routes its 5x5 median filter through native per-channel calls** (`_median_filter_per_channel`, `median_filter_native`): the original single `scipy.ndimage.median_filter(stacked, size=(5,5,1))` call -- the first thing Phase 4 does, on every stack -- measured 5.1s on a real full-res frame; scipy's generic N-D rank-filter machinery has no fast path for a size-1 axis, so it silently bypassed this codebase's own already-existing native 2D median kernel (the same class of miss as `_fix_hot_bayer`'s pre-native-routing bug in `debayer.py`, just never caught here). 3 independent native per-channel calls are equivalent by construction and validated against the combined-axis scipy call in `tests/test_native.py` | -| [src/difference_imaging.py](src/difference_imaging.py) | Proper image subtraction and transient detection (`--transient-detect REF.fits`). Answers "did anything change?" rather than "what does my target look like?" -- novae, outbursts, supernovae, asteroids, variables. `zogy()` implements Zackay, Ofek & Gal-Yam (2016): rather than degrading one epoch to match the other (Alard-Lupton), it cross-convolves each image with the **other's** PSF, so both sides acquire the same effective PSF and stellar residuals cancel in closed form even across differing seeing -- a plain subtraction leaves a dipole at every star, scaling with brightness, i.e. worst exactly where transients hide. Returns `D` (difference), `S` (match-filtered score) and `S_corr` (score in units of its own propagated sigma, so a threshold is a real significance). **`S_corr`'s astrometric noise term is not optional**: a sub-pixel registration slip leaves a residual proportional to the local gradient, largest at bright stars, and with `astrometric_sigma=0` every bright star reports as a transient (asserted in [tests/test_difference_imaging.py](tests/test_difference_imaging.py)). `_prepare_psf` zero-pads and **rolls the PSF so its centre lands on index [0,0]** -- the FFT's origin; omitting the roll shifts every output by half the frame. `estimate_background_sigma` uses *symmetric* iterative sigma clipping even though stars are a one-sided contaminant: the obvious "keep pixels below the 80th percentile, take their MAD" truncates the Gaussian core and reads 5.7 against an injected 7.0, a 19% underestimate -- and that sigma is the denominator of every significance, so underestimating it manufactures false positives at exactly the threshold users trust. **Runs on the LINEAR stack** (`fits_stacked` in `pipeline.py`, the `RAWSTACK` product the output FITS contains), never the post-processed array: Phase 4's nonlinear stretches/denoise/local-contrast break photometric linearity, and comparing a post-processed frame against a linear reference mismatches the flux scale by a fraction of a percent -- several sigma on a bright star, reporting a transient at every star in the field. **`--transient-detect` refuses a reference without `RAWSTACK=True`** (same rule as `--merge`). The warped reference's uncovered area -- field rotation leaves empty corners -- is masked via a footprint (`_align_reference` warps an all-ones image too; `_erode` uses `border_value=1` so the frame's own edge is not treated as uncovered, which measured 66% "covered" for a 93%-covered frame): unmasked, stars in those corners came out as confident 'brightenings' (6 false candidates on a 9 deg synthetic rotation, 0 masked). The astrometric sigma is the *measured* matched-star RMS residual with `_ASTROMETRIC_SIGMA_FLOOR_PX = 0.3` as a floor -- it used to be a hard-coded 0.3 reported as a measurement. `zogy` pads to `scipy.fft.next_fast_len` (3.4x on the Origin's 1096 = 8x137 axis, whose prime factor pushes pocketfft onto Bluestein; `S_corr` identical to 2e-5 sigma in the interior). `detect_transients` is a single sorted pass, verified identical to the old per-candidate argmax loop on 300 randomized fields including ties and NaNs. `psf_difference` was dropped from `ZogyResult` (no reader). The catalogue's WCS must be built with `naxis=2` (`pipeline.py`): a bare `WCS(header)` on the `(3, H, W)` cube is 3-axis, still passes `has_celestial`, and blanks every RA/Dec | +| [src/difference_imaging.py](src/difference_imaging.py) | Proper image subtraction and transient detection (`--transient-detect REF.fits`). Answers "did anything change?" rather than "what does my target look like?" -- novae, outbursts, supernovae, asteroids, variables. `zogy()` implements Zackay, Ofek & Gal-Yam (2016): rather than degrading one epoch to match the other (Alard-Lupton), it cross-convolves each image with the **other's** PSF, so both sides acquire the same effective PSF and stellar residuals cancel in closed form even across differing seeing -- a plain subtraction leaves a dipole at every star, scaling with brightness, i.e. worst exactly where transients hide. Returns `D` (difference), `S` (match-filtered score) and `S_corr` (score in units of its own propagated sigma, so a threshold is a real significance). **`S_corr`'s astrometric noise term is not optional**: a sub-pixel registration slip leaves a residual proportional to the local gradient, largest at bright stars, and with `astrometric_sigma=0` every bright star reports as a transient (asserted in [tests/test_difference_imaging.py](tests/test_difference_imaging.py)). `_prepare_psf` zero-pads and **rolls the PSF so its centre lands on index [0,0]** -- the FFT's origin; omitting the roll shifts every output by half the frame. `estimate_background_sigma` uses *symmetric* iterative sigma clipping even though stars are a one-sided contaminant: the obvious "keep pixels below the 80th percentile, take their MAD" truncates the Gaussian core and reads 5.7 against an injected 7.0, a 19% underestimate -- and that sigma is the denominator of every significance, so underestimating it manufactures false positives at exactly the threshold users trust. **Runs on the LINEAR stack** (`fits_stacked` in `pipeline.py`, the `RAWSTACK` product the output FITS contains), never the post-processed array: Phase 4's nonlinear stretches/denoise/local-contrast break photometric linearity, and comparing a post-processed frame against a linear reference mismatches the flux scale by a fraction of a percent -- several sigma on a bright star, reporting a transient at every star in the field. **`--transient-detect` refuses a reference without `RAWSTACK=True`** (same rule as `--merge`). The warped reference's uncovered area -- field rotation leaves empty corners -- is masked via a footprint (`_align_reference` warps an all-ones image too; `_erode` uses `border_value=1` so the frame's own edge is not treated as uncovered, which measured 66% "covered" for a 93%-covered frame): unmasked, stars in those corners came out as confident 'brightenings' (6 false candidates on a 9 deg synthetic rotation, 0 masked). The astrometric sigma is the *measured* matched-star RMS residual with `_ASTROMETRIC_SIGMA_FLOOR_PX = 0.3` as a floor -- it used to be a hard-coded 0.3 reported as a measurement. `zogy` pads to `scipy.fft.next_fast_len` (3.4x on the Origin's 1096 = 8x137 axis, whose prime factor pushes pocketfft onto Bluestein; `S_corr` identical to 2e-5 sigma in the interior). `detect_transients` is a single sorted pass, verified identical to the old per-candidate argmax loop on 300 randomized fields including ties and NaNs. `psf_difference` was dropped from `ZogyResult` (no reader). The catalogue's WCS must be built with `naxis=2` (`pipeline.py`): a bare `WCS(header)` on the `(3, H, W)` cube is 3-axis, still passes `has_celestial`, and blanks every RA/Dec. **`--transient-triage`** ([src/transient_triage.py](src/transient_triage.py)) scores each `detect_transients` candidate with a small CNN for a `real_probability` -- advisory only, never drops a candidate, the same role ZTF's BTSbot / Rubin's DIA triage play downstream of classical image differencing, and (per the 2026-09 deep-research pass into recent stacking techniques) not something any amateur tool does today. Stamp input is the standard "triplet" (`new`, warped `ref`, `difference`), each channel normalised by its own frame-level `estimate_background_sigma` rather than `originvision`'s percentile stretch, which is for photographic display and would destroy the physical sigma units ZOGY's own significance relies on. Native-only for now (`astro_native.transient_triage_score`, `ext/astro_native/src/lib.rs`'s `mod transient_triage`) -- deliberately no numpy/onnxruntime fallback yet, unlike every other native kernel in this project; self-disables with a warning when unavailable. The bundled model (`src/data/transient_triage.onnx`) is trained entirely on synthetic data -- `tools/gen_transient_triage_data.py` renders synthetic star fields through the *real* `zogy()` + `detect_transients()` (real transients, cosmic rays, undersuppressed sub-pixel registration-slip dipoles, hot pixels) and `tools/train_transient_triage.py` (a script-local `torch` dependency, not in `requirements.txt` -- model training happens outside the shipped package, same stance as `originvision.onnx` itself) trains and exports it -- so treat it as a first cut, not a production classifier; no labelled real transients exist yet | | [src/sky_model.py](src/sky_model.py) | **EXPERIMENTAL** physics-based sky background model (`--bg-method physical`). Fits `sky = c0 + c_air*airglow(z) + c_moon*moonlight(rho,z) + c_zodi*zodiacal(lambda,beta) + c_lp*skyglow(az,z)`, where every component's *spatial shape* is fixed by geometry and only a scalar amplitude is free, so unlike mesh/DBE/wavelet it has nowhere to put a nebula. **Real-data verdict: it does not work on a typical ~1 deg deep-sky field, and now detects that and declines.** On a real Lagoon session the zenith angle varies by only 0.94 deg across the whole frame and the azimuth by 1.5 deg, so every component map is essentially flat, the fit has nothing to grip, and subtracting it made the corner-to-corner gradient *worse* (67 -> 111 ADU) where DBE removed 68%; sweeping `--light-pollution-azimuth` through all 360 deg moved the residual by under 0.01 ADU. Structural, not a tuning problem: physical components vary on ten-degree scales, so a narrow field's gradient is dominated by *instrumental* effects (vignetting, amp glow, filter gradients) a sky model cannot represent. `remove_physical_sky` therefore measures `_corner_gradient` before and after and returns None unless it improved things, letting `postprocess.py::_apply_physical_sky` fall back to DBE with an accurate reason (it must distinguish 'no GPS' from 'could not help' -- reporting the former on a session that had full GPS sent the reader hunting for metadata that was already present). The guard is empirical, not a field-size rule, and its corner-based proxy is weakest on radially symmetric gradients whose four corners are equal by construction. **Two earlier claims are corrected by this testing**: DBE does *not* eat nebulosity (99.5% retained on the Lagoon -- the damage that motivated this work came from the `sky_residual` residual passes, a different step `--auto` already skips), and the '98% synthetic nebula preserved' figure holds only for a gradient built from the model's own basis. Coefficients are constrained non-negative (`scipy.optimize.nnls`) and that *is* load-bearing: across a real field the maps are nearly collinear (condition ~1e5), so an unbounded fit builds an interior maximum from cancelling coefficients and inverts a nebula (-25% preservation). Clipping is upward-only (stars sit above sky). Ephemerides are closed-form, not astropy: `EarthLocation`/`AltAz` pull in the IERS tables [packaging/originstack.spec](packaging/originstack.spec) excludes -- validated against astropy at **0.009 deg (sun) / 0.05 deg (moon)**. `julian_date` honours timezone offsets: a Celestron Origin `info.json` stamps local time (`2026-08-31T20:40:32-0700`), which the first version failed to parse at all -- silently disabling the model on exactly its target data -- and which loses 7 hours of moon position if the offset is merely stripped. Positions are mean-equinox-of-date while WCS pixels are J2000, so separations carry a ~0.35 deg precession offset (uncorrected, deliberately; and worth knowing, since that offset *looks* like an ephemeris bug -- the real one found this way was 19.6 deg, from Schlyter's lunar elements being epoched at 1999-12-31.0 rather than J2000.0). `describe_fit` gates component attribution on the basis condition number: removal can be valid while the *split* between components is unidentifiable. **Later changes:** the FOV gate now runs *before* `build_geometry` (a telescope field is declined in ~0 s / 0 MB instead of ~1.3 s / 578 MB); `fit_sky_model` fits on a strided sample (`_FIT_MAX_SAMPLES`, ~100k pixels; 65x faster, dominant coefficient within 0.25%) but evaluates the model at full resolution; `remove_physical_sky_with_reason` returns `(result, reason)` with the *real* reason (narrow field / failed fit / no improvement / no geometry) and `remove_physical_sky` is a thin wrapper; `_nnls` has **no fallback** -- it used to catch every exception (incl. scipy's max-iteration `RuntimeError`) and substitute a clamped `lstsq`, i.e. the unbounded fit this module documents as inverting nebulae, and the guard cannot see radially symmetric damage. A failure now declines to DBE with a warning. Azimuth and helio-ecliptic longitude are upsampled via sin/cos (`_up_angle`): interpolating across the 360/0 seam ramped the long way round, measured as a 22.5 deg/px step where the truth is ~0.05. The unreleased multi-frame joint fit and `amp_glow_basis` were removed -- nothing in `src/` called them | | [src/uncertainty.py](src/uncertainty.py) | End-to-end uncertainty propagation (`--uncertainty-propagate`) and confidence mapping. Phase 3's `--uncertainty-map` sigma describes the *linear* stack; Phase 4 then reshapes the noise field, so this carries the error bars through to the delivered image. `propagate_uncertainty` is **Monte Carlo, not analytic**: it draws K noise realizations (`--uncertainty-realizations`, default 8) from the Phase 3 sigma map and pushes each through the *unmodified* `postprocess_stack`, taking the per-pixel spread. Deliberate — nine Phase 4 steps are nonlinear denoisers and four are iterative deconvolvers (RL, FISTA, anisotropic diffusion), several spatially adaptive (BayesShrink thresholds, the structure-tensor coherence map), so no closed-form Jacobian exists for most of the chain; the MC estimator is exact up to `~1/sqrt(2K)` *for a chain that does not adapt to its input's noise level*, and stays correct automatically when a denoiser is added. **It is slightly biased low for adaptive steps:** each realization is `stacked + N(0, sigma)` but `stacked` already carries ~sigma, so Phase 4 sees sqrt(2)x the real noise and steps that estimate parameters from the data (BayesShrink, DBE sky sigma) denoise it harder. Measured against this project's own `wavelet_denoise` over sigma {1,4,12} x threshold {2,3,5}: ratio 0.93-1.03 -- a few percent, inside the ~25% MC error at K=8. A first-principles argument predicted ~2x understatement; it did not reproduce (shrinkage and threshold move together), and a reviewer's toy-chain figure was likewise wrong -- measure against the real denoiser before believing either. `propagate_uncertainty` returns `(sigma_post, mean_post, adaptivity)`, where `adaptivity` is sigma(full amp)/sigma(half amp): 2.0 = scale-invariant, and `pipeline.py` warns below 1.5. Realizations run under `_quiet_args` (file-writing/network steps off — `_QUIET_OFF`: `remove_stars`, `nmf_separate`, `photometric_calibration`, `annotate`, `aberration_report`, `diagnostic`, `export_masks`, `keep_intermediates`, `comet_radial_renorm`, `comet_larson_sekanina`, and `denoise_strength_calibrate`, a 9-point sweep. **Any Phase 4 step that writes a file or calls out belongs in that list**: every realization runs under `redirect_stdout`, so a step left on prints its own "Saved:" into a swallowed buffer and the file left on disk is the *last noise realization*, silently) with stdout swallowed, so K passes don't emit K sets of sidecars or K Gaia queries; everything that shapes the *noise* is left exactly as the real pass ran it. `confidence_map` turns the propagated sigma into per-pixel SNR above sky and returns **`NaN` where the propagated sigma is exactly zero** — those pixels were clamped to a constant by the chain (sky pedestal lift, non-negativity clips), so they carry no measurement, and dividing by ~0 would otherwise rank the pipeline's own floor artifacts as the most confident pixels in the frame (observed at ~25% of a real synthetic frame). `error_aware_black_point` (`--error-aware-stretch SIGMA`) returns the faintest pixel clearing SIGMA confidence, used as the preview black point so sub-threshold content clips to black instead of being stretched into apparent structure. Validated against chains with known variance transformation — identity recovers the input sigma, a x3 scale scales it x3, a 3x3 box blur divides it by exactly 3 ([tests/test_uncertainty_propagation.py](tests/test_uncertainty_propagation.py)) | | [src/psf_deconvolution.py](src/psf_deconvolution.py) | PSF estimation, Richardson-Lucy (global + spatially-variant `richardson_lucy_svpsf`), TV, and `sparse_wavelet_deconvolve` (`--deconvolve sparse`) -- FISTA (Beck & Teboulle 2009), L1-regularised in this project's own wavelet basis (`src/wavelet.py`) rather than TV's spatial-gradient basis; a different sparsity prior, same forward/adjoint PSF convolution and positivity-pedestal pattern as the RL/TV paths | @@ -163,7 +163,7 @@ The old local-normalisation step (`--local-normalize`) was **removed**: it did l The preview JPEG black point is set per target by the auto-advisor (`preview_black_sigma`, overridable with `--preview-black-sigma`); higher values (2–3) clip the sky-noise tail to black for a small target on empty sky. ### Incremental stacking (`--merge`) -The main output FITS is the linear pre-post-processing stack (`RAWSTACK=True`) with `NFRAMES`/`INTGTIME`/`TOTEXP` headers. `--merge PREV.fits [...]` processes only the new session through Phases 1-3, registers each previous stack onto the new grid (blind rigid star-pattern match in [src/blind_match.py](src/blind_match.py) first — nights differ by arbitrary field rotation on alt-az mounts, and this makes no assumption about the angle — translation-seeded star-match affine and translation-only fallbacks, hard error on failure or <25% overlap), and combines them before Phase 4 runs once on the result: each previous stack is first mapped onto the current stack's flux scale (`merge._match_flux_scale`: per-channel gain + sky offset, Tukey-IRLS on smoothed pixels bright in both -- a 25 s ISO 500 stack is not comparable to a 10 s ISO 200 one in raw ADU, and averaging them as-is mixed two brightness scales), then a per-pixel **inverse-noise-variance** weighted mean inside each warped footprint (`merge._pixel_noise`, MAD of lag-4 pixel differences; falls back to `NFRAMES` weights if any stack's noise is unmeasurable). `NFRAMES` is only a proxy for noise when every session has the same per-frame exposure. Header aggregates are summed, so the output chains into future merges. There is no cross-session outlier rejection (each session already rejected internally); not supported with `--drizzle-scale > 1`. +The main output FITS is the linear pre-post-processing stack (`RAWSTACK=True`) with `NFRAMES`/`INTGTIME`/`TOTEXP` headers. `--merge PREV.fits [...]` processes only the new session through Phases 1-3, registers each previous stack onto the new grid (blind rigid star-pattern match in [src/blind_match.py](src/blind_match.py) first — nights differ by arbitrary field rotation on alt-az mounts, and this makes no assumption about the angle — translation-seeded star-match affine and translation-only fallbacks, hard error on failure or <25% overlap), and combines them before Phase 4 runs once on the result: each previous stack is first mapped onto the current stack's flux scale (`merge._match_flux_scale`: per-channel gain + sky offset, Tukey-IRLS on smoothed pixels bright in both -- a 25 s ISO 500 stack is not comparable to a 10 s ISO 200 one in raw ADU, and averaging them as-is mixed two brightness scales), then a per-pixel **inverse-noise-variance** weighted mean inside each warped footprint (`merge._pixel_noise`, MAD of lag-4 pixel differences; falls back to `NFRAMES` weights if any stack's noise is unmeasurable). `NFRAMES` is only a proxy for noise when every session has the same per-frame exposure. Header aggregates are summed, so the output chains into future merges. There is no cross-session outlier rejection (each session already rejected internally); not supported with `--drizzle-scale > 1`. A previous stack's own shape rarely matches the current run's (different dither pattern, different Phase 3 common-crop), so it's zero-padded/cropped onto the current grid first (`src.utils.embed_to_shape`, top-left, pixel coordinates preserved) before registration -- the blind matcher needs no positional correspondence between the two canvases, only relative star geometry, so this is purely a shape-reconciliation step, not an alignment hint. `--transient-detect`'s `_align_reference` ([src/difference_imaging.py](src/difference_imaging.py)) shares this same helper for the identical reason: two independently-stacked sessions of the same target routinely differ in pixel dimensions too. It used to hard-refuse a shape mismatch there; the fix also had to carry a real *valid-data* mask through the warp (not an all-ones one) so the zero-padded border reads as uncovered, not real reference data -- otherwise stars in that padding would report as false 'brightening' transients, the same failure mode the rotation-wedge footprint mask already guards against. ### Streaming memory model Frames are processed one at a time: load → process → accumulate → free. Memory usage stays at ~1-2 frames regardless of total frame count. This is the core design constraint — never accumulate all frames in memory. @@ -215,6 +215,7 @@ with `bayerPattern` to override. ### Native (Rust) acceleration [ext/astro_native/](ext/astro_native/) is a PyO3/maturin crate of hot-path kernels, all with a numpy fallback. Coverage: - **originvision inference** (`ext/astro_native/src/lib.rs` `mod originvision`, `src/originvision_infer.py::score_rgb`, `--originvision`): the one kernel that isn't a numpy-accelerator — it runs the whole `--originvision` scoring path natively (preprocessing + ONNX forward pass via the pure-Rust **`tract`** runtime), so a source checkout with `astro_native` built and the packaged app need **no Python ONNX dependency at all**. Preprocessing mirrors the Python fallback exactly (per-channel [0.5, 99.5] stretch → gaussian-prefiltered bilinear resize/centre-crop → NCHW /255); result heads are gated on the ONNX metadata `tasks` list, and a graph-output count that doesn't match `head_order` is a hard error (both backends). The compute runs inside `py.allow_threads` and under `std::panic::catch_unwind` — `tract` parses an external `.onnx` and a malformed one can *panic* inside the parser (a `pyo3_runtime.PanicException`, which is a `BaseException` and slips past callers' `except Exception`), so it's converted to a plain `RuntimeError` here; `panic = "unwind"` (not `"abort"`) in the release profile is what lets that unwind happen. Session cached per `(model_path, size)` (`OnceLock>`; the mutex is dropped before the forward pass). `tract` pulls ~120 transitive crates and lengthens the crate's first build noticeably. Fallback is `src/originvision_infer.py`'s numpy/scipy + `onnxruntime` port (see "originvision" module row above). Parity (native vs the onnxruntime fallback: identical category/flags, scalars within tolerance) in `tests/test_native.py`, and CI's `native` job (`.github/workflows/ci.yml`) builds the crate + installs `onnxruntime` so that parity actually runs on every push. +- **transient triage inference** (`ext/astro_native/src/lib.rs` `mod transient_triage`, `src/transient_triage.py::score_candidates`, `--transient-triage`): a much smaller sibling of the originvision kernel above -- no percentile stretch, no resize (candidate stamps arrive already extracted at a fixed size and per-channel sigma-normalized in Python), no multi-head metadata decode, just a batched tract forward pass over `(N, 3, size, size)` returning one sigmoid probability per candidate in a single call rather than one call per candidate. Same panic-guard (`catch_unwind` + `py.detach`) and `(path, size)`-keyed session cache pattern as originvision. **Deliberately has no numpy/onnxruntime fallback yet** -- unlike every other native kernel in this file, a source checkout without `astro_native` built simply cannot use `--transient-triage` (self-disables with a warning) until one is added. - **Stacking combines** (`src/stacking.py`): `sigma_clip_combine` (~37×), `esd_combine` (~24×), `percentile_clip_combine` (~13×), `median_combine` (~6×). ESD's Student-t critical-value table is precomputed in Python (`_esd_lambda_table`) and passed to Rust — exact parity, no stats crate. Native path is taken when a rejection mask is not requested and the input is a C-contiguous float32 `(N,H,W,C)` array; the aligned stack memmap qualifies, so Rust views it zero-copy and the streaming memmap model is preserved. (`trimmed_mean_combine` was removed 2026-08 — Python and Rust — once recognized as functionally identical to `percentile_clip_combine` at matching params, just parameterized by trim-fraction instead of percentile bounds.) - **Linear Fit Clipping** (`src/stacking.py` `linear_fit_clip_combine`, `--stack-method linear_fit`): PixInsight's algorithm — sorts each pixel's per-frame stack ascending (order statistics), fits a line to value-vs-rank by least squares, rejects samples whose residual from that fit exceeds `sigma_low`/`sigma_high` times the residual scale, refits on survivors, iterates. More robust to non-Gaussian tails than sigma-clip's mean/std test since it doesn't assume the per-pixel distribution across frames is Gaussian, just that it's locally near-linear in sorted order. No independent open-source reference implementation exists to bit-validate against (unlike Malvar/Menon2007) since PixInsight is closed-source — validated instead via a numpy mirror (`_linear_fit_clip_tile`) checked for native/numpy parity plus a synthetic-outlier-injection test (`tests/test_native.py`) confirming a single wild sample per pixel doesn't survive into the combined result. - **Inverse-variance-weighted combine** (`src/stacking.py` `ivw_combine`, `--stack-method ivw`): the Gauss-Markov-optimal linear combiner — weights each frame by `1/noise²` using that frame's own measured Phase 1 background sigma (a real statistical optimum under a per-frame-homoscedastic noise model, unlike `--weight-noise`'s ad-hoc multiplicative heuristic). Optionally adds a per-pixel Poisson shot-noise term (`--config ivw_gain`, electrons/ADU) so brighter regions are correctly down-weighted relative to sky background in noisier frames — this is why it still routes through the native kernel's per-pixel loop rather than a single static-weight numpy broadcast, even though the no-gain case has no iteration or sorting at all. Does not reject outliers (cosmic rays/trails get a small nonzero weight, not zero) — meant to complement this pipeline's existing per-frame pre-filters (`--cosmic-ray-rejection`, `--trail-reject`), not replace them. diff --git a/ext/astro_native/Cargo.lock b/ext/astro_native/Cargo.lock index 67de6aa..031c263 100644 --- a/ext/astro_native/Cargo.lock +++ b/ext/astro_native/Cargo.lock @@ -49,7 +49,7 @@ checksum = "fb5dfbc6d8d2675589ccbe4d0fd61df2419075625f8c1a62325e718e2b0049f9" [[package]] name = "astro_native" -version = "0.35.0" +version = "0.36.0" dependencies = [ "numpy", "pyo3", diff --git a/ext/astro_native/Cargo.toml b/ext/astro_native/Cargo.toml index 2b84ac3..a40dd11 100644 --- a/ext/astro_native/Cargo.toml +++ b/ext/astro_native/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "astro_native" -version = "0.35.0" +version = "0.36.0" edition = "2021" description = "Native (Rust) hot-path kernels for OriginStack: stacking combine, etc." diff --git a/ext/astro_native/pyproject.toml b/ext/astro_native/pyproject.toml index f380506..24850f1 100644 --- a/ext/astro_native/pyproject.toml +++ b/ext/astro_native/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "maturin" [project] name = "astro_native" -version = "0.35.0" +version = "0.36.0" description = "Native Rust hot-path kernels for OriginStack" requires-python = ">=3.10" classifiers = ["Programming Language :: Rust"] diff --git a/ext/astro_native/src/lib.rs b/ext/astro_native/src/lib.rs index 121d913..abde232 100644 --- a/ext/astro_native/src/lib.rs +++ b/ext/astro_native/src/lib.rs @@ -6469,6 +6469,151 @@ mod originvision { } } +// --------------------------------------------------------------------------- +// ZOGY transient triage: real/bogus classification of --transient-detect +// candidates (src/transient_triage.py, --transient-triage) +// --------------------------------------------------------------------------- +// +// A much smaller sibling of `mod originvision` above: no percentile stretch, +// no resize -- stamps arrive already extracted at a fixed size and +// per-channel sigma-normalized in Python (there is no perf-relevant work to +// move into Rust for a few hundred 31x31x3 stamps), and no multi-head +// metadata decode, just one scalar sigmoid probability per candidate. +// Batched: all of a frame's candidates score in a single tract forward pass +// over (N, 3, size, size), not one call per candidate. Advisory only -- +// never filters `detect_transients`' output, just attaches a +// `real_probability`. +// +// The bundled model (src/data/transient_triage.onnx) is trained entirely on +// synthetic data (tools/gen_transient_triage_data.py + +// tools/train_transient_triage.py) -- no labelled real transients exist yet +// -- so treat it as a first cut, not a production classifier. Deliberately +// has no numpy/onnxruntime fallback yet, unlike every other native kernel in +// this file: a source checkout without astro_native built simply can't use +// --transient-triage until one is added (self-disables with a warning, see +// src/transient_triage.py). +mod transient_triage { + use pyo3::prelude::*; + use std::collections::HashMap; + use std::sync::{Arc, Mutex, OnceLock}; + use tract_onnx::prelude::*; + + type Runnable = TypedRunnableModel; + + struct Session { + model: Runnable, + } + + fn cache() -> &'static Mutex>> { + static C: OnceLock>>> = OnceLock::new(); + C.get_or_init(|| Mutex::new(HashMap::new())) + } + + fn load(path: &str, _size: usize) -> TractResult { + // Unlike `originvision::load`, the batch axis here must stay + // symbolic: this kernel scores a whole frame's candidates in one + // batched call (N varies per frame), while originvision always + // calls with N=1. The channel/H/W axes are already concrete in the + // exported graph (torch.onnx.export's dynamic_axes only marks axis + // 0 as dynamic), so no `with_input_fact` override is needed -- one + // was tried and made every N != 1 call fail with a tract symbol + // resolution clash against the fixed batch=1 it forced. + let proto = tract_onnx::onnx().proto_model_for_path(path)?; + let model = tract_onnx::onnx() + .model_for_proto_model(&proto)? + .into_optimized()? + .into_runnable()?; + Ok(Session { model }) + } + + fn get_session(path: &str, size: usize) -> TractResult> { + // Key on (path, size): the input size is baked into the compiled + // graph by `load`'s `with_input_fact` + `into_optimized`, same + // reasoning as `originvision`'s cache above. + let key = format!("{path}\u{0}{size}"); + { + let c = cache().lock().unwrap(); + if let Some(s) = c.get(&key) { + return Ok(Arc::clone(s)); + } + } + let s = Arc::new(load(path, size)?); + cache().lock().unwrap().insert(key, Arc::clone(&s)); + Ok(s) + } + + fn sigmoid(x: f64) -> f64 { + 1.0 / (1.0 + (-x).exp()) + } + + /// Pure compute: batched forward pass over N pre-normalized stamps, no + /// `Python` token -- runs inside `py.detach`. + fn compute(stamps: &[f32], n: usize, size: usize, model_path: &str) -> Result, String> { + if n == 0 { + return Ok(Vec::new()); + } + let sess = get_session(model_path, size).map_err(|e| e.to_string())?; + // `stamps` is already NCHW-ordered per candidate: (n, 3, size, size). + let input = tract_ndarray::Array4::from_shape_vec((n, 3, size, size), stamps.to_vec()) + .map_err(|e| e.to_string())? + .into_tensor(); + let outputs = sess.model.run(tvec!(input.into())).map_err(|e| e.to_string())?; + let raw = outputs + .first() + .ok_or_else(|| "model produced no output".to_string())? + .to_array_view::() + .map_err(|e| e.to_string())?; + if raw.len() != n { + return Err(format!( + "model produced {} outputs for {n} candidates -- refusing to guess", + raw.len() + )); + } + Ok(raw.iter().map(|&v| sigmoid(v as f64)).collect()) + } + + #[pyfunction] + #[pyo3(signature = (stamps, model_path, size=31))] + pub fn transient_triage_score( + py: Python<'_>, + stamps: numpy::PyReadonlyArray4, + model_path: &str, + size: usize, + ) -> PyResult> { + let a = stamps.as_array(); + let sh = a.shape(); + if sh.len() != 4 || sh[1] != 3 || sh[2] != size || sh[3] != size { + return Err(pyo3::exceptions::PyValueError::new_err(format!( + "expected stamps shaped (N, 3, {size}, {size}), got {:?}", + sh + ))); + } + let n = sh[0]; + let flat: Vec = a.iter().cloned().collect(); + let model_path = model_path.to_string(); + + // Heavy work off the GIL, panic-guarded -- same reasoning as + // `originvision_score`: `tract` parses an external .onnx file and a + // malformed one can panic inside the parser, which would otherwise + // unwind past callers' `except Exception` as a bare PanicException. + let outcome = py.detach(|| { + std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + compute(&flat, n, size, &model_path) + })) + }); + + match outcome { + Ok(Ok(probs)) => Ok(probs), + Ok(Err(msg)) => Err(pyo3::exceptions::PyRuntimeError::new_err(format!( + "transient_triage native inference failed: {msg}" + ))), + Err(_) => Err(pyo3::exceptions::PyRuntimeError::new_err( + "transient_triage native inference panicked (malformed model?)".to_string(), + )), + } + } +} + // --------------------------------------------------------------------------- // CFA drizzle: splat one frame's measured Bayer samples (src/cfa_drizzle.py) // --------------------------------------------------------------------------- @@ -7913,6 +8058,7 @@ fn white_balance_grayworld_inplace<'py>( #[pymodule] fn astro_native(m: &Bound<'_, PyModule>) -> PyResult<()> { m.add_function(wrap_pyfunction!(originvision::originvision_score, m)?)?; + m.add_function(wrap_pyfunction!(transient_triage::transient_triage_score, m)?)?; m.add_function(wrap_pyfunction!(sigma_clip_combine, m)?)?; m.add_function(wrap_pyfunction!(online_sigma_clip_combine, m)?)?; m.add_function(wrap_pyfunction!(online_sigma_clip_seed_burnin, m)?)?; diff --git a/src/cli.py b/src/cli.py index e80b0ee..adc9b91 100644 --- a/src/cli.py +++ b/src/cli.py @@ -1828,6 +1828,21 @@ def build_parser() -> argparse.ArgumentParser: 'corrected score image (default: 5.0). The score is calibrated ' '(source + astrometric noise are propagated), so this is a real ' 'significance, not an arbitrary cut.') + g_post.add_argument('--transient-triage', action='store_true', + help='Score each --transient-detect candidate with a small CNN for a ' + 'real_probability (real transient vs. cosmic ray / registration-slip ' + 'dipole / hot pixel) -- the same role ZTF\'s BTSbot / Rubin\'s DIA ' + 'triage play downstream of classical image differencing. Advisory ' + 'only: never drops a candidate, just adds a column to ' + '_transients.csv. Native-only (astro_native.transient_triage_score, ' + 'no onnxruntime fallback yet) -- self-disables with a warning if ' + 'unavailable. The bundled model is trained entirely on synthetic data ' + '(tools/gen_transient_triage_data.py + tools/train_transient_triage.py, ' + 'no labelled real transients exist yet), so treat it as a first cut. ' + 'Requires --transient-detect.') + g_post.add_argument('--transient-triage-model', default=None, metavar='PATH', + help='Override the bundled transient-triage model ' + '(src/data/transient_triage.onnx). Requires --transient-triage.') g_post.add_argument('--light-pollution-azimuth', type=float, default=0.0, metavar='DEG', help='physical bg-method only: compass azimuth (degrees east of north) ' @@ -2472,6 +2487,11 @@ def _passed_on_cli(action) -> bool: # for "ran but found nothing" rather than "didn't run at all". safe_print(" WARNING: --originvision-score-all has no effect without --originvision") + if getattr(args, 'transient_triage', False) and not getattr(args, 'transient_detect', None): + safe_print(" WARNING: --transient-triage has no effect without --transient-detect") + if getattr(args, 'transient_triage_model', None) and not getattr(args, 'transient_triage', False): + safe_print(" WARNING: --transient-triage-model has no effect without --transient-triage") + return args diff --git a/src/data/transient_triage.onnx b/src/data/transient_triage.onnx new file mode 100644 index 0000000000000000000000000000000000000000..a73e74d54ea0379542af444f47e6c7118190b063 GIT binary patch literal 96951 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 kwfSSlfDyw> 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

Qa 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�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

MUF-oh{|))oElynN= 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 L*~y97lj8pmMZpq> literal 0 HcmV?d00001 diff --git a/src/difference_imaging.py b/src/difference_imaging.py index 8f7b059..5681702 100644 --- a/src/difference_imaging.py +++ b/src/difference_imaging.py @@ -78,6 +78,7 @@ class Transient(NamedTuple): x: float significance: float # S_corr value, in sigma kind: str # 'brightening' | 'fading' + real_probability: Optional[float] = None # --transient-triage; None if not scored def _to_luminance(img: np.ndarray) -> np.ndarray: @@ -372,15 +373,20 @@ def write_transient_catalog(path: str, transients: Sequence[Transient], has_wcs = wcs is not None n_failed = 0 first_error = None + has_triage = any(t.real_probability is not None for t in transients) with open(path, 'w', newline='', encoding='utf-8') as fh: writer = csv.writer(fh) header = ['x', 'y', 'significance_sigma', 'kind'] + if has_triage: + header += ['real_probability'] if has_wcs: header += ['ra_deg', 'dec_deg'] writer.writerow(header) for t in transients: row = [f"{t.x:.2f}", f"{t.y:.2f}", f"{t.significance:.2f}", t.kind] + if has_triage: + row += [f"{t.real_probability:.3f}" if t.real_probability is not None else ''] if has_wcs: try: ra, dec = wcs.all_pix2world(t.x, t.y, 0) @@ -410,54 +416,45 @@ def write_transient_catalog(path: str, transients: Sequence[Transient], _RESIDUAL_MATCH_TOL_PX = 3.0 -def run_transient_detection(stacked: np.ndarray, reference_path: str, - output_path: str, threshold: float = 5.0, - wcs=None) -> Optional[dict]: - """Orchestrate a two-epoch comparison: align, subtract, detect, report. - - Returns a summary dict, or None when the comparison could not be set up - (missing or non-linear reference, too few stars to estimate a PSF, no - overlap). Setup failures are reported and return None; an I/O failure - writing the outputs (disk full, read-only directory) *does* raise, so the - caller must still guard this -- ``pipeline.py`` does. +class EpochComparison(NamedTuple): + """The core two-epoch ZOGY comparison result -- everything + ``run_transient_detection`` needs to write its outputs, and everything + ``tools/gen_transient_triage_data.py``'s real-data mining mode needs to + build training stamps, without going through that function's file I/O.""" + transients: List[Transient] + difference: np.ndarray # D, NaN outside the reference footprint + score_corr: np.ndarray # S_corr, NaN outside the reference footprint + new_lum: np.ndarray # pedestal-subtracted, not yet warped (it's the reference frame) + ref_lum: np.ndarray # pedestal-subtracted AND warped onto new_lum's grid + covered: float + measured_axis: Optional[float] + astro_sigma_px: float + flux_ratio: float + + +def _compare_epochs(stacked: np.ndarray, ref: np.ndarray, + threshold: float = 5.0) -> Optional[EpochComparison]: + """Align two RGB (or 2D) epochs, run ZOGY, and detect candidates. + + ``stacked``/``ref`` are raw pixel arrays (RGB or luminance), not yet + reduced to luminance or background-subtracted -- this does both, then + registration, PSF estimation, ``zogy()`` and ``detect_transients``. + Returns ``None`` on any setup failure (logged here), same conditions + ``run_transient_detection`` always reported at this call site. """ - from src.io_fits import load_fits - - if not os.path.exists(reference_path): - safe_print(f" WARNING: transient reference not found: {reference_path}") - return None - - try: - ref, ref_header = load_fits(reference_path) - except Exception as exc: - safe_print(f" WARNING: could not read transient reference: {exc}") - return None - - # The comparison is only meaningful between two LINEAR stacks. Phase 4's - # stretches, denoisers and local contrast break photometric linearity, so - # a post-processed reference mismatches the flux scale by a fraction of a - # percent -- several sigma on a bright star -- and every star in the field - # reports as a confident transient. --merge refuses the same file for the - # same reason. - if not bool((ref_header or {}).get('RAWSTACK', False)): - safe_print(f" WARNING: {os.path.basename(reference_path)} is not a linear " - f"(pre-post-processing) stack: header RAWSTACK is missing or " - f"False. Pass the main output FITS of a previous run, not the " - f"_processed one -- skipping difference imaging.") - return None - - # The pipeline writes RGB planes as (C, H, W); load_fits hands them back - # in that order, while everything here works in (H, W, C). - ref = np.asarray(ref) - if ref.ndim == 3 and ref.shape[0] in (3, 4) and ref.shape[0] < ref.shape[-1]: - ref = np.transpose(ref, (1, 2, 0)) - new_lum = _to_luminance(stacked) ref_lum = _to_luminance(ref) if new_lum.shape != ref_lum.shape: - safe_print(f" WARNING: reference epoch is {ref_lum.shape}, this stack is " - f"{new_lum.shape} -- cannot compare different frame sizes") - return None + # Two independently-stacked sessions of the same target routinely + # differ in pixel dimensions -- different dither pattern, different + # Phase 3 common-crop -- even though they're the same field. This + # used to be a hard failure; `_align_reference` now embeds the + # reference onto this stack's own grid (same trick `merge.py` uses + # for its differently-shaped previous stacks) before the blind + # star-pattern match, which doesn't need or assume equal shapes or + # any positional correspondence between the two canvases anyway. + safe_print(f" reference epoch is {ref_lum.shape}, this stack is " + f"{new_lum.shape} -- reconciling onto a common grid") # ZOGY assumes background-subtracted inputs, so remove each epoch's own # sky level rather than trusting them to share one. This is not a @@ -505,6 +502,86 @@ def run_transient_detection(stacked: np.ndarray, reference_path: str, covered = float(valid.mean()) transients = detect_transients(score_corr, threshold=threshold) + return EpochComparison(transients=transients, difference=difference, + score_corr=score_corr, new_lum=new_lum, ref_lum=ref_lum, + covered=covered, measured_axis=measured_axis, + astro_sigma_px=astro_sigma_px, flux_ratio=flux_ratio) + + +def run_transient_detection(stacked: np.ndarray, reference_path: str, + output_path: str, threshold: float = 5.0, + wcs=None, triage: bool = False, + triage_model_path: Optional[str] = None) -> Optional[dict]: + """Orchestrate a two-epoch comparison: align, subtract, detect, report. + + ``triage`` (``--transient-triage``) additionally scores each candidate + with a small CNN (``src/transient_triage.py``) for a ``real_probability`` + -- advisory only, never drops a candidate. + + Returns a summary dict, or None when the comparison could not be set up + (missing or non-linear reference, too few stars to estimate a PSF, no + overlap). Setup failures are reported and return None; an I/O failure + writing the outputs (disk full, read-only directory) *does* raise, so the + caller must still guard this -- ``pipeline.py`` does. + """ + from src.io_fits import load_fits + + if not os.path.exists(reference_path): + safe_print(f" WARNING: transient reference not found: {reference_path}") + return None + + try: + ref, ref_header = load_fits(reference_path) + except Exception as exc: + safe_print(f" WARNING: could not read transient reference: {exc}") + return None + + # The comparison is only meaningful between two LINEAR stacks. Phase 4's + # stretches, denoisers and local contrast break photometric linearity, so + # a post-processed reference mismatches the flux scale by a fraction of a + # percent -- several sigma on a bright star -- and every star in the field + # reports as a confident transient. --merge refuses the same file for the + # same reason. + if not bool((ref_header or {}).get('RAWSTACK', False)): + safe_print(f" WARNING: {os.path.basename(reference_path)} is not a linear " + f"(pre-post-processing) stack: header RAWSTACK is missing or " + f"False. Pass the main output FITS of a previous run, not the " + f"_processed one -- skipping difference imaging.") + return None + + # The pipeline writes RGB planes as (C, H, W); load_fits hands them back + # in that order, while everything here works in (H, W, C). + ref = np.asarray(ref) + if ref.ndim == 3 and ref.shape[0] in (3, 4) and ref.shape[0] < ref.shape[-1]: + ref = np.transpose(ref, (1, 2, 0)) + + comparison = _compare_epochs(stacked, ref, threshold=threshold) + if comparison is None: + return None + transients = comparison.transients + difference = comparison.difference + score_corr = comparison.score_corr + new_lum = comparison.new_lum + ref_lum = comparison.ref_lum + covered = comparison.covered + measured_axis = comparison.measured_axis + astro_sigma_px = comparison.astro_sigma_px + flux_ratio = comparison.flux_ratio + + n_triaged = 0 + if triage and transients: + from src.transient_triage import score_candidates + # Recomputed rather than threaded out of `zogy()`'s ZogyResult -- + # cheap (same robust-sigma estimator, run on arrays already in hand) + # and avoids widening that return type for an opt-in feature. + sigma_new = estimate_background_sigma(new_lum) + sigma_ref = estimate_background_sigma(ref_lum) + sigma_diff = estimate_background_sigma(difference) + probs = score_candidates(new_lum, ref_lum, difference, transients, + sigma_new, sigma_ref, sigma_diff, + model_path=triage_model_path) + transients = [t._replace(real_probability=p) for t, p in zip(transients, probs)] + n_triaged = sum(1 for p in probs if p is not None) stem = os.path.splitext(output_path)[0] _write_fits_plane(stem + '_difference.fits', difference, @@ -520,6 +597,16 @@ def run_transient_detection(stacked: np.ndarray, reference_path: str, safe_print(f" Difference imaging: {len(transients)} candidate(s) above " f"{threshold:g} sigma ({n_bright} brightening, " f"{len(transients) - n_bright} fading)") + if triage: + if n_triaged: + n_likely = sum(1 for t in transients + if t.real_probability is not None and t.real_probability > 0.5) + safe_print(f" triage: {n_triaged}/{len(transients)} scored, " + f"{n_likely} likely real (real_probability > 0.5) -- " + f"advisory only, nothing was dropped") + else: + safe_print(" triage: requested but unavailable (see warning above) " + "-- candidates left unscored") if measured_axis is None: reg = (f"registration residual not measurable -- assumed " f"{astro_sigma_px:.2f} px/axis") @@ -563,13 +650,22 @@ def _align_reference(new_lum: np.ndarray, ref_lum: np.ndarray): Cross-night pairs differ by arbitrary field rotation on an alt-az mount, so this goes through the same blind star-pattern matcher ``--merge`` uses - rather than assuming a pure translation. + rather than assuming a pure translation. The matcher itself needs no + positional correspondence between ``new_lum``/``ref_lum`` -- it matches on + relative star geometry -- so unequal shapes are reconciled first by + embedding ``ref_lum`` onto ``new_lum``'s grid (top-left, zero-padded; + ``src.utils.embed_to_shape``, the same trick ``merge.py`` uses for a + previous stack whose own shape rarely matches the current run's): a + no-op when the shapes already match. Returns ``(warped_ref, footprint, residual_px, new_stars)`` or None: - ``footprint`` is the warped reference's coverage in [0, 1] -- the same - transform applied to an all-ones image. Outside it the reference is - fill, not data. + transform applied to a mask of where ``ref_lum`` had real data (ones + only inside its own original extent, before any embedding). Outside it + the reference is fill, not data -- and that now covers both the warp's + own uncovered wedges (field rotation) and any embed-padding border, so + neither reads as a bogus "transient" the way an all-ones mask would. - ``residual_px`` is the RMS 2D distance between matched star pairs after the transform: a real measurement of how well the epochs line up, which feeds ZOGY's astrometric noise term. None when too few pairs match to @@ -579,6 +675,13 @@ def _align_reference(new_lum: np.ndarray, ref_lum: np.ndarray): """ from src.registration import apply_transform from src.star_detect import detect_stars_matched_filter + from src.utils import embed_to_shape + + ref_valid_mask = np.ones_like(ref_lum, dtype=np.float32) + if ref_lum.shape != new_lum.shape: + H, W = new_lum.shape + ref_valid_mask = embed_to_shape(ref_valid_mask, H, W) + ref_lum = embed_to_shape(ref_lum, H, W) try: new_stars = detect_stars_matched_filter(new_lum.astype(np.float32)) @@ -602,8 +705,7 @@ def _align_reference(new_lum: np.ndarray, ref_lum: np.ndarray): try: warped = apply_transform(ref_lum.astype(np.float32), transform=transform) - footprint = apply_transform(np.ones_like(ref_lum, dtype=np.float32), - transform=transform) + footprint = apply_transform(ref_valid_mask, transform=transform) except Exception as exc: _log.debug("transient alignment: warp failed (%s)", exc) return None diff --git a/src/merge.py b/src/merge.py index ed17d80..6949425 100644 --- a/src/merge.py +++ b/src/merge.py @@ -36,6 +36,7 @@ import numpy as np from src.models import Config +from src.utils import embed_to_shape as _embed_to_shape from src.utils import get_logger, safe_print _log = get_logger() @@ -57,22 +58,6 @@ def _detect_stars(lum: np.ndarray) -> Optional[Any]: return None -def _embed_to_shape(arr: np.ndarray, H: int, W: int) -> np.ndarray: - """Place ``arr`` in the top-left of an (H, W[, C]) zero canvas (crop if - larger). Pixel coordinates are preserved, so a transform computed on the - embedded luminance applies directly to the embedded image.""" - if arr.shape[0] == H and arr.shape[1] == W: - return arr - if arr.ndim == 3: - out = np.zeros((H, W, arr.shape[2]), dtype=arr.dtype) - else: - out = np.zeros((H, W), dtype=arr.dtype) - h = min(H, arr.shape[0]) - w = min(W, arr.shape[1]) - out[:h, :w] = arr[:h, :w] - return out - - def _register_stack(new_lum: np.ndarray, prev_lum: np.ndarray, new_stars: Optional[Any]) -> Tuple[Optional[Any], Optional[Tuple[float, float]]]: diff --git a/src/pipeline.py b/src/pipeline.py index 30e5cd2..8d141df 100644 --- a/src/pipeline.py +++ b/src/pipeline.py @@ -1153,7 +1153,9 @@ def _psf_fallback_suffix() -> str: run_transient_detection( _transient_src, args.transient_detect, output_path, threshold=float(getattr(args, 'transient_threshold', 5.0)), - wcs=_wcs_for_transients) + wcs=_wcs_for_transients, + triage=bool(getattr(args, 'transient_triage', False)), + triage_model_path=getattr(args, 'transient_triage_model', None)) safe_print(f" Difference imaging: {time.time() - _td_start:.1f}s") except Exception as e: # Diagnostic add-on: a failure here must never cost the stack. diff --git a/src/transient_triage.py b/src/transient_triage.py new file mode 100644 index 0000000..6ce463d --- /dev/null +++ b/src/transient_triage.py @@ -0,0 +1,152 @@ +"""ZOGY candidate real/bogus triage (``--transient-triage``). + +``detect_transients`` (``src/difference_imaging.py``) returns every ``S_corr`` +peak above threshold with no filtering: cosmic rays, sub-pixel +registration-slip dipoles and hot pixels all surface as candidates alongside +genuine transients. This scores each candidate with a small CNN -- the same +role ZTF's BTSbot / Rubin's DIA triage play downstream of classical image +differencing -- and attaches a ``real_probability`` to it. Advisory only: it +never drops a candidate, exactly like ``originvision.py`` never touches +``FrameInfo.accepted``/``metrics['score']``. + +Native-only for now, deliberately -- unlike every other native kernel in this +project, there is no numpy/onnxruntime fallback yet (see the module docstring +in ``ext/astro_native/src/lib.rs``'s ``mod transient_triage``). Without +``astro_native`` built, ``--transient-triage`` self-disables with a warning. + +The bundled model (``src/data/transient_triage.onnx``) is trained entirely on +synthetic data (``tools/gen_transient_triage_data.py`` + +``tools/train_transient_triage.py``) -- no labelled real transients exist yet +-- so it is a first cut, not a production classifier. +""" +from __future__ import annotations + +import logging +import os +from typing import List, Optional, Sequence + +import numpy as np + +logger = logging.getLogger('originstack') + +try: + import astro_native as _native + _HAS_NATIVE_TRIAGE = hasattr(_native, 'transient_triage_score') +except Exception: # pragma: no cover + _native = None + _HAS_NATIVE_TRIAGE = False + +DEFAULT_STAMP_SIZE = 31 + + +def scoring_backend_available() -> bool: + """True when the native kernel is built. No fallback exists yet.""" + return _HAS_NATIVE_TRIAGE + + +def bundled_model_path() -> str: + """Path to the model shipped inside the package.""" + return os.path.join(os.path.dirname(os.path.abspath(__file__)), 'data', + 'transient_triage.onnx') + + +def resolve_model_path(explicit: Optional[str] = None) -> Optional[str]: + """Return the first usable model path: an explicit override, else the + bundled copy. ``None`` if neither exists on disk.""" + for cand in (explicit, bundled_model_path()): + if cand and os.path.isfile(cand): + return cand + return None + + +def _extract_stamp(arr: np.ndarray, y: float, x: float, size: int) -> np.ndarray: + """Fixed-size square cutout centred on ``(y, x)``, reflect-padded at the + frame border -- same boundary convention as this codebase's other + fixed-window extractions (``_resize_center_crop``, the native + gaussian/median kernels' mirror boundary).""" + h, w = arr.shape + half = size // 2 + cy, cx = int(round(y)), int(round(x)) + top, left = cy - half, cx - half + pad_top = max(0, -top) + pad_left = max(0, -left) + pad_bottom = max(0, (top + size) - h) + pad_right = max(0, (left + size) - w) + if pad_top or pad_left or pad_bottom or pad_right: + padded = np.pad(arr, ((pad_top, pad_bottom), (pad_left, pad_right)), + mode='reflect') + top += pad_top + left += pad_left + return padded[top:top + size, left:left + size] + return arr[top:top + size, left:left + size] + + +def _normalize(stamp: np.ndarray, sigma: float) -> np.ndarray: + s = sigma if (sigma is not None and np.isfinite(sigma) and sigma > 0) else 1.0 + # A candidate near the footprint edge can pull in NaN fill from outside + # the warped reference's coverage (difference_imaging.py's `valid` mask); + # zero is the right fill -- both epochs arrive background-subtracted, so + # zero already means "sky" everywhere else in this pipeline's ZOGY code. + clean = np.nan_to_num(stamp.astype(np.float32), nan=0.0, posinf=0.0, neginf=0.0) + return clean / np.float32(s) + + +def build_stamps(new_lum: np.ndarray, ref_lum: np.ndarray, + difference: np.ndarray, positions: Sequence, + sigma_new: float, sigma_ref: float, + sigma_diff: float, size: int = DEFAULT_STAMP_SIZE) -> np.ndarray: + """Build the ``(N, 3, size, size)`` NCHW input for ``transient_triage_score``. + + Channels are ``new``, ``ref``, ``difference`` (the standard real/bogus + "triplet"), each normalized by its own frame-level robust sigma so a + candidate's stamp is architecture-agnostic across sessions of different + noise level -- not the ``originvision`` percentile stretch, which is for + photographic display and would destroy the physical sigma units ZOGY's + own significance already relies on. + """ + n = len(positions) + out = np.zeros((n, 3, size, size), dtype=np.float32) + for i, (y, x) in enumerate(positions): + out[i, 0] = _normalize(_extract_stamp(new_lum, y, x, size), sigma_new) + out[i, 1] = _normalize(_extract_stamp(ref_lum, y, x, size), sigma_ref) + out[i, 2] = _normalize(_extract_stamp(difference, y, x, size), sigma_diff) + return out + + +def score_candidates(new_lum: np.ndarray, ref_lum: np.ndarray, + difference: np.ndarray, transients: Sequence, + sigma_new: float, sigma_ref: float, sigma_diff: float, + *, model_path: Optional[str] = None, + size: int = DEFAULT_STAMP_SIZE) -> List[Optional[float]]: + """Return one ``real_probability`` (or ``None``) per entry in + ``transients``, in the same order. ``None`` for every candidate -- logged + once, not per candidate -- when the native backend or the model file + isn't available.""" + if not transients: + return [] + + if not _HAS_NATIVE_TRIAGE: + logger.warning("transient triage requested but astro_native's " + "transient_triage_score is unavailable -- skipping " + "(no candidates will be scored)") + return [None] * len(transients) + + mp = resolve_model_path(model_path) + if mp is None: + logger.warning("transient triage requested but no model found " + "(bundled src/data/transient_triage.onnx missing) " + "-- skipping") + return [None] * len(transients) + + positions = [(t.y, t.x) for t in transients] + stamps = build_stamps(np.asarray(new_lum, dtype=np.float32), + np.asarray(ref_lum, dtype=np.float32), + np.asarray(difference, dtype=np.float32), + positions, sigma_new, sigma_ref, sigma_diff, size=size) + try: + probs = _native.transient_triage_score(stamps, mp, size) + except Exception as exc: + logger.warning(f"transient triage: native inference failed ({exc}) " + f"-- skipping") + return [None] * len(transients) + return [float(p) for p in probs] diff --git a/src/utils.py b/src/utils.py index aeff06f..1888b54 100644 --- a/src/utils.py +++ b/src/utils.py @@ -224,6 +224,31 @@ def format_time(seconds: float) -> str: return f"{hours}h {mins}m" +def embed_to_shape(arr, H: int, W: int): + """Place ``arr`` in the top-left of an ``(H, W[, C])`` zero canvas (crop + if larger). Pixel coordinates are preserved, so a transform computed on + the embedded array applies directly to it. + + Shared by ``merge.py`` (a previous stack's own shape rarely matches the + current run's) and ``difference_imaging.py`` (two independently-stacked + sessions of the same target routinely differ in pixel dimensions -- + different dither pattern, different Phase 3 common-crop -- even though + they're the same field). A no-op (identity, no copy) when the shape + already matches. + """ + import numpy as np + if arr.shape[0] == H and arr.shape[1] == W: + return arr + if arr.ndim == 3: + out = np.zeros((H, W, arr.shape[2]), dtype=arr.dtype) + else: + out = np.zeros((H, W), dtype=arr.dtype) + h = min(H, arr.shape[0]) + w = min(W, arr.shape[1]) + out[:h, :w] = arr[:h, :w] + return out + + def get_memory_usage_mb() -> float: """Get current process memory usage in MB.""" if HAS_PSUTIL: diff --git a/tests/test_difference_imaging.py b/tests/test_difference_imaging.py index 8ab953f..a785cc1 100644 --- a/tests/test_difference_imaging.py +++ b/tests/test_difference_imaging.py @@ -15,7 +15,9 @@ import unittest import numpy as np +import pytest +import src.transient_triage as _tt_mod from src.difference_imaging import ( Transient, _prepare_psf, @@ -435,9 +437,21 @@ def test_a_missing_reference_returns_none(self): self.assertIsNone(run_transient_detection( _rgb(new), f"{self.tmp.name}/nope.fits", self.out_path)) - def test_mismatched_frame_sizes_return_none(self): - new, ref = self._epochs() - self.assertIsNone(self._run(new[:200, :200], ref)) + def test_mismatched_frame_sizes_are_reconciled_not_rejected(self): + """Two independently-stacked sessions of the same target routinely + differ in pixel dimensions (different dither pattern, different + Phase 3 common-crop) even though they cover the same field -- this + used to be a hard failure. `_align_reference` now embeds the smaller + epoch onto the other's grid (`src.utils.embed_to_shape`, the same + trick `merge.py` uses for a previous stack's own mismatched shape) + instead of refusing, and a real transient inside the overlap is + still found afterward.""" + ty, tx = 80.0, 90.0 # inside the 200x200 crop below + new, ref = self._epochs(transient=(ty, tx, 14000.0)) + summary = self._run(new[:200, :200], ref) + self.assertIsNotNone(summary, "a shape mismatch must not be a hard failure") + best = max(summary['transients'], key=lambda t: t.significance) + self.assertLess(math.hypot(best.y - ty, best.x - tx), 3.0) def test_the_registration_sigma_is_measured_and_floored_not_hardcoded(self): """The old code returned a literal 0.3 and reported it as a measured @@ -493,6 +507,36 @@ def test_a_fully_covered_pair_reports_full_coverage(self): self.assertIsNotNone(summary) self.assertGreater(summary['covered_fraction'], 0.99) + def test_stars_beyond_a_smaller_references_extent_are_not_transients(self): + """A genuinely smaller reference (a different session's own Phase 3 + crop, not just a slice of the same array) gets zero-padded onto the + new stack's grid before registration (`_align_reference` / + `src.utils.embed_to_shape`). Stars in `new` beyond the reference's + real extent have nothing to subtract against there -- same failure + mode as a rotated reference's empty wedges, just from padding instead + of rotation -- and must not be reported as 'brightening' either.""" + # n_stars kept low relative to the small shape: _wide_field's + # rejection sampling (each star >20px from every other) needs real + # headroom -- 30 stars in this shape's margins is near the packing + # limit and made the sampling loop pathologically slow. + stars = _wide_field(n_stars=12, seed=9, shape=(140, 150)) + edge_stars = [(180.0, 210.0, 9000.0), (190.0, 30.0, 9000.0), + (30.0, 220.0, 9000.0)] + new = _render_field((220, 240), stars + edge_stars, fwhm=3.0, + sky=0.0, noise=1.0, seed=51) + # A genuinely smaller array -- the reference's own (unpadded) shape, + # not new[:140, :150] -- so embedding must zero-pad it, not just crop. + ref = _render_field((140, 150), stars, fwhm=3.0, sky=0.0, noise=1.0, seed=50) + + summary = self._run(new, ref) + + self.assertIsNotNone(summary, "a smaller reference must not be a hard failure") + self.assertLess(summary['covered_fraction'], 0.95, + "the padded exterior is not reference coverage") + self.assertEqual( + [t for t in summary['transients'] if t.kind == 'brightening'], [], + "stars beyond the reference's real extent are not transients") + def test_outputs_are_written_next_to_the_output_path(self): import os new, ref = self._epochs(transient=(110.0, 120.0, 14000.0)) @@ -503,6 +547,45 @@ def test_outputs_are_written_next_to_the_output_path(self): with self.subTest(suffix=suffix): self.assertTrue(os.path.exists(stem + suffix)) + def test_triage_disabled_leaves_real_probability_none_and_off_the_csv(self): + new, ref = self._epochs(transient=(110.0, 120.0, 14000.0)) + summary = self._run(new, ref, triage=False) + self.assertIsNotNone(summary) + self.assertTrue(all(t.real_probability is None for t in summary['transients'])) + with open(summary['catalog']) as fh: + header = fh.readline() + self.assertNotIn('real_probability', header) + + def test_triage_requested_but_unavailable_does_not_crash(self): + """--transient-triage without a native backend/model self-disables + with a warning (checked via the returned real_probability, not the + log) rather than raising -- mirrors --originvision's own gate.""" + import src.transient_triage as tt_mod + had = tt_mod._HAS_NATIVE_TRIAGE + tt_mod._HAS_NATIVE_TRIAGE = False + try: + new, ref = self._epochs(transient=(110.0, 120.0, 14000.0)) + summary = self._run(new, ref, triage=True) + finally: + tt_mod._HAS_NATIVE_TRIAGE = had + self.assertIsNotNone(summary) + self.assertTrue(all(t.real_probability is None for t in summary['transients'])) + + @pytest.mark.skipif( + not _tt_mod.scoring_backend_available() or _tt_mod.resolve_model_path(None) is None, + reason='native transient_triage_score / bundled model absent -- run ' + 'tools/gen_transient_triage_data.py + tools/train_transient_triage.py') + def test_triage_populates_real_probability_and_csv_column(self): + new, ref = self._epochs(transient=(110.0, 120.0, 14000.0)) + summary = self._run(new, ref, triage=True) + self.assertIsNotNone(summary) + self.assertTrue(summary['transients'], "expected at least the injected transient") + self.assertTrue(all(t.real_probability is not None for t in summary['transients'])) + self.assertTrue(all(0.0 <= t.real_probability <= 1.0 for t in summary['transients'])) + with open(summary['catalog']) as fh: + header = fh.readline() + self.assertIn('real_probability', header) + class TestErodeFootprint(unittest.TestCase): """``_erode`` shrinks the covered region away from *uncovered* pixels only. diff --git a/tests/test_native.py b/tests/test_native.py index 4a15d7c..d874693 100644 --- a/tests/test_native.py +++ b/tests/test_native.py @@ -2645,3 +2645,39 @@ def test_gpu_quality_pool_size_stays_within_core_budget(): assert fp._gpu_quality_pool_size(n_workers=20, cpu_count=16, n_frames=100) == 1 # Never more threads than there are frames to process. assert fp._gpu_quality_pool_size(n_workers=2, cpu_count=16, n_frames=3) == 3 + + +# --------------------------------------------------------------------------- +# transient_triage_score (src/transient_triage.py, --transient-triage) +# --------------------------------------------------------------------------- + +import src.transient_triage as _tt_mod # noqa: E402 + +_tt_model = _tt_mod.resolve_model_path(None) +_have_tt = hasattr(native, 'transient_triage_score') and _tt_model is not None +_tt_skip = pytest.mark.skipif( + not _have_tt, + reason='native transient_triage_score / bundled model absent -- run ' + 'tools/gen_transient_triage_data.py + tools/train_transient_triage.py') + + +@_tt_skip +def test_transient_triage_score_native_shape_and_range(): + rng = np.random.default_rng(3) + stamps = rng.normal(0, 1, (5, 3, 31, 31)).astype(np.float32) + probs = native.transient_triage_score(stamps, _tt_model, 31) + assert len(probs) == 5 + assert all(0.0 <= p <= 1.0 for p in probs) + + +@_tt_skip +def test_transient_triage_score_native_empty_batch(): + stamps = np.zeros((0, 3, 31, 31), dtype=np.float32) + assert native.transient_triage_score(stamps, _tt_model, 31) == [] + + +@_tt_skip +def test_transient_triage_score_native_rejects_wrong_shape(): + stamps = np.zeros((2, 3, 20, 20), dtype=np.float32) # size mismatch + with pytest.raises(ValueError): + native.transient_triage_score(stamps, _tt_model, 31) diff --git a/tools/gen_transient_triage_data.py b/tools/gen_transient_triage_data.py new file mode 100644 index 0000000..9945ff6 --- /dev/null +++ b/tools/gen_transient_triage_data.py @@ -0,0 +1,193 @@ +"""Synthetic training-data generator for the ZOGY transient-triage model +(``--transient-triage``, ``src/transient_triage.py``). + +No labelled real transients exist yet, so this bootstraps a training set the +same way this codebase already validates ZOGY itself +(``tests/test_difference_imaging.py``): synthetic star fields, rendered at two +different seeings, run through the *real* ``zogy()`` + ``detect_transients()`` +so the stamps a model trains on match what ``run_transient_detection`` actually +produces in production -- not a shortcut simulation of what a candidate stamp +"should" look like. + +Four scene kinds, chosen at random per pair: + +- ``real`` -- an extra star present only in the new epoch (genuine + brightening). Positive label. +- ``cosmic_ray`` -- a single-pixel spike added post-hoc to the new epoch + only, with no PSF. Negative label. +- ``dipole`` -- the new epoch's star field is rendered with a small + sub-pixel ``(dy, dx)`` offset from the reference, and + ``zogy()`` is deliberately given a smaller + ``astrometric_sigma`` than the true offset -- an + *undersuppressed* registration slip, i.e. a hard negative + of exactly the artefact ``astrometric_sigma`` exists to + catch. Negative label. +- ``hot_pixel`` -- a fixed-position single-pixel spike in the new epoch's + noise realization only (not present in the reference, not + aligned with any star). Negative label. + +For a ``real`` pair, every OTHER candidate the detector turns up (there can be +more than one, e.g. noise peaks) is also a hard negative -- only the injected +position is positive. + +Usage: + python tools/gen_transient_triage_data.py --n-pairs 4000 --out transient_triage_data.npz +""" +from __future__ import annotations + +import argparse +import math +import os +import sys + +import numpy as np +from scipy.signal import fftconvolve + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..')) + +from src.difference_imaging import ( # noqa: E402 + detect_transients, + estimate_background_sigma, + zogy, +) +from src.transient_triage import DEFAULT_STAMP_SIZE, build_stamps # noqa: E402 + +_MATCH_RADIUS_PX = 4.0 # a candidate this close to the injected star is the positive + + +def _gaussian_psf(size: int, fwhm: float) -> np.ndarray: + """Normalised Gaussian kernel, odd-sized and centred -- same construction + as tests/test_difference_imaging.py's own PSF helper, kept independent + here rather than importing from a test module.""" + if size % 2 == 0: + size += 1 + sigma = fwhm / (2.0 * math.sqrt(2.0 * math.log(2.0))) + c = size // 2 + yy, xx = np.mgrid[0:size, 0:size] + g = np.exp(-(((yy - c) ** 2 + (xx - c) ** 2) / (2.0 * sigma ** 2))) + return g / g.sum() + + +def _render_field(shape, stars, fwhm: float, sky: float, noise: float, + rng: np.random.Generator) -> np.ndarray: + """Star field convolved to a given seeing, with Gaussian read noise -- + same shape as tests/test_difference_imaging.py's ``_render_field``.""" + h, w = shape + img = np.zeros((h, w), dtype=np.float64) + for y, x, flux in stars: + iy, ix = int(round(y)), int(round(x)) + if 0 <= iy < h and 0 <= ix < w: + img[iy, ix] += flux + img = fftconvolve(img, _gaussian_psf(21, fwhm), mode='same') + return img + sky + rng.normal(0.0, noise, (h, w)) + + +def _random_star_field(rng: np.random.Generator, shape, n_stars: int): + h, w = shape + stars = [] + while len(stars) < n_stars: + y, x = rng.uniform(20, h - 20), rng.uniform(20, w - 20) + if all(math.hypot(y - sy, x - sx) > 15 for sy, sx, _ in stars): + stars.append((y, x, float(rng.uniform(2000, 12000)))) + return stars + + +def make_pair(rng: np.random.Generator, size: int = DEFAULT_STAMP_SIZE, + shape=(160, 180), n_stars: int = 30, max_candidates: int = 6): + """Build one synthetic (new, ref) pair, run it through the real ZOGY path, + and return ``(stamps, labels)`` for whatever candidates were detected -- + zero or more per pair, since a bogus-kind pair can turn up nothing and a + noisy one can turn up spurious hard negatives alongside the label.""" + kind = rng.choice(['real', 'cosmic_ray', 'dipole', 'hot_pixel']) + stars = _random_star_field(rng, shape, n_stars) + ref_fwhm, new_fwhm = rng.uniform(2.5, 4.5), rng.uniform(2.5, 4.5) + + ref = _render_field(shape, stars, fwhm=ref_fwhm, sky=0.0, noise=1.0, rng=rng) + + inject_yx = None + new_stars = list(stars) + astro_sigma = 0.3 + + if kind == 'real': + h, w = shape + iy, ix = rng.uniform(20, h - 20), rng.uniform(20, w - 20) + new_stars.append((iy, ix, float(rng.uniform(3000, 20000)))) + inject_yx = (iy, ix) + new = _render_field(shape, new_stars, fwhm=new_fwhm, sky=0.0, noise=1.0, rng=rng) + elif kind == 'dipole': + dy, dx = rng.uniform(0.4, 1.2) * rng.choice([-1, 1]), rng.uniform(0.4, 1.2) * rng.choice([-1, 1]) + shifted = [(y + dy, x + dx, f) for y, x, f in stars] + new = _render_field(shape, shifted, fwhm=new_fwhm, sky=0.0, noise=1.0, rng=rng) + # Undersuppressed on purpose: the true offset is ~0.4-1.2 px/axis, + # this is the floor ZOGY normally applies when nothing better is + # measured -- exactly the case that leaves a residual dipole. + astro_sigma = 0.3 + else: + new = _render_field(shape, new_stars, fwhm=new_fwhm, sky=0.0, noise=1.0, rng=rng) + h, w = shape + py, px = int(rng.uniform(15, h - 15)), int(rng.uniform(15, w - 15)) + spike = float(rng.uniform(4000, 15000)) + if kind == 'cosmic_ray': + new[py, px] += spike # no PSF -- a single raw pixel, unlike a star + else: # hot_pixel + new[py, px] += spike * 0.6 + new[py, px] += rng.normal(0.0, 1.0) + + psf_new, psf_ref = _gaussian_psf(21, new_fwhm), _gaussian_psf(21, ref_fwhm) + result = zogy(new, ref, psf_new, psf_ref, astrometric_sigma=(astro_sigma, astro_sigma)) + candidates = detect_transients(result.score_corr, threshold=5.0, + max_candidates=max_candidates) + if not candidates: + return np.zeros((0, 3, size, size), dtype=np.float32), np.zeros((0,), dtype=np.float32) + + labels = np.zeros(len(candidates), dtype=np.float32) + if inject_yx is not None: + iy, ix = inject_yx + for i, c in enumerate(candidates): + if math.hypot(c.y - iy, c.x - ix) <= _MATCH_RADIUS_PX: + labels[i] = 1.0 + + sigma_new = estimate_background_sigma(new) + sigma_ref = estimate_background_sigma(ref) + sigma_diff = estimate_background_sigma(result.difference) + stamps = build_stamps(new.astype(np.float32), ref.astype(np.float32), + result.difference, [(c.y, c.x) for c in candidates], + sigma_new, sigma_ref, sigma_diff, size=size) + return stamps, labels + + +def main(): + parser = argparse.ArgumentParser(description=__doc__, + formatter_class=argparse.RawDescriptionHelpFormatter) + parser.add_argument('--n-pairs', type=int, default=4000, + help='Number of synthetic (new, ref) epoch pairs to generate (default: 4000)') + parser.add_argument('--size', type=int, default=DEFAULT_STAMP_SIZE, + help=f'Stamp size (default: {DEFAULT_STAMP_SIZE}, must match training/inference)') + parser.add_argument('--seed', type=int, default=0) + parser.add_argument('--out', default=None, + help='Output .npz path (default: tools/../transient_triage_data.npz)') + args = parser.parse_args() + + out = args.out or os.path.abspath(os.path.join( + os.path.dirname(__file__), '..', 'transient_triage_data.npz')) + + rng = np.random.default_rng(args.seed) + all_stamps, all_labels = [], [] + for i in range(args.n_pairs): + stamps, labels = make_pair(rng, size=args.size) + if len(labels): + all_stamps.append(stamps) + all_labels.append(labels) + if (i + 1) % 200 == 0: + print(f' {i + 1}/{args.n_pairs} pairs ' + f'({sum(len(l) for l in all_labels)} candidates so far)') + + X = np.concatenate(all_stamps, axis=0) if all_stamps else np.zeros((0, 3, args.size, args.size), dtype=np.float32) + y = np.concatenate(all_labels, axis=0) if all_labels else np.zeros((0,), dtype=np.float32) + np.savez(out, X=X, y=y, size=args.size) + n_pos = int(y.sum()) + print(f'Wrote {len(y)} labelled stamps ({n_pos} real, {len(y) - n_pos} bogus) to {out}') + + +if __name__ == '__main__': + main() diff --git a/tools/mine_real_transient_data.py b/tools/mine_real_transient_data.py new file mode 100644 index 0000000..07a991f --- /dev/null +++ b/tools/mine_real_transient_data.py @@ -0,0 +1,290 @@ +"""Mine REAL astrophotography sessions for transient-triage training data, +as a companion to the fully-synthetic ``tools/gen_transient_triage_data.py``. + +Real light frames of the same target on different nights give two things a +synthetic star field can't: + +- **Real negatives (bogus class)**: stack each session with this project's + own pipeline (a real ``originstack.py`` run, not a shortcut), then run the + same ``--transient-detect`` comparison this codebase ships between + consecutive sessions. Since no known real transient is expected in most + amateur fields, every candidate that survives is a genuine artifact -- + cosmic ray, registration-slip dipole, hot pixel -- that got past Phase 1, + the real production population rather than a synthetic guess at it. +- **Real positives (real-transient class)**: still can't get for free (no + labelled real transients exist), but injecting a synthetic point source + into a *copy* of one real stacked epoch before differencing rides on real + noise, real PSF and real artifacts -- a meaningful upgrade over a fully + synthetic star field for the "real" class too. + +Usage: + python tools/mine_real_transient_data.py \\ + --target-dir "G:\\astro\\Astrophotography\\Fireworks Galaxy" \\ + --work-dir transient_triage_real_work \\ + --out transient_triage_real_data.npz \\ + [--max-sessions N] [--n-inject-per-session 3] [--threshold 5.0] + +Each session subfolder under ``--target-dir`` is stacked once and cached in +``--work-dir`` (an existing ``.fits`` there is reused, not +re-stacked) -- a real multi-hundred-frame session can take minutes, so re-runs +while iterating on this script don't pay that cost twice. Never writes +anything back into ``--target-dir``. +""" +from __future__ import annotations + +import argparse +import glob +import math +import os +import subprocess +import sys +import time + +import numpy as np + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..')) + +from src.difference_imaging import _compare_epochs, estimate_background_sigma # noqa: E402 +from src.transient_triage import DEFAULT_STAMP_SIZE, build_stamps # noqa: E402 + +_MATCH_RADIUS_PX = 4.0 +_ORIGINSTACK_PY = os.path.abspath(os.path.join(os.path.dirname(__file__), '..', 'originstack.py')) + + +def _gaussian_psf(size: int, fwhm: float) -> np.ndarray: + """Normalised Gaussian kernel -- same construction as + tools/gen_transient_triage_data.py's copy, kept independent per this + project's tools/ convention of freestanding scripts.""" + if size % 2 == 0: + size += 1 + sigma = fwhm / (2.0 * math.sqrt(2.0 * math.log(2.0))) + c = size // 2 + yy, xx = np.mgrid[0:size, 0:size] + g = np.exp(-(((yy - c) ** 2 + (xx - c) ** 2) / (2.0 * sigma ** 2))) + return g / g.sum() + + +def _inject_point_source(rgb: np.ndarray, y: float, x: float, flux: float, + size: int = 21, fwhm: float = 3.0) -> np.ndarray: + """Additively place a synthetic point source into a COPY of ``rgb``, + equally across channels. ``flux`` is total (the kernel already sums to + 1), matching the convention tools/gen_transient_triage_data.py's + ``_render_field`` uses (place flux at one pixel, then PSF-convolve).""" + out = rgb.copy() + patch = _gaussian_psf(size, fwhm) * flux + half = size // 2 + h, w = rgb.shape[:2] + y0, x0 = int(round(y)) - half, int(round(x)) - half + ys, ye = max(0, y0), min(h, y0 + size) + xs, xe = max(0, x0), min(w, x0 + size) + if ys >= ye or xs >= xe: + return out + py0, px0 = ys - y0, xs - x0 + py1, px1 = py0 + (ye - ys), px0 + (xe - xs) + sub_patch = patch[py0:py1, px0:px1] + if out.ndim == 3: + out[ys:ye, xs:xe, :] += sub_patch[:, :, None] + else: + out[ys:ye, xs:xe] += sub_patch + return out + + +def _load_rgb(path: str) -> np.ndarray: + from src.io_fits import load_fits + arr, _ = load_fits(path) + arr = np.asarray(arr) + if arr.ndim == 3 and arr.shape[0] in (3, 4) and arr.shape[0] < arr.shape[-1]: + arr = np.transpose(arr, (1, 2, 0)) + return arr + + +def discover_sessions(target_dir: str) -> list: + """Immediate subdirectories of ``target_dir`` that contain FITS light + frames, sorted by name -- session folder names are timestamped + (``Target_YYYY-MM-DD_HH-MM-SS``), so name order is chronological order.""" + sessions = [] + for entry in sorted(os.listdir(target_dir)): + d = os.path.join(target_dir, entry) + if not os.path.isdir(d): + continue + if glob.glob(os.path.join(d, '*.fit*')): + sessions.append(d) + return sessions + + +def stack_session(session_dir: str, out_fits: str) -> bool: + """Stack one session with the real pipeline, caching the result. Returns + True on success (including a cache hit).""" + if os.path.exists(out_fits): + print(f' (cached) {os.path.basename(out_fits)}') + return True + t0 = time.time() + proc = subprocess.run( + [sys.executable, _ORIGINSTACK_PY, '-d', session_dir, '-o', out_fits], + capture_output=True, text=True) + elapsed = time.time() - t0 + if proc.returncode != 0 or not os.path.exists(out_fits): + print(f' FAILED to stack {session_dir} ({elapsed:.0f}s):') + print(' ' + (proc.stderr or proc.stdout)[-2000:].replace('\n', '\n ')) + return False + print(f' stacked {os.path.basename(out_fits)} in {elapsed:.0f}s') + return True + + +def mine_negatives(new_path: str, ref_path: str, size: int, threshold: float): + """Every candidate from a real cross-session comparison is a hard + negative -- no known real transient is expected between two ordinary + nights of the same amateur target.""" + new_rgb, ref_rgb = _load_rgb(new_path), _load_rgb(ref_path) + comparison = _compare_epochs(new_rgb, ref_rgb, threshold=threshold) + if comparison is None or not comparison.transients: + return np.zeros((0, 3, size, size), dtype=np.float32), np.zeros((0,), dtype=np.float32) + + sigma_new = estimate_background_sigma(comparison.new_lum) + sigma_ref = estimate_background_sigma(comparison.ref_lum) + sigma_diff = estimate_background_sigma(comparison.difference) + positions = [(t.y, t.x) for t in comparison.transients] + stamps = build_stamps(comparison.new_lum.astype(np.float32), + comparison.ref_lum.astype(np.float32), + comparison.difference, positions, + sigma_new, sigma_ref, sigma_diff, size=size) + labels = np.zeros(len(positions), dtype=np.float32) # all bogus + return stamps, labels + + +def mine_positives(stack_path: str, size: int, threshold: float, + n_inject: int, rng: np.random.Generator): + """Inject synthetic point sources into a copy of a real stacked epoch, + difference against the untouched original, and label the recovered + injection sites real (everything else found is a hard negative).""" + base_rgb = _load_rgb(stack_path) + from src.difference_imaging import _to_luminance + base_sigma = estimate_background_sigma(_to_luminance(base_rgb)) + h, w = base_rgb.shape[:2] + + all_stamps, all_labels = [], [] + margin = 25 + for _ in range(n_inject): + y, x = rng.uniform(margin, h - margin), rng.uniform(margin, w - margin) + flux = float(rng.uniform(20, 60)) * base_sigma + injected = _inject_point_source(base_rgb, y, x, flux, + fwhm=float(rng.uniform(2.5, 4.0))) + comparison = _compare_epochs(injected, base_rgb, threshold=threshold) + if comparison is None or not comparison.transients: + continue + sigma_new = estimate_background_sigma(comparison.new_lum) + sigma_ref = estimate_background_sigma(comparison.ref_lum) + sigma_diff = estimate_background_sigma(comparison.difference) + positions = [(t.y, t.x) for t in comparison.transients] + stamps = build_stamps(comparison.new_lum.astype(np.float32), + comparison.ref_lum.astype(np.float32), + comparison.difference, positions, + sigma_new, sigma_ref, sigma_diff, size=size) + labels = np.array([1.0 if math.hypot(t.y - y, t.x - x) <= _MATCH_RADIUS_PX else 0.0 + for t in comparison.transients], dtype=np.float32) + all_stamps.append(stamps) + all_labels.append(labels) + + if not all_stamps: + return np.zeros((0, 3, size, size), dtype=np.float32), np.zeros((0,), dtype=np.float32) + return np.concatenate(all_stamps, axis=0), np.concatenate(all_labels, axis=0) + + +def main(): + parser = argparse.ArgumentParser(description=__doc__, + formatter_class=argparse.RawDescriptionHelpFormatter) + parser.add_argument('--target-dir', required=True, + help='Directory of session subfolders for ONE target, e.g. ' + '"G:\\astro\\Astrophotography\\Fireworks Galaxy"') + parser.add_argument('--work-dir', default=None, + help='Where stacked FITS + sidecars are cached (default: ' + 'tools/../transient_triage_real_work/)') + parser.add_argument('--out', default=None, + help='Output .npz (default: tools/../transient_triage_real_data.npz)') + parser.add_argument('--append-to', default=None, + help='Merge with an existing .npz (e.g. the synthetic one from ' + 'gen_transient_triage_data.py) instead of overwriting --out') + parser.add_argument('--max-sessions', type=int, default=None, + help='Only stack/use the first N sessions, by chronological name ' + 'order (default: all)') + parser.add_argument('--session-filter', default=None, metavar='SUBSTR', + help='Only use session folders whose name contains this substring ' + '(applied before --max-sessions) -- handy for picking a small ' + 'session to validate against before committing to a big one') + parser.add_argument('--n-inject-per-session', type=int, default=3) + parser.add_argument('--threshold', type=float, default=5.0) + parser.add_argument('--size', type=int, default=DEFAULT_STAMP_SIZE) + parser.add_argument('--seed', type=int, default=0) + args = parser.parse_args() + + target_name = os.path.basename(os.path.normpath(args.target_dir)) + work_dir = args.work_dir or os.path.abspath(os.path.join( + os.path.dirname(__file__), '..', 'transient_triage_real_work', target_name)) + os.makedirs(work_dir, exist_ok=True) + out_path = args.out or os.path.abspath(os.path.join( + os.path.dirname(__file__), '..', 'transient_triage_real_data.npz')) + + sessions = discover_sessions(args.target_dir) + if args.session_filter: + sessions = [s for s in sessions if args.session_filter in os.path.basename(s)] + if args.max_sessions: + sessions = sessions[:args.max_sessions] + if len(sessions) < 2: + raise SystemExit(f"only {len(sessions)} session(s) with FITS lights found under " + f"{args.target_dir} -- need at least 2 to compare epochs") + print(f'{target_name}: {len(sessions)} session(s)') + + stacked_paths = [] + for s in sessions: + out_fits = os.path.join(work_dir, os.path.basename(s) + '.fits') + if stack_session(s, out_fits): + stacked_paths.append(out_fits) + + if len(stacked_paths) < 2: + raise SystemExit(f"only {len(stacked_paths)} session(s) stacked successfully -- " + f"need at least 2") + + rng = np.random.default_rng(args.seed) + all_stamps, all_labels = [], [] + + print('Mining real negatives from consecutive session pairs...') + for i in range(len(stacked_paths) - 1): + stamps, labels = mine_negatives(stacked_paths[i + 1], stacked_paths[i], + args.size, args.threshold) + print(f' {os.path.basename(stacked_paths[i + 1])} vs ' + f'{os.path.basename(stacked_paths[i])}: {len(labels)} candidate(s)') + if len(labels): + all_stamps.append(stamps) + all_labels.append(labels) + + if args.n_inject_per_session > 0: + print('Mining real-image-injection positives...') + for p in stacked_paths: + stamps, labels = mine_positives(p, args.size, args.threshold, + args.n_inject_per_session, rng) + n_pos = int(labels.sum()) + print(f' {os.path.basename(p)}: {n_pos} real, {len(labels) - n_pos} bogus ' + f'(of {args.n_inject_per_session} injected)') + if len(labels): + all_stamps.append(stamps) + all_labels.append(labels) + + X = (np.concatenate(all_stamps, axis=0) if all_stamps + else np.zeros((0, 3, args.size, args.size), dtype=np.float32)) + y = np.concatenate(all_labels, axis=0) if all_labels else np.zeros((0,), dtype=np.float32) + + if args.append_to and os.path.exists(args.append_to): + prev = np.load(args.append_to) + if int(prev['size']) != args.size: + raise SystemExit(f"--append-to size {int(prev['size'])} != --size {args.size}") + X = np.concatenate([prev['X'], X], axis=0) + y = np.concatenate([prev['y'], y], axis=0) + out_path = args.append_to + + np.savez(out_path, X=X, y=y, size=args.size) + n_pos = int(y.sum()) + print(f'Wrote {len(y)} labelled stamps ({n_pos} real, {len(y) - n_pos} bogus) to {out_path}') + + +if __name__ == '__main__': + main() diff --git a/tools/train_transient_triage.py b/tools/train_transient_triage.py new file mode 100644 index 0000000..48fbb5e --- /dev/null +++ b/tools/train_transient_triage.py @@ -0,0 +1,159 @@ +"""Train the ZOGY transient-triage model from synthetic data +(tools/gen_transient_triage_data.py) and export it to ONNX +(src/data/transient_triage.onnx, consumed by +astro_native.transient_triage_score / src/transient_triage.py). + +``torch`` is a script-local optional dependency, not part of this project's +runtime dependencies -- model training happens outside the shipped package, +the same stance ``src/data/originvision.onnx`` itself was trained under (see +vendor/originvision/README.md). + +The model is deliberately small (a handful of conv layers) given the tiny +31x31x3 input and a synthetic-only training set -- there is no reason to +reach for originvision's 256x256-real-photograph-classifier capacity here. + +Usage: + pip install torch onnx + python tools/gen_transient_triage_data.py --n-pairs 4000 + python tools/train_transient_triage.py +""" +from __future__ import annotations + +import argparse +import os + +import numpy as np + +try: + import torch + import torch.nn as nn +except ImportError as exc: # pragma: no cover - environment-dependent + raise SystemExit( + "tools/train_transient_triage.py needs torch, which is not part of " + "this project's runtime dependencies (model training happens outside " + "the shipped package -- see vendor/originvision/README.md for the " + "same stance on originvision.onnx). Install it with: pip install torch" + ) from exc + + +class TriageNet(nn.Module): + """Small conv net -> one logit. The native kernel applies sigmoid itself, + so this exports a raw logit, matching astro_native's `compute()`.""" + + def __init__(self): + super().__init__() + self.features = nn.Sequential( + nn.Conv2d(3, 16, 3, padding=1), nn.ReLU(inplace=True), nn.MaxPool2d(2), + nn.Conv2d(16, 32, 3, padding=1), nn.ReLU(inplace=True), nn.MaxPool2d(2), + nn.Conv2d(32, 64, 3, padding=1), nn.ReLU(inplace=True), + nn.AdaptiveAvgPool2d(1), + ) + self.fc = nn.Linear(64, 1) + + def forward(self, x): + x = self.features(x) + x = x.flatten(1) + return self.fc(x).squeeze(-1) + + +def main(): + parser = argparse.ArgumentParser(description=__doc__, + formatter_class=argparse.RawDescriptionHelpFormatter) + parser.add_argument('--data', default=None, + help='.npz from gen_transient_triage_data.py ' + '(default: tools/../transient_triage_data.npz)') + parser.add_argument('--epochs', type=int, default=20) + parser.add_argument('--batch-size', type=int, default=64) + parser.add_argument('--lr', type=float, default=1e-3) + parser.add_argument('--val-frac', type=float, default=0.15) + parser.add_argument('--seed', type=int, default=0) + parser.add_argument('--out', default=None, + help='Output ONNX path (default: src/data/transient_triage.onnx)') + args = parser.parse_args() + + data_path = args.data or os.path.abspath(os.path.join( + os.path.dirname(__file__), '..', 'transient_triage_data.npz')) + out_path = args.out or os.path.abspath(os.path.join( + os.path.dirname(__file__), '..', 'src', 'data', 'transient_triage.onnx')) + + npz = np.load(data_path) + X, y, size = npz['X'], npz['y'], int(npz['size']) + n = len(y) + if n < 50: + raise SystemExit(f"only {n} labelled stamps in {data_path} -- generate " + f"more with tools/gen_transient_triage_data.py first") + + rng = np.random.default_rng(args.seed) + perm = rng.permutation(n) + n_val = max(1, int(n * args.val_frac)) + val_idx, train_idx = perm[:n_val], perm[n_val:] + + torch.manual_seed(args.seed) + device = torch.device('cuda' if torch.cuda.is_available() else 'cpu') + model = TriageNet().to(device) + opt = torch.optim.Adam(model.parameters(), lr=args.lr) + loss_fn = nn.BCEWithLogitsLoss() + + X_t = torch.from_numpy(X).float() + y_t = torch.from_numpy(y).float() + + def _batches(idx): + order = idx.copy() + rng.shuffle(order) + for i in range(0, len(order), args.batch_size): + b = order[i:i + args.batch_size] + yield X_t[b].to(device), y_t[b].to(device) + + for epoch in range(args.epochs): + model.train() + total_loss = 0.0 + for xb, yb in _batches(train_idx): + opt.zero_grad() + loss = loss_fn(model(xb), yb) + loss.backward() + opt.step() + total_loss += loss.detach().item() * len(yb) + model.eval() + with torch.no_grad(): + val_pred = (torch.sigmoid(model(X_t[val_idx].to(device))) > 0.5).float() + val_acc = float((val_pred.cpu() == y_t[val_idx]).float().mean()) + n_train = max(1, len(train_idx)) + print(f'epoch {epoch + 1}/{args.epochs} ' + f'train_loss={total_loss / n_train:.4f} val_acc={val_acc:.3f}') + + model.eval() + model.to('cpu') # export from CPU regardless of training device + os.makedirs(os.path.dirname(out_path), exist_ok=True) + dummy = torch.zeros(1, 3, size, size) + torch.onnx.export( + model, dummy, out_path, + input_names=['stamps'], output_names=['logit'], + dynamic_axes={'stamps': {0: 'batch'}, 'logit': {0: 'batch'}}, + opset_version=13, + # The newer dynamo-based exporter (torch's default since 2.x) needs + # `onnxscript`, an extra dependency beyond torch itself; the legacy + # TorchScript-tracing exporter doesn't and is plenty for this model. + dynamo=False, + ) + + # Lightweight provenance metadata -- astro_native's kernel doesn't read + # it (it takes `size` as an explicit call argument and applies sigmoid + # itself), but it mirrors originvision.onnx's own metadata_props and + # matters for a future re-train/re-sync. Best-effort: onnx is not a + # project dependency either. + try: + import onnx + m = onnx.load(out_path) + for k, v in {'stamp_size': str(size), 'channels': 'new,ref,diff', + 'trained_on': 'synthetic'}.items(): + e = m.metadata_props.add() + e.key, e.value = k, v + onnx.save(m, out_path) + except ImportError: + print("(skipping ONNX metadata -- `pip install onnx` to include it)") + + print(f'Wrote {out_path}') + + +if __name__ == '__main__': + main() From c771ebfdd60aae73f37afb1f818e510cab25c98e Mon Sep 17 00:00:00 2001 From: Hans Davenport <35202271+hd152@users.noreply.github.com> Date: Wed, 23 Sep 2026 06:54:36 -0700 Subject: [PATCH 2/5] Add desktop app Cancel button; speed up originvision scoring Cancel is cooperative (threading.Event on args._cancel_event), not a thread kill -- CPython threads can't be force-stopped and Phase 1 workers are real OS processes mid-computation anyway. RunCancelled (src/models.py) propagates from a handful of checkpoints: frame_processor._check_cancel between Phase 1 frames in all three dispatch paths (ProcessPool/GPU-thread-pool/sequential, with explicit cancel_futures=True shutdown so the wait is bounded to whatever's already in flight, not the whole remaining session), and between targets in cli.process_directory's multi-session loop. Phases 2-4 of a single target aren't interruptible yet. RunManager reports a 'cancelled' status distinct from 'error'; the desktop app shows a Cancel button next to Start and a distinct "Cancelled" terminal state. originvision perf, both measured on a real full-res frame before shipping: --originvision-workers default 2->8 (near-linear scaling confirmed: 2751 -> 473 ms/frame effective at 8 workers, pure concurrency, zero accuracy risk). Also built a pre-downsample step for score_rgb's preprocessing, which scales with input pixel count even though only a 256x256 crop is ever used (85% of a full-res call was spent on discarded pixels) -- up to 7x faster, but the reject/quality heads proved genuinely resolution-sensitive (defect_probability swung +0.04 to +0.40 depending on aggressiveness, enough to flip is_defective near the boundary and feed auto_settings.py's defensive nudges). Left off by default (score_rgb(..., fast_preprocess=True), unwired to any CLI flag) rather than shipped as a silent accuracy/speed tradeoff. Co-Authored-By: Claude Sonnet 5 --- CLAUDE.md | 4 +- src/cli.py | 12 ++- src/desktop_app.py | 26 ++++- src/desktop_control.py | 27 ++++- src/frame_processor.py | 199 ++++++++++++++++++++++--------------- src/models.py | 10 ++ src/originvision.py | 2 +- src/originvision_infer.py | 74 +++++++++++++- tests/test_cancel.py | 161 ++++++++++++++++++++++++++++++ tests/test_originvision.py | 40 ++++++++ 10 files changed, 461 insertions(+), 94 deletions(-) create mode 100644 tests/test_cancel.py diff --git a/CLAUDE.md b/CLAUDE.md index a0910d5..0da88df 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -111,7 +111,7 @@ The pipeline is split across `src/` modules. [originstack.py](originstack.py) is | [src/live_stack.py](src/live_stack.py) | Real-time stacking (`--live`): watches the capture directory and folds each new sub into a running weighted-mean stack, pushing the growing result + running SNR to whatever UI is attached (console-only on a plain CLI run) | | [src/ui_events.py](src/ui_events.py) | In-process UI event/state sink for the desktop app (replaced `webview.py`'s HTTP/SSE dashboard, 2026-08): `UIEvents` holds live phase progress, log stream, per-frame quality ticker, and preview state (named milestone slots with a retained downsized float16 source for on-demand re-stretch, a per-frame thumbnail ring) — the same state model the old dashboard served over SSE, now polled directly by `desktop_app.py`'s `root.after()` timer via `snapshot()`/`version` instead of pushed over a socket. Every `safe_print()`/`print_phase()` call in the codebase tees into it for free (no-op unless `attach()`ed, i.e. true no-op on a plain CLI run). `restretch()` re-renders a retained milestone at new stretch params on demand | | [src/desktop_control.py](src/desktop_control.py) | Desktop-app control layer: `get_form_schema()` introspects `cli.build_parser()` live (no hand-maintained duplicate of ~120 flags to drift; toolkit-agnostic plain dicts, consumed by `desktop_app.py`'s tkinter form builder), `build_argv_from_form()` turns a submitted form back into a synthetic argv fed through the real `cli.parse_args()` (so preset/config/`--auto` precedence and `_explicit_cli_dests` come out exactly as from a real command line), and `RunManager`/`get_run_manager()` runs one pipeline job at a time on a background thread, publishing through `ui_events.py`. `RunManager.is_running()` backs `desktop_app.py`'s close-confirmation (warns before quitting mid-run) | -| [src/desktop_app.py](src/desktop_app.py) | `python desktop_app.py`: a native `tkinter` window (stdlib — no `pywebview`/WebView2 Runtime dependency). A Setup form auto-built from `desktop_control.get_form_schema()` (one `Notebook` tab per argparse group), a live progress/log panel, and a `Canvas`-based preview viewer (zoom via mouse wheel, pan via drag, before/after wipe-slider compare via `PIL.Image.paste` compositing, per-frame thumbnail ring) fed by polling `ui_events.py`'s `UIEvents.snapshot()`. Layout is two columns: left = Setup form + pipeline bar + log (the log gets the whole lower-left); right = preview + thumbnail strip + a RECENT FRAMES quality table. (The old GHS-slider STRETCH panel was removed 2026-09; `ui_events.restretch()` / `PreviewCanvas.replace_pixels()` stay as unused-from-GUI API.) File/folder pickers are stdlib `tkinter.filedialog` (no `Api`/JS bridge needed — same process, no IPC). `--verify-headless ` skips the GUI/mainloop entirely and runs a real stack through the same frozen entry point, for `packaging/verify_build.ps1`'s multiprocessing regression check. Every failure path routes through `_fatal()` (log to `%LOCALAPPDATA%\OriginStack\logs\` + a `native_dialog.py` error dialog) — load-bearing for the packaged build (see below), which runs windowed with no console for a bare `print()` to reach. `root.protocol('WM_DELETE_WINDOW', ...)` confirms via `RunManager.is_running()` before quitting mid-run | +| [src/desktop_app.py](src/desktop_app.py) | `python desktop_app.py`: a native `tkinter` window (stdlib — no `pywebview`/WebView2 Runtime dependency). A Setup form auto-built from `desktop_control.get_form_schema()` (one `Notebook` tab per argparse group), a live progress/log panel, and a `Canvas`-based preview viewer (zoom via mouse wheel, pan via drag, before/after wipe-slider compare via `PIL.Image.paste` compositing, per-frame thumbnail ring) fed by polling `ui_events.py`'s `UIEvents.snapshot()`. Layout is two columns: left = Setup form + pipeline bar + log (the log gets the whole lower-left); right = preview + thumbnail strip + a RECENT FRAMES quality table. (The old GHS-slider STRETCH panel was removed 2026-09; `ui_events.restretch()` / `PreviewCanvas.replace_pixels()` stay as unused-from-GUI API.) File/folder pickers are stdlib `tkinter.filedialog` (no `Api`/JS bridge needed — same process, no IPC). `--verify-headless ` skips the GUI/mainloop entirely and runs a real stack through the same frozen entry point, for `packaging/verify_build.ps1`'s multiprocessing regression check. Every failure path routes through `_fatal()` (log to `%LOCALAPPDATA%\OriginStack\logs\` + a `native_dialog.py` error dialog) — load-bearing for the packaged build (see below), which runs windowed with no console for a bare `print()` to reach. `root.protocol('WM_DELETE_WINDOW', ...)` confirms via `RunManager.is_running()` before quitting mid-run. **Cancel button** (next to Start, enabled only while a run is active): cooperative, not a thread kill -- `RunManager.cancel()` sets a `threading.Event` shared onto the run's `args` as `_cancel_event`, noticed at a handful of checkpoints (`frame_processor._check_cancel`, called between Phase 1 frames in all three dispatch paths -- ProcessPool, GPU/thread pool, sequential -- since Phase 1 is usually the longest phase; and between targets in `cli.process_directory`'s per-target loop, for multi-session/hierarchical runs). Since every Phase 1 future is submitted upfront, the ProcessPool/thread-pool paths explicitly `shutdown(wait=False, cancel_futures=True)` on a caught `RunCancelled` so the `with` block's own exit only waits for whatever's already mid-frame, not the full remainder of the session. Phases 2-4 of a single target aren't interruptible yet -- once one starts it runs to completion, same as before this existed. `RunCancelled` (`src/models.py`) propagates up to `RunManager._run`, which sets `status = 'cancelled'` (a status distinct from `'ok'`/`'error'`) rather than reporting it as a failure | | [src/notify.py](src/notify.py) | `notify_windows(title, message)`: best-effort native Windows balloon-tip notification via a small `ctypes`/`Shell_NotifyIcon` wrapper (no dependency — matches this project's preference for small native/stdlib implementations over dependency weight, e.g. over `plyer`). No-op on non-Windows; any failure is swallowed, since this is cosmetic and must never affect run state | | [src/stream_stack.py](src/stream_stack.py) | Two-pass streaming stack of an already-complete directory (`--stream`): O(1) full-resolution memory via an online (single-pass) sigma-clip Welford accumulator (`online_sigma_clip_seed_burnin`/`online_sigma_clip_fold_frame` in `stacking.py`) instead of materializing the whole `(N,H,W,C)` aligned stack. Sibling to `--live` (shares `build_session_masters`); v1 limitations: reference frame picked by quality score alone, hard-limit quality gating only, no `--elastic-registration`/drizzle/patch-weighted combine/`--merge`. Mutually exclusive with `--live` | | [src/channel_combine.py](src/channel_combine.py) | `combine` subcommand: LRGB + narrowband palettes (SHO/HOO), SCNR green removal (`--scnr`), magenta-star fix (`--star-recolor`). `--continuum FILE --continuum-target {ha,oiii,sii}` subtracts a broadband/OSC continuum reference before combination; the subtraction scale is fit automatically (`optimal_continuum_scale`) by sweeping candidate scales and picking the one that *maximises* the background residual's pixel skewness -- verified against synthetic ground truth in `tests/test_continuum_subtraction.py` (a first "zero-crossing" design was tried and empirically shown wrong by that same test before shipping: skewness peaks at the true scale, it doesn't cross zero there). Since the subtraction residual is linear in the scale factor, its skewness at every swept scale is a closed-form polynomial of just 7 central moments of the masked `(narrowband, continuum)` pixel pair -- computed once (native Rust `continuum_scale_moments`, numpy fallback) instead of re-scanning the full pixel array once per scale; see "Native (Rust) acceleration" below | @@ -127,7 +127,7 @@ The pipeline is split across `src/` modules. [originstack.py](originstack.py) is | [src/photometry_timeseries.py](src/photometry_timeseries.py) | Per-frame differential light curves (`--photometry-timeseries`): runs right after Phase 3 while the registered frames are still in `mem_rgb`, warps + crops each sub, aperture-photometers a fixed `match_gaia_field` star list on every frame (`aperture_photometry_batch`), then iteratively ensemble-calibrates a per-frame per-channel zero point (comparison stars = well-detected, unsaturated, mid-brightness; high-scatter members clipped out). Writes `_lightcurves.csv` (one row per frame×star: MJD, airmass, per-channel mag/magerr, flag) and `_lightcurve_stats.csv` (per star: mean/rms/MAD/ptp/reduced-χ² + a `variable` flag). `--photometry-target "RA,DEC"` / `"px:X,Y"` marks one star and prints its stats. Needs a session `info.json` WCS (`--plate-solve` runs after this point); differential only — no absolute ZP, extinction cancels in the ensemble | | [src/gain_ptc.py](src/gain_ptc.py) | Photon-transfer gain / read-noise from raw calibration frames (`estimate_gain_ptc`, auto-run in `cli._build_masters` when `--photometry`/`--photometry-timeseries` is set and ≥2 bias + ≥2 flat frames exist and no `--photometry-gain` was given). Janesick two-frame difference: `gain = (Σmean_flat - Σmean_bias) / (var(flat₁-flat₂) - var(bias₁-bias₂))`, `read_noise_adu = std(bias₁-bias₂)/√2`, on a central sigma-clipped window (dodges vignetting/amp-glow). One flat level → a single (signal, variance) point, not a full PTC curve; OSC frames reduced to luma. Result stashed as `masters['ptc_gain_e_per_adu']` / `args._ptc_gain_e_per_adu` and consumed by `photometry._read_gain` | | [src/annotation.py](src/annotation.py) | Object annotation (`--annotate`): circles + labels bright stars and named deep-sky objects (galaxies, nebulae, clusters) on a copy of the preview, via the header's WCS and live SIMBAD cone-search queries. Needs a WCS (`--plate-solve` or a session solve); fails soft otherwise | -| [src/originvision.py](src/originvision.py) | originvision integration: defect/quality/category scoring by a separately-trained vision classifier, **run in-process** (no subprocess, no external folder, no venv, no network). Inference runs through the **native `astro_native.originvision_score` kernel** ([ext/astro_native/src/lib.rs](ext/astro_native/src/lib.rs), `mod originvision`): the whole path — per-channel percentile stretch, gaussian-prefiltered bilinear resize/centre-crop, and the ONNX forward pass via the **pure-Rust `tract` runtime** — runs inside `py.allow_threads` (a thread pool of scoring calls genuinely parallelises), panic-guarded (`catch_unwind` → `RuntimeError`, so a malformed `--originvision-model` never SIGABRTs), with no Python ONNX dependency and no extra DLL. [src/originvision_infer.py](src/originvision_infer.py) is now a thin dispatcher: native when `astro_native` is built (the only path in the packaged app), else a numpy/scipy + Python-`onnxruntime` fallback for a source checkout without the crate. The exported model **ships inside the package** at `src/data/originvision.onnx` (~11 MB, "v4" — a from-scratch, no-SSL 7-task run, best-by-category checkpoint at epoch 15); the native kernel loads it by path (session cached per `(path, size)`). `--originvision` self-disables with a warning when neither backend nor the model file is available. The graph emits **8** outputs but ONNX metadata `tasks` lists **7** (`reject`/`quality`/`category`/`exposure`/`sky_brightness`/`stray_light_gradient`/`background_grid`) — `trailing` is a real graph output with *untrained* weights on this checkpoint, so `score_rgb` gates every head on `tasks` membership, never on output presence. `trailing` and `background_grid` are deliberately not surfaced (the latter trained but unused — OriginStack runs its own DBE). A model whose graph-output count doesn't match its `head_order` metadata is rejected (both backends) rather than scored around. `category` is a 4-class head — galaxy/nebula/star_cluster/comet — but `comet` is distrusted on the current checkpoint, so a top `comet` pick is demoted to the runner-up (`shape_gate=False` disables that); `exposure` is a 7-class classifier. `src/originvision_infer.py::_load_image_any` debayers a raw `.fits` light itself (single-frame Bayer → RGB) then feeds the array straight to `score_rgb` — no temp file; TIFF/PNG/JPG pass through `tifffile`/`Pillow`. **Model provenance / re-sync**: [vendor/originvision/](vendor/originvision/) holds the upstream snapshot the port + `src/data/originvision.onnx` are copied from (see `vendor/originvision/VENDORED_FROM.txt`); it is **not on the runtime path** (numpy-mirror pattern). `--originvision-model PATH` overrides the bundled model; legacy `--originvision-dir`/`--originvision-checkpoint` still resolve a path (`--originvision-python`/`--originvision-script`/`--originvision-timeout` were removed with the subprocess). `--originvision` alone, with `--auto` active (the default), samples 3 light frames spread through the session — the sampled category feeds `--auto`'s target-classification prior the same way SIMBAD/header metadata does, and a defect flag nudges settings defensively (`--trail-reject` on, stronger chroma denoising) via `_originvision_defect_flagged` in `auto_settings.py`. `--originvision-score-all` (opt-in, needs `--originvision` too — a no-op and warned about otherwise) additionally scores every accepted light frame after Phase 1 (before `quality_gate`) and once more on the final stacked master, gated in two places (`pipeline.py`'s call site and inside `score_lights_with_originvision` itself). Advisory/logging only while the model is still finishing its first training run — results are stored in `FrameInfo.metrics['originvision']` and logged (defective/stray-light flags, session-relative below-average `quality_score`, master category vs. the pipeline's own inferred target type) but never set `accepted` or feed `metrics['score']`, so nothing is auto-dropped | +| [src/originvision.py](src/originvision.py) | originvision integration: defect/quality/category scoring by a separately-trained vision classifier, **run in-process** (no subprocess, no external folder, no venv, no network). Inference runs through the **native `astro_native.originvision_score` kernel** ([ext/astro_native/src/lib.rs](ext/astro_native/src/lib.rs), `mod originvision`): the whole path — per-channel percentile stretch, gaussian-prefiltered bilinear resize/centre-crop, and the ONNX forward pass via the **pure-Rust `tract` runtime** — runs inside `py.allow_threads` (a thread pool of scoring calls genuinely parallelises), panic-guarded (`catch_unwind` → `RuntimeError`, so a malformed `--originvision-model` never SIGABRTs), with no Python ONNX dependency and no extra DLL. [src/originvision_infer.py](src/originvision_infer.py) is now a thin dispatcher: native when `astro_native` is built (the only path in the packaged app), else a numpy/scipy + Python-`onnxruntime` fallback for a source checkout without the crate. The exported model **ships inside the package** at `src/data/originvision.onnx` (~11 MB, "v4" — a from-scratch, no-SSL 7-task run, best-by-category checkpoint at epoch 15); the native kernel loads it by path (session cached per `(path, size)`). `--originvision` self-disables with a warning when neither backend nor the model file is available. The graph emits **8** outputs but ONNX metadata `tasks` lists **7** (`reject`/`quality`/`category`/`exposure`/`sky_brightness`/`stray_light_gradient`/`background_grid`) — `trailing` is a real graph output with *untrained* weights on this checkpoint, so `score_rgb` gates every head on `tasks` membership, never on output presence. `trailing` and `background_grid` are deliberately not surfaced (the latter trained but unused — OriginStack runs its own DBE). A model whose graph-output count doesn't match its `head_order` metadata is rejected (both backends) rather than scored around. `category` is a 4-class head — galaxy/nebula/star_cluster/comet — but `comet` is distrusted on the current checkpoint, so a top `comet` pick is demoted to the runner-up (`shape_gate=False` disables that); `exposure` is a 7-class classifier. `src/originvision_infer.py::_load_image_any` debayers a raw `.fits` light itself (single-frame Bayer → RGB) then feeds the array straight to `score_rgb` — no temp file; TIFF/PNG/JPG pass through `tifffile`/`Pillow`. **Model provenance / re-sync**: [vendor/originvision/](vendor/originvision/) holds the upstream snapshot the port + `src/data/originvision.onnx` are copied from (see `vendor/originvision/VENDORED_FROM.txt`); it is **not on the runtime path** (numpy-mirror pattern). `--originvision-model PATH` overrides the bundled model; legacy `--originvision-dir`/`--originvision-checkpoint` still resolve a path (`--originvision-python`/`--originvision-script`/`--originvision-timeout` were removed with the subprocess). `--originvision` alone, with `--auto` active (the default), samples 3 light frames spread through the session — the sampled category feeds `--auto`'s target-classification prior the same way SIMBAD/header metadata does, and a defect flag nudges settings defensively (`--trail-reject` on, stronger chroma denoising) via `_originvision_defect_flagged` in `auto_settings.py`. `--originvision-score-all` (opt-in, needs `--originvision` too — a no-op and warned about otherwise) additionally scores every accepted light frame after Phase 1 (before `quality_gate`) and once more on the final stacked master, gated in two places (`pipeline.py`'s call site and inside `score_lights_with_originvision` itself). Advisory/logging only while the model is still finishing its first training run — results are stored in `FrameInfo.metrics['originvision']` and logged (defective/stray-light flags, session-relative below-average `quality_score`, master category vs. the pipeline's own inferred target type) but never set `accepted` or feed `metrics['score']`, so nothing is auto-dropped. **Performance (2026-09 profiling pass)**: `--originvision-workers` default raised 2 → 8 — measured near-linear scaling on a real full-res frame (2751 ms/frame at 1 worker → 1415 at 2 → 473 at 8; the previous default of 2 left most of the free GIL-released parallelism the docstring already claimed on the table). Separately, `score_rgb`'s preprocessing (percentile stretch + resize) scales with *input* pixel count even though only a 256x256 crop is ever used — 85% of a full-res (1936x1096) call was spent on pixels the model never sees (1315 ms → 196 ms once already at 256x256). A pre-downsample step (`_downsample_if_large`, `originvision_infer.py`) fixes that (up to 7x on a real frame) but was **measured and found unsafe as a default**: unlike `_resize_center_crop`'s own cv2→scipy swap (class/flag heads unaffected to ~0.002), the `reject`/`quality` heads are genuinely resolution-sensitive — `defect_probability` swung +0.04 to +0.40 on one real frame across every tested aggressiveness, enough to flip `is_defective` near the 0.5 boundary, which feeds `auto_settings.py`'s defensive nudges, not just a log line. Kept as a dormant, undocumented-to-the-CLI opt-in (`score_rgb(..., fast_preprocess=True)`) rather than wired to a flag or shipped as default — see its docstring for the full factor-vs-delta sweep | | [src/pipeline.py](src/pipeline.py) | Thin orchestrator: `stack_target` wires all four phases | | [src/health_check.py](src/health_check.py) | `run_health_check` | | [src/cli.py](src/cli.py) | `process_directory`, `parse_args`, `main`. `save_effective_config` writes strings through `_toml_str`: an unescaped Windows path (`log_file = "C:\Users\..."`) made every GUI-saved config unparseable ("Invalid hex value"), and the run then silently fell back to defaults with only a warning; regression-tested by round-tripping through `tomllib`. `tools/lint_conventions.py`'s `_git` decodes as UTF-8 for the same platform-codepage reason | diff --git a/src/cli.py b/src/cli.py index adc9b91..1856f44 100644 --- a/src/cli.py +++ b/src/cli.py @@ -942,6 +942,10 @@ def process_directory(directory: str, output: str, args: argparse.Namespace): produced = [] _effective_args: dict = {} # produced stack path -> the args it was made with for target_idx, (d, outp) in enumerate(targets, 1): + _cancel_event = getattr(args, '_cancel_event', None) + if _cancel_event is not None and _cancel_event.is_set(): + from src.models import RunCancelled + raise RunCancelled(f"cancelled before target {target_idx}/{len(targets)}") if _baseline is not None: _restore_args(args, _baseline) @@ -1954,11 +1958,13 @@ def build_parser() -> argparse.ArgumentParser: g_originvision.add_argument('--originvision-model', default=None, metavar='PATH', help='Path to an exported originvision ONNX model, overriding the bundled ' 'src/data/originvision.onnx (e.g. to test a newer checkpoint).') - g_originvision.add_argument('--originvision-workers', type=int, default=2, metavar='N', - help='Thread-pool size for per-frame originvision scoring calls (default: 2). ' + g_originvision.add_argument('--originvision-workers', type=int, default=8, metavar='N', + help='Thread-pool size for per-frame originvision scoring calls (default: 8). ' 'Both backends release the GIL during the forward pass (the native ' 'tract kernel via py.allow_threads), so a thread pool parallelises it ' - 'without a ProcessPoolExecutor.') + 'without a ProcessPoolExecutor -- measured near-linear scaling 1->8 ' + 'workers on a real frame (2751 -> 473 ms/frame effective at 8), so the ' + 'previous default of 2 (1415 ms/frame) left real throughput on the table.') # Back-compat, hidden: --originvision-dir / --originvision-checkpoint still # resolve a model path for command lines written against the pre-in-process # layout. diff --git a/src/desktop_app.py b/src/desktop_app.py index 232a2d0..b452ae5 100644 --- a/src/desktop_app.py +++ b/src/desktop_app.py @@ -883,6 +883,9 @@ def _build_left(self, parent: ttk.Frame) -> None: self.start_btn = ttk.Button(run_row, text='Start', style='Accent.TButton', command=self._on_start) self.start_btn.pack(side='left') + self.cancel_btn = ttk.Button(run_row, text='Cancel', command=self._on_cancel) + self.cancel_btn.pack(side='left', padx=(6, 0)) + self.cancel_btn.state(['disabled']) self.status_var = tk.StringVar(value='Idle') ttk.Label(run_row, textvariable=self.status_var, style='Dim.TLabel').pack( side='left', padx=10) @@ -978,11 +981,21 @@ def _on_start(self) -> None: self.status_var.set(f"Error: {result.get('error')}") return self.start_btn.state(['disabled']) + self.cancel_btn.state(['!disabled']) self.status_var.set('Running…') self._shown_log_lines = 0 self.frames_tree.delete(*self.frames_tree.get_children()) self.summary_var.set('') + def _on_cancel(self) -> None: + # Cooperative, not instant -- takes effect at the next checkpoint + # (RunManager/frame_processor._check_cancel's docstrings). Disabling + # the button immediately is the honest signal: pressing it again + # wouldn't make the pipeline notice any sooner. + self.rm.cancel() + self.cancel_btn.state(['disabled']) + self.status_var.set('Cancelling…') + def _on_closing(self) -> None: if self.rm.is_running(): from src.native_dialog import ask_yes_no @@ -1135,13 +1148,18 @@ def _refresh_run_button(self, snap: Optional[Dict[str, Any]] = None) -> None: running = self.rm.is_running() if running: self.start_btn.state(['disabled']) - self.header_status_var.set('Running…') + self.header_status_var.set('Cancelling…' if self.rm.is_cancelling() else 'Running…') else: self.start_btn.state(['!disabled']) - if snap is not None and snap['run_status'] in ('ok', 'error'): + self.cancel_btn.state(['disabled']) + if snap is not None and snap['run_status'] in ('ok', 'error', 'cancelled'): done_ok = snap['run_status'] == 'ok' - self.status_var.set('Done' if done_ok else f"Failed: {snap['run_error']}") - self.header_status_var.set('Complete' if done_ok else 'Failed') + if snap['run_status'] == 'cancelled': + self.status_var.set('Cancelled') + self.header_status_var.set('Cancelled') + else: + self.status_var.set('Done' if done_ok else f"Failed: {snap['run_error']}") + self.header_status_var.set('Complete' if done_ok else 'Failed') # Edge-triggered (not every poll tick) so it only pops once # per completed run, not repeatedly while the status holds. if done_ok and self._last_run_status != 'ok' and self.open_folder_var.get(): diff --git a/src/desktop_control.py b/src/desktop_control.py index 209d1a1..1f8ad06 100644 --- a/src/desktop_control.py +++ b/src/desktop_control.py @@ -146,16 +146,35 @@ class RunManager: progress through the ``UIEvents`` singleton (``src/ui_events.py``) -- the desktop app attaches it before entering the tkinter mainloop, so it's already active by the time a run starts; only the pipeline work - itself needs to move off the GUI's own (main) thread.""" + itself needs to move off the GUI's own (main) thread. + + Cancellation is cooperative, not a thread kill (CPython threads can't be + force-stopped, and Phase 1 workers are real OS processes/subprocesses + mid-computation anyway): ``cancel()`` sets a ``threading.Event`` shared + onto the run's ``args`` as ``_cancel_event``, and the pipeline notices it + at a handful of checkpoints (``frame_processor._check_cancel`` between + Phase 1 frames -- usually the longest phase -- and between targets in + ``cli.process_directory``). Phases 2-4 of a single target aren't + interruptible yet: once one starts, it runs to completion.""" def __init__(self) -> None: self._lock = threading.Lock() self.status = 'idle' self.thread: Optional[threading.Thread] = None + self._cancel_event = threading.Event() def is_running(self) -> bool: return self.status == 'running' + def is_cancelling(self) -> bool: + return self.status == 'running' and self._cancel_event.is_set() + + def cancel(self) -> None: + """Request a stop. A no-op if nothing is running; safe to call more + than once. Takes effect at the next checkpoint, not instantly.""" + if self.status == 'running': + self._cancel_event.set() + def start(self, form: Dict[str, Any]) -> Dict[str, Any]: with self._lock: if self.status == 'running': @@ -165,6 +184,7 @@ def start(self, form: Dict[str, Any]) -> Dict[str, Any]: except Exception as e: return {'ok': False, 'error': f'invalid form: {e}'} self.status = 'running' + self._cancel_event = threading.Event() # fresh flag per run self.thread = threading.Thread(target=self._run, args=(argv,), name='desktop-run', daemon=True) self.thread.start() @@ -175,6 +195,7 @@ def _run(self, argv: List[str]) -> None: import tempfile from src.cli import apply_post_parse_setup, parse_args, process_directory + from src.models import RunCancelled from src.ui_events import get_ui_events from src.utils import get_logger, safe_print @@ -182,6 +203,7 @@ def _run(self, argv: List[str]) -> None: status, error = 'ok', None try: args = parse_args(argv) + args._cancel_event = self._cancel_event # Default a durable log file for GUI-triggered runs specifically # (not in apply_post_parse_setup, which is also the plain CLI's @@ -199,6 +221,9 @@ def _run(self, argv: List[str]) -> None: wv.run_started() process_directory(args.directory, args.output, args) + except RunCancelled: + status = 'cancelled' + safe_print(" Run cancelled.") except (Exception, SystemExit) as e: status = 'error' error = str(e) or e.__class__.__name__ diff --git a/src/frame_processor.py b/src/frame_processor.py index 3abc13d..40fa6d9 100644 --- a/src/frame_processor.py +++ b/src/frame_processor.py @@ -33,7 +33,7 @@ from src.frame_discovery import is_nebula_filter from src.gpu_context import get_gpu from src.io_fits import load_frame -from src.models import Config, FrameInfo, ProcessingStats +from src.models import Config, FrameInfo, ProcessingStats, RunCancelled from src.quality import compute_quality_metrics, estimate_bortle, validate_image_data from src.stacking import lacosmic_reject from src.utils import format_time, mp_context, print_quality_table, safe_print @@ -915,6 +915,17 @@ def _parallel_frame_worker( return (frame_idx, metrics_clean, None, timings) +def _check_cancel(args: argparse.Namespace) -> None: + """Raise ``RunCancelled`` when the GUI's cancel button has been pressed + (``RunManager.cancel()``, ``args._cancel_event``). A plain CLI run never + sets this, so it's a no-op there. Called between frames -- not inside a + worker, which has already committed to processing the frame it picked + up -- so cancelling stops new work starting, not work in flight.""" + ev = getattr(args, '_cancel_event', None) + if ev is not None and ev.is_set(): + raise RunCancelled("cancelled during Phase 1 frame processing") + + @_with_session_cfa def execute_frame_processing( lights: List[FrameInfo], @@ -1025,40 +1036,51 @@ def _accum(timings: Optional[dict]) -> None: futures = {pool.submit(_parallel_frame_worker, t): t[1] for t in tasks} _wv = _get_ui_events() _wv_done = 0 - for future in tqdm(as_completed(futures), total=n, - desc=" Processing", unit="frame", - disable=args.verbose): - idx = futures[future] - frame_idx, metrics, error, timings = future.result() - _accum(timings) - f = lights[frame_idx] - _wv_done += 1 - _wv.progress('Processing frames', _wv_done, n) - _wv.frame_metrics(os.path.basename(f.path), metrics, - accepted=error is None) - if error: - f.accepted = False - f.metrics = {'error': error} - rejected_reasons[f.path] = error - stats.add_error(f.path, error) - if args.verbose: - print(f' REJECT {os.path.basename(f.path)}: {error}') - else: - f.metrics = metrics - _publish_frame_thumb(_wv, args, - os.path.basename(f.path), - mem_rgb[frame_idx], _wv_thumb_count) - if args.verbose: - m = f.metrics - safe_print(f' {os.path.basename(f.path)}: ' - f'score={m["score"]:.0f} SNR={m["snr"]:.1f} ' - f'stars={m["star_count"]} FWHM={m.get("fwhm",0):.1f} ' - f'sharpness={m.get("sharpness",0):.0f}') - safe_print(f' bg={m.get("background",0):.1f} ' - f'noise={m.get("noise",0):.2f} ' - f'brightness={m.get("brightness",0):.1f} ' - f'contrast={m.get("contrast",0):.1f} ' - f'dynamic_range={m.get("dynamic_range",0):.0f}') + try: + for future in tqdm(as_completed(futures), total=n, + desc=" Processing", unit="frame", + disable=args.verbose): + _check_cancel(args) + idx = futures[future] + frame_idx, metrics, error, timings = future.result() + _accum(timings) + f = lights[frame_idx] + _wv_done += 1 + _wv.progress('Processing frames', _wv_done, n) + _wv.frame_metrics(os.path.basename(f.path), metrics, + accepted=error is None) + if error: + f.accepted = False + f.metrics = {'error': error} + rejected_reasons[f.path] = error + stats.add_error(f.path, error) + if args.verbose: + print(f' REJECT {os.path.basename(f.path)}: {error}') + else: + f.metrics = metrics + _publish_frame_thumb(_wv, args, + os.path.basename(f.path), + mem_rgb[frame_idx], _wv_thumb_count) + if args.verbose: + m = f.metrics + safe_print(f' {os.path.basename(f.path)}: ' + f'score={m["score"]:.0f} SNR={m["snr"]:.1f} ' + f'stars={m["star_count"]} FWHM={m.get("fwhm",0):.1f} ' + f'sharpness={m.get("sharpness",0):.0f}') + safe_print(f' bg={m.get("background",0):.1f} ' + f'noise={m.get("noise",0):.2f} ' + f'brightness={m.get("brightness",0):.1f} ' + f'contrast={m.get("contrast",0):.1f} ' + f'dynamic_range={m.get("dynamic_range",0):.0f}') + except RunCancelled: + # Cancel every not-yet-started future so the `with` block's + # own shutdown(wait=True) on the way out only waits for + # whatever's already mid-frame in the worker processes -- + # not the full remainder of the session (every future was + # submitted upfront, so a plain exit here would otherwise + # wait for all n frames regardless of the cancel). + pool.shutdown(wait=False, cancel_futures=True) + raise finally: # Release shared memory after all workers are done. for shm in shm_blocks: @@ -1150,54 +1172,66 @@ def _thread_process_frame(i, f): # quality metrics are computed asynchronously and only printed AFTER # this loop, so disabling the bar under -v would leave the whole GPU # processing loop with no output at all (looks hung). - for future in tqdm(as_completed(futures), total=n, - desc=" Processing", unit="frame", - disable=False): - i, metrics, error, lum_arr, timings = future.result() - _accum(timings) - f = lights[i] - if error: - f.accepted = False - f.metrics = {'error': error} - rejected_reasons[f.path] = error - stats.add_error(f.path, error) - if args.verbose: - safe_print(f' REJECT {os.path.basename(f.path)}: {error}') - else: - cached_lums[i] = lum_arr - if _use_qpool and lum_arr is not None: - # Submit quality to CPU pool; GPU thread is already freed. - # Its compute time runs concurrently with other frames' - # GPU work and isn't attributable to a single frame here, - # so it is not folded into the per-step totals below — - # the GPU path's timing breakdown is best-effort. - _qfuts[i] = _qpool.submit( - compute_quality_metrics, lum_arr, advanced_metrics=_adv) - else: - f.metrics = metrics + try: + for future in tqdm(as_completed(futures), total=n, + desc=" Processing", unit="frame", + disable=False): + _check_cancel(args) + i, metrics, error, lum_arr, timings = future.result() + _accum(timings) + f = lights[i] + if error: + f.accepted = False + f.metrics = {'error': error} + rejected_reasons[f.path] = error + stats.add_error(f.path, error) if args.verbose: - m = f.metrics - safe_print(f' {os.path.basename(f.path)}: ' - f'score={m["score"]:.0f} SNR={m["snr"]:.1f} ' - f'stars={m["star_count"]} FWHM={m.get("fwhm",0):.1f} ' - f'sharpness={m.get("sharpness",0):.0f}') - safe_print(f' bg={m.get("background",0):.1f} ' - f'noise={m.get("noise",0):.2f} ' - f'brightness={m.get("brightness",0):.1f} ' - f'contrast={m.get("contrast",0):.1f} ' - f'dynamic_range={m.get("dynamic_range",0):.0f}') - _completed += 1 - _wv = _get_ui_events() - _wv.progress('Processing frames', _completed, n) - if error is None and f.metrics: - _wv.frame_metrics(os.path.basename(f.path), f.metrics) - if error is None: - _publish_frame_thumb(_wv, args, os.path.basename(f.path), - mem_rgb[i], _wv_thumb_count) - # Periodically free CuPy's cached memory pool to prevent VRAM exhaustion - # from accumulating unused cached blocks across many completed frames. - if gpu.active and (_completed % _free_interval == 0): - gpu.free_pool() + safe_print(f' REJECT {os.path.basename(f.path)}: {error}') + else: + cached_lums[i] = lum_arr + if _use_qpool and lum_arr is not None: + # Submit quality to CPU pool; GPU thread is already freed. + # Its compute time runs concurrently with other frames' + # GPU work and isn't attributable to a single frame here, + # so it is not folded into the per-step totals below — + # the GPU path's timing breakdown is best-effort. + _qfuts[i] = _qpool.submit( + compute_quality_metrics, lum_arr, advanced_metrics=_adv) + else: + f.metrics = metrics + if args.verbose: + m = f.metrics + safe_print(f' {os.path.basename(f.path)}: ' + f'score={m["score"]:.0f} SNR={m["snr"]:.1f} ' + f'stars={m["star_count"]} FWHM={m.get("fwhm",0):.1f} ' + f'sharpness={m.get("sharpness",0):.0f}') + safe_print(f' bg={m.get("background",0):.1f} ' + f'noise={m.get("noise",0):.2f} ' + f'brightness={m.get("brightness",0):.1f} ' + f'contrast={m.get("contrast",0):.1f} ' + f'dynamic_range={m.get("dynamic_range",0):.0f}') + _completed += 1 + _wv = _get_ui_events() + _wv.progress('Processing frames', _completed, n) + if error is None and f.metrics: + _wv.frame_metrics(os.path.basename(f.path), f.metrics) + if error is None: + _publish_frame_thumb(_wv, args, os.path.basename(f.path), + mem_rgb[i], _wv_thumb_count) + # Periodically free CuPy's cached memory pool to prevent VRAM exhaustion + # from accumulating unused cached blocks across many completed frames. + if gpu.active and (_completed % _free_interval == 0): + gpu.free_pool() + except RunCancelled: + # Same reasoning as the ProcessPool path above: every future + # was submitted upfront, so cancel the ones not yet started + # before letting the `with` block's own shutdown wait only on + # whatever's already in flight. + if _use_qpool: + _qpool.shutdown(wait=False, cancel_futures=True) + _io_pool.shutdown(wait=False, cancel_futures=True) + executor.shutdown(wait=False, cancel_futures=True) + raise # Collect deferred quality results (CPU pool runs while GPU was active) if _qfuts: @@ -1236,6 +1270,7 @@ def _thread_process_frame(i, f): for i, f in tqdm(enumerate(lights), total=n, desc=" Processing", unit="frame", disable=args.verbose): + _check_cancel(args) result = _process_single_frame( f.path, f.header, masters, args.debayer_method, args.white_balance, ca_correction=getattr(args, 'ca_correction', False), diff --git a/src/models.py b/src/models.py index 2f38ad0..361f52a 100644 --- a/src/models.py +++ b/src/models.py @@ -6,6 +6,16 @@ from typing import Dict, List, Optional, Tuple +class RunCancelled(Exception): + """Raised at a cooperative checkpoint (``args._cancel_event`` set) to + unwind a run cleanly -- not a failure. Checked in the Phase 1 per-frame + loops (``frame_processor.execute_frame_processing``, the highest-value + spot: usually the longest-running phase) and between targets in + ``cli.process_directory`` (multi-session/hierarchical runs). Phases 2-4 + of a single target aren't interruptible yet -- once one starts it runs + to completion, same as before this existed.""" + + class Config: """Central configuration for magic numbers and thresholds.""" HOT_PIXEL_THRESHOLD = 12.0 diff --git a/src/originvision.py b/src/originvision.py index 9bf6ffa..762dfdd 100644 --- a/src/originvision.py +++ b/src/originvision.py @@ -64,7 +64,7 @@ def score_lights_with_originvision(lights: List[FrameInfo], args) -> None: model_path = _originvision_model(args) if model_path is None: return - workers = max(1, int(getattr(args, 'originvision_workers', 2))) + workers = max(1, int(getattr(args, 'originvision_workers', 8))) targets = [f for f in lights if f.accepted] if not targets: diff --git a/src/originvision_infer.py b/src/originvision_infer.py index 79d25f1..5b83ef9 100644 --- a/src/originvision_infer.py +++ b/src/originvision_infer.py @@ -197,12 +197,82 @@ def _prep_rgb(rgb: np.ndarray) -> Optional[np.ndarray]: return np.ascontiguousarray(arr[:, :, :3], dtype=np.float32) +def _downsample_if_large(arr: np.ndarray, max_long_side: int) -> np.ndarray: + """Shrink ``arr`` (aspect preserved, no crop) if its longer side exceeds + ``max_long_side``, same gaussian-prefiltered bilinear zoom as + ``_resize_center_crop`` -- just earlier, before the percentile stretch, + and without the crop. + + Measured on a full Origin sensor frame (1936x1096): the percentile + stretch + final resize scale with *input* pixel count even though only a + 256x256 crop is ever used, so at full resolution 85% of a + ``score_rgb`` call (1315 -> 196 ms) was spent processing pixels the model + never sees. Both backends call this identically (before the + native/onnxruntime dispatch below), so native/fallback parity holds and + this is a pure precomputation, not a behaviour fork. + + Not free of numerical effect -- a second resampling stage changes the + stretch's own percentile estimate and adds another antialiasing pass on + top of ``_resize_center_crop``'s. Validated against the un-downsampled + path the same way ``_resize_center_crop``'s own cv2->scipy swap was + (docstring above): category/defect/stray-light flags unchanged, quality + score within the same few-points-on-a-0-400-scale tolerance already + accepted there (session-relative only, never an absolute gate). + """ + h, w = arr.shape[:2] + long_side = max(h, w) + if long_side <= max_long_side: + return arr + scale = max_long_side / long_side + sigma = ((1.0 / scale) - 1.0) / 2.0 + f = arr + if sigma > 0.01: + f = ndimage.gaussian_filter(f, (sigma, sigma, 0) if f.ndim == 3 else (sigma, sigma)) + factor = (scale, scale, 1) if f.ndim == 3 else (scale, scale) + return np.ascontiguousarray(ndimage.zoom(f, factor, order=1, mode='reflect'), + dtype=np.float32) + + +# `_resize_center_crop` already needs 2x oversample margin on the shorter +# side to antialias well into `size`; capping the longer side at this factor +# leaves that margin on both axes while still discarding the bulk of a +# full-res frame's pixels before the expensive full-frame percentile scan. +# +# Opt-in (score_rgb's fast_preprocess=False by default), not a default-on +# speedup: measured on a real full-res frame (1370x2833, Fireworks Galaxy), +# the `reject`/`quality` heads are genuinely resolution-sensitive, not just +# resampling-noise-sensitive like _resize_center_crop's own cv2->scipy swap +# (which left them within ~0.002/a few points). Sweeping this factor on that +# same frame (ms/call, speedup, defect_probability delta, quality_score +# delta vs. no pre-downsample): +# factor=2 (512px): 400ms 7.04x defect +0.40 quality -160 +# factor=3 (768px): 413ms 6.82x defect +0.26 quality -92 +# factor=4 (1024px): 492ms 5.72x defect +0.13 quality -35 +# factor=6 (1536px): 822ms 3.42x defect +0.06 quality -22 +# factor=8 (2048px): 1494ms 1.88x defect +0.04 quality -3 +# defect_probability swinging by tenths (not thousandths) on one real frame +# at every tested factor -- including the mild ones -- is enough to flip +# is_defective on a frame that sits near 0.5, and that flag feeds +# auto_settings.py's defensive nudges (trail-reject on, stronger chroma +# denoise), not just a log line. Left off by default pending a decision on +# whether/how to expose it (a CLI flag, a specific factor) rather than +# shipping a silent accuracy/speed tradeoff. +_PREDOWNSAMPLE_FACTOR = 4 + + def score_rgb(rgb: np.ndarray, *, model_path: Optional[str] = None, - size: int = 256, shape_gate: bool = True) -> Optional[dict]: + size: int = 256, shape_gate: bool = True, + fast_preprocess: bool = False) -> Optional[dict]: """Score an ``(H, W, 3)`` RGB array (any range/dtype -- it's percentile- stretched here). Returns the result dict, or ``None`` on any failure (logged). Uses the native tract kernel when ``astro_native`` is built, otherwise the ``onnxruntime`` fallback. + + ``fast_preprocess`` (default off): pre-downsample large inputs before the + percentile stretch (see ``_downsample_if_large``'s docstring for the + measured speed-vs-accuracy tradeoff) -- real speedup, but the `reject`/ + `quality` heads shift more than this project's usual resampling + tolerance, so it's opt-in, not the default. """ mp = resolve_model_path(model_path) if mp is None: @@ -210,6 +280,8 @@ def score_rgb(rgb: np.ndarray, *, model_path: Optional[str] = None, arr = _prep_rgb(rgb) if arr is None: return None + if fast_preprocess: + arr = _downsample_if_large(arr, size * _PREDOWNSAMPLE_FACTOR) if _HAS_NATIVE_OV: try: diff --git a/tests/test_cancel.py b/tests/test_cancel.py new file mode 100644 index 0000000..646fec0 --- /dev/null +++ b/tests/test_cancel.py @@ -0,0 +1,161 @@ +"""Tests for cooperative run cancellation (the desktop app's Cancel button): +``RunCancelled`` (src/models.py), ``frame_processor._check_cancel``, +``cli.process_directory``'s per-target guard, and ``RunManager``'s +cancel()/is_cancelling() plumbing (src/desktop_control.py). + +Cancellation is cooperative -- a ``threading.Event`` checked at a handful of +checkpoints, not a thread kill -- so these tests exercise the checkpoints +directly rather than timing a real multi-minute stacking run. +""" +from __future__ import annotations + +import argparse +import os +import tempfile +import threading +import unittest +from unittest.mock import patch + +from src.models import RunCancelled + + +class TestCheckCancel(unittest.TestCase): + def test_raises_when_event_set(self): + from src.frame_processor import _check_cancel + ns = argparse.Namespace(_cancel_event=threading.Event()) + ns._cancel_event.set() + with self.assertRaises(RunCancelled): + _check_cancel(ns) + + def test_no_op_when_event_unset(self): + from src.frame_processor import _check_cancel + ns = argparse.Namespace(_cancel_event=threading.Event()) + _check_cancel(ns) # must not raise + + def test_no_op_without_a_cancel_event_at_all(self): + """A plain CLI run never sets args._cancel_event -- must be a no-op, + not an AttributeError.""" + from src.frame_processor import _check_cancel + _check_cancel(argparse.Namespace()) + + +class TestExecuteFrameProcessingStopsEarly(unittest.TestCase): + def test_sequential_path_raises_before_processing_any_frame(self): + """A pre-cancelled event must stop the sequential dispatch path + (n < 4, no process pool) before it touches the first frame -- + cheap and deterministic, unlike timing a real multi-frame run.""" + from src.frame_processor import execute_frame_processing + from src.models import FrameInfo, ProcessingStats + + lights = [FrameInfo(path='does-not-exist.fits', type='light', header={})] + args = argparse.Namespace( + parallel=1, verbose=False, debayer_method='malvar', white_balance='grayworld', + ca_correction=False, cosmic_ray_rejection=False, advanced_metrics=True, + pre_gradient_removal=False, trail_reject=False, + _cancel_event=threading.Event()) + args._cancel_event.set() + + with patch('src.frame_processor._process_single_frame') as mock_proc: + with self.assertRaises(RunCancelled): + execute_frame_processing( + lights, {}, args, + mem_rgb=None, mem_lum=None, mm_rgb_path='', mm_lum_path='', + cached_lums=[None], rgb_shape=(1, 4, 4, 3), lum_shape=(1, 4, 4), + rejected_reasons={}, stats=ProcessingStats()) + mock_proc.assert_not_called() + + +class TestProcessDirectoryPerTargetGuard(unittest.TestCase): + def test_raises_before_the_first_target_when_precancelled(self): + from src.cli import process_directory + + with tempfile.TemporaryDirectory() as tmp: + for name in ('session_a', 'session_b'): + d = os.path.join(tmp, name) + os.makedirs(d) + open(os.path.join(d, 'light_000.fits'), 'w').close() + + args = argparse.Namespace(hierarchical=True, mosaic=False, + preset=None, _cancel_event=threading.Event()) + args._cancel_event.set() + + with patch('src.cli._want_combine_sessions', return_value=False), \ + patch('src.cli.discover_frames') as mock_discover: + with self.assertRaises(RunCancelled): + process_directory(tmp, os.path.join(tmp, 'out.fits'), args) + mock_discover.assert_not_called() + + +class TestRunManagerCancel(unittest.TestCase): + def test_cancel_is_a_noop_while_idle(self): + from src.desktop_control import RunManager + rm = RunManager() + rm.cancel() + self.assertFalse(rm._cancel_event.is_set()) + + def test_cancel_sets_the_event_while_running(self): + from src.desktop_control import RunManager + rm = RunManager() + rm.status = 'running' + rm.cancel() + self.assertTrue(rm._cancel_event.is_set()) + + def test_is_cancelling_reflects_both_status_and_event(self): + from src.desktop_control import RunManager + rm = RunManager() + self.assertFalse(rm.is_cancelling()) + rm.status = 'running' + self.assertFalse(rm.is_cancelling()) + rm._cancel_event.set() + self.assertTrue(rm.is_cancelling()) + rm.status = 'ok' + self.assertFalse(rm.is_cancelling(), "a finished run is not 'cancelling'") + + def test_start_gives_each_run_a_fresh_event(self): + """A cancel() from a previous run must not leak into the next one.""" + from src.desktop_control import RunManager + rm = RunManager() + rm._cancel_event.set() + with patch('src.cli.process_directory'): + rm.start({'directory': 'foo', 'output': 'bar.fits'}) + rm.thread.join(timeout=5) + self.assertEqual(rm.status, 'ok') + + def test_start_threads_its_cancel_event_onto_args(self): + """frame_processor._check_cancel / cli.process_directory's guard + read args._cancel_event -- RunManager._run must set it to the + SAME Event object cancel() sets.""" + from src.desktop_control import RunManager + rm = RunManager() + captured = {} + + def _capture(directory, output, args): + captured['ev'] = args._cancel_event + + with patch('src.cli.process_directory', side_effect=_capture): + rm.start({'directory': 'foo', 'output': 'bar.fits'}) + rm.thread.join(timeout=5) + self.assertIs(captured['ev'], rm._cancel_event) + + def test_pipeline_raising_run_cancelled_sets_status_cancelled_not_error(self): + from src.desktop_control import RunManager + rm = RunManager() + with patch('src.cli.process_directory', side_effect=RunCancelled('stop')): + result = rm.start({'directory': 'foo', 'output': 'bar.fits'}) + self.assertTrue(result['ok']) + rm.thread.join(timeout=5) + self.assertEqual(rm.status, 'cancelled') + + def test_run_finished_receives_cancelled_status(self): + from src.desktop_control import RunManager + rm = RunManager() + with patch('src.cli.process_directory', side_effect=RunCancelled('stop')), \ + patch('src.ui_events.get_ui_events') as mock_get_wv: + mock_wv = mock_get_wv.return_value + rm.start({'directory': 'foo', 'output': 'bar.fits'}) + rm.thread.join(timeout=5) + mock_wv.run_finished.assert_called_once_with('cancelled', None) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/test_originvision.py b/tests/test_originvision.py index f80c253..f799379 100644 --- a/tests/test_originvision.py +++ b/tests/test_originvision.py @@ -69,6 +69,27 @@ def test_resize_center_crop_upscale(self): out = infer_mod._resize_center_crop(img, 128) assert out.shape == (128, 128, 3) + def test_downsample_if_large_is_a_no_op_under_the_cap(self): + img = (np.random.default_rng(2).random((100, 150, 3)) * 255).astype(np.float32) + out = infer_mod._downsample_if_large(img, max_long_side=200) + assert out is img # identity, not just equal -- no copy when already small + + def test_downsample_if_large_preserves_aspect_and_caps_long_side(self): + img = (np.random.default_rng(3).random((1000, 2000, 3)) * 255).astype(np.float32) + out = infer_mod._downsample_if_large(img, max_long_side=500) + assert out.shape[1] == 500 # long side (width here) hits the cap exactly + assert abs(out.shape[0] / out.shape[1] - img.shape[0] / img.shape[1]) < 0.01 + assert out.dtype == np.float32 + + def test_downsample_if_large_never_crops(self): + """Unlike _resize_center_crop, this must keep the whole frame -- + cropping here, before the percentile stretch even runs, would change + which pixels the stretch is computed over.""" + img = (np.random.default_rng(4).random((300, 900, 3)) * 255).astype(np.float32) + out = infer_mod._downsample_if_large(img, max_long_side=300) + assert out.shape[0] == 100 # short side scaled down, not cropped away + assert out.shape[1] == 300 + class _FakeCatSession: """Minimal onnxruntime-style session with only a `category` head, so the @@ -171,6 +192,25 @@ def test_score_rgb_returns_expected_keys(self): assert r['category'] in ('galaxy', 'nebula', 'star_cluster', 'comet') assert 0.0 <= r['category_confidence'] <= 1.0 + def test_fast_preprocess_defaults_off_and_matches_explicit_false(self): + """The dormant fast_preprocess opt-in must never change behaviour + unless a caller explicitly asks for it -- default-arg omission and + an explicit False must be identical.""" + rgb = self._synth_rgb() + r_default = infer_mod.score_rgb(rgb) + r_explicit_false = infer_mod.score_rgb(rgb, fast_preprocess=False) + assert r_default == r_explicit_false + + def test_fast_preprocess_true_actually_changes_the_input(self): + """Opting in must take a measurably different (cheaper) path -- a + small synthetic frame is already near/under the downsample cap, so + scale up first to guarantee the cap actually bites.""" + big = np.tile(self._synth_rgb(), (3, 3, 1)) # 900x1200, well over 256*4 + r_full = infer_mod.score_rgb(big) + r_fast = infer_mod.score_rgb(big, fast_preprocess=True) + assert r_full is not None and r_fast is not None + assert r_full['quality_score'] != r_fast['quality_score'] + def test_untrained_and_unused_heads_not_surfaced(self): """The bundled v4 graph emits 8 outputs incl. `trailing` (untrained, excluded from `tasks`) and `background_grid` (trained but unused). From 53bac8f1db03937900fcb22b690083c7e7326488 Mon Sep 17 00:00:00 2001 From: Hans Davenport <35202271+hd152@users.noreply.github.com> Date: Wed, 23 Sep 2026 07:15:02 -0700 Subject: [PATCH 3/5] Enable originvision by default --originvision is on by default now; only --no-originvision exists to turn it off (same no-positive-flag shape as --auto/--no-auto). A bare --originvision on an old command line now errors rather than being a no-op -- deliberate, since desktop_control.py's form-schema/argv machinery keys purely on argparse dest with no dest-collision handling, so a redundant positive flag alongside the new negative one would have silently duplicated or dropped that GUI field. Co-Authored-By: Claude Sonnet 5 --- CLAUDE.md | 2 +- packaging/verify_build.ps1 | 10 ++++++---- src/cli.py | 36 ++++++++++++++++++++++++------------ src/frame_processor.py | 2 +- tests/test_originvision.py | 6 ++++-- 5 files changed, 36 insertions(+), 20 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 0da88df..daee102 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -127,7 +127,7 @@ The pipeline is split across `src/` modules. [originstack.py](originstack.py) is | [src/photometry_timeseries.py](src/photometry_timeseries.py) | Per-frame differential light curves (`--photometry-timeseries`): runs right after Phase 3 while the registered frames are still in `mem_rgb`, warps + crops each sub, aperture-photometers a fixed `match_gaia_field` star list on every frame (`aperture_photometry_batch`), then iteratively ensemble-calibrates a per-frame per-channel zero point (comparison stars = well-detected, unsaturated, mid-brightness; high-scatter members clipped out). Writes `_lightcurves.csv` (one row per frame×star: MJD, airmass, per-channel mag/magerr, flag) and `_lightcurve_stats.csv` (per star: mean/rms/MAD/ptp/reduced-χ² + a `variable` flag). `--photometry-target "RA,DEC"` / `"px:X,Y"` marks one star and prints its stats. Needs a session `info.json` WCS (`--plate-solve` runs after this point); differential only — no absolute ZP, extinction cancels in the ensemble | | [src/gain_ptc.py](src/gain_ptc.py) | Photon-transfer gain / read-noise from raw calibration frames (`estimate_gain_ptc`, auto-run in `cli._build_masters` when `--photometry`/`--photometry-timeseries` is set and ≥2 bias + ≥2 flat frames exist and no `--photometry-gain` was given). Janesick two-frame difference: `gain = (Σmean_flat - Σmean_bias) / (var(flat₁-flat₂) - var(bias₁-bias₂))`, `read_noise_adu = std(bias₁-bias₂)/√2`, on a central sigma-clipped window (dodges vignetting/amp-glow). One flat level → a single (signal, variance) point, not a full PTC curve; OSC frames reduced to luma. Result stashed as `masters['ptc_gain_e_per_adu']` / `args._ptc_gain_e_per_adu` and consumed by `photometry._read_gain` | | [src/annotation.py](src/annotation.py) | Object annotation (`--annotate`): circles + labels bright stars and named deep-sky objects (galaxies, nebulae, clusters) on a copy of the preview, via the header's WCS and live SIMBAD cone-search queries. Needs a WCS (`--plate-solve` or a session solve); fails soft otherwise | -| [src/originvision.py](src/originvision.py) | originvision integration: defect/quality/category scoring by a separately-trained vision classifier, **run in-process** (no subprocess, no external folder, no venv, no network). Inference runs through the **native `astro_native.originvision_score` kernel** ([ext/astro_native/src/lib.rs](ext/astro_native/src/lib.rs), `mod originvision`): the whole path — per-channel percentile stretch, gaussian-prefiltered bilinear resize/centre-crop, and the ONNX forward pass via the **pure-Rust `tract` runtime** — runs inside `py.allow_threads` (a thread pool of scoring calls genuinely parallelises), panic-guarded (`catch_unwind` → `RuntimeError`, so a malformed `--originvision-model` never SIGABRTs), with no Python ONNX dependency and no extra DLL. [src/originvision_infer.py](src/originvision_infer.py) is now a thin dispatcher: native when `astro_native` is built (the only path in the packaged app), else a numpy/scipy + Python-`onnxruntime` fallback for a source checkout without the crate. The exported model **ships inside the package** at `src/data/originvision.onnx` (~11 MB, "v4" — a from-scratch, no-SSL 7-task run, best-by-category checkpoint at epoch 15); the native kernel loads it by path (session cached per `(path, size)`). `--originvision` self-disables with a warning when neither backend nor the model file is available. The graph emits **8** outputs but ONNX metadata `tasks` lists **7** (`reject`/`quality`/`category`/`exposure`/`sky_brightness`/`stray_light_gradient`/`background_grid`) — `trailing` is a real graph output with *untrained* weights on this checkpoint, so `score_rgb` gates every head on `tasks` membership, never on output presence. `trailing` and `background_grid` are deliberately not surfaced (the latter trained but unused — OriginStack runs its own DBE). A model whose graph-output count doesn't match its `head_order` metadata is rejected (both backends) rather than scored around. `category` is a 4-class head — galaxy/nebula/star_cluster/comet — but `comet` is distrusted on the current checkpoint, so a top `comet` pick is demoted to the runner-up (`shape_gate=False` disables that); `exposure` is a 7-class classifier. `src/originvision_infer.py::_load_image_any` debayers a raw `.fits` light itself (single-frame Bayer → RGB) then feeds the array straight to `score_rgb` — no temp file; TIFF/PNG/JPG pass through `tifffile`/`Pillow`. **Model provenance / re-sync**: [vendor/originvision/](vendor/originvision/) holds the upstream snapshot the port + `src/data/originvision.onnx` are copied from (see `vendor/originvision/VENDORED_FROM.txt`); it is **not on the runtime path** (numpy-mirror pattern). `--originvision-model PATH` overrides the bundled model; legacy `--originvision-dir`/`--originvision-checkpoint` still resolve a path (`--originvision-python`/`--originvision-script`/`--originvision-timeout` were removed with the subprocess). `--originvision` alone, with `--auto` active (the default), samples 3 light frames spread through the session — the sampled category feeds `--auto`'s target-classification prior the same way SIMBAD/header metadata does, and a defect flag nudges settings defensively (`--trail-reject` on, stronger chroma denoising) via `_originvision_defect_flagged` in `auto_settings.py`. `--originvision-score-all` (opt-in, needs `--originvision` too — a no-op and warned about otherwise) additionally scores every accepted light frame after Phase 1 (before `quality_gate`) and once more on the final stacked master, gated in two places (`pipeline.py`'s call site and inside `score_lights_with_originvision` itself). Advisory/logging only while the model is still finishing its first training run — results are stored in `FrameInfo.metrics['originvision']` and logged (defective/stray-light flags, session-relative below-average `quality_score`, master category vs. the pipeline's own inferred target type) but never set `accepted` or feed `metrics['score']`, so nothing is auto-dropped. **Performance (2026-09 profiling pass)**: `--originvision-workers` default raised 2 → 8 — measured near-linear scaling on a real full-res frame (2751 ms/frame at 1 worker → 1415 at 2 → 473 at 8; the previous default of 2 left most of the free GIL-released parallelism the docstring already claimed on the table). Separately, `score_rgb`'s preprocessing (percentile stretch + resize) scales with *input* pixel count even though only a 256x256 crop is ever used — 85% of a full-res (1936x1096) call was spent on pixels the model never sees (1315 ms → 196 ms once already at 256x256). A pre-downsample step (`_downsample_if_large`, `originvision_infer.py`) fixes that (up to 7x on a real frame) but was **measured and found unsafe as a default**: unlike `_resize_center_crop`'s own cv2→scipy swap (class/flag heads unaffected to ~0.002), the `reject`/`quality` heads are genuinely resolution-sensitive — `defect_probability` swung +0.04 to +0.40 on one real frame across every tested aggressiveness, enough to flip `is_defective` near the 0.5 boundary, which feeds `auto_settings.py`'s defensive nudges, not just a log line. Kept as a dormant, undocumented-to-the-CLI opt-in (`score_rgb(..., fast_preprocess=True)`) rather than wired to a flag or shipped as default — see its docstring for the full factor-vs-delta sweep | +| [src/originvision.py](src/originvision.py) | originvision integration: defect/quality/category scoring by a separately-trained vision classifier, **run in-process** (no subprocess, no external folder, no venv, no network). Inference runs through the **native `astro_native.originvision_score` kernel** ([ext/astro_native/src/lib.rs](ext/astro_native/src/lib.rs), `mod originvision`): the whole path — per-channel percentile stretch, gaussian-prefiltered bilinear resize/centre-crop, and the ONNX forward pass via the **pure-Rust `tract` runtime** — runs inside `py.allow_threads` (a thread pool of scoring calls genuinely parallelises), panic-guarded (`catch_unwind` → `RuntimeError`, so a malformed `--originvision-model` never SIGABRTs), with no Python ONNX dependency and no extra DLL. [src/originvision_infer.py](src/originvision_infer.py) is now a thin dispatcher: native when `astro_native` is built (the only path in the packaged app), else a numpy/scipy + Python-`onnxruntime` fallback for a source checkout without the crate. The exported model **ships inside the package** at `src/data/originvision.onnx` (~11 MB, "v4" — a from-scratch, no-SSL 7-task run, best-by-category checkpoint at epoch 15); the native kernel loads it by path (session cached per `(path, size)`). **On by default since 2026-09** (`--no-originvision` to disable — a single action, same no-positive-flag shape as `--auto`/`--no-auto`; bare `--originvision` on an old command line now errors rather than being a no-op, deliberately, since `desktop_control.py`'s form-schema/argv machinery keys purely on dest with no dest-collision handling). Self-disables with a warning when neither backend nor the model file is available, so a source checkout with neither `astro_native` nor `onnxruntime` built still runs cleanly. The graph emits **8** outputs but ONNX metadata `tasks` lists **7** (`reject`/`quality`/`category`/`exposure`/`sky_brightness`/`stray_light_gradient`/`background_grid`) — `trailing` is a real graph output with *untrained* weights on this checkpoint, so `score_rgb` gates every head on `tasks` membership, never on output presence. `trailing` and `background_grid` are deliberately not surfaced (the latter trained but unused — OriginStack runs its own DBE). A model whose graph-output count doesn't match its `head_order` metadata is rejected (both backends) rather than scored around. `category` is a 4-class head — galaxy/nebula/star_cluster/comet — but `comet` is distrusted on the current checkpoint, so a top `comet` pick is demoted to the runner-up (`shape_gate=False` disables that); `exposure` is a 7-class classifier. `src/originvision_infer.py::_load_image_any` debayers a raw `.fits` light itself (single-frame Bayer → RGB) then feeds the array straight to `score_rgb` — no temp file; TIFF/PNG/JPG pass through `tifffile`/`Pillow`. **Model provenance / re-sync**: [vendor/originvision/](vendor/originvision/) holds the upstream snapshot the port + `src/data/originvision.onnx` are copied from (see `vendor/originvision/VENDORED_FROM.txt`); it is **not on the runtime path** (numpy-mirror pattern). `--originvision-model PATH` overrides the bundled model; legacy `--originvision-dir`/`--originvision-checkpoint` still resolve a path (`--originvision-python`/`--originvision-script`/`--originvision-timeout` were removed with the subprocess). `--originvision` alone, with `--auto` active (the default), samples 3 light frames spread through the session — the sampled category feeds `--auto`'s target-classification prior the same way SIMBAD/header metadata does, and a defect flag nudges settings defensively (`--trail-reject` on, stronger chroma denoising) via `_originvision_defect_flagged` in `auto_settings.py`. `--originvision-score-all` (opt-in, needs `--originvision` too — a no-op and warned about otherwise) additionally scores every accepted light frame after Phase 1 (before `quality_gate`) and once more on the final stacked master, gated in two places (`pipeline.py`'s call site and inside `score_lights_with_originvision` itself). Advisory/logging only while the model is still finishing its first training run — results are stored in `FrameInfo.metrics['originvision']` and logged (defective/stray-light flags, session-relative below-average `quality_score`, master category vs. the pipeline's own inferred target type) but never set `accepted` or feed `metrics['score']`, so nothing is auto-dropped. **Performance (2026-09 profiling pass)**: `--originvision-workers` default raised 2 → 8 — measured near-linear scaling on a real full-res frame (2751 ms/frame at 1 worker → 1415 at 2 → 473 at 8; the previous default of 2 left most of the free GIL-released parallelism the docstring already claimed on the table). Separately, `score_rgb`'s preprocessing (percentile stretch + resize) scales with *input* pixel count even though only a 256x256 crop is ever used — 85% of a full-res (1936x1096) call was spent on pixels the model never sees (1315 ms → 196 ms once already at 256x256). A pre-downsample step (`_downsample_if_large`, `originvision_infer.py`) fixes that (up to 7x on a real frame) but was **measured and found unsafe as a default**: unlike `_resize_center_crop`'s own cv2→scipy swap (class/flag heads unaffected to ~0.002), the `reject`/`quality` heads are genuinely resolution-sensitive — `defect_probability` swung +0.04 to +0.40 on one real frame across every tested aggressiveness, enough to flip `is_defective` near the 0.5 boundary, which feeds `auto_settings.py`'s defensive nudges, not just a log line. Kept as a dormant, undocumented-to-the-CLI opt-in (`score_rgb(..., fast_preprocess=True)`) rather than wired to a flag or shipped as default — see its docstring for the full factor-vs-delta sweep | | [src/pipeline.py](src/pipeline.py) | Thin orchestrator: `stack_target` wires all four phases | | [src/health_check.py](src/health_check.py) | `run_health_check` | | [src/cli.py](src/cli.py) | `process_directory`, `parse_args`, `main`. `save_effective_config` writes strings through `_toml_str`: an unescaped Windows path (`log_file = "C:\Users\..."`) made every GUI-saved config unparseable ("Invalid hex value"), and the run then silently fell back to defaults with only a warning; regression-tested by round-tripping through `tomllib`. `tools/lint_conventions.py`'s `_git` decodes as UTF-8 for the same platform-codepage reason | diff --git a/packaging/verify_build.ps1 b/packaging/verify_build.ps1 index 5ac17c1..7832572 100644 --- a/packaging/verify_build.ps1 +++ b/packaging/verify_build.ps1 @@ -88,12 +88,14 @@ if (-not (Test-Path $synthDir)) { throw "synthetic_data was not created -- canno $outPath = "$env:TEMP\originstack_verify_out.fits" $headlessLog = "$env:TEMP\originstack_verify_stdout.txt" if (Test-Path $headlessLog) { Remove-Item $headlessLog -Force } -# --originvision exercises the bundled native scorer (astro_native.originvision_score -# + src/data/originvision.onnx). It self-disables with a warning if either is -# missing from the frozen build -- asserted absent below. +# originvision runs by default now (--no-originvision to disable; no positive +# flag exists, see cli.py) and exercises the bundled native scorer +# (astro_native.originvision_score + src/data/originvision.onnx). It +# self-disables with a warning if either is missing from the frozen build -- +# asserted absent below. $headlessArgs = @('--verify-headless', '-d', (Resolve-Path $synthDir).Path, '-o', $outPath, '--parallel', '4', '--debayer-method', 'malvar', - '--white-balance', 'grayworld', '--stack-method', 'median', '--originvision') + '--white-balance', 'grayworld', '--stack-method', 'median') $headlessProc = Start-Process -FilePath $ExePath -ArgumentList $headlessArgs -PassThru ` -RedirectStandardOutput $headlessLog diff --git a/src/cli.py b/src/cli.py index 1856f44..14cb170 100644 --- a/src/cli.py +++ b/src/cli.py @@ -1934,21 +1934,33 @@ def build_parser() -> argparse.ArgumentParser: 'accepted, rejection_reason)') g_debug.add_argument('--export-frames-dir', default=None, metavar='PATH', help='Directory to write a stretched JPEG for every accepted frame after Phase 1') - g_originvision.add_argument('--originvision', action='store_true', - help='Score the final stacked master with originvision (separately-trained ' - 'defect/quality/category classifier), run in-process against the ' + # Single action, no positive `--originvision` flag -- same reason --auto + # (src/cli.py:_UNSUPPORTED... see the --no-auto entry above) has none: + # desktop_control.py's get_form_schema()/build_argv_from_form() key + # purely on argparse dest, with no dest-collision handling, so two + # actions sharing one dest would silently double up the GUI field (a + # second tk.Variable, one of them dropped) or pick one arbitrarily in + # _dest_action_map's dest->action dict. `--originvision` text on an + # existing command line now errors (unrecognized argument) rather than + # being a redundant no-op -- deliberate, matching --auto's own + # no-positive-flag precedent, not an oversight. + g_originvision.add_argument('--no-originvision', dest='originvision', action='store_false', + default=True, + help='Disable originvision scoring (defect/quality/category classifier, on ' + 'by default). Scores the final stacked master in-process against the ' 'bundled model (src/data/originvision.onnx -- no external folder or ' 'venv). Inference is the native astro_native kernel (pure-Rust tract, ' 'nothing extra to install); a source checkout without astro_native ' - 'falls back to a Python onnxruntime path. When --auto is also active ' - '(the default -- pass --no-auto to disable), also samples 3 light ' - 'frames spread through the session: the sampled category feeds the ' - 'same target-classification prior SIMBAD/header metadata uses, and a ' - 'defect flag nudges settings defensively (trail-reject, stronger ' - 'chroma denoising) -- never auto-rejects a frame, this model is still ' - 'finishing its first training run. Pair with --originvision-score-all ' - 'to also score every accepted frame (slower on a large session). ' - 'Self-disables with a warning when no backend is available.') + 'falls back to a Python onnxruntime path -- and self-disables with a ' + 'warning if neither backend nor the model file is available, so this ' + 'runs cleanly either way. When --auto is also active (the default -- ' + 'pass --no-auto to disable), also samples 3 light frames spread through ' + 'the session: the sampled category feeds the same target-classification ' + 'prior SIMBAD/header metadata uses, and a defect flag nudges settings ' + 'defensively (trail-reject, stronger chroma denoising) -- never ' + 'auto-rejects a frame, this model is still finishing its first training ' + 'run. Pair with --originvision-score-all to also score every accepted ' + 'frame (slower on a large session).') g_originvision.add_argument('--originvision-score-all', action='store_true', help='Also score every accepted light frame with originvision (not just ' 'the fast 3-frame sample --originvision always does), logging advisory ' diff --git a/src/frame_processor.py b/src/frame_processor.py index 40fa6d9..40a061a 100644 --- a/src/frame_processor.py +++ b/src/frame_processor.py @@ -1055,7 +1055,7 @@ def _accum(timings: Optional[dict]) -> None: rejected_reasons[f.path] = error stats.add_error(f.path, error) if args.verbose: - print(f' REJECT {os.path.basename(f.path)}: {error}') + safe_print(f' REJECT {os.path.basename(f.path)}: {error}') else: f.metrics = metrics _publish_frame_thumb(_wv, args, diff --git a/tests/test_originvision.py b/tests/test_originvision.py index f799379..3b2b7a2 100644 --- a/tests/test_originvision.py +++ b/tests/test_originvision.py @@ -535,9 +535,11 @@ def test_all_samples_failing_returns_none(self): class TestCliOriginvisionResolution: def _parse(self, tmp_path, *extra): + # No explicit --originvision -- it's on by default now (a single + # --no-originvision action, same no-positive-flag shape as --auto; + # see the flag's own definition in cli.py for why). from src import cli - return cli.parse_args(['-d', str(tmp_path), '-o', str(tmp_path / 'o.fits'), - '--originvision', *extra]) + return cli.parse_args(['-d', str(tmp_path), '-o', str(tmp_path / 'o.fits'), *extra]) @_real_infer def test_originvision_stays_enabled_with_bundled_model(self, tmp_path, monkeypatch): From 7502ff7b40b6bc12e4c718d27c914b0605189aca Mon Sep 17 00:00:00 2001 From: Hans Davenport <35202271+hd152@users.noreply.github.com> Date: Wed, 23 Sep 2026 07:15:05 -0700 Subject: [PATCH 4/5] Release 2.3.0 ZOGY real/bogus transient triage (--transient-triage), a desktop-app Cancel button, originvision on by default with a measured 8-worker throughput default, and a fix for --transient-detect refusing two differently-shaped sessions of the same target. Co-Authored-By: Claude Sonnet 5 --- CHANGELOG.md | 36 ++++++++++++++++++++++++++++++++++++ VERSION | 2 +- 2 files changed, 37 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e8f4cf1..4ac48aa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,42 @@ match the `VERSION` file and `v*` git tags. ## [Unreleased] +## [2.3.0] - 2026-09-23 + +### Added + +- **`--transient-triage`**: scores each `--transient-detect` candidate with a small CNN (new/ref/diff + stamp triplet, native `tract` inference) for a `real_probability` -- the same real/bogus triage role + ZTF's BTSbot / Rubin's DIA play downstream of classical image differencing, and the first amateur + stacking tool to do it. Advisory only, never drops a candidate. The bundled model is trained entirely + on synthetic data (`tools/gen_transient_triage_data.py` + `tools/train_transient_triage.py`); a + companion `tools/mine_real_transient_data.py` mines real cross-session negatives and real-epoch + injection positives from a user's own multi-night sessions for future retraining. Native-only for now + (no numpy/onnxruntime fallback yet). +- **Cancel button** in the desktop app. Cooperative (a shared `threading.Event`, not a thread kill): + noticed between Phase 1 frames -- usually the longest phase -- and between targets in a multi-session + run. Phases 2-4 of a single target aren't interruptible yet. + +### Changed + +- **`--originvision` is on by default now** (`--no-originvision` to disable -- a single action, same + shape as `--auto`/`--no-auto`). A bare `--originvision` on an old command line now errors instead of + being a no-op, deliberately: the desktop app's auto-generated form keys purely on argparse dest with + no dest-collision handling, so a redundant positive flag alongside the new negative one would have + silently duplicated or dropped that field. +- **`--originvision-workers` default raised 2 -> 8**, measured (not guessed): near-linear scaling on a + real full-resolution frame, 2751 ms/frame at 1 worker down to 473 ms at 8 -- the previous default left + most of the free GIL-released parallelism the code already claimed on the table. + +### Fixed + +- **`--transient-detect` no longer hard-refuses two epochs with different pixel dimensions.** Two + independently-stacked sessions of the same target routinely differ in shape (different dither pattern, + different Phase 3 crop) even though they cover the same field; `_align_reference` now reconciles them + onto a common grid first (`src.utils.embed_to_shape`, the same trick `--merge` already used for its own + differently-shaped previous stacks), carrying a real valid-data mask through the warp so the padded + border reads as uncovered, not real reference data. + ## [2.2.6] - 2026-09-22 ### Changed diff --git a/VERSION b/VERSION index bda8fbe..276cbf9 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -2.2.6 +2.3.0 From 7d3bc6182c6bffcca8a3c4f32b13e200ebb87030 Mon Sep 17 00:00:00 2001 From: Hans Davenport <35202271+hd152@users.noreply.github.com> Date: Wed, 23 Sep 2026 07:22:42 -0700 Subject: [PATCH 5/5] Parallelize test suite with pytest-xdist Measured 3.2x on a 16-core machine (1665 tests, 169s -> 52s, no isolation issues -- same pass count either way). Wired into both CI jobs. Not the local default via pyproject.toml addopts: xdist's forked workers break --pdb/breakpoint() and interleave -s output, so it stays an explicit `-n auto` flag locally, documented in CLAUDE.md. Co-Authored-By: Claude Sonnet 5 --- .github/workflows/ci.yml | 8 ++++---- CLAUDE.md | 7 +++++++ requirements-dev.txt | 1 + 3 files changed, 12 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6a8c407..f82368e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -62,13 +62,13 @@ jobs: run: | python -m pip install --upgrade pip pip install -r requirements.txt - pip install pytest maturin onnxruntime + pip install pytest pytest-xdist maturin onnxruntime cd ext/astro_native maturin build --release pip install --force-reinstall --no-deps target/wheels/astro_native-*.whl - name: Native kernel + originvision parity tests - run: pytest -q tests/test_native.py tests/test_originvision.py + run: pytest -q -n auto tests/test_native.py tests/test_originvision.py test: runs-on: ubuntu-latest @@ -97,7 +97,7 @@ jobs: run: | python -m pip install --upgrade pip pip install -r requirements.txt - pip install pytest pip-audit bandit + pip install pytest pytest-xdist pip-audit bandit - name: Audit dependencies run: pip-audit -r requirements.txt @@ -106,7 +106,7 @@ jobs: run: bandit -r src/ -ll -q - name: Run tests - run: pytest -q + run: pytest -q -n auto - name: Smoke run (synthetic) run: | diff --git a/CLAUDE.md b/CLAUDE.md index daee102..e15b5d7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -17,9 +17,16 @@ pip install maturin && (cd ext/astro_native && maturin develop --release) ### Run tests ```bash pytest -q +# Full suite, parallel (pytest-xdist, in requirements-dev.txt) -- measured 3.2x on a +# 16-core machine (1665 tests: 169s -> 52s, no isolation issues found) +pytest -q -n auto # Run a single test pytest tests/test_core.py::test_calculate_shift_recovery -v ``` +`-n auto` is not the default (no `addopts` in `pyproject.toml`) -- deliberately: xdist runs each +test in a forked worker process, which breaks `--pdb`/`breakpoint()` interactive debugging (a +worker's stdin isn't wired up for it) and interleaves `-s` print output across workers. CI uses +`-n auto` on both jobs (`.github/workflows/ci.yml`) where that tradeoff doesn't apply. ### Lint ```bash diff --git a/requirements-dev.txt b/requirements-dev.txt index 97e7203..7c7044e 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -2,5 +2,6 @@ # pip install -r requirements-dev.txt ruff==0.16.8 pytest +pytest-xdist pip-audit bandit