2026-09-01 22:36:13 -04:00
|
|
|
"""Bad-frame detectors.
|
|
|
|
|
|
|
|
|
|
Every detector is a pure function: data in, a :class:`Verdict` out. Nothing here
|
|
|
|
|
renames a file, writes a plot, or touches the database -- that is the driver's job.
|
|
|
|
|
This is what lets the same code run in production (``filter_FITS.py``) and under the
|
|
|
|
|
test bench, and lets several detectors judge the same frame independently.
|
|
|
|
|
|
|
|
|
|
Two shapes of detector:
|
|
|
|
|
|
|
|
|
|
* **Frame-level** (:func:`header_v1`, :func:`geometry_v1`) judge one frame alone.
|
|
|
|
|
* **Series-level** (:func:`temporal_v1`, :func:`crosssat_v1`) judge a frame in the
|
|
|
|
|
context of its neighbours in time, or of the other satellite at the same instant.
|
|
|
|
|
They work from :class:`FrameFeatures` -- cached header values plus a small
|
|
|
|
|
thumbnail -- rather than full 1280x1280 arrays, which keeps a whole day of context
|
|
|
|
|
in memory at once.
|
|
|
|
|
|
|
|
|
|
Every detector reports continuous ``scores`` alongside its verdict. The driver
|
|
|
|
|
stores those, so re-tuning a threshold later is a database query rather than another
|
|
|
|
|
pass over the archive.
|
|
|
|
|
"""
|
|
|
|
|
|
2026-10-14 00:27:18 -04:00
|
|
|
import bisect
|
|
|
|
|
import functools
|
2026-09-01 22:36:13 -04:00
|
|
|
from dataclasses import dataclass, field
|
|
|
|
|
|
|
|
|
|
import cv2 as cv
|
|
|
|
|
import numpy as np
|
|
|
|
|
|
2026-10-14 00:27:18 -04:00
|
|
|
from . import measures
|
|
|
|
|
|
2026-09-01 22:36:13 -04:00
|
|
|
# --------------------------------------------------------------------------- types
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
|
|
|
class Verdict:
|
|
|
|
|
"""One detector's judgement of one frame."""
|
|
|
|
|
|
|
|
|
|
verdict: str # 'good' | 'bad' | 'unknown'
|
|
|
|
|
reason: str | None = None
|
|
|
|
|
scores: dict = field(default_factory=dict)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
GOOD = "good"
|
|
|
|
|
BAD = "bad"
|
|
|
|
|
UNKNOWN = "unknown"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@dataclass
|
|
|
|
|
class FrameFeatures:
|
|
|
|
|
"""The cheap summary of a frame that series-level detectors work from."""
|
|
|
|
|
|
|
|
|
|
slot: tuple # (satellite, wavelength, t_start)
|
|
|
|
|
header: dict = field(default_factory=dict)
|
|
|
|
|
#: Read error from fitsio, if the frame could not be parsed at all.
|
|
|
|
|
error: str | None = None
|
|
|
|
|
#: Small float32 image (see THUMBNAIL_SIZE), or None if not loaded.
|
|
|
|
|
thumbnail: np.ndarray | None = None
|
2026-10-14 00:27:18 -04:00
|
|
|
#: The frame's own single-frame scores (field_v1's stripe measures among them),
|
|
|
|
|
#: for series checks that compare a measure with the neighbours' values.
|
|
|
|
|
scores: dict = field(default_factory=dict)
|
2026-09-01 22:36:13 -04:00
|
|
|
|
|
|
|
|
@property
|
|
|
|
|
def t_start(self):
|
|
|
|
|
return self.slot[2]
|
|
|
|
|
|
|
|
|
|
@property
|
|
|
|
|
def wavelength(self):
|
|
|
|
|
return self.slot[1]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
#: Series detectors compare thumbnails, not full frames. 128px preserves the disc,
|
|
|
|
|
#: active regions and gross structure while making a day of context cheap to hold.
|
|
|
|
|
THUMBNAIL_SIZE = 128
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def thumbnail(image, size=THUMBNAIL_SIZE):
|
|
|
|
|
"""Downsample a frame for structural comparison. NaNs become zero."""
|
|
|
|
|
clean = np.nan_to_num(np.asarray(image, dtype=np.float32), nan=0.0, posinf=0.0, neginf=0.0)
|
|
|
|
|
return cv.resize(clean, (size, size), interpolation=cv.INTER_AREA)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ------------------------------------------------------------------ header_v1
|
|
|
|
|
|
|
|
|
|
#: Plausible radiance range of a healthy frame, per passband, in W m-2 sr-1.
|
|
|
|
|
#:
|
|
|
|
|
#: Deliberately wide -- roughly a factor of five outside the observed annual range of
|
|
|
|
|
#: good frames. Sampled across 24 days of 2024 on both satellites, the *rejected*
|
|
|
|
|
#: frames' IMG_MEAN distribution sits almost entirely inside the good one (171A: good
|
|
|
|
|
#: 0.288-0.452, rejected median 0.352), so a tight bound here would cost enormous
|
|
|
|
|
#: numbers of false positives while catching little. What this rule is actually for
|
|
|
|
|
#: is gross dropout and blowout -- eclipse frames run 1e-4, three orders of magnitude
|
|
|
|
|
#: below the floor -- and it is priced accordingly.
|
|
|
|
|
HEADER_MEAN_BOUNDS = {
|
|
|
|
|
94: (0.004, 0.41),
|
|
|
|
|
131: (0.005, 0.39),
|
|
|
|
|
171: (0.057, 2.3),
|
|
|
|
|
195: (0.15, 5.4),
|
|
|
|
|
284: (0.13, 7.0),
|
|
|
|
|
304: (0.29, 20.0),
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#: Solar diameter in pixels; varies ~3% over the year with Earth-Sun distance.
|
|
|
|
|
DIAM_SUN_RANGE = (740.0, 800.0)
|
|
|
|
|
#: How far the recorded sun centre may sit from the image centre, in pixels.
|
|
|
|
|
MAX_CRPIX_OFFSET = 8.0
|
|
|
|
|
|
|
|
|
|
|
2026-10-14 00:27:18 -04:00
|
|
|
def unreadable(error):
|
|
|
|
|
"""The one way an undecodable frame becomes a verdict.
|
|
|
|
|
|
|
|
|
|
Several drivers used to build this Verdict by hand (bench, filter_FITS, and
|
|
|
|
|
header_v1 itself), and hand-rolled copies of a quality rule are exactly how
|
|
|
|
|
the bench came to disagree with the pipeline. A frame that cannot be read
|
|
|
|
|
carries no information regardless of what any other rule might say.
|
|
|
|
|
"""
|
|
|
|
|
return Verdict(BAD, f"unreadable: {error or 'no image data'}", {"read_ok": 0.0})
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def judge_frame(slot, cards, header_error=None, read_image=None):
|
|
|
|
|
"""THE production judgment for one frame, from its parsed header onward.
|
|
|
|
|
|
|
|
|
|
header_v1 and lunar_v1 on the header, then -- only if those pass and
|
|
|
|
|
`read_image` is given -- disc_v1 and field_v1 on the pixels. This is the one sequence the corpus
|
|
|
|
|
builder, the bench and the real-data renderer all use, so none of them can
|
|
|
|
|
hold a different opinion of the same file. The first BAD decides; UNKNOWN
|
|
|
|
|
abstains in favour of the others' answers.
|
|
|
|
|
|
|
|
|
|
`read_image` is called lazily -- a header rejection costs no decompression --
|
|
|
|
|
and must return (image, error) like ``fitsio.read_image``.
|
|
|
|
|
|
|
|
|
|
Returns (source, verdict, image): `source` names the check that decided a BAD
|
|
|
|
|
verdict ("header_v1", "lunar_v1", "read", "disc_v1", "field_v1") or is None, and `image` is
|
|
|
|
|
whatever was decoded, so a caller that needs the pixels need not read twice.
|
|
|
|
|
"""
|
|
|
|
|
from . import fitsio
|
|
|
|
|
|
|
|
|
|
values = fitsio.header_metadata(cards or {})
|
|
|
|
|
verdict = header_v1(FrameFeatures(slot=slot, header=values, error=header_error))
|
|
|
|
|
if verdict.verdict == BAD:
|
|
|
|
|
return "header_v1", verdict, None
|
|
|
|
|
# Every check's measurements travel with the verdict, whichever check decided:
|
|
|
|
|
# the stored measurement run keeps them all.
|
|
|
|
|
scores = dict(verdict.scores)
|
|
|
|
|
lunar = lunar_v1(cards)
|
|
|
|
|
scores.update(lunar.scores)
|
|
|
|
|
if lunar.verdict == BAD:
|
|
|
|
|
return "lunar_v1", Verdict(BAD, lunar.reason, scores), None
|
|
|
|
|
if read_image is None:
|
|
|
|
|
return None, Verdict(verdict.verdict, verdict.reason, scores), None
|
|
|
|
|
image, image_error = read_image()
|
|
|
|
|
if image is None:
|
|
|
|
|
return "read", unreadable(image_error), None
|
|
|
|
|
if image.shape != (1280, 1280):
|
|
|
|
|
return "read", Verdict(BAD, f"wrong shape {image.shape}", scores), image
|
|
|
|
|
refcrota = (cards or {}).get("REFCROTA")
|
|
|
|
|
refcrota = float(refcrota) if isinstance(refcrota, (int, float)) else None
|
|
|
|
|
for name, judged in (("disc_v1", disc_v1(image, slot[1], header=values)),
|
|
|
|
|
("field_v1", field_v1(image, slot[1], header=values,
|
|
|
|
|
refcrota=refcrota))):
|
|
|
|
|
scores.update(judged.scores)
|
|
|
|
|
if judged.verdict == BAD:
|
|
|
|
|
return name, Verdict(BAD, judged.reason, dict(scores)), image
|
|
|
|
|
if judged.verdict == GOOD:
|
|
|
|
|
verdict = judged
|
|
|
|
|
return None, Verdict(verdict.verdict, verdict.reason, scores), image
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
#: Detector-run name under which every frame's measurements are stored
|
|
|
|
|
#: (``filter_FITS.py measure``): the single-frame verdict and everything the
|
|
|
|
|
#: judgment compares, read from the archive once.
|
|
|
|
|
MEASURE_RUN = "measure"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def measure_config():
|
|
|
|
|
"""Everything a stored measurement depends on. Matched exactly, like the
|
|
|
|
|
judgment's, so a changed single-frame limit or measure starts a fresh run."""
|
|
|
|
|
return {
|
|
|
|
|
"driver": "filter_FITS measure",
|
|
|
|
|
"sequence": ["header_v1", "lunar_v1", "disc_v1", "field_v1"],
|
|
|
|
|
"header_mean_bounds": {str(k): v for k, v in HEADER_MEAN_BOUNDS.items()},
|
|
|
|
|
"disc_bounds": {str(k): v for k, v in DISC_BOUNDS.items()},
|
|
|
|
|
"field_scale": {str(k): v for k, v in FIELD_SCALE.items()},
|
|
|
|
|
"field_limits": FIELD_LIMITS,
|
|
|
|
|
"lunar_margin_arcmin": LUNAR_MARGIN_ARCMIN,
|
|
|
|
|
"n_long_rule": True,
|
|
|
|
|
"measures": {"size": measures.MEASURE_SIZE, "sectors": measures.SECTORS,
|
|
|
|
|
"sector_band": list(measures.SECTOR_BAND),
|
|
|
|
|
"edge_limit": measures.EDGE_LIMIT,
|
|
|
|
|
"centre_limit": measures.CENTRE_LIMIT,
|
|
|
|
|
"echo_lags": list(measures.ECHO_LAGS)},
|
|
|
|
|
"temporal2_reach": TEMPORAL2_REACH,
|
|
|
|
|
"temporal2_floor": TEMPORAL2_FLOOR,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def measure_run(conn):
|
|
|
|
|
"""The stored measurement run made under the current rules, or None."""
|
|
|
|
|
from . import db
|
|
|
|
|
|
|
|
|
|
return db.latest_run_id(conn, MEASURE_RUN, measure_config())
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
#: Detector-run name under which the production judgment is stored in the index
|
|
|
|
|
#: (``filter_FITS.py judge``); every consumer reads verdicts from it.
|
|
|
|
|
JUDGMENT_RUN = "judge"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def judgment_config():
|
|
|
|
|
"""Everything a stored judgment depends on. Runs are matched on it exactly, so
|
|
|
|
|
changing any rule or limit -- or the measurements it is made from -- makes
|
|
|
|
|
consumers look for, and the judge command build, a fresh run rather than
|
|
|
|
|
mixing verdicts made under different rules."""
|
|
|
|
|
return {
|
|
|
|
|
"driver": "filter_FITS judge",
|
|
|
|
|
"measure": measure_config(),
|
|
|
|
|
"sequence": ["header_v1", "lunar_v1", "disc_v1", "field_v1", "temporal_v2",
|
|
|
|
|
"relative_v2", "pointing_v1"],
|
|
|
|
|
"position_bands": list(POSITION_BANDS),
|
|
|
|
|
"temporal2_limits": TEMPORAL2_LIMITS,
|
|
|
|
|
"relative_limits": RELATIVE_LIMITS,
|
|
|
|
|
"relative_reach": RELATIVE_REACH,
|
|
|
|
|
"relative_references": RELATIVE_REFERENCES,
|
|
|
|
|
"baseline_reach": BASELINE_REACH,
|
|
|
|
|
"baseline_references": BASELINE_REFERENCES,
|
|
|
|
|
"steady": [STEADY, STEADY_MIN],
|
|
|
|
|
"earth_reference_km": EARTH_REFERENCE_KM,
|
|
|
|
|
"pointing_bands": list(POINTING_BANDS),
|
|
|
|
|
"pointing_min_others": POINTING_MIN_OTHERS,
|
|
|
|
|
"pointing_evidence": list(POINTING_EVIDENCE),
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def judgment_run(conn):
|
|
|
|
|
"""The stored run made under the current rules, or None if there is none yet."""
|
|
|
|
|
from . import db
|
|
|
|
|
|
|
|
|
|
return db.latest_run_id(conn, JUDGMENT_RUN, judgment_config())
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def judge_file(slot, path, decode=True):
|
|
|
|
|
"""``judge_frame`` for a file on disk; returns only the Verdict."""
|
|
|
|
|
from . import fitsio
|
|
|
|
|
|
|
|
|
|
cards, header_error = fitsio.read_header(path)
|
|
|
|
|
reader = (lambda: fitsio.read_image(path)) if decode else None
|
|
|
|
|
return judge_frame(slot, cards, header_error, reader)[1]
|
|
|
|
|
|
|
|
|
|
|
2026-09-01 22:36:13 -04:00
|
|
|
def header_v1(features, mean_bounds=None, diam_range=DIAM_SUN_RANGE,
|
|
|
|
|
max_crpix_offset=MAX_CRPIX_OFFSET):
|
|
|
|
|
"""Judge a frame from its header alone -- no pixel decompression.
|
|
|
|
|
|
|
|
|
|
The instrument already reports most of what matters: ``ECLIPSE`` marks frames
|
|
|
|
|
taken through Earth's shadow, ``EMPTY`` marks frames built from no source
|
|
|
|
|
imagery, and ``IMG_MEAN``/``IMG_SDEV`` summarise the radiance. On sampled
|
|
|
|
|
archive days every frame the pixel filter rejected also carried ``ECLIPSE=2``,
|
|
|
|
|
at roughly a fifth of the cost of decompressing the image.
|
|
|
|
|
|
|
|
|
|
``DEGRADED`` is recorded as a score but is deliberately *not* a rejection rule.
|
|
|
|
|
It is set for reasons unrelated to a frame's usability: across sampled days the
|
|
|
|
|
195A band carried ``DEGRADED=T`` on 40/40 good frames on 2024-01-20 and 0/40 on
|
|
|
|
|
2024-07-04, on both satellites. Rejecting on it would discard entire months of
|
|
|
|
|
that band. The bench can re-weight it from the stored score if that turns out
|
|
|
|
|
to be worth doing.
|
|
|
|
|
"""
|
|
|
|
|
mean_bounds = mean_bounds or HEADER_MEAN_BOUNDS
|
|
|
|
|
header = features.header
|
|
|
|
|
scores = {}
|
|
|
|
|
|
|
|
|
|
if features.error:
|
2026-10-14 00:27:18 -04:00
|
|
|
return unreadable(features.error)
|
2026-09-01 22:36:13 -04:00
|
|
|
if not header:
|
|
|
|
|
return Verdict(UNKNOWN, "no header cached", scores)
|
|
|
|
|
|
|
|
|
|
for field_name in ("img_mean", "img_sdev", "diam_sun", "eclipse", "degraded"):
|
|
|
|
|
if field_name in header:
|
|
|
|
|
scores[field_name] = float(header[field_name])
|
|
|
|
|
|
|
|
|
|
if header.get("empty"):
|
|
|
|
|
return Verdict(BAD, "EMPTY flag set", scores)
|
|
|
|
|
eclipse = header.get("eclipse")
|
|
|
|
|
if eclipse:
|
|
|
|
|
return Verdict(BAD, f"ECLIPSE flag set ({int(eclipse)})", scores)
|
|
|
|
|
|
|
|
|
|
# A frame built from no source images carries no information regardless of what
|
|
|
|
|
# the radiance statistics happen to say.
|
|
|
|
|
if header.get("num_imgs") is not None and header["num_imgs"] < 1:
|
|
|
|
|
scores["num_imgs"] = float(header["num_imgs"])
|
|
|
|
|
return Verdict(BAD, "no source images", scores)
|
|
|
|
|
|
2026-10-14 00:27:18 -04:00
|
|
|
# A composite with no long exposure is built from short exposures scaled up to
|
|
|
|
|
# the long-exposure level, and their noise is scaled with them: the frame reads
|
|
|
|
|
# as static. Measured on 360 such frames from 2024, every one had far-corner
|
|
|
|
|
# pixel noise of 0.03-3.3 against at most 0.0076 on ~560 frames with a long
|
|
|
|
|
# exposure, in every band, and ~3/4 of them passed every other check here.
|
|
|
|
|
if header.get("n_long") is not None and header["n_long"] < 1:
|
|
|
|
|
scores["n_long"] = float(header["n_long"])
|
|
|
|
|
return Verdict(BAD, "no long exposure (N_LONG=0): noise-dominated", scores)
|
|
|
|
|
|
2026-09-01 22:36:13 -04:00
|
|
|
# The band the file claims must match the band its name puts it in, or the
|
|
|
|
|
# archive has a misfiled frame and every downstream threshold is wrong.
|
|
|
|
|
declared = header.get("wavelnth")
|
|
|
|
|
if declared is not None and int(declared) != features.wavelength:
|
|
|
|
|
scores["wavelnth"] = float(declared)
|
|
|
|
|
return Verdict(BAD, f"wavelength {int(declared)} != {features.wavelength}", scores)
|
|
|
|
|
|
|
|
|
|
mean = header.get("img_mean")
|
|
|
|
|
if mean is not None:
|
|
|
|
|
low, high = mean_bounds.get(features.wavelength, (0.0, np.inf))
|
|
|
|
|
if mean < low:
|
|
|
|
|
return Verdict(BAD, f"IMG_MEAN {mean:.3g} below {low:g}", scores)
|
|
|
|
|
if mean > high:
|
|
|
|
|
return Verdict(BAD, f"IMG_MEAN {mean:.3g} above {high:g}", scores)
|
|
|
|
|
|
|
|
|
|
sdev = header.get("img_sdev")
|
|
|
|
|
if sdev is not None and sdev <= 0.0:
|
|
|
|
|
return Verdict(BAD, "zero variance", scores)
|
|
|
|
|
|
|
|
|
|
diameter = header.get("diam_sun")
|
|
|
|
|
if diameter is not None and not (diam_range[0] <= diameter <= diam_range[1]):
|
|
|
|
|
return Verdict(BAD, f"DIAM_SUN {diameter:.1f} outside {diam_range}", scores)
|
|
|
|
|
|
|
|
|
|
crpix1, crpix2 = header.get("crpix1"), header.get("crpix2")
|
|
|
|
|
if crpix1 is not None and crpix2 is not None:
|
|
|
|
|
offset = max(abs(crpix1 - 640.5), abs(crpix2 - 640.5))
|
|
|
|
|
scores["crpix_offset"] = float(offset)
|
|
|
|
|
if offset > max_crpix_offset:
|
|
|
|
|
return Verdict(BAD, f"sun centre offset {offset:.1f}px", scores)
|
|
|
|
|
|
|
|
|
|
return Verdict(GOOD, None, scores)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------- geometry_v1
|
|
|
|
|
|
|
|
|
|
#: Per-band radiance ceiling used to normalise the image before shape analysis.
|
|
|
|
|
#: Index order matches paths.WAVELENGTHS.
|
|
|
|
|
GEOMETRY_THRESHOLDS = {94: 0.050, 131: 0.10, 171: 1.00, 195: 1.40, 284: 1.0, 304: 2.50}
|
|
|
|
|
|
|
|
|
|
EXPECTED_DIMS = 1280
|
|
|
|
|
HALF_DIMS = EXPECTED_DIMS // 2
|
|
|
|
|
VALID_RADII = (383, 394)
|
|
|
|
|
AVG_RADIUS = (VALID_RADII[0] + VALID_RADII[1]) // 2
|
|
|
|
|
MAX_RADIUS_ERROR = 80
|
|
|
|
|
MAX_CENTER_SKEW = 7
|
|
|
|
|
RATIO_ABOVE_THRESH_MAX = 0.4
|
|
|
|
|
RATIO_ABOVE_THRESH_MIN = 0.07
|
|
|
|
|
EDGE_THRESH = 0.98
|
|
|
|
|
MAX_GOF = 0.2
|
|
|
|
|
|
|
|
|
|
_ideal_axis_cache = {}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def ideal_disc_axis():
|
|
|
|
|
"""Column-mean profile of a perfect solar disc, used as the shape reference."""
|
|
|
|
|
if "axis" not in _ideal_axis_cache:
|
|
|
|
|
disc = np.zeros((HALF_DIMS, HALF_DIMS))
|
|
|
|
|
cv.circle(disc, (HALF_DIMS // 2, HALF_DIMS // 2), AVG_RADIUS // 2, 1, -1)
|
|
|
|
|
_ideal_axis_cache["axis"] = np.average(disc, 0)
|
|
|
|
|
return _ideal_axis_cache["axis"]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def geometry_v1(image, wavelength):
|
|
|
|
|
"""The pre-existing pixel-geometry filter, preserved as the baseline.
|
|
|
|
|
|
|
|
|
|
Normalises against a per-band radiance ceiling, then checks the fraction of
|
|
|
|
|
saturated pixels, the intensity-weighted centroid, the radius implied by the
|
|
|
|
|
98th-percentile cumulative edges, and the fit against an ideal disc profile.
|
|
|
|
|
|
|
|
|
|
Deliberately kept behaviourally identical to the original so bench numbers
|
|
|
|
|
describe what the pipeline actually did, with two known weaknesses left intact
|
|
|
|
|
for the bench to quantify:
|
|
|
|
|
|
|
|
|
|
* the centre check is one-sided -- ``HALF_DIMS//2 - centre > skew`` catches a
|
|
|
|
|
disc displaced up/left but not one displaced down/right;
|
|
|
|
|
* non-finite pixels propagate as NaN through every comparison, and ``NaN > x``
|
|
|
|
|
is False, so an all-NaN frame passes every check.
|
|
|
|
|
|
|
|
|
|
The one deliberate change: the original raised on an unexpected image size and
|
|
|
|
|
left the file unlabelled. Here that is a ``bad`` verdict, because "no verdict"
|
|
|
|
|
is not a usable baseline to score against.
|
|
|
|
|
"""
|
|
|
|
|
scores = {}
|
|
|
|
|
if image is None:
|
|
|
|
|
return Verdict(BAD, "no image data", scores)
|
|
|
|
|
if image.shape[0] != EXPECTED_DIMS or image.shape[1] != EXPECTED_DIMS:
|
|
|
|
|
return Verdict(BAD, f"unexpected dimensions {image.shape}", scores)
|
|
|
|
|
|
|
|
|
|
threshold = GEOMETRY_THRESHOLDS.get(wavelength)
|
|
|
|
|
if threshold is None:
|
|
|
|
|
return Verdict(UNKNOWN, f"no threshold for band {wavelength}", scores)
|
|
|
|
|
|
|
|
|
|
data = cv.resize(
|
|
|
|
|
np.asarray(image, dtype=np.float32),
|
|
|
|
|
dsize=(HALF_DIMS, HALF_DIMS),
|
|
|
|
|
interpolation=cv.INTER_LINEAR,
|
|
|
|
|
)
|
|
|
|
|
above = data > threshold
|
|
|
|
|
normalised = np.copy(data)
|
|
|
|
|
normalised[above] = threshold
|
|
|
|
|
normalised /= threshold
|
|
|
|
|
|
|
|
|
|
ratio = np.count_nonzero(above) / data.shape[0] / data.shape[1]
|
|
|
|
|
scores["ratio_above_thresh"] = float(ratio)
|
|
|
|
|
if ratio > RATIO_ABOVE_THRESH_MAX:
|
|
|
|
|
return Verdict(BAD, f"ratio_above_thresh {ratio:.2f} too high", scores)
|
|
|
|
|
if ratio < RATIO_ABOVE_THRESH_MIN:
|
|
|
|
|
return Verdict(BAD, f"ratio_above_thresh {ratio:.2f} too low", scores)
|
|
|
|
|
|
|
|
|
|
xavg = np.average(normalised, 0)
|
|
|
|
|
yavg = np.average(normalised, 1)
|
|
|
|
|
axis = list(range(HALF_DIMS))
|
|
|
|
|
|
|
|
|
|
centre_x = np.average(axis, 0, xavg)
|
|
|
|
|
centre_y = np.average(axis, 0, yavg)
|
|
|
|
|
scores["centre_x"] = float(centre_x)
|
|
|
|
|
scores["centre_y"] = float(centre_y)
|
|
|
|
|
good_centre = not (
|
|
|
|
|
(HALF_DIMS // 2 - centre_x > MAX_CENTER_SKEW)
|
|
|
|
|
or (HALF_DIMS // 2 - centre_y > MAX_CENTER_SKEW)
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
high_x = np.argmax(np.cumsum(xavg) > (np.sum(xavg) * EDGE_THRESH))
|
|
|
|
|
low_x = len(xavg) - np.argmax(np.cumsum(np.flip(xavg)) > (np.sum(xavg) * EDGE_THRESH))
|
|
|
|
|
high_y = np.argmax(np.cumsum(yavg) > (np.sum(yavg) * EDGE_THRESH))
|
|
|
|
|
low_y = len(yavg) - np.argmax(np.cumsum(np.flip(yavg)) > (np.sum(yavg) * EDGE_THRESH))
|
|
|
|
|
# Not halved: the image is already downsampled by two in each dimension.
|
|
|
|
|
radius = ((high_x - low_x) + (high_y - low_y)) / 2.0
|
|
|
|
|
scores["radius"] = float(radius)
|
|
|
|
|
good_radius = abs(radius - AVG_RADIUS) <= MAX_RADIUS_ERROR
|
|
|
|
|
|
|
|
|
|
reference = ideal_disc_axis()
|
|
|
|
|
gof_x = np.sum(np.abs(xavg - reference)) / HALF_DIMS
|
|
|
|
|
gof_y = np.sum(np.abs(yavg - reference)) / HALF_DIMS
|
|
|
|
|
scores["gof_x"] = float(gof_x)
|
|
|
|
|
scores["gof_y"] = float(gof_y)
|
|
|
|
|
good_fit = not (gof_x > MAX_GOF or gof_y > MAX_GOF)
|
|
|
|
|
|
|
|
|
|
if good_centre and good_radius and good_fit:
|
|
|
|
|
return Verdict(GOOD, None, scores)
|
|
|
|
|
failed = [
|
|
|
|
|
name
|
|
|
|
|
for name, ok in (("centre", good_centre), ("radius", good_radius), ("fit", good_fit))
|
|
|
|
|
if not ok
|
|
|
|
|
]
|
|
|
|
|
return Verdict(BAD, "failed " + "+".join(failed), scores)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# -------------------------------------------------------------------- disc_v1
|
|
|
|
|
|
|
|
|
|
#: Angular samples in the polar resampling. The measurements are all averages over
|
|
|
|
|
#: this axis, so more angles cost little and reduce noise.
|
|
|
|
|
DISC_ANGLES = 720
|
|
|
|
|
#: Working resolution. Halving 1280 keeps the limb several pixels wide while making
|
|
|
|
|
#: the polar transform cheap.
|
|
|
|
|
DISC_SIZE = 640
|
|
|
|
|
|
|
|
|
|
#: Radial bands, in units of the expected solar radius, used to characterise the
|
|
|
|
|
#: profile. The on-disc band avoids both the centre (where limb darkening and
|
|
|
|
|
#: filaments live) and the limb itself; the off-limb band sits outside the corona's
|
|
|
|
|
#: steepest falloff but inside the frame -- 1.45R of a ~193px radius is 280px, within
|
|
|
|
|
#: the 320px half-width.
|
|
|
|
|
DISC_ON_BAND = (0.30, 0.70)
|
|
|
|
|
DISC_OFF_BAND = (1.25, 1.45)
|
|
|
|
|
#: Where the limb is allowed to be found, again in units of expected radius.
|
|
|
|
|
DISC_SEARCH_BAND = (0.75, 1.25)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def disc_profile(image, expected_radius, size=DISC_SIZE, angles=DISC_ANGLES):
|
|
|
|
|
"""Measure the solar disc from its azimuthally averaged radial profile.
|
|
|
|
|
|
|
|
|
|
Returns a dict of continuous measurements, or with None values where the frame
|
|
|
|
|
is too degenerate to measure. Separated from the verdict logic so that
|
|
|
|
|
``filter_FITS.py calibrate`` can gather the same numbers over a sample.
|
|
|
|
|
|
|
|
|
|
Averaging over angle is the entire point. An active region is localised in
|
|
|
|
|
angle, so it barely shifts the averaged profile; the existing ``geometry_v1``
|
|
|
|
|
averages along image rows and columns instead, which a bright region shifts
|
|
|
|
|
directly -- the cause of its 36.9% rejection rate.
|
|
|
|
|
|
|
|
|
|
`expected_radius` is in pixels *at the working resolution*, i.e. DIAM_SUN / 4.
|
|
|
|
|
"""
|
|
|
|
|
empty = {"limb_contrast": None, "radius_ratio": None, "limb_width": None}
|
|
|
|
|
if image is None or image.ndim != 2 or min(image.shape) < 16:
|
|
|
|
|
return empty
|
|
|
|
|
if not expected_radius or not np.isfinite(expected_radius) or expected_radius <= 0:
|
|
|
|
|
return empty
|
|
|
|
|
|
|
|
|
|
working = cv.resize(
|
|
|
|
|
np.nan_to_num(np.asarray(image, dtype=np.float32), nan=0.0, posinf=0.0, neginf=0.0),
|
|
|
|
|
(size, size),
|
|
|
|
|
interpolation=cv.INTER_AREA,
|
|
|
|
|
)
|
|
|
|
|
# Radiance spans orders of magnitude; log compresses a flare so it cannot
|
|
|
|
|
# dominate the profile the way it dominates a linear mean.
|
|
|
|
|
working = np.log1p(np.clip(working, 0.0, None))
|
|
|
|
|
|
|
|
|
|
max_radius = size / 2.0
|
|
|
|
|
polar = cv.warpPolar(
|
|
|
|
|
working,
|
|
|
|
|
(int(max_radius), angles),
|
|
|
|
|
(size / 2.0, size / 2.0),
|
|
|
|
|
max_radius,
|
|
|
|
|
cv.WARP_POLAR_LINEAR,
|
|
|
|
|
)
|
|
|
|
|
profile = polar.mean(axis=0)
|
|
|
|
|
radii = np.arange(profile.size, dtype=np.float64)
|
|
|
|
|
scale = expected_radius
|
|
|
|
|
|
|
|
|
|
def band(low, high):
|
|
|
|
|
mask = (radii > low * scale) & (radii < high * scale)
|
|
|
|
|
return profile[mask] if mask.any() else np.array([])
|
|
|
|
|
|
|
|
|
|
on_values, off_values = band(*DISC_ON_BAND), band(*DISC_OFF_BAND)
|
|
|
|
|
if on_values.size < 3 or off_values.size < 3:
|
|
|
|
|
return empty
|
|
|
|
|
on_disc, off_limb = float(np.median(on_values)), float(np.median(off_values))
|
|
|
|
|
if not np.isfinite(on_disc) or not np.isfinite(off_limb):
|
|
|
|
|
return empty
|
|
|
|
|
if on_disc <= 0:
|
|
|
|
|
# No signal at all where the disc should be. This is a conclusion, not a
|
|
|
|
|
# failure to measure: report zero contrast so the caller rejects the frame
|
|
|
|
|
# rather than abstaining on it.
|
|
|
|
|
return {"limb_contrast": 0.0, "radius_ratio": None, "limb_width": None}
|
|
|
|
|
|
|
|
|
|
contrast = (on_disc - off_limb) / on_disc
|
|
|
|
|
measured = {"limb_contrast": float(contrast), "radius_ratio": None, "limb_width": None}
|
|
|
|
|
if on_disc <= off_limb:
|
|
|
|
|
# No radial falloff at all: there is no disc here to measure the size of.
|
|
|
|
|
return measured
|
|
|
|
|
|
|
|
|
|
# The limb is the steepest descent of the smoothed profile. Taken on the
|
|
|
|
|
# *averaged* profile rather than per angle: fitting each angle separately failed
|
|
|
|
|
# outright on frames where too few angles yielded a usable edge.
|
|
|
|
|
smoothed = cv.GaussianBlur(profile.astype(np.float32).reshape(1, -1), (9, 1), 0).ravel()
|
|
|
|
|
derivative = np.gradient(smoothed.astype(np.float64))
|
|
|
|
|
low, high = DISC_SEARCH_BAND
|
|
|
|
|
window = (radii >= low * scale) & (radii <= high * scale)
|
|
|
|
|
if window.sum() < 5:
|
|
|
|
|
return measured
|
|
|
|
|
indices = np.flatnonzero(window)
|
|
|
|
|
local = derivative[indices]
|
|
|
|
|
trough = int(indices[np.argmin(local)])
|
|
|
|
|
depth = derivative[trough]
|
|
|
|
|
if not np.isfinite(depth) or depth >= 0:
|
|
|
|
|
return measured # no descending edge: not a disc
|
|
|
|
|
|
|
|
|
|
measured["radius_ratio"] = float(trough / scale)
|
|
|
|
|
|
|
|
|
|
# Width of the descent at half its depth. A disc displaced by d smears the
|
|
|
|
|
# averaged limb across roughly 2d, so this is what makes the measurement
|
|
|
|
|
# sensitive to decentring without ever having to locate a centre.
|
|
|
|
|
half = depth / 2.0
|
|
|
|
|
left = trough
|
|
|
|
|
while left > 0 and derivative[left - 1] <= half:
|
|
|
|
|
left -= 1
|
|
|
|
|
right = trough
|
|
|
|
|
last = derivative.size - 1
|
|
|
|
|
while right < last and derivative[right + 1] <= half:
|
|
|
|
|
right += 1
|
|
|
|
|
measured["limb_width"] = float((right - left + 1) / scale)
|
|
|
|
|
return measured
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
#: Per-band acceptance ranges for the disc_v1 measurements.
|
|
|
|
|
#:
|
|
|
|
|
#: Generated by ``filter_FITS.py calibrate``; regenerate and paste the output here
|
|
|
|
|
#: rather than hand-editing. Derived from 400 frames per band sampled evenly across
|
|
|
|
|
#: the archive's whole time range, with 0/400 sample failures in every band.
|
|
|
|
|
#:
|
|
|
|
|
#: The bounds sit well outside the observed spread of good frames, because the
|
|
|
|
|
#: false-positive budget for this detector is <0.1%: the method it replaces was
|
|
|
|
|
#: discarding 36.9% of perfectly good frames, which is the entire reason it exists.
|
|
|
|
|
#: Only the diagnostic side of each measurement is bounded -- see CALIBRATION_SIDES
|
|
|
|
|
#: in filter_FITS.py.
|
|
|
|
|
#:
|
|
|
|
|
#: Note what the wide contrast floors mean in practice. Good frames in most bands
|
|
|
|
|
#: reach as low as 0.11-0.22 contrast, so a floor tight enough to catch a partially
|
|
|
|
|
#: degraded frame would reject real data. At this budget limb_contrast therefore
|
|
|
|
|
#: catches total signal loss (all-zero, NaN, uniform, which measure exactly 0.0) and
|
|
|
|
|
#: little else; size and shape faults are caught by the other two measurements.
|
|
|
|
|
#: 171A is the exception, where good frames never drop below 0.68.
|
|
|
|
|
#:
|
|
|
|
|
#: A measurement whose calibrated bounds cannot achieve the budget is set to None
|
|
|
|
|
#: here, which records it as a score without letting it fail a frame.
|
|
|
|
|
DISC_BOUNDS = {
|
|
|
|
|
94: {
|
|
|
|
|
"limb_contrast": (0.02, 1.0),
|
|
|
|
|
"radius_ratio": (0.9631, 1.1176),
|
|
|
|
|
"limb_width": (0.0, 0.1217),
|
|
|
|
|
},
|
|
|
|
|
131: {
|
|
|
|
|
"limb_contrast": (0.02, 1.0),
|
|
|
|
|
"radius_ratio": (0.9578, 1.0854),
|
|
|
|
|
"limb_width": (0.0, 0.1011),
|
|
|
|
|
},
|
|
|
|
|
171: {
|
|
|
|
|
"limb_contrast": (0.3183, 1.0),
|
|
|
|
|
"radius_ratio": (0.9616, 1.1062),
|
|
|
|
|
"limb_width": (0.0, 0.1375),
|
|
|
|
|
},
|
|
|
|
|
195: {
|
|
|
|
|
"limb_contrast": (0.02, 1.0),
|
|
|
|
|
"radius_ratio": (0.9676, 1.1167),
|
|
|
|
|
"limb_width": (0.0, 0.1704),
|
|
|
|
|
},
|
|
|
|
|
284: {
|
|
|
|
|
"limb_contrast": (0.02, 1.0),
|
|
|
|
|
"radius_ratio": (0.9548, 1.1489),
|
|
|
|
|
"limb_width": (0.0, 0.3177),
|
|
|
|
|
},
|
|
|
|
|
304: {
|
|
|
|
|
"limb_contrast": (0.02, 1.0),
|
|
|
|
|
"radius_ratio": (0.9548, 1.0813),
|
|
|
|
|
"limb_width": (0.0, 0.1016),
|
|
|
|
|
},
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def disc_v1(image, wavelength, header=None, bounds=None):
|
|
|
|
|
"""Verify that the frame holds a correctly sized, centred, sharp solar disc.
|
|
|
|
|
|
|
|
|
|
Replaces ``geometry_v1``'s intensity-weighted centroid, which a large active
|
|
|
|
|
region drags off centre -- rejecting entire days of good data during exactly the
|
|
|
|
|
solar activity most worth watching.
|
|
|
|
|
|
|
|
|
|
Everything measured here is an average over angle, so where the bright regions
|
|
|
|
|
sit on the disc does not move it. What it deliberately cannot see is rotation:
|
|
|
|
|
``rotate`` and ``yaw_flip`` leave every azimuthally averaged quantity unchanged,
|
|
|
|
|
and one frame carries no absolute rotation reference. Those belong to
|
|
|
|
|
``temporal_v1`` and ``crosssat_v1``.
|
|
|
|
|
|
|
|
|
|
The expected radius comes from the frame's own ``DIAM_SUN``, which varies by
|
|
|
|
|
3.3% over the year with the Earth-Sun distance.
|
|
|
|
|
"""
|
|
|
|
|
bounds = bounds or DISC_BOUNDS
|
|
|
|
|
if image is None:
|
|
|
|
|
return Verdict(BAD, "no image data", {})
|
|
|
|
|
|
|
|
|
|
diameter = (header or {}).get("diam_sun")
|
|
|
|
|
if not diameter:
|
|
|
|
|
return Verdict(UNKNOWN, "no DIAM_SUN in header", {})
|
|
|
|
|
|
|
|
|
|
# DIAM_SUN is a full diameter at full resolution; halve for radius, halve again
|
|
|
|
|
# for the working downsample.
|
|
|
|
|
expected_radius = float(diameter) / 4.0
|
|
|
|
|
measured = disc_profile(image, expected_radius)
|
|
|
|
|
scores = {k: v for k, v in measured.items() if v is not None}
|
|
|
|
|
|
|
|
|
|
if measured["limb_contrast"] is None:
|
|
|
|
|
return Verdict(UNKNOWN, "radial profile not measurable", scores)
|
|
|
|
|
|
|
|
|
|
limits = bounds.get(wavelength)
|
|
|
|
|
if limits is None:
|
|
|
|
|
return Verdict(UNKNOWN, f"no disc bounds for band {wavelength}", scores)
|
|
|
|
|
|
|
|
|
|
failed = []
|
|
|
|
|
for name, limit in limits.items():
|
|
|
|
|
if limit is None:
|
|
|
|
|
continue # recorded as a score, not used to judge
|
|
|
|
|
value = measured.get(name)
|
|
|
|
|
if value is None:
|
|
|
|
|
failed.append(f"{name} unmeasurable")
|
|
|
|
|
elif not (limit[0] <= value <= limit[1]):
|
|
|
|
|
failed.append(f"{name} {value:.3f} outside [{limit[0]:.3f}, {limit[1]:.3f}]")
|
|
|
|
|
|
|
|
|
|
if failed:
|
|
|
|
|
return Verdict(BAD, "; ".join(failed), scores)
|
|
|
|
|
return Verdict(GOOD, None, scores)
|
|
|
|
|
|
|
|
|
|
|
2026-10-14 00:27:18 -04:00
|
|
|
# ------------------------------------------------------------------ field_v1
|
|
|
|
|
|
|
|
|
|
#: Where the far field starts, in solar radii. Beyond ~1.6R a healthy frame holds
|
|
|
|
|
#: only faint corona, so this is where bars, blocks, ghost or displaced discs and
|
|
|
|
|
#: short-exposure static stand out -- the defects an angle-averaged profile dilutes.
|
|
|
|
|
#: At a ~390px radius this leaves ~25% of a 1280px frame, mostly the corners.
|
|
|
|
|
FAR_FIELD_RADIUS = 1.6
|
|
|
|
|
#: Percentile summarising far-field brightness. The 99th ignores thin real features
|
|
|
|
|
#: (a flare's diffraction spikes cover well under 1% of the far field) while any
|
|
|
|
|
#: defect covering a few percent of it -- every bar seen in the archive -- lifts it.
|
|
|
|
|
FAR_FIELD_PERCENTILE = 99.0
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _far_field_mask(image, radius):
|
|
|
|
|
"""Boolean mask of pixels beyond FAR_FIELD_RADIUS * radius of the centre, or None."""
|
|
|
|
|
if image is None or np.ndim(image) != 2 or min(np.shape(image)) < 16:
|
|
|
|
|
return None
|
|
|
|
|
if not radius or not np.isfinite(radius) or radius <= 0:
|
|
|
|
|
return None
|
|
|
|
|
height, width = image.shape
|
|
|
|
|
yy, xx = np.ogrid[:height, :width]
|
|
|
|
|
far = np.hypot(xx - (width - 1) / 2.0, yy - (height - 1) / 2.0) > FAR_FIELD_RADIUS * radius
|
|
|
|
|
return far
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def far_field(image, radius):
|
|
|
|
|
"""Brightness of the far field: the 99th percentile of radiance beyond 1.6R.
|
|
|
|
|
|
|
|
|
|
`radius` is the solar radius in pixels at the image's own resolution. Returns
|
|
|
|
|
None when there is no far field to measure (no finite pixels beyond 1.6R).
|
|
|
|
|
"""
|
|
|
|
|
mask = _far_field_mask(image, radius)
|
|
|
|
|
if mask is None:
|
|
|
|
|
return None
|
|
|
|
|
values = np.asarray(image, dtype=np.float64)[mask]
|
|
|
|
|
values = values[np.isfinite(values)]
|
|
|
|
|
if values.size == 0:
|
|
|
|
|
return None
|
|
|
|
|
return float(np.percentile(values, FAR_FIELD_PERCENTILE))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def far_field_noise(image, radius):
|
|
|
|
|
"""Pixel-to-pixel noise in the far field: MAD of horizontal first differences.
|
|
|
|
|
|
|
|
|
|
First differences cancel the smooth corona, and the median absolute deviation
|
|
|
|
|
ignores the odd star or cosmic ray, so what remains is the frame's grain. A
|
|
|
|
|
short-exposure-only frame reads 0.03-3.3 here against <= 0.0076 for a normal one.
|
|
|
|
|
"""
|
|
|
|
|
mask = _far_field_mask(image, radius)
|
|
|
|
|
if mask is None:
|
|
|
|
|
return None
|
|
|
|
|
data = np.asarray(image, dtype=np.float64)
|
|
|
|
|
diffs = np.diff(data, axis=1)[mask[:, 1:] & mask[:, :-1]]
|
|
|
|
|
diffs = diffs[np.isfinite(diffs)]
|
|
|
|
|
if diffs.size == 0:
|
|
|
|
|
return None
|
|
|
|
|
return float(np.median(np.abs(diffs - np.median(diffs))))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
#: Typical far-field brightness per band (99th percentile beyond 1.6R, W m-2 sr-1):
|
|
|
|
|
#: the median over ~1,600 random frames with a long exposure, 2023-01..2025-03,
|
|
|
|
|
#: from ``defect_audit.py``. It scales each band's radiance to a common footing,
|
|
|
|
|
#: not a bound -- it held within 0.67-1.25x in every band across those years.
|
|
|
|
|
FIELD_SCALE = {94: 0.018, 131: 0.0045, 171: 0.026, 195: 0.059, 284: 0.10, 304: 0.074}
|
|
|
|
|
|
|
|
|
|
#: Limits for field_v1, set on the user-confirmed real defect set
|
|
|
|
|
#: (``labels/real_defects.json``, scored by ``detector_eval.py``):
|
|
|
|
|
#:
|
|
|
|
|
#: * ``far_field``: far-field brightness / FIELD_SCALE. Normal frames and frames
|
|
|
|
|
#: around the era's largest flares (X9.0 included) reach 3.2x, and one large
|
|
|
|
|
#: erupting prominence 4.4x; bars, blocks and ghost discs in the corners run
|
|
|
|
|
#: 4x-2,500x. At 5x no labelled defect is lost that the stripe test does not
|
|
|
|
|
#: also catch, and no clean frame is rejected.
|
|
|
|
|
#: * ``stripe_level``: the largest step of any whole detector row or column
|
|
|
|
|
#: against the lines either side (asinh units, see ``stripe_profile``). Clean
|
|
|
|
|
#: frames reach 0.93 (the hardest being quiet 94A frames); every labelled bar
|
|
|
|
|
#: exceeds 1.4.
|
|
|
|
|
#: * ``stripe_grain``: the largest excess pixel-to-pixel noise of a detector line
|
|
|
|
|
#: over the lines either side. Clean frames reach 0.12; short-exposure static and
|
|
|
|
|
#: noise-filled bars run 0.75-3.
|
|
|
|
|
FIELD_LIMITS = {"far_field": 5.0, "stripe_level": 1.1, "stripe_grain": 0.25}
|
|
|
|
|
#: Working size and line offsets for the stripe profiles. At 640px a bar is ~10-40
|
|
|
|
|
#: lines wide; comparing each line with those 6 and 16 away sees both edges of a
|
|
|
|
|
#: narrow bar and the body of a wide one.
|
|
|
|
|
STRIPE_SIZE = 640
|
|
|
|
|
STRIPE_OFFSETS = (6, 16)
|
|
|
|
|
#: A line needs this many valid pixels to be profiled.
|
|
|
|
|
STRIPE_MIN_PIXELS = 100
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _row_medians(values, ok):
|
|
|
|
|
"""Median of each row over its `ok` entries; NaN for a row with none.
|
|
|
|
|
|
|
|
|
|
Exactly ``np.nanmedian(np.where(ok, values, nan), axis=1)`` -- the two middle
|
|
|
|
|
values are averaged for an even count -- but by one sort rather than
|
|
|
|
|
nanmedian's per-row partitioning, which was most of field_v1's cost.
|
|
|
|
|
"""
|
|
|
|
|
ordered = np.sort(np.where(ok, values, np.nan), axis=1) # NaN sorts last
|
|
|
|
|
counts = ok.sum(axis=1)
|
|
|
|
|
rows = np.arange(values.shape[0])
|
|
|
|
|
low = ordered[rows, np.maximum(counts - 1, 0) // 2]
|
|
|
|
|
high = ordered[rows, np.maximum(counts, 1) // 2 - (counts == 0)]
|
|
|
|
|
out = (low + high) / 2.0
|
|
|
|
|
out[counts == 0] = np.nan
|
|
|
|
|
return out
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def stripe_profile(image, wavelength, refcrota, size=STRIPE_SIZE, offsets=STRIPE_OFFSETS):
|
|
|
|
|
"""Largest whole-line anomalies along the detector's rows and columns.
|
|
|
|
|
|
|
|
|
|
Every bar in the archive lies exactly along a detector row or column: in the
|
|
|
|
|
delivered image, rotated to solar north, that is at -REFCROTA. Rotating back
|
|
|
|
|
by REFCROTA makes those lines the image rows and columns again, and a defect
|
|
|
|
|
that fills a whole line then stands out against the lines either side of it,
|
|
|
|
|
wherever it crosses the disc. Natural features never fill a line: the disc is
|
|
|
|
|
at most ~60% of a row, and a flare or loop far less, so the *median* over a
|
|
|
|
|
line ignores them.
|
|
|
|
|
|
|
|
|
|
Two profiles, both in asinh units of the band's FIELD_SCALE:
|
|
|
|
|
|
|
|
|
|
* ``stripe_level`` -- median over each line of (pixel - mean of the pixels
|
|
|
|
|
`k` lines either side): a bright or dark bar, a band of dropped rows.
|
|
|
|
|
* ``stripe_grain`` -- median |step| along each line, minus the same for the
|
|
|
|
|
lines `k` either side: a bar filled with noise rather than light, which
|
|
|
|
|
leaves the level almost unchanged.
|
|
|
|
|
|
|
|
|
|
Blind spot: a bar no brighter than the disc vanishes where it crosses it, and
|
|
|
|
|
if the disc fills most of that line the median follows the disc. Real bars are
|
|
|
|
|
saturated far above the disc, and one this dim is still caught in the corners
|
|
|
|
|
by the far-field test.
|
|
|
|
|
|
|
|
|
|
Returns {"stripe_level": float, "stripe_grain": float}, or None when the band
|
|
|
|
|
has no scale or no rotation is known.
|
|
|
|
|
"""
|
|
|
|
|
scale = FIELD_SCALE.get(wavelength)
|
|
|
|
|
if scale is None or refcrota is None or not np.isfinite(refcrota):
|
|
|
|
|
return None
|
|
|
|
|
if image is None or np.ndim(image) != 2 or min(np.shape(image)) < 4 * max(offsets):
|
|
|
|
|
return None
|
|
|
|
|
data = np.nan_to_num(np.asarray(image, dtype=np.float32), nan=0.0, posinf=0.0, neginf=0.0)
|
|
|
|
|
# Pixels outside the delivered field of view are exactly zero.
|
|
|
|
|
valid = (data != 0).astype(np.float32)
|
|
|
|
|
height, width = data.shape
|
|
|
|
|
rotation = cv.getRotationMatrix2D(((width - 1) / 2.0, (height - 1) / 2.0),
|
|
|
|
|
float(refcrota), 1.0)
|
|
|
|
|
data = cv.warpAffine(data, rotation, (width, height), flags=cv.INTER_LINEAR)
|
|
|
|
|
valid = cv.warpAffine(valid, rotation, (width, height), flags=cv.INTER_NEAREST)
|
|
|
|
|
data = cv.resize(data, (size, size), interpolation=cv.INTER_AREA)
|
|
|
|
|
valid = cv.resize(valid, (size, size), interpolation=cv.INTER_NEAREST) > 0.5
|
|
|
|
|
# Interpolation smears the field-of-view edge; keep clear of it.
|
|
|
|
|
valid = cv.erode(valid.astype(np.uint8), np.ones((5, 5), np.uint8)) > 0
|
|
|
|
|
values = np.arcsinh(np.clip(data, 0.0, None) / scale)
|
|
|
|
|
|
|
|
|
|
level = grain = 0.0
|
|
|
|
|
for lines, mask in ((values, valid), (values.T, valid.T)):
|
|
|
|
|
steps = np.abs(np.diff(lines, axis=1))
|
|
|
|
|
step_ok = mask[:, 1:] & mask[:, :-1]
|
|
|
|
|
enough = step_ok.sum(axis=1) >= STRIPE_MIN_PIXELS
|
|
|
|
|
line_grain = np.full(lines.shape[0], np.nan)
|
|
|
|
|
if enough.any():
|
|
|
|
|
line_grain[enough] = _row_medians(steps[enough], step_ok[enough])
|
|
|
|
|
for k in offsets:
|
|
|
|
|
ok = mask[k:-k] & mask[:-2 * k] & mask[2 * k:]
|
|
|
|
|
rows = ok.sum(axis=1) >= STRIPE_MIN_PIXELS
|
|
|
|
|
if rows.any():
|
|
|
|
|
excess = lines[k:-k] - 0.5 * (lines[:-2 * k] + lines[2 * k:])
|
|
|
|
|
medians = _row_medians(excess[rows], ok[rows])
|
|
|
|
|
level = max(level, float(np.nanmax(np.abs(medians))))
|
|
|
|
|
relative = line_grain[k:-k] - 0.5 * (line_grain[:-2 * k] + line_grain[2 * k:])
|
|
|
|
|
if np.isfinite(relative).any():
|
|
|
|
|
grain = max(grain, float(np.nanmax(relative)))
|
|
|
|
|
return {"stripe_level": level, "stripe_grain": grain}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def field_v1(image, wavelength, header=None, refcrota=None, limits=None):
|
|
|
|
|
"""Judge everything off the disc and across it that disc_v1 cannot see.
|
|
|
|
|
|
|
|
|
|
disc_v1 averages around the disc, so a bar crossing it dilutes to nothing, and
|
|
|
|
|
it never looks past 1.45R. This checks the far field's brightness (bars,
|
|
|
|
|
blocks, ghost discs in the corners) and every detector row and column for a
|
|
|
|
|
whole-line anomaly (bars anywhere, including noise-filled ones lying across
|
|
|
|
|
the disc). Measurements that cannot be made are skipped; if none can, the
|
|
|
|
|
verdict is UNKNOWN.
|
|
|
|
|
"""
|
|
|
|
|
limits = limits or FIELD_LIMITS
|
|
|
|
|
if image is None:
|
|
|
|
|
return Verdict(BAD, "no image data", {})
|
|
|
|
|
scale = FIELD_SCALE.get(wavelength)
|
|
|
|
|
if scale is None:
|
|
|
|
|
return Verdict(UNKNOWN, f"no field scale for band {wavelength}", {})
|
|
|
|
|
scores = {}
|
|
|
|
|
diameter = (header or {}).get("diam_sun")
|
|
|
|
|
if diameter:
|
|
|
|
|
brightness = far_field(image, float(diameter) / 2.0)
|
|
|
|
|
if brightness is not None:
|
|
|
|
|
scores["far_field"] = brightness / scale
|
|
|
|
|
stripes = stripe_profile(image, wavelength, refcrota)
|
|
|
|
|
if stripes is not None:
|
|
|
|
|
scores.update(stripes)
|
|
|
|
|
if not scores:
|
|
|
|
|
return Verdict(UNKNOWN, "nothing measurable off the disc", scores)
|
|
|
|
|
failed = [f"{name} {scores[name]:.2f} above {limit:g}"
|
|
|
|
|
for name, limit in limits.items()
|
|
|
|
|
if name in scores and scores[name] > limit]
|
|
|
|
|
if failed:
|
|
|
|
|
return Verdict(BAD, "; ".join(failed), scores)
|
|
|
|
|
return Verdict(GOOD, None, scores)
|
|
|
|
|
|
|
|
|
|
|
2026-09-01 22:36:13 -04:00
|
|
|
# ---------------------------------------------------------------- temporal_v1
|
|
|
|
|
|
|
|
|
|
#: Frames either side used to build the local reference. At a 4-minute cadence,
|
|
|
|
|
#: +/-6 spans about 50 minutes -- long enough to be robust, short enough that real
|
|
|
|
|
#: solar evolution does not dominate.
|
|
|
|
|
TEMPORAL_WINDOW = 6
|
|
|
|
|
#: Robust z-score above which a frame's brightness is judged anomalous.
|
|
|
|
|
TEMPORAL_BRIGHTNESS_Z = 8.0
|
|
|
|
|
#: Structural difference from neighbours, relative to the neighbours' own churn.
|
|
|
|
|
TEMPORAL_STRUCTURE_RATIO = 6.0
|
|
|
|
|
#: Below this relative difference two consecutive frames are considered identical.
|
|
|
|
|
FROZEN_TOLERANCE = 1e-6
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _robust_z(values, index, window):
|
|
|
|
|
"""Median-absolute-deviation z-score of values[index] against its neighbours."""
|
|
|
|
|
low = max(0, index - window)
|
|
|
|
|
high = min(len(values), index + window + 1)
|
|
|
|
|
neighbours = np.array(
|
|
|
|
|
[values[i] for i in range(low, high) if i != index and np.isfinite(values[i])]
|
|
|
|
|
)
|
|
|
|
|
if neighbours.size < 3:
|
|
|
|
|
return None
|
|
|
|
|
median = np.median(neighbours)
|
|
|
|
|
mad = np.median(np.abs(neighbours - median))
|
|
|
|
|
if mad <= 0:
|
|
|
|
|
# A perfectly steady neighbourhood: fall back to the spread, and treat an
|
|
|
|
|
# exactly-constant one as uninformative rather than infinitely sensitive.
|
|
|
|
|
spread = neighbours.std()
|
|
|
|
|
if spread <= 0:
|
|
|
|
|
return 0.0 if values[index] == median else np.inf
|
|
|
|
|
return abs(values[index] - median) / spread
|
|
|
|
|
return abs(values[index] - median) / (1.4826 * mad)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def temporal_v1(series, window=TEMPORAL_WINDOW, brightness_z=TEMPORAL_BRIGHTNESS_Z,
|
|
|
|
|
structure_ratio=TEMPORAL_STRUCTURE_RATIO,
|
|
|
|
|
frozen_tolerance=FROZEN_TOLERANCE):
|
|
|
|
|
"""Judge each frame against its neighbours in the same band and satellite.
|
|
|
|
|
|
|
|
|
|
Catches what single-frame checks cannot: a frame that is individually plausible
|
|
|
|
|
but inconsistent with the minutes either side of it -- a frozen duplicate, a
|
|
|
|
|
brightness step, a substituted frame from another time.
|
|
|
|
|
|
|
|
|
|
`series` must be ordered by time and come from one (satellite, wavelength)
|
|
|
|
|
stream; gaps are fine, they simply widen the neighbourhood in wall-clock terms.
|
|
|
|
|
Returns one Verdict per input frame.
|
|
|
|
|
"""
|
|
|
|
|
count = len(series)
|
|
|
|
|
means = [
|
|
|
|
|
f.header.get("img_mean", np.nan) if not f.error else np.nan for f in series
|
|
|
|
|
]
|
|
|
|
|
verdicts = []
|
|
|
|
|
|
|
|
|
|
# Structural churn between consecutive thumbnails, when they are available.
|
|
|
|
|
diffs = [np.nan] * count
|
|
|
|
|
for i in range(1, count):
|
|
|
|
|
a, b = series[i - 1].thumbnail, series[i].thumbnail
|
|
|
|
|
if a is not None and b is not None and a.shape == b.shape:
|
|
|
|
|
scale = float(np.abs(a).mean() + np.abs(b).mean()) / 2.0
|
|
|
|
|
diffs[i] = float(np.abs(a - b).mean()) / scale if scale > 0 else np.nan
|
|
|
|
|
|
|
|
|
|
for i, frame in enumerate(series):
|
|
|
|
|
scores = {}
|
|
|
|
|
if frame.error:
|
|
|
|
|
verdicts.append(Verdict(BAD, f"unreadable: {frame.error}", {"read_ok": 0.0}))
|
|
|
|
|
continue
|
|
|
|
|
|
|
|
|
|
z = _robust_z(means, i, window)
|
|
|
|
|
if z is not None:
|
|
|
|
|
scores["brightness_z"] = float(z)
|
|
|
|
|
|
|
|
|
|
# A frame identical to its predecessor means the feed stalled.
|
|
|
|
|
if i > 0 and np.isfinite(diffs[i]) and diffs[i] < frozen_tolerance:
|
|
|
|
|
scores["frame_diff"] = float(diffs[i])
|
|
|
|
|
verdicts.append(Verdict(BAD, "identical to previous frame", scores))
|
|
|
|
|
continue
|
|
|
|
|
|
|
|
|
|
neighbour_diffs = np.array(
|
|
|
|
|
[
|
|
|
|
|
diffs[j]
|
|
|
|
|
for j in range(max(1, i - window), min(count, i + window + 1))
|
|
|
|
|
if j != i and np.isfinite(diffs[j])
|
|
|
|
|
]
|
|
|
|
|
)
|
|
|
|
|
if np.isfinite(diffs[i]) and neighbour_diffs.size >= 3:
|
|
|
|
|
typical = float(np.median(neighbour_diffs))
|
|
|
|
|
scores["frame_diff"] = float(diffs[i])
|
|
|
|
|
scores["frame_diff_ratio"] = float(diffs[i] / typical) if typical > 0 else 0.0
|
|
|
|
|
if typical > 0 and diffs[i] / typical > structure_ratio:
|
|
|
|
|
verdicts.append(
|
|
|
|
|
Verdict(BAD, f"structural jump {diffs[i] / typical:.1f}x typical", scores)
|
|
|
|
|
)
|
|
|
|
|
continue
|
|
|
|
|
|
|
|
|
|
if z is not None and z > brightness_z:
|
|
|
|
|
verdicts.append(Verdict(BAD, f"brightness z={z:.1f}", scores))
|
|
|
|
|
continue
|
|
|
|
|
|
|
|
|
|
if z is None and not np.isfinite(diffs[i]):
|
|
|
|
|
verdicts.append(Verdict(UNKNOWN, "insufficient context", scores))
|
|
|
|
|
continue
|
|
|
|
|
|
|
|
|
|
verdicts.append(Verdict(GOOD, None, scores))
|
|
|
|
|
|
|
|
|
|
return verdicts
|
|
|
|
|
|
|
|
|
|
|
2026-10-14 00:27:18 -04:00
|
|
|
# ---------------------------------------------------------------- temporal_v2
|
|
|
|
|
|
|
|
|
|
#: Frames either side searched for a production-good reference, in slots.
|
|
|
|
|
TEMPORAL2_REACH = 6
|
|
|
|
|
#: Limits, set on the user-confirmed real defect set (``detector_eval.py``).
|
|
|
|
|
#:
|
|
|
|
|
#: * ``brightness``: |log| of how far IMG_MEAN falls outside the range spanned by
|
|
|
|
|
#: the nearest good frames before and after. A flare rising between them stays
|
|
|
|
|
#: inside that range; a one-frame gain jump (0.39 -> 1.10 -> 0.39) does not.
|
|
|
|
|
#: * ``structure``: how far the frame's thumbnail departs from the mean of those
|
|
|
|
|
#: two frames, relative to how far they differ from each other (plus a floor for
|
|
|
|
|
#: a static Sun). Real evolution lies between its neighbours; a ghost limb or a
|
|
|
|
|
#: hole does not.
|
|
|
|
|
#:
|
|
|
|
|
#: Structure 2.0 rather than 3.0 caught two more defects for one more false
|
|
|
|
|
#: positive. Brightness was 0.25 until the user's neighbour review: every clean
|
|
|
|
|
#: frame it rejected was an impulsive flare peak in 94A or 131A (1.29x-1.66x its
|
|
|
|
|
#: neighbours), while every defect it caught also failed structure or jumped
|
|
|
|
|
#: 2.4x or more. At 0.7 (2.0x) those flare peaks -- frames worth keeping -- pass.
|
|
|
|
|
TEMPORAL2_LIMITS = {"brightness": 0.7, "structure": 2.0}
|
|
|
|
|
#: Relative thumbnail difference below which two good frames count as identical,
|
|
|
|
|
#: so a quiet Sun does not make every small change look large.
|
|
|
|
|
TEMPORAL2_FLOOR = 0.02
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _asinh_thumbnail(thumbnail, wavelength):
|
|
|
|
|
scale = FIELD_SCALE.get(wavelength)
|
|
|
|
|
if thumbnail is None or scale is None:
|
|
|
|
|
return None
|
|
|
|
|
return np.arcsinh(np.clip(thumbnail, 0.0, None) / scale)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def temporal_v2(series, good, reach=TEMPORAL2_REACH, limits=None, floor=TEMPORAL2_FLOOR):
|
|
|
|
|
"""Judge each frame against its nearest *good* neighbours in the same stream.
|
|
|
|
|
|
|
|
|
|
temporal_v1 compares a frame with every neighbour, so a clean frame between two
|
|
|
|
|
eclipse frames, or beside a bar, looks anomalous (6.8% false positives on real
|
|
|
|
|
clean frames). This uses as references only frames the production judgment
|
|
|
|
|
passed (`good`, a set of slots), and asks whether the frame lies *between* the
|
|
|
|
|
nearest good frame before and after it -- in brightness and in structure --
|
|
|
|
|
which real solar change does and a one-frame defect does not. With good
|
|
|
|
|
frames on one side only, that side serves as both.
|
|
|
|
|
|
|
|
|
|
`series` is one (satellite, wavelength) stream of FrameFeatures ordered by
|
|
|
|
|
time, with thumbnails. Returns one Verdict per frame.
|
|
|
|
|
"""
|
|
|
|
|
limits = limits or TEMPORAL2_LIMITS
|
|
|
|
|
cadence = 240
|
|
|
|
|
times = [f.t_start for f in series]
|
|
|
|
|
verdicts = []
|
|
|
|
|
for i, frame in enumerate(series):
|
|
|
|
|
if frame.error:
|
|
|
|
|
verdicts.append(Verdict(BAD, f"unreadable: {frame.error}", {"read_ok": 0.0}))
|
|
|
|
|
continue
|
|
|
|
|
before = next((series[j] for j in range(i - 1, -1, -1)
|
|
|
|
|
if times[i] - times[j] <= reach * cadence and series[j].slot in good),
|
|
|
|
|
None)
|
|
|
|
|
after = next((series[j] for j in range(i + 1, len(series))
|
|
|
|
|
if times[j] - times[i] <= reach * cadence and series[j].slot in good),
|
|
|
|
|
None)
|
|
|
|
|
if before is None and after is None:
|
|
|
|
|
verdicts.append(Verdict(UNKNOWN, "no good neighbour within reach", {}))
|
|
|
|
|
continue
|
|
|
|
|
before, after = before or after, after or before
|
|
|
|
|
scores = {}
|
|
|
|
|
mean = frame.header.get("img_mean")
|
|
|
|
|
refs = [r.header.get("img_mean") for r in (before, after)]
|
|
|
|
|
if mean and mean > 0 and all(r and r > 0 for r in refs):
|
|
|
|
|
low, high = min(refs), max(refs)
|
|
|
|
|
scores["brightness"] = float(max(np.log(mean / high), np.log(low / mean), 0.0))
|
|
|
|
|
band = frame.wavelength
|
|
|
|
|
own, a, b = (_asinh_thumbnail(x.thumbnail, band) for x in (frame, before, after))
|
|
|
|
|
if own is not None and a is not None and b is not None \
|
|
|
|
|
and own.shape == a.shape == b.shape:
|
|
|
|
|
level = float(np.mean(np.abs(a)) + np.mean(np.abs(b))) / 2.0 or 1.0
|
|
|
|
|
departure = float(np.mean(np.abs(own - (a + b) / 2.0))) / level
|
|
|
|
|
spread = float(np.mean(np.abs(a - b))) / level
|
|
|
|
|
scores["structure"] = departure / max(spread, floor)
|
|
|
|
|
if not scores:
|
|
|
|
|
verdicts.append(Verdict(UNKNOWN, "nothing comparable", scores))
|
|
|
|
|
continue
|
|
|
|
|
failed = [f"{name} {scores[name]:.2f} above {limit:g}"
|
|
|
|
|
for name, limit in limits.items()
|
|
|
|
|
if name in scores and scores[name] > limit]
|
|
|
|
|
verdicts.append(Verdict(BAD, "; ".join(failed), scores) if failed
|
|
|
|
|
else Verdict(GOOD, None, scores))
|
|
|
|
|
return verdicts
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ------------------------------------------------------------ temporal_verdict
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def temporal_verdict(scores, limits=None):
|
|
|
|
|
"""temporal_v2's verdict from its stored scores (``temporal_brightness``,
|
|
|
|
|
``temporal_structure``, as ``filter_FITS.py measure`` keeps them). UNKNOWN
|
|
|
|
|
when the measuring pass found nothing comparable."""
|
|
|
|
|
limits = limits or TEMPORAL2_LIMITS
|
|
|
|
|
measured = {name: scores.get(f"temporal_{name}") for name in limits}
|
|
|
|
|
measured = {name: value for name, value in measured.items() if value is not None}
|
|
|
|
|
if not measured:
|
|
|
|
|
return Verdict(UNKNOWN, "no series measure", {})
|
|
|
|
|
failed = [f"{name} {value:.2f} above {limits[name]:g}"
|
|
|
|
|
for name, value in measured.items() if value > limits[name]]
|
|
|
|
|
return Verdict(BAD, "; ".join(failed), {}) if failed else Verdict(GOOD, None, {})
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------- relative_v2
|
|
|
|
|
|
|
|
|
|
#: Near references: up to RELATIVE_REFERENCES good frames per side within
|
|
|
|
|
#: RELATIVE_REACH slots (an hour). Their median, not the nearest frame alone, so
|
|
|
|
|
#: one bad frame among them cannot vouch for another.
|
|
|
|
|
RELATIVE_REACH = 15
|
|
|
|
|
RELATIVE_REFERENCES = 3
|
|
|
|
|
#: The baseline the half-brightness test compares with: up to BASELINE_REFERENCES
|
|
|
|
|
#: good frames per side within BASELINE_REACH slots (two hours), wide enough that a
|
|
|
|
|
#: cluster of half frames in a noon episode cannot be its own baseline.
|
|
|
|
|
BASELINE_REACH = 30
|
|
|
|
|
BASELINE_REFERENCES = 15
|
|
|
|
|
#: A side may stand alone for the baseline only if it is steady -- at least
|
|
|
|
|
#: STEADY_MIN frames, the brightest within STEADY of the faintest -- because a side
|
|
|
|
|
#: that is rising may be a flare, against which the frame before it looks dim.
|
|
|
|
|
STEADY = 1.2
|
|
|
|
|
STEADY_MIN = 3
|
|
|
|
|
#: Limits, from the user-reviewed labels and neighbour sheets:
|
|
|
|
|
#:
|
|
|
|
|
#: * ``dim``: disc median over the lower near side's. Both sides required: from
|
|
|
|
|
#: one side a flare onset (2024-10-02 05:28) and a dim frame look the same.
|
|
|
|
|
#: * ``half``: disc median over the baseline -- half-exposure frames sit at 0.50.
|
|
|
|
|
#: * ``sector``: the faintest sector against the same sector of the lower near
|
|
|
|
|
#: side, both sides required -- a grazing eclipse dims one side of the disc.
|
|
|
|
|
#: The user chose 0.3 (2026-10-11): user-fine frames reach 0.31, so a mildly
|
|
|
|
|
#: dimmed limb is kept, at the cost of passing the mildest dark-labelled grazes.
|
|
|
|
|
#: * ``stripe``: the smaller of the stripe_level and stripe_grain ratios over the
|
|
|
|
|
#: higher side -- the square-outline artifact reaches ~3.7.
|
|
|
|
|
#: * ``shift``: how far, in solar radii, the disc (``measures.ring_position``) has
|
|
|
|
|
#: moved from where the near references put it. Labelled clean frames, flares
|
|
|
|
|
#: included, move at most 0.073 and archive 171A jitter reaches 0.08; displaced
|
|
|
|
|
#: and double discs 0.15-0.69.
|
|
|
|
|
#: * ``echo``: how much more the frame repeats itself (``measures.echo``) than the
|
|
|
|
|
#: near references do. Clean frames up to 0.10, a 131A flare's spikes 0.14;
|
|
|
|
|
#: double exposures mostly 0.16-0.31.
|
|
|
|
|
#:
|
|
|
|
|
#: Position and echo are judged against neighbours, not absolutely: the Sun itself
|
|
|
|
|
#: moves the ring fit -- a lopsided corona held 284A discs 0.2-0.4R "off centre" for
|
|
|
|
|
#: whole days on both satellites -- and an active Sun repeats itself, so fixed
|
|
|
|
|
#: limits rejected ~3,000 clean frames across the archive (user-reviewed sheets,
|
|
|
|
|
#: 2026-10-11).
|
|
|
|
|
RELATIVE_LIMITS = {"dim": 0.85, "half": 0.6, "sector": 0.3, "stripe": 2.5,
|
|
|
|
|
"shift": 0.12, "echo": 0.15}
|
|
|
|
|
#: Bands whose disc position is judged. Not 284A: its ring fit jumps 0.1-0.4R
|
|
|
|
|
#: between consecutive clean frames, as the corona outshines the limb.
|
|
|
|
|
POSITION_BANDS = (94, 131, 171, 195, 304)
|
|
|
|
|
#: Frames whose line of sight to the Sun passes this close to Earth (km above the
|
|
|
|
|
#: surface, ``measures.earth_tangent_km``) are never references: grazing the
|
|
|
|
|
#: atmosphere dims them, and dimmed neighbours made a grazing frame look normal.
|
|
|
|
|
EARTH_REFERENCE_KM = 400.0
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _median(values):
|
|
|
|
|
values = [v for v in values if v is not None and v > 0]
|
|
|
|
|
return float(np.median(values)) if values else None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _neighbours(times, ref_times, refs, i, count, reach, cadence=240):
|
|
|
|
|
"""Up to `count` reference indices either side of frame i within `reach`."""
|
|
|
|
|
lo = bisect.bisect_left(ref_times, times[i])
|
|
|
|
|
hi = bisect.bisect_right(ref_times, times[i])
|
|
|
|
|
before = [refs[j] for j in range(lo - 1, max(lo - count, 0) - 1, -1)
|
|
|
|
|
if times[i] - ref_times[j] <= reach * cadence]
|
|
|
|
|
after = [refs[j] for j in range(hi, min(hi + count, len(refs)))
|
|
|
|
|
if ref_times[j] - times[i] <= reach * cadence]
|
|
|
|
|
return before, after
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _relative_pass(series, references, limits, indices):
|
|
|
|
|
"""Verdicts for the frames at `indices`: {index: Verdict}."""
|
|
|
|
|
times = [f.t_start for f in series]
|
|
|
|
|
refs = [i for i, f in enumerate(series) if f.slot in references]
|
|
|
|
|
ref_times = [times[i] for i in refs]
|
|
|
|
|
value = lambda i, key: series[i].scores.get(key)
|
|
|
|
|
verdicts = {}
|
|
|
|
|
for i in indices:
|
|
|
|
|
frame = series[i]
|
|
|
|
|
before, after = _neighbours(times, ref_times, refs, i, RELATIVE_REFERENCES,
|
|
|
|
|
RELATIVE_REACH)
|
|
|
|
|
far_before, far_after = _neighbours(times, ref_times, refs, i,
|
|
|
|
|
BASELINE_REFERENCES, BASELINE_REACH)
|
|
|
|
|
if not (before or after or far_before or far_after):
|
|
|
|
|
verdicts[i] = Verdict(UNKNOWN, "no good neighbour within reach", {})
|
|
|
|
|
continue
|
|
|
|
|
scores, failed = {}, []
|
|
|
|
|
own = frame.scores.get("disc_median")
|
|
|
|
|
if own is not None and before and after:
|
|
|
|
|
near = [_median(value(j, "disc_median") for j in side) for side in (before, after)]
|
|
|
|
|
if all(near):
|
|
|
|
|
scores["dim"] = own / min(near)
|
|
|
|
|
if own is not None:
|
|
|
|
|
sides = []
|
|
|
|
|
for side in (far_before, far_after):
|
|
|
|
|
levels = [value(j, "disc_median") for j in side]
|
|
|
|
|
levels = [v for v in levels if v is not None and v > 0]
|
|
|
|
|
sides.append(levels)
|
|
|
|
|
if sides[0] and sides[1]:
|
|
|
|
|
level = min(_median(sides[0]), _median(sides[1]))
|
|
|
|
|
else:
|
|
|
|
|
alone = sides[0] or sides[1]
|
|
|
|
|
steady = len(alone) >= STEADY_MIN and max(alone) <= STEADY * min(alone)
|
|
|
|
|
level = _median(alone) if steady else None
|
|
|
|
|
if level:
|
|
|
|
|
scores["half"] = own / level
|
|
|
|
|
if before and after:
|
|
|
|
|
ratios = []
|
|
|
|
|
for k in range(measures.SECTORS):
|
|
|
|
|
mine = frame.scores.get(f"sector_{k}")
|
|
|
|
|
low = [_median(value(j, f"sector_{k}") for j in side) for side in (before, after)]
|
|
|
|
|
if mine is not None and all(low):
|
|
|
|
|
ratios.append(mine / min(low))
|
|
|
|
|
if ratios:
|
|
|
|
|
scores["sector"] = float(min(ratios))
|
|
|
|
|
scores["sector_index"] = float(int(np.argmin(ratios)))
|
|
|
|
|
stripe = []
|
|
|
|
|
for name in ("stripe_level", "stripe_grain"):
|
|
|
|
|
mine = frame.scores.get(name)
|
|
|
|
|
high = [m for m in (_median(value(j, name) for j in side)
|
|
|
|
|
for side in (before, after) if side) if m]
|
|
|
|
|
if mine is not None and high:
|
|
|
|
|
stripe.append(mine / max(high))
|
|
|
|
|
if len(stripe) == 2:
|
|
|
|
|
scores["stripe"] = float(min(stripe))
|
|
|
|
|
near = before + after
|
|
|
|
|
dx, dy = frame.scores.get("ring_dx"), frame.scores.get("ring_dy")
|
|
|
|
|
if dx is not None and dy is not None and frame.wavelength in POSITION_BANDS:
|
|
|
|
|
ref = [(value(j, "ring_dx"), value(j, "ring_dy")) for j in near]
|
|
|
|
|
ref = [r for r in ref if r[0] is not None and r[1] is not None]
|
|
|
|
|
if ref:
|
|
|
|
|
scores["shift"] = float(np.hypot(dx - np.median([r[0] for r in ref]),
|
|
|
|
|
dy - np.median([r[1] for r in ref])))
|
|
|
|
|
own_echo = frame.scores.get("echo")
|
|
|
|
|
ref = [value(j, "echo") for j in near if value(j, "echo") is not None]
|
|
|
|
|
if own_echo is not None and ref:
|
|
|
|
|
scores["echo"] = float(own_echo - np.median(ref))
|
|
|
|
|
if not scores:
|
|
|
|
|
verdicts[i] = Verdict(UNKNOWN, "nothing comparable", scores)
|
|
|
|
|
continue
|
|
|
|
|
if scores.get("dim", 1.0) < limits["dim"]:
|
|
|
|
|
failed.append(f"brightness {scores['dim']:.2f}x its good neighbours")
|
|
|
|
|
if scores.get("half", 1.0) < limits["half"]:
|
|
|
|
|
failed.append(f"half brightness: {scores['half']:.2f}x the two-hour level")
|
|
|
|
|
if scores.get("sector", 1.0) < limits["sector"]:
|
|
|
|
|
failed.append(f"sector {int(scores['sector_index'])} at {scores['sector']:.2f}x "
|
|
|
|
|
"its good neighbours")
|
|
|
|
|
if scores.get("stripe", 0.0) > limits["stripe"]:
|
|
|
|
|
failed.append(f"stripes {scores['stripe']:.2f}x its good neighbours")
|
|
|
|
|
if scores.get("shift", 0.0) > limits["shift"]:
|
|
|
|
|
failed.append(f"disc moved {scores['shift']:.2f}R from its good neighbours")
|
|
|
|
|
if scores.get("echo", 0.0) > limits["echo"]:
|
|
|
|
|
failed.append(f"double exposure: echo {scores['echo']:+.2f} over its good "
|
|
|
|
|
"neighbours")
|
|
|
|
|
scores = {f"relative_{k}": v for k, v in scores.items()}
|
|
|
|
|
verdicts[i] = (Verdict(BAD, "; ".join(failed), scores) if failed
|
|
|
|
|
else Verdict(GOOD, None, scores))
|
|
|
|
|
return verdicts
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def relative_v2(series, good, limits=None, earth_reference_km=EARTH_REFERENCE_KM):
|
|
|
|
|
"""Judge each frame's brightness, by region, and its stripes against its good
|
|
|
|
|
neighbours, from the frame's stored measures.
|
|
|
|
|
|
|
|
|
|
Brightness is the disc's median (``measures.disc_brightness``), which a flare
|
|
|
|
|
kernel does not lift the way it lifts IMG_MEAN. Four tests, each against a
|
|
|
|
|
median of references so one bad frame cannot vouch for another: dim against
|
|
|
|
|
the lower near side and both sides required; half brightness against a
|
|
|
|
|
two-hour baseline, one side allowed only when steady; the faintest sector
|
|
|
|
|
against the same sector nearby, for a disc dimmed on one side; stripes; and
|
|
|
|
|
the disc's position and self-repetition, for displaced and double discs.
|
|
|
|
|
The pass repeats without the frames it flagged until it flags nothing new, so
|
|
|
|
|
a run of dim frames falls from the ends inwards; a frame once flagged stays
|
|
|
|
|
flagged. Frames grazing Earth's atmosphere are judged but never references.
|
|
|
|
|
|
|
|
|
|
`series` is one (satellite, wavelength) stream of FrameFeatures in time order
|
|
|
|
|
with the measures in `scores`; `good` the slots that passed every single-frame
|
|
|
|
|
check. Returns one Verdict per frame.
|
|
|
|
|
"""
|
|
|
|
|
limits = limits or RELATIVE_LIMITS
|
|
|
|
|
grazing = {f.slot for f in series
|
|
|
|
|
if (f.scores.get("earth_tangent_km") is not None
|
|
|
|
|
and f.scores["earth_tangent_km"] < earth_reference_km)}
|
|
|
|
|
good, flagged = set(good) - grazing, {}
|
|
|
|
|
times = [f.t_start for f in series]
|
|
|
|
|
verdicts = _relative_pass(series, good, limits, range(len(series)))
|
|
|
|
|
while True:
|
|
|
|
|
new = {i for i, v in verdicts.items()
|
|
|
|
|
if v.verdict == BAD and series[i].slot not in flagged}
|
|
|
|
|
if not new:
|
|
|
|
|
return [flagged.get(f.slot, verdicts[i]) for i, f in enumerate(series)]
|
|
|
|
|
flagged.update({series[i].slot: verdicts[i] for i in new})
|
|
|
|
|
# Only frames within reach of a newly flagged one have lost a reference.
|
|
|
|
|
reach = BASELINE_REACH * 240
|
|
|
|
|
changed = sorted({j for i in new
|
|
|
|
|
for j in range(bisect.bisect_left(times, times[i] - reach),
|
|
|
|
|
bisect.bisect_right(times, times[i] + reach))})
|
|
|
|
|
verdicts.update(_relative_pass(series, good - set(flagged), limits, changed))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------- pointing_v1
|
|
|
|
|
|
|
|
|
|
#: Bands the pointing check judges. A pointing fault composes two discs into one
|
|
|
|
|
#: frame, and in 94A that double disc is too faint for disc_v1's limb fit. The
|
|
|
|
|
#: other bands are left to their own checks: within one slot some bands come out
|
|
|
|
|
#: double while the rest are clean, and judging those by their neighbours was wrong
|
|
|
|
|
#: for 13 of 20 sampled frames, against 1 of 20 in 94A.
|
|
|
|
|
POINTING_BANDS = (94,)
|
|
|
|
|
#: How many of the satellite's other bands in the same slot must have failed.
|
|
|
|
|
POINTING_MIN_OTHERS = 3
|
|
|
|
|
#: Checks whose rejection of another band counts as evidence of a pointing fault.
|
|
|
|
|
#: Header rejections do not: in the daily noon episodes most bands are empty while
|
|
|
|
|
#: the 94A frame beside them is fine, and in eclipse season the others go dark.
|
|
|
|
|
POINTING_EVIDENCE = ("disc_v1", "field_v1", "temporal_v2", "relative_v2")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def pointing_v1(wavelength, others, bands=POINTING_BANDS, min_others=POINTING_MIN_OTHERS,
|
|
|
|
|
evidence=POINTING_EVIDENCE):
|
|
|
|
|
"""Judge a frame by the pixel checks its satellite's other bands failed in the
|
|
|
|
|
same slot. `others` maps each other band present in the slot to the check that
|
|
|
|
|
rejected it, or None if it passed. UNKNOWN for a band this check does not judge.
|
|
|
|
|
"""
|
|
|
|
|
if wavelength not in bands:
|
|
|
|
|
return Verdict(UNKNOWN, "band not judged by pointing", {})
|
|
|
|
|
failed = sorted((band, source) for band, source in others.items()
|
|
|
|
|
if band != wavelength and source in evidence)
|
|
|
|
|
scores = {"pointing_others": float(len(failed))}
|
|
|
|
|
if len(failed) >= min_others:
|
|
|
|
|
named = ", ".join(f"{band} {source}" for band, source in failed)
|
|
|
|
|
return Verdict(BAD, f"pointing fault: {len(failed)} other bands failed ({named})",
|
|
|
|
|
scores)
|
|
|
|
|
return Verdict(GOOD, None, scores)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ------------------------------------------------------------------- lunar_v1
|
|
|
|
|
|
|
|
|
|
#: Extra separation, in arcminutes, before the Moon counts as clear of the Sun:
|
|
|
|
|
#: the corona the frame shows extends past the photospheric limb.
|
|
|
|
|
LUNAR_MARGIN_ARCMIN = 4.0
|
|
|
|
|
#: Geocentric Sun-Moon elongation, in degrees, beyond which no transit is possible
|
|
|
|
|
#: from geostationary orbit. The observer sits 42,164 km from Earth's centre, so
|
|
|
|
|
#: the Moon (>= 356,000 km away) shifts by at most 6.8 degrees of parallax; add both
|
|
|
|
|
#: radii (~0.55), the margin, and an hour of the Moon's ~0.55 deg/h drift -- the
|
|
|
|
|
#: elongation is computed once per hour and cached -- and 10 degrees is safe.
|
|
|
|
|
LUNAR_GATE_DEG = 10.0
|
|
|
|
|
_MOON_RADIUS_KM = 1737.4
|
|
|
|
|
_SUN_RADIUS_KM = 695700.0
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@functools.lru_cache(maxsize=4096)
|
|
|
|
|
def _geocentric_elongation(hour):
|
|
|
|
|
"""Sun-Moon angle in degrees from Earth's centre, at the start of a UTC hour."""
|
|
|
|
|
from astropy.coordinates import get_body, solar_system_ephemeris
|
|
|
|
|
from astropy.time import Time
|
|
|
|
|
import astropy.units as u
|
|
|
|
|
|
|
|
|
|
moment = Time(hour * 3600, format="unix", scale="utc")
|
|
|
|
|
with solar_system_ephemeris.set("builtin"):
|
|
|
|
|
separation = get_body("sun", moment).separation(get_body("moon", moment))
|
|
|
|
|
return float(separation.to(u.deg).value)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def lunar_v1(cards, margin=LUNAR_MARGIN_ARCMIN):
|
|
|
|
|
"""Whether the Moon stood in front of the Sun as seen from the satellite.
|
|
|
|
|
|
|
|
|
|
Lunar transits are invisible to every image test that compares a frame with
|
|
|
|
|
its neighbours: the whole sequence dims smoothly together. But they are
|
|
|
|
|
predictable exactly -- the header carries the satellite's position
|
|
|
|
|
(OBSGEO-X/Y/Z, metres, ITRS) and the observation time -- so this computes
|
|
|
|
|
the apparent Sun-Moon separation from there. GOES-16 saw the 2023-10-14
|
|
|
|
|
annular eclipse (centres 22' apart, radii 16' and 17') while GOES-18, 60
|
|
|
|
|
degrees of longitude away, did not.
|
|
|
|
|
"""
|
|
|
|
|
from astropy.coordinates import EarthLocation, get_body, solar_system_ephemeris
|
|
|
|
|
from astropy.time import Time
|
|
|
|
|
import astropy.units as u
|
|
|
|
|
|
|
|
|
|
cards = cards or {}
|
|
|
|
|
position = [cards.get(k) for k in ("OBSGEO-X", "OBSGEO-Y", "OBSGEO-Z")]
|
|
|
|
|
when = cards.get("DATE-OBS") or cards.get("DATE-BEG")
|
|
|
|
|
if not when or not all(isinstance(v, (int, float)) for v in position):
|
|
|
|
|
return Verdict(UNKNOWN, "no observer position or time in header", {})
|
|
|
|
|
try:
|
|
|
|
|
location = EarthLocation.from_geocentric(*position, unit=u.m)
|
|
|
|
|
# An explicit format: left to guess, astropy keeps every failed parse's
|
|
|
|
|
# exception, whose traceback cycles hold the callers' frames -- and with
|
|
|
|
|
# them judge_frame's 6.5 MB image -- alive until a full GC pass.
|
|
|
|
|
moment = Time(str(when), format="isot", scale="utc")
|
|
|
|
|
elongation = _geocentric_elongation(int(moment.unix // 3600))
|
|
|
|
|
if min(elongation, _geocentric_elongation(int(moment.unix // 3600) + 1)) \
|
|
|
|
|
> LUNAR_GATE_DEG:
|
|
|
|
|
return Verdict(GOOD, None, {"moon_elongation": elongation})
|
|
|
|
|
with solar_system_ephemeris.set("builtin"):
|
|
|
|
|
sun = get_body("sun", moment, location)
|
|
|
|
|
moon = get_body("moon", moment, location)
|
|
|
|
|
except (ValueError, TypeError) as exc:
|
|
|
|
|
return Verdict(UNKNOWN, f"ephemeris failed: {exc}", {})
|
|
|
|
|
separation = float(sun.separation(moon).to(u.arcmin).value)
|
|
|
|
|
sun_radius = float(np.degrees(_SUN_RADIUS_KM / sun.distance.to(u.km).value)) * 60
|
|
|
|
|
moon_radius = float(np.degrees(_MOON_RADIUS_KM / moon.distance.to(u.km).value)) * 60
|
|
|
|
|
limit = sun_radius + moon_radius + margin
|
|
|
|
|
scores = {"moon_separation": separation, "moon_limit": limit}
|
|
|
|
|
if separation < limit:
|
|
|
|
|
return Verdict(BAD, f"Moon in front of the Sun: {separation:.1f}' < {limit:.1f}'", scores)
|
|
|
|
|
return Verdict(GOOD, None, scores)
|
|
|
|
|
|
|
|
|
|
|
2026-09-01 22:36:13 -04:00
|
|
|
# ---------------------------------------------------------------- crosssat_v1
|
|
|
|
|
|
|
|
|
|
#: Relative disagreement between satellites above which one of them is wrong.
|
|
|
|
|
CROSSSAT_MAX_DIFF = 0.35
|
|
|
|
|
#: Alignment shift beyond which the two views cannot be meaningfully compared.
|
|
|
|
|
#: Geostationary parallax is under ~7px at 2.5 arcsec/px; more means a pointing fault.
|
|
|
|
|
CROSSSAT_MAX_SHIFT = 12.0
|
|
|
|
|
#: Plausible range for the calibration gain between the two flight models. Wide
|
|
|
|
|
#: enough to absorb genuine instrument differences, narrow enough that a blackout or
|
|
|
|
|
#: a saturation blowout cannot be rescaled into agreement.
|
|
|
|
|
CROSSSAT_GAIN_RANGE = (0.2, 5.0)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _gain_match(source, reference):
|
|
|
|
|
"""Least-squares gain and offset putting `source` on `reference`'s scale.
|
|
|
|
|
|
|
|
|
|
The two instruments are different flight models with different responses, so a
|
|
|
|
|
raw difference conflates calibration with genuine disagreement.
|
|
|
|
|
|
|
|
|
|
Returns (matched, gain, offset). The gain is returned rather than hidden
|
|
|
|
|
because matching is otherwise *too* effective: a frame uniformly scaled by 1e-4
|
|
|
|
|
fits perfectly after rescaling, so a detector that only looked at the residual
|
|
|
|
|
would call a total blackout a match. The size of the correction is itself
|
|
|
|
|
evidence.
|
|
|
|
|
"""
|
|
|
|
|
x = source.ravel().astype(np.float64)
|
|
|
|
|
y = reference.ravel().astype(np.float64)
|
|
|
|
|
finite = np.isfinite(x) & np.isfinite(y)
|
|
|
|
|
if finite.sum() < 16:
|
|
|
|
|
return source, 1.0, 0.0
|
|
|
|
|
x, y = x[finite], y[finite]
|
|
|
|
|
variance = float(((x - x.mean()) ** 2).sum())
|
|
|
|
|
if variance <= 0:
|
|
|
|
|
return source, 1.0, 0.0
|
|
|
|
|
gain = float(((x - x.mean()) * (y - y.mean())).sum() / variance)
|
|
|
|
|
offset = float(y.mean() - gain * x.mean())
|
|
|
|
|
return source * gain + offset, gain, offset
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def align_shift(a, b):
|
|
|
|
|
"""Sub-pixel translation between two thumbnails, via phase correlation."""
|
|
|
|
|
if a is None or b is None or a.shape != b.shape:
|
|
|
|
|
return None
|
|
|
|
|
window = cv.createHanningWindow((a.shape[1], a.shape[0]), cv.CV_64F)
|
|
|
|
|
(dx, dy), _ = cv.phaseCorrelate(
|
|
|
|
|
np.nan_to_num(a.astype(np.float64)), np.nan_to_num(b.astype(np.float64)), window
|
|
|
|
|
)
|
|
|
|
|
return float(np.hypot(dx, dy))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def crosssat_v1(features_a, features_b, temporal_a=None, temporal_b=None,
|
|
|
|
|
max_diff=CROSSSAT_MAX_DIFF, max_shift=CROSSSAT_MAX_SHIFT,
|
|
|
|
|
gain_range=CROSSSAT_GAIN_RANGE):
|
|
|
|
|
"""Compare the two satellites' view of the same instant.
|
|
|
|
|
|
|
|
|
|
GOES-16 and GOES-18 observe the same Sun from geostationary orbit, so at 1 AU
|
|
|
|
|
their views differ only by a few pixels of parallax and by instrument
|
|
|
|
|
calibration. Sustained disagreement therefore means one of them is wrong.
|
|
|
|
|
|
|
|
|
|
Deciding *which* one needs outside information: `temporal_a`/`temporal_b` are the
|
|
|
|
|
corresponding :func:`temporal_v1` verdicts, and whichever frame also disagrees
|
|
|
|
|
with its own history is blamed. With no tiebreak available both are reported
|
|
|
|
|
``unknown`` rather than guessing -- a wrong attribution would discard a good
|
|
|
|
|
frame and keep a bad one.
|
|
|
|
|
|
|
|
|
|
Returns (verdict_a, verdict_b).
|
|
|
|
|
"""
|
|
|
|
|
if features_a is None or features_b is None:
|
|
|
|
|
return Verdict(UNKNOWN, "no counterpart", {}), Verdict(UNKNOWN, "no counterpart", {})
|
|
|
|
|
if features_a.error or features_b.error:
|
|
|
|
|
# Frame-level errors are not this detector's job; defer rather than double-count.
|
|
|
|
|
return Verdict(UNKNOWN, "counterpart unreadable", {}), Verdict(
|
|
|
|
|
UNKNOWN, "counterpart unreadable", {}
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
a, b = features_a.thumbnail, features_b.thumbnail
|
|
|
|
|
if a is None or b is None or a.shape != b.shape:
|
|
|
|
|
return Verdict(UNKNOWN, "no comparable thumbnail", {}), Verdict(
|
|
|
|
|
UNKNOWN, "no comparable thumbnail", {}
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
scores = {}
|
|
|
|
|
shift = align_shift(a, b)
|
|
|
|
|
if shift is not None:
|
|
|
|
|
scores["align_shift"] = shift
|
|
|
|
|
|
|
|
|
|
matched, gain, offset = _gain_match(b, a)
|
|
|
|
|
scale = float(np.abs(a).mean() + np.abs(matched).mean()) / 2.0
|
|
|
|
|
difference = float(np.abs(a - matched).mean()) / scale if scale > 0 else np.inf
|
|
|
|
|
scores["cross_diff"] = difference
|
|
|
|
|
scores["cross_gain"] = gain
|
|
|
|
|
scores["cross_offset"] = offset
|
|
|
|
|
|
|
|
|
|
# A correction this large means the two frames are not on comparable scales at
|
|
|
|
|
# all, whatever the residual says once it has been applied.
|
|
|
|
|
gain_ok = gain_range[0] <= gain <= gain_range[1]
|
|
|
|
|
agree = difference <= max_diff and gain_ok and (shift is None or shift <= max_shift)
|
|
|
|
|
if agree:
|
|
|
|
|
return Verdict(GOOD, None, scores), Verdict(GOOD, None, scores)
|
|
|
|
|
|
|
|
|
|
reason = f"cross-satellite disagreement {difference:.2f}"
|
|
|
|
|
if not gain_ok:
|
|
|
|
|
reason = f"cross-satellite gain {gain:.3g} outside {gain_range}"
|
|
|
|
|
elif shift is not None and shift > max_shift:
|
|
|
|
|
reason = f"cross-satellite misalignment {shift:.1f}px"
|
|
|
|
|
|
|
|
|
|
a_suspect = temporal_a is not None and temporal_a.verdict == BAD
|
|
|
|
|
b_suspect = temporal_b is not None and temporal_b.verdict == BAD
|
|
|
|
|
if a_suspect and not b_suspect:
|
|
|
|
|
return Verdict(BAD, reason, scores), Verdict(GOOD, None, scores)
|
|
|
|
|
if b_suspect and not a_suspect:
|
|
|
|
|
return Verdict(GOOD, None, scores), Verdict(BAD, reason, scores)
|
|
|
|
|
return Verdict(UNKNOWN, reason + " (source unclear)", scores), Verdict(
|
|
|
|
|
UNKNOWN, reason + " (source unclear)", scores
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
#: Detectors the drivers and bench expose by name.
|
|
|
|
|
FRAME_DETECTORS = {"header_v1": header_v1, "geometry_v1": geometry_v1, "disc_v1": disc_v1}
|
2026-10-14 00:27:18 -04:00
|
|
|
SERIES_DETECTORS = {"temporal_v1": temporal_v1, "temporal_v2": temporal_v2,
|
|
|
|
|
"crosssat_v1": crosssat_v1}
|
2026-09-01 22:36:13 -04:00
|
|
|
ALL_DETECTORS = tuple(FRAME_DETECTORS) + tuple(SERIES_DETECTORS)
|