noaa-goes-visualization/detector_eval.py

678 lines
31 KiB
Python
Raw Permalink Normal View History

2026-10-14 00:27:18 -04:00
#!/usr/bin/env python
"""Score bad-frame detectors against REAL, hand-confirmed archive defects.
The bench scores detectors on damage it injected itself, against a notion of
"clean" produced by the detectors being scored. That is how the production pair
reached precision 1.000 on the bench while passing saturated bars, ghost discs and
short-exposure static in the real archive. This scores them on
``labels/real_defects.json`` instead (built by ``defect_audit.py``, confirmed by
eye), reporting recall per defect category and false positives separately on
ordinary and flare frames -- the flare frames being the ones a detector tuned to
catch brightness anomalies is most likely to throw away.
detector_eval.py measure --labels labels/real_defects.json --out AUDIT/eval.json
detector_eval.py score --labels labels/real_defects.json --features AUDIT/eval.json
detector_eval.py stored --labels labels/real_defects.json [--run N]
detector_eval.py dry-run --labels labels/real_defects.json --start T --end T --out new.json
detector_eval.py sheet --rows new.json --out sheet.jpg [--run N]
``measure`` reads every labelled frame with enough context around it for the
series detectors (+/-TEMPORAL_WINDOW frames on both satellites) and stores each
detector's verdict plus the raw far-field measurements. ``score`` is cheap and
repeatable: combinations (``a+b`` = bad if either says bad) and far-field
thresholds (``far_field@4``) are evaluated from the stored results, so trying a
rule does not mean re-reading the archive. ``stored`` scores a judgment run already
in the index (the production one by default) -- the check that what the archive-wide
judge stored is what the rules promised. ``dry-run`` judges stored measurements in
memory (nothing written) and lists every frame whose verdict changes against a
reference run; ``sheet`` draws such frames between their nearest good neighbours
for review. Read-only against archive and index.
"""
import argparse
import calendar
import collections
import json
import os
import sys
import time
from multiprocessing import Pool
import numpy as np
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from defect_audit import CLEAN, DEFECTS, NEUTRAL
from suvi import db, detectors, fitsio, paths
#: Labelled frames of one band closer than this many slots share a context read.
CLUSTER_GAP = 24
#: Context read either side of a cluster. Two windows, so a frame's temporal
#: neighbours have their own neighbours for the structural-churn baseline.
CONTEXT_SLOTS = 2 * detectors.TEMPORAL_WINDOW
#: Verdicts each labelled frame gets from `measure`.
FRAME_VERDICTS = ("header_v1", "header_v1_old", "lunar_v1", "disc_v1", "geometry_v1",
"field_v1")
#: Every verdict a measured frame can carry.
MEASURED = FRAME_VERDICTS + ("temporal_v1", "crosssat_v1", "legacy")
#: Judged from stored scores at scoring time, against the current limits, so
#: tuning a limit needs no new read of the archive.
FROM_SCORES = {"temporal_v2": ("temporal_v2_scores", "TEMPORAL2_LIMITS")}
#: Rows `score` prints when none are asked for: what production used when the 2024
#: render and the corpus were made, what it uses now, and the alternatives.
DEFAULT_ROWS = ("pair_old", "pair", "legacy", "temporal_v1", "temporal_v2", "field_v1",
"production", "production+temporal_v2")
#: Named combinations, expanded before evaluation.
ALIASES = {"pair": "header_v1+disc_v1", "pair_old": "header_v1_old+disc_v1",
"production": "header_v1+lunar_v1+disc_v1+field_v1"}
# ------------------------------------------------------------------- clustering
def clusters(labels, gap=CLUSTER_GAP, cadence=paths.CADENCE):
"""Group labelled frames by band into runs whose members are < `gap` slots apart.
Both satellites share a cluster, because the cross-satellite check needs the
other satellite's frames at the same instants anyway.
"""
by_band = collections.defaultdict(list)
for record in labels:
by_band[record["wavelength"]].append(record)
out = []
for wavelength in sorted(by_band):
members = sorted(by_band[wavelength], key=lambda r: r["t_start"])
current = [members[0]]
for record in members[1:]:
if record["t_start"] - current[-1]["t_start"] < gap * cadence:
current.append(record)
else:
out.append((wavelength, current))
current = [record]
out.append((wavelength, current))
return out
# ---------------------------------------------------------------------- measure
def _frame(job):
"""Header, thumbnail, production verdict and (for labelled frames) every
single-frame verdict. Returns (features, production_good, record)."""
slot, path, labelled = job
cards, header_error = fitsio.read_header(path)
values = fitsio.header_metadata(cards)
refcrota = cards.get("REFCROTA")
refcrota = float(refcrota) if isinstance(refcrota, (int, float)) else None
image, image_error = fitsio.read_image(path)
features = detectors.FrameFeatures(
slot=slot, header=values, error=header_error or (None if image is not None else image_error),
thumbnail=detectors.thumbnail(image) if image is not None else None)
_, production, _ = detectors.judge_frame(slot, cards, header_error,
lambda: (image, image_error))
good = production.verdict != detectors.BAD
if not labelled:
return features, good, None
def pack(verdict):
return [verdict.verdict, verdict.reason]
old = dict(values)
old.pop("n_long", None)
header = detectors.FrameFeatures(slot=slot, header=values, error=header_error)
record = {
"header_v1": pack(detectors.header_v1(header)),
"header_v1_old": pack(detectors.header_v1(detectors.FrameFeatures(
slot=slot, header=old, error=header_error))),
"lunar_v1": pack(detectors.lunar_v1(cards)),
}
if image is None:
record["disc_v1"] = pack(detectors.unreadable(image_error))
record["geometry_v1"] = pack(detectors.unreadable(image_error))
record["field_v1"] = pack(detectors.unreadable(image_error))
record["far_field"] = record["noise"] = None
else:
record["disc_v1"] = pack(detectors.disc_v1(image, slot[1], header=values))
record["geometry_v1"] = pack(detectors.geometry_v1(image, slot[1]))
field = detectors.field_v1(image, slot[1], header=values, refcrota=refcrota)
record["field_v1"] = pack(field)
record["field_scores"] = field.scores
radius = values.get("diam_sun")
radius = radius / 2.0 if radius else None
record["far_field"] = detectors.far_field(image, radius)
record["noise"] = detectors.far_field_noise(image, radius)
return features, good, record
def measure_cluster(job):
"""Every verdict for one cluster's labelled frames, with series context."""
wavelength, members, streams = job
fitsio.quiet_astropy()
wanted = {(r["satellite"], r["wavelength"], r["t_start"]) for r in members}
frames, records, good = {}, {}, set()
for satellite, rows in streams.items():
for t_start, path in rows:
slot = (satellite, wavelength, t_start)
features, passed, record = _frame((slot, path, slot in wanted))
frames[slot] = features
if passed:
good.add(slot)
if record is not None:
records[slot] = record
temporal, temporal2 = {}, {}
for satellite in streams:
ordered = sorted((f for s, f in frames.items() if s[0] == satellite),
key=lambda f: f.t_start)
for features, verdict in zip(ordered, detectors.temporal_v1(ordered)):
temporal[features.slot] = verdict
for features, verdict in zip(ordered, detectors.temporal_v2(ordered, good)):
temporal2[features.slot] = verdict
out = []
for record in members:
slot = (record["satellite"], record["wavelength"], record["t_start"])
measured = records.get(slot)
if measured is None: # vanished from the archive
continue
verdict = temporal.get(slot)
measured["temporal_v1"] = [verdict.verdict, verdict.reason] if verdict else None
second = temporal2.get(slot)
measured["temporal_v2_scores"] = second.scores if second else None
other = [s for s in streams if s != slot[0]]
cross = None
if other:
counterpart = (other[0], wavelength, slot[2])
mine, theirs = detectors.crosssat_v1(
frames.get(slot), frames.get(counterpart),
temporal.get(slot), temporal.get(counterpart))
cross = [mine.verdict, mine.reason]
measured["crosssat_v1"] = cross
out.append({"satellite": slot[0], "wavelength": wavelength, "t_start": slot[2],
**measured})
return out
def legacy_verdicts(conn, labels):
"""The legacy pixel filter's recorded verdict per labelled slot, where it has one."""
out = {}
for record in labels:
row = conn.execute(
"SELECT d.verdict FROM detection d JOIN detector_run r ON r.id = d.run_id "
"JOIN frame f ON f.id = d.frame_id WHERE r.name = 'legacy_filter' AND "
"f.satellite = ? AND f.wavelength = ? AND f.t_start = ? LIMIT 1",
(record["satellite"], record["wavelength"], record["t_start"])).fetchone()
if row is not None:
out[(record["satellite"], record["wavelength"], record["t_start"])] = row[0]
return out
def cmd_measure(args):
with open(args.labels) as handle:
labels = json.load(handle)["labels"]
conn = db.connect(args.db, readonly=True)
root = paths.data_root()
jobs = []
for wavelength, members in clusters(labels):
t0 = members[0]["t_start"] - CONTEXT_SLOTS * paths.CADENCE
t1 = members[-1]["t_start"] + CONTEXT_SLOTS * paths.CADENCE
streams = {}
for satellite in (16, 18):
streams[satellite] = [
(row[0], paths.abspath(row[1], root)) for row in conn.execute(
"SELECT t_start, path FROM frame WHERE satellite = ? AND wavelength = ? "
"AND t_start >= ? AND t_start <= ? AND size_bytes >= ? ORDER BY t_start",
(satellite, wavelength, t0, t1, 100_000))]
jobs.append((wavelength, members, streams))
reads = sum(len(rows) for _, _, s in jobs for rows in s.values())
print(f"{len(labels)} labelled frames in {len(jobs)} clusters; {reads} frames to read",
flush=True)
legacy = legacy_verdicts(conn, labels)
results, done = [], 0
with Pool(args.workers, initializer=fitsio.quiet_astropy) as pool:
for batch in pool.imap_unordered(measure_cluster, jobs):
results.extend(batch)
done += 1
if done % 50 == 0:
print(f" {done}/{len(jobs)} clusters", flush=True)
for record in results:
verdict = legacy.get((record["satellite"], record["wavelength"], record["t_start"]))
record["legacy"] = [verdict, None] if verdict else None
os.makedirs(os.path.dirname(os.path.abspath(args.out)), exist_ok=True)
with open(args.out, "w") as handle:
json.dump({"labels": os.path.abspath(args.labels), "frames": results}, handle)
print(f"wrote {len(results)} measured frames to {args.out}")
return 0
def _refresh_one(job):
slot, path = job
fitsio.quiet_astropy()
return slot, _frame((slot, path, True))[2]
def cmd_refresh(args):
"""Re-run only the single-frame detectors on the labelled frames, in place.
After a frame detector changes, the series context `measure` read is still
valid, so this reads each labelled frame once instead of every neighbour.
"""
with open(args.features) as handle:
stored = json.load(handle)
conn = db.connect(args.db, readonly=True)
root = paths.data_root()
jobs = []
for record in stored["frames"]:
row = conn.execute("SELECT path FROM frame WHERE satellite = ? AND wavelength = ? "
"AND t_start = ?", _slot(record)).fetchone()
if row is not None:
jobs.append((_slot(record), paths.abspath(row[0], root)))
by_slot = {_slot(r): r for r in stored["frames"]}
with Pool(args.workers, initializer=fitsio.quiet_astropy) as pool:
for slot, fresh in pool.imap_unordered(_refresh_one, jobs, 8):
by_slot[slot].update(fresh)
with open(args.features + ".tmp", "w") as handle:
json.dump(stored, handle)
os.replace(args.features + ".tmp", args.features)
print(f"refreshed {len(jobs)} frames in {args.features}")
return 0
# ------------------------------------------------------------------------ score
def far_field_baselines(frames, labels_by_slot):
"""Median far-field brightness per band over frames labelled `normal`."""
per_band = collections.defaultdict(list)
for frame in frames:
if (labels_by_slot.get(_slot(frame)) == "normal"
and frame.get("far_field") is not None):
per_band[frame["wavelength"]].append(frame["far_field"])
return {band: float(np.median(values)) for band, values in per_band.items() if values}
def _slot(record):
return (record["satellite"], record["wavelength"], record["t_start"])
def says_bad(frame, term, baselines):
"""Whether one detector term calls `frame` bad. UNKNOWN and absent are not bad."""
if term.startswith("far_field@"):
factor = float(term.split("@", 1)[1])
base = baselines.get(frame["wavelength"])
value = frame.get("far_field")
return base is not None and value is not None and value > factor * base
if term in FROM_SCORES:
key, limits = FROM_SCORES[term]
scores = frame.get(key) or {}
return any(scores.get(name, 0.0) > limit
for name, limit in getattr(detectors, limits).items())
if term not in MEASURED:
raise KeyError(f"unknown detector term {term!r}")
verdict = frame.get(term)
return bool(verdict) and verdict[0] == detectors.BAD
def expand(row):
"""'pair+temporal_v1' -> ['header_v1', 'disc_v1', 'temporal_v1']."""
terms = []
for part in row.split("+"):
terms.extend(ALIASES.get(part, part).split("+"))
return terms
def score(frames, labels_by_slot, rows, baselines):
"""{row: {category: (caught, total)}} over every labelled category present."""
table = {}
for row in rows:
terms = expand(row)
counts = collections.defaultdict(lambda: [0, 0])
for frame in frames:
category = labels_by_slot.get(_slot(frame))
if category is None or category in NEUTRAL:
continue
counts[category][1] += 1
if any(says_bad(frame, term, baselines) for term in terms):
counts[category][0] += 1
table[row] = {k: tuple(v) for k, v in counts.items()}
return table
def summary(counts):
"""(defect recall, normal FPR, flare FPR) from one row's per-category counts."""
def rate(categories):
caught = sum(counts.get(c, (0, 0))[0] for c in categories)
total = sum(counts.get(c, (0, 0))[1] for c in categories)
return caught / total if total else float("nan")
return rate(DEFECTS), rate(("normal",)), rate(("flare_normal",))
def format_table(table):
categories = [c for c in DEFECTS + CLEAN
if any(c in counts for counts in table.values())]
head = f"{'detector':<32}" + "".join(f"{c[:9]:>10}" for c in categories)
head += f"{'RECALL':>9}{'FPR':>8}{'FPR flr':>9}"
lines = [head]
totals = next(iter(table.values()))
lines.append(f"{'(frames)':<32}" + "".join(f"{totals.get(c, (0, 0))[1]:>10}"
for c in categories))
for row, counts in table.items():
cells = "".join(
f"{counts[c][0] / counts[c][1]:>10.3f}" if counts.get(c, (0, 0))[1] else f"{'-':>10}"
for c in categories)
recall, fpr, flare = summary(counts)
lines.append(f"{row:<32}{cells}{recall:>9.3f}{fpr:>8.4f}{flare:>9.4f}")
return "\n".join(lines)
def cmd_score(args):
with open(args.labels) as handle:
labels_by_slot = {_slot(r): r["category"] for r in json.load(handle)["labels"]}
with open(args.features) as handle:
frames = json.load(handle)["frames"]
baselines = far_field_baselines(frames, labels_by_slot)
rows = [r.strip() for r in args.rows.split(",")] if args.rows else list(DEFAULT_ROWS)
table = score(frames, labels_by_slot, rows, baselines)
print(f"{len(frames)} measured frames; recall per category, FPR on normal and flare frames")
print(format_table(table))
if args.misses:
terms = expand(args.misses)
missed = [f for f in frames if labels_by_slot.get(_slot(f)) in DEFECTS
and not any(says_bad(f, t, baselines) for t in terms)]
false = [f for f in frames if labels_by_slot.get(_slot(f)) in CLEAN
and any(says_bad(f, t, baselines) for t in terms)]
print(f"\n{args.misses}: {len(missed)} defects missed, {len(false)} clean frames rejected")
for f in missed + false:
base = baselines.get(f["wavelength"])
ratio = f["far_field"] / base if base and f.get("far_field") is not None else None
print(f" {labels_by_slot[_slot(f)]:<13} g{f['satellite']} {f['wavelength']:>3} "
f"{f['t_start']} far x{ratio if ratio is None else round(ratio, 1)}")
if args.out:
with open(args.out, "w") as handle:
json.dump({row: {c: list(v) for c, v in counts.items()}
for row, counts in table.items()}, handle, indent=1)
return 0
def score_verdicts(verdicts, labels):
"""Score verdicts against the labels.
`verdicts` maps (satellite, wavelength, t_start) to (verdict, reason). Returns
({category: (caught, total)}, missed, rejected): `missed` the defects passed and
`rejected` the clean frames rejected, each [(record, reason)]. Labelled frames
with no verdict (outside the judged range) are left out.
"""
counts = collections.defaultdict(lambda: [0, 0])
missed, rejected = [], []
for record in labels:
category = record["category"]
found = verdicts.get(_slot(record))
if category in NEUTRAL or found is None:
continue
verdict, reason = found
bad = verdict == detectors.BAD
counts[category][1] += 1
counts[category][0] += bad
if category in DEFECTS and not bad:
missed.append((record, reason))
elif category in CLEAN and bad:
rejected.append((record, reason))
return {k: tuple(v) for k, v in counts.items()}, missed, rejected
def stored_table(conn, run_id, labels):
"""``score_verdicts`` for a judgment run stored in the index."""
verdicts = {}
for record in labels:
row = conn.execute(
"SELECT d.verdict, d.reason FROM frame f JOIN detection d ON d.frame_id = f.id "
"AND d.run_id = ? WHERE f.satellite = ? AND f.wavelength = ? AND f.t_start = ?",
(run_id, record["satellite"], record["wavelength"], record["t_start"])).fetchone()
if row is not None:
verdicts[_slot(record)] = (row["verdict"], row["reason"])
return score_verdicts(verdicts, labels)
def print_scored(title, scored):
counts, missed, rejected = scored
print(f"{title}: {sum(total for _, total in counts.values())} labelled frames judged")
print(format_table({title: counts}))
for heading, rows in (("defects passed", missed), ("clean frames rejected", rejected)):
print(f"\n{heading}: {len(rows)}")
for record, reason in rows:
print(f" {record['category']:<13} g{record['satellite']} "
f"{record['wavelength']:>3} {_when(record['t_start'])} {reason or ''}")
def _when(t_start):
return time.strftime("%Y-%m-%d %H:%M", time.gmtime(t_start))
def cmd_stored(args):
with open(args.labels) as handle:
labels = json.load(handle)["labels"]
conn = db.connect(args.db, readonly=True)
run_id = args.run or detectors.judgment_run(conn)
if run_id is None:
raise SystemExit("No production judgment under the current rules; pass --run")
print_scored(f"judgment run {run_id}", stored_table(conn, run_id, labels))
return 0
def dry_run(conn, measure_run, start, end, satellites):
"""The production judgment of [start, end) from stored measurements, in memory:
{slot: (verdict, source, reason)}. Nothing is written."""
import filter_FITS
margin = filter_FITS.JUDGE_MARGIN * paths.CADENCE
out = {}
for chunk in range(start, end, filter_FITS.JUDGE_CHUNK):
hi = min(chunk + filter_FITS.JUDGE_CHUNK, end)
for satellite in satellites:
streams = filter_FITS.load_measured(conn, measure_run, satellite,
chunk - margin, hi + margin)
for slot, source, verdict in filter_FITS.judge_measured(streams).values():
if chunk <= slot[2] < hi:
out[slot] = (verdict.verdict, source, verdict.reason)
return out
def run_verdicts(conn, run_id, start, end, satellites):
"""{slot: (verdict, reason)} of a stored run over [start, end)."""
marks = ",".join("?" * len(satellites))
return {(r["satellite"], r["wavelength"], r["t_start"]): (r["verdict"], r["reason"])
for r in conn.execute(
f"SELECT f.satellite, f.wavelength, f.t_start, d.verdict, d.reason FROM frame f "
f"JOIN detection d ON d.frame_id = f.id AND d.run_id = ? WHERE f.t_start >= ? "
f"AND f.t_start < ? AND f.satellite IN ({marks})",
(run_id, start, end, *satellites))}
def changes(verdicts, reference):
"""Frames whose verdict differs from a reference run: (newly_bad, newly_good),
each [(slot, reason)] in time order; frames the reference never judged are left
out."""
newly_bad, newly_good = [], []
for slot in sorted(verdicts, key=lambda s: (s[2], s[0], s[1])):
verdict, source, reason = verdicts[slot]
before = reference.get(slot)
if before is None:
continue
why = f"{source}: {reason}" if source and reason else reason
if verdict == detectors.BAD and before[0] != detectors.BAD:
newly_bad.append((slot, why))
elif verdict != detectors.BAD and before[0] == detectors.BAD:
newly_good.append((slot, f"was {before[1]}"))
return newly_bad, newly_good
def cmd_dry_run(args):
with open(args.labels) as handle:
labels = json.load(handle)["labels"]
conn = db.connect(args.db, readonly=True)
measure_run = args.measure_run or detectors.measure_run(conn)
if measure_run is None:
raise SystemExit("No measurements under the current rules; pass --measure-run")
satellites = tuple(int(s) for s in args.satellites.split(","))
verdicts = dry_run(conn, measure_run, args.start, args.end, satellites)
print(f"{len(verdicts)} frames judged from measure run {measure_run}")
print_scored("dry run", score_verdicts(
{slot: (v, f"{src}: {why}" if src and why else why)
for slot, (v, src, why) in verdicts.items()}, labels))
newly_bad, newly_good = changes(
verdicts, run_verdicts(conn, args.reference, args.start, args.end, satellites))
print(f"\nagainst run {args.reference}: {len(newly_bad)} newly bad, "
f"{len(newly_good)} newly good")
tally = collections.Counter((why or "").split(":")[0] for _, why in newly_bad)
for source, count in tally.most_common():
print(f" newly bad by {source:<14}{count}")
if args.out:
rows = lambda pairs: [[s[0], s[1], _when(s[2]), why or ""] for s, why in pairs]
with open(args.out, "w") as handle:
json.dump({"newly_bad": rows(newly_bad), "newly_good": rows(newly_good)},
handle, indent=1)
return 0
#: Tile size of a review sheet, in pixels.
SHEET_TILE = 360
def _sheet_time(value):
if isinstance(value, (int, float)):
return int(value)
return calendar.timegm(time.strptime(value, "%Y-%m-%d %H:%M"))
def cmd_sheet(args):
"""A 3-column neighbour sheet: each row the previous good frame of the same band,
the frame under review, and the next good frame -- at any distance, skipping
frames judged bad and every frame listed for review or exclusion."""
from PIL import Image, ImageDraw
from suvi import metrics
with open(args.rows) as handle:
rows = json.load(handle)
rows = rows.get(args.key, []) if isinstance(rows, dict) else rows
rows = [(int(r[0]), int(r[1]), _sheet_time(r[2]), r[3] if len(r) > 3 else "")
for r in rows[:args.limit]]
excluded = {(s, w, t) for s, w, t, _ in rows}
if args.exclude:
with open(args.exclude) as handle:
extra = json.load(handle)
extra = extra.get(args.key, []) if isinstance(extra, dict) else extra
excluded |= {(int(r[0]), int(r[1]), _sheet_time(r[2])) for r in extra}
conn = db.connect(args.db, readonly=True)
run_id = args.run or detectors.judgment_run(conn)
if run_id is None:
raise SystemExit("No production judgment under the current rules; pass --run")
def lookup(satellite, wavelength, t_start, direction):
if direction == 0:
row = conn.execute(
"SELECT f.t_start, f.path, d.verdict FROM frame f LEFT JOIN detection d "
"ON d.frame_id = f.id AND d.run_id = ? WHERE f.satellite = ? AND "
"f.wavelength = ? AND f.t_start = ?",
(run_id, satellite, wavelength, t_start)).fetchone()
return tuple(row) if row else None
op, order = ("<", "DESC") if direction < 0 else (">", "ASC")
for row in conn.execute(
f"SELECT f.t_start, f.path, d.verdict FROM frame f JOIN detection d "
f"ON d.frame_id = f.id AND d.run_id = ? WHERE f.satellite = ? AND "
f"f.wavelength = ? AND f.t_start {op} ? AND d.verdict = 'good' "
f"ORDER BY f.t_start {order} LIMIT 500",
(run_id, satellite, wavelength, t_start)):
if (satellite, wavelength, row[0]) not in excluded:
return tuple(row)
return None
tile, head = SHEET_TILE, 40
sheet = Image.new("RGB", (3 * tile, len(rows) * (tile + head)), (40, 40, 40))
for index, (satellite, wavelength, t_start, note) in enumerate(rows):
for column, direction in enumerate((-1, 0, 1)):
cell = Image.new("RGB", (tile, tile + head))
draw = ImageDraw.Draw(cell)
found = lookup(satellite, wavelength, t_start, direction)
if found is None:
draw.text((6, 6), "no good frame found", fill=(255, 80, 80))
else:
when, relpath, verdict = found
image, _ = fitsio.read_image(paths.abspath(relpath))
if image is not None:
shown = np.clip(np.nan_to_num(metrics.to_display(
np.asarray(image, np.float32), wavelength)), 0, 1)
cell.paste(Image.fromarray((shown * 255).astype(np.uint8))
.resize((tile, tile)).convert("RGB"), (0, head))
gap = round((when - t_start) / paths.CADENCE)
draw.text((6, 4), f"G{satellite} {wavelength}A {_when(when)} judged {verdict}",
fill=(255, 255, 0))
draw.text((6, 20), (f"REVIEW: {note}" if direction == 0 else
f"{gap:+d} slots")[:56], fill=(160, 220, 255))
sheet.paste(cell, (column * tile, index * (tile + head)))
sheet.save(args.out, quality=90)
print(f"{args.out}: {len(rows)} rows")
return 0
def main(argv=None):
parser = argparse.ArgumentParser(description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter)
sub = parser.add_subparsers(dest="command", required=True)
measure = sub.add_parser("measure", help="read labelled frames and run every detector")
measure.add_argument("--labels", required=True)
measure.add_argument("--out", required=True)
measure.add_argument("--workers", type=int, default=4)
measure.add_argument("--db", default=None)
measure.set_defaults(func=cmd_measure)
refresh = sub.add_parser("refresh", help="re-run the single-frame detectors in place")
refresh.add_argument("--features", required=True)
refresh.add_argument("--workers", type=int, default=4)
refresh.add_argument("--db", default=None)
refresh.set_defaults(func=cmd_refresh)
scoring = sub.add_parser("score", help="tabulate recall and false positives")
scoring.add_argument("--labels", required=True)
scoring.add_argument("--features", required=True)
scoring.add_argument("--rows", default=None,
help="comma-separated detectors; '+' combines (bad if any says bad)")
scoring.add_argument("--misses", default=None,
help="list the defects this row misses and clean frames it rejects")
scoring.add_argument("--out", default=None)
scoring.set_defaults(func=cmd_score)
run = sub.add_parser("stored", help="score a judgment run stored in the index")
run.add_argument("--labels", required=True)
run.add_argument("--run", type=int, default=None,
help="detector run id (default: the production judgment)")
run.add_argument("--db", default=None)
run.set_defaults(func=cmd_stored)
dry = sub.add_parser("dry-run", help="judge stored measurements in memory and score")
dry.add_argument("--labels", required=True)
dry.add_argument("--start", type=int, required=True)
dry.add_argument("--end", type=int, required=True)
dry.add_argument("--satellites", default="16,18")
dry.add_argument("--measure-run", type=int, default=None)
dry.add_argument("--reference", type=int, default=46,
help="stored run to list changed verdicts against (default 46)")
dry.add_argument("--out", default=None, help="JSON of newly bad / newly good rows")
dry.add_argument("--db", default=None)
dry.set_defaults(func=cmd_dry_run)
sheet = sub.add_parser("sheet", help="draw a 3-column neighbour sheet")
sheet.add_argument("--rows", required=True,
help="JSON rows [[satellite, band, 'YYYY-MM-DD HH:MM', note]]")
sheet.add_argument("--key", default="newly_bad",
help="list to draw when the rows file is a dry-run output")
sheet.add_argument("--exclude", default=None, help="more rows never used as neighbours")
sheet.add_argument("--limit", type=int, default=40)
sheet.add_argument("--run", type=int, default=None,
help="judgment run deciding what is good (default: production)")
sheet.add_argument("--out", required=True)
sheet.add_argument("--db", default=None)
sheet.set_defaults(func=cmd_sheet)
args = parser.parse_args(argv)
return args.func(args)
if __name__ == "__main__":
sys.exit(main())