Difference-imaging anomaly detector for transient discovery. Takes a reference frame and one or more follow-up search frames of the same field, matches their backgrounds, subtracts, and reports the groups of pixels that got brighter — candidates for supernovae, novae, asteroids, comets, variable stars, or anything else that changed between frames. An optional minor-body cross-match path can plate-solve the reference frame, query the SASpro minor-body catalog at the exact observation time, and label the candidates that correspond to already-known asteroids and comets so what's left is the interesting set.
(I - δ) / (1 - δ) dynamic-range-preserving correction, run SASpro's polynomial gradient removal on both sides so matching backgrounds subtract cleanly, apply asymmetric stretch to the matched target median so faint positive residuals pop out of the subtraction, convert to mono, subtract and clip to positive-only residuals (so disappearing stars don't flood the detections, only new or brightened sources do), threshold at a user-chosen level with the first/last 5% of each axis masked out to kill alignment-induced edge artefacts, group with 8-connected components, filter by area (≥ 25 px, ≤ 200×200) — is Franklin Marek's design. The optional minor-body cross-match path is also original: lazily plate-solve the reference via SASpro's Plate Solver when a WCS isn't already attached, derive mid-exposure JD from DATE-OBS + EXPTIME/2 with XISF FITSKeywords and FITS-header fallbacks (and the TT scale conversion where that matters), query SASpro's minor-body catalog for bright asteroids and comets, compute Skyfield ephemerides at the derived JD (topocentric when settings carry an observatory location), project the predicted RA/Dec through the reference WCS to pixel coordinates, cull predictions outside the FOV, and cross-match each anomaly's bounding-box centre against the predicted catalogue positions with a WCS-aware pixel search radius computed from the actual plate scale √|det CD| · 3600. The colour-coded preview (green for anomalies that match a catalogued minor body, red for unmatched candidates, yellow circles for every predicted minor body in field whether an anomaly fell on it or not), the Push to New View handler that re-reads the source file so the pushed anomaly-marked image keeps the original FITS/XISF header and metadata intact, the background-threaded QThread + QObject + moveToThread minor-body worker with progress reporting, and the candidate-list text summary with per-anomaly H-magnitude annotations are all original work.
0.10). Click Process — the dialog preprocesses both sides, subtracts, finds anomalies, and shows a tree of results. Double-click an entry to open its anomaly-marked preview. From the preview you can zoom with Ctrl + wheel, pan by dragging, and push the marked image back into SASpro as a new document.
The signature move is positive-only residuals:
Clipping negative residuals to zero asymmetrises the subtraction: a star that disappeared between the reference and the search produces D < 0 and is thrown away; a star that appeared or got brighter survives. For transient hunting this is exactly what you want — a supernova, a nova, or an asteroid passing through the field is a new source added onto the background, not something taken away.
The two paths at the top of the dialog select the inputs:
The two frames don't need to be bit-for-bit co-registered going in — the background neutralisation and gradient-removal steps leave the geometry alone and simply match brightness levels across the two sides — but they do need to be of the same field at the same approximate scale. If you have one with and one without an alignment pass, align first (SASpro's Star Alignment or the Stacking Suite's integration output both work).
Check Apply Cosmetic Correction before Preprocessing to clean hot and cold pixels on every search image before subtraction. Uses SASpro's shared Numba cosmetic correction kernel (the same code the Stacking Suite uses) at 5σ hot and 5σ cold with a 3-pixel window. Important when your search frames are raw single-sub exposures — one or two hot pixels in a search sub that aren't in the reference will produce single-pixel “transients” that pass the threshold. The area filter (§9.8) catches most of these anyway, but turning this on removes them at the source.
Leave it off when both sides are already-stacked integrations — the stacking rejection has already cleaned them.
Slider from 0.01 to 0.50, default 0.10. The threshold is applied to the clipped difference image after normalisation and asymmetric stretch (target median 0.25), so 0.10 corresponds to roughly “40% dimmer than typical stretched star core”. Lower values catch fainter transients at the cost of more false positives; higher values find only bright anomalies.
For a 15-minute search frame against a deep stacked reference, 0.10 is a good starting point. For noisier shorter exposures, nudge up to 0.15 or 0.20.
The Process button runs the whole pipeline in order:
Each phase updates the status label and either the preprocessing or search progress label as it goes. The dialog stays responsive — processEvents is called between images so you can see progress live.
After Process completes, a non-modal Anomaly Results window opens with a two-column tree: the search image's filename and the number of anomaly groups found in it. Double-click any row to open that image's anomaly-marked preview (§6).
A separate Detailed Anomaly Results text panel also opens, listing each image's anomalies with their bounding-box corners and any catalogued minor-body candidates that fell inside them.
The preview is a zoomable, pannable, non-modal viewer with its own toolbar. On top of the stretched search image, SASpro draws:
Clears every selection, resets threshold to 0.10, drops the loaded preprocessed images, and clears the WCS / JD / predicted-minor-body state. Equivalent to closing and reopening the dialog but keeps your window position.
The minor-body path runs as a separate background operation after Process has produced anomalies. SASpro doesn't auto-trigger it on every search — it's a slow catalogue + ephemeris query, so you ask for it explicitly, and the dialog lets you tune the magnitude limits and time offset before kicking it off.
When the minor-body search is requested, a modal dialog asks for:
9.0. Bright asteroids only. For deep searches, raise toward 15–18.5000.10.0.1000.0, range ±72 hours.The search runs in a background QThread-wrapped QObject worker, so the dialog stays responsive. A dedicated progress bar appears below the status label, reporting through the phases: opening the catalogue, selecting bright asteroids and comets, computing positions (asteroids then comets), projecting onto image pixels. On completion, SASpro re-opens the detailed results dialog with every anomaly annotated with its candidate matches.
The preprocessing pipeline, difference extraction, and minor-body cross-match in order.
Both the reference and every search image run through identical preprocessing before subtraction:
The two sides are preprocessed independently with the same code path, so background levels, gradients, and brightness are matched. Any residual difference after preprocessing is — by construction — local structural difference, not global illumination.
For colour frames, SASpro removes any residual tint by subtracting a per-channel offset derived from a 10% central sample patch. For each channel c, taking med(C_c) as the sample-region median and the mean of all three channel medians as the target:
The denominator (1 - δ_c) is the dynamic-range-preserving correction: a plain subtraction I_c - δ_c would push a bright white pixel below 1.0 wherever a channel was tinted up, and crush its headroom. Rescaling by 1 - δ_c preserves white at white while removing the tint. Clipped to [0, 1].
Only applied to colour frames (img.ndim == 3 and shape[2] == 3). Mono frames pass through unchanged.
SASpro's PolyGradientRemoval fits a 2D polynomial of degree 2 to the background and subtracts it in the original (linear) domain. The fit runs on a 4× downsample with 100 sample points placed over the frame; sample locations are chosen to avoid stars and bright structures (the same algorithm as SASpro's standalone Polynomial Gradient Removal tool).
Why on both sides: the whole point of the subtraction is to isolate local differences. A residual uncorrected gradient on either frame would print the gradient into the difference image and pollute the anomaly search. Running the same ABE on both sides matches their gradients to the same reference (flat zero), so the difference image sees only local structure.
Both sides are stretched to the same target median:
via SASpro's shared midtones-transfer stretch (stretch_mono_image / stretch_color_image, same code used by GHS and Autostretch). The 0.25 target is deliberately brighter than a conventional linear or quarter-median stretch — it pushes faint positive residuals into a visible brightness regime where the threshold slider can bite on them, while leaving the deep background still noticeably dark. For visual transient hunting on web-exported JPEGs this is close to ideal.
Colour frames are stretched per-channel unlinked, so the ABE'd colour balance is preserved into the stretched output.
Both frames collapse to mono (channel average for RGB) and the stretched reference is subtracted from the stretched search:
Clipping the lower bound at zero makes this a positive-only detector — it will find things that appeared or brightened, never things that disappeared or dimmed. For transient-source discovery that's exactly the physics: supernovae, novae, dwarf-nova outbursts, and asteroids passing through all show as new / brightened pixels. A star variable that fades isn't being searched for here (and classical difference imaging with a two-sided threshold would catch it, but at much higher false-positive rates). The upper clip at 1.0 is defensive, not substantive — stretched values stay in [0, 1] already.
A 5% border on each side of the frame is zeroed out of the detection mask:
Why: if the two frames weren't perfectly aligned going in (small rotation, differential drift, atmospheric refraction at low altitudes), the edges of the frame are where the misregistration prints the strongest, and they would produce rings of edge-aligned false transients. Masking the outer 5% off keeps the detector well-behaved on imperfect alignment at the cost of ignoring any real transients in the extreme corners.
SASpro thresholds the difference image at the user's slider setting:
and runs 8-connected components labelling (cv2.connectedComponentsWithStats) to group the above-threshold pixels into distinct candidate anomalies. Each component gets a bounding box (minX, minY, maxX, maxY) and an area in pixels — the pieces of information the preview and the minor-body cross-match need.
Components are kept only if:
The lower bound rejects noise speckle: a single hot pixel or an edge artefact that scraped past the border exclusion is tiny; a real transient on a stacked frame will seat on 25+ connected pixels after stretching. The upper bound rejects large structures — a globally-brighter nebulosity region, a passing airplane trail, or a satellite streak. For transient-point-source hunting both ends of the filter are appropriate.
If you need to catch something intermediate (a bright comet coma, say), consider raising the max bound or dropping this filter in a one-off edit.
For minor-body cross-matching, SASpro needs the exact observation time of the reference frame. The clock used is the midpoint of the reference's exposure:
where JD_obs is derived from the DATE-OBS keyword and t_exp is the exposure time in seconds (EXPTIME or EXPOSURE).
Minor bodies move during the exposure; the midpoint is where they are “on average” across the frame and where their predicted centroid should land. Three retrieval paths:
DATE-OBS and EXPOSURE (or EXPTIME) from the file's FITSKeywords metadata, parsed through astropy's Time. Handles the ISO-8601-with-fractional-seconds format that SharpCap / N.I.N.A. / SGP emit.DATE-OBS and EXPTIME/EXPOSURE from the FITS header. Converted to TT scale (t.tt.jd) because that's the planetary-ephemeris reference.Minor-body prediction requires a WCS on the reference. If one is already attached (because the frame came from a solved stacking integration, say), SASpro uses it directly. If not, it calls into the Plate Solver's four-pass core (_solve_numpy_with_fallback) on-demand, strips any existing WCS keywords from the acquisition header via _strip_wcs_keys, and merges the fresh solver WCS back in via _merge_wcs_into_base_header. The result is identical to what Plate Solver would produce as a standalone tool, so the fall-back ladder (Gaia DR3 engine → ASTAP seeded → ASTAP blind → astrometry.net) is available here too.
The minor-body catalogue (SASpro's packaged MPC + comet orbit database, managed by minorbodycatalog) is queried for bright asteroids and comets up to the user-configured absolute-magnitude caps (H_ast_max, H_com_max) with row count caps for each (ast_max_count, com_max_count). The result is two DataFrames of orbital elements.
Each selected body's position is propagated to the mid-exposure JD using Skyfield's catalogue ephemerides. Topocentric observation is used when the SASpro settings carry an observatory latitude, longitude, and elevation; otherwise geocentric (parallax error for an outer-main-belt asteroid at geocentric vs. topocentric is at most ~arcseconds, well below the default 60 arcsec cross-match radius).
The predicted RA/Dec is projected to pixel coordinates through the reference's WCS:
Bodies landing outside the frame are discarded; the survivors are the predicted catalogue positions on this specific frame.
Each anomaly's bounding-box centre is tested against every predicted body position. The search radius is specified in arcseconds (60 arcsec default) and converted to pixels using the actual plate scale derived from the reference WCS's CD matrix:
and the per-anomaly pixel search radius:
Pixel distance from the anomaly centre to each predicted body:
and acceptance:
All matches within the radius are kept and sorted by distance; the closest becomes the anomaly's primary match (the matched_body field), used to colour its box green in the preview. Multiple close candidates (two asteroids within an arcminute of each other) still both show up in the text results so you can inspect them.
The anomaly-marked preview is drawn with OpenCV rectangle and circle primitives on an 8-bit RGB copy of the stretched image:
matched_bodies is non-empty, red otherwise.Pushing the anomaly-marked preview back into SASpro as a new document goes through a careful metadata-preserving path rather than just handing over the pixel buffer. The push handler:
load_image(..., return_metadata=True) to recover the complete header, bit-depth record, mono flag, and metadata dict.[0, 255] to [0, 1] if needed).FITSKeywords, WCS, DATE-OBS, EXPTIME, the lot.Why this matters: the new document is a legitimate astronomy image, not a screenshot. You can plate-solve it (it will solve to the same solution, since it's the same field), feed it back into another SASpro tool, save it as FITS with proper headers, or send it to a collaborator for independent confirmation.
clip(S - R, 0, 1)) that filters the detector to new/brightened sources; the 5% border exclusion against alignment-edge artefacts; the minor-body cross-match path (lazy plate solve, mid-exposure JD from XISF or FITS header, Skyfield ephemerides topocentric when a site is configured, plate-scale-aware pixel search radius from √|det CD|·3600, nearest-match primary + full candidate list); the colour-coded anomaly preview (green matched / red unmatched / yellow predicted) and its interactive QGraphicsView with Ctrl + wheel cursor-anchored zoom; the metadata-preserving push-to-view that re-reads the source file so the pushed anomaly image keeps its original FITS/XISF header intact; and the background-threaded minor-body worker with its QThread + QObject + moveToThread lifecycle and granular progress callbacks are original work.
bulk_cosmetic_correction_numba, 5σ hot + 5σ cold); Polynomial Gradient Removal (PolyGradientRemoval, degree-2 polynomial, 4× downsample, 100 sample points); midtones-transfer stretch (stretch_mono_image / stretch_color_image, same code behind Autostretch and GHS); unified image I/O (load_image, save_image) for FITS, XISF, TIFF, PNG with metadata round-tripping; the Plate Solver four-pass fallback ladder (_solve_numpy_with_fallback, _strip_wcs_keys, _merge_wcs_into_base_header) which is reused here for on-demand WCS attachment; and the minor-body catalogue infrastructure (minorbodycatalog with its MPC + comet orbit database, ensure_minor_body_db managed-download, and Skyfield-driven position computation).
connectedComponentsWithStats. Mid-exposure epoch handling for moving-source astrometry is standard IAU practice; the TT time scale (used for the FITS-fallback JD) is the correct reference for planetary and minor-body ephemerides.
cv2) provides connected-components, rectangle and circle drawing, and the base image-processing primitives. NumPy provides array operations and linear-algebra helpers (linalg.det for the plate-scale derivation). Astropy provides the WCS implementation (astropy.wcs.WCS), the Time object with scale conversions (UTC ↔ TT) and ISO-8601 parsing (astropy.time.Time), units (astropy.units), and the FITS header I/O (astropy.io.fits.Header). Skyfield computes the minor-body ephemerides from the catalogue orbital elements using JPL DE-series planetary ephemerides. PyQt6 provides the UI framework: QGraphicsView / QGraphicsScene for the zoomable preview, QTreeWidget for the results tree, the QThread+QObject worker pattern, and the dialog toolkit.