noaa-goes-visualization/render_qa.py

291 lines
12 KiB
Python

#!/usr/bin/env python
"""Scan rendered composites for defects that reached the output.
render_qa.py renders/compare_v8/new-detection/learned/goes16 \\
renders/compare_v8/new-detection/optical_flow/goes16 \\
--periods 2024-10-03..2024-10-06 --out renders/compare_v8/qa
Detection is scored on labelled archive frames (`detector_eval.py`), but what the
viewer sees is the composite, and a defect can reach it without any frame-level
check failing: a judged-good frame that is bad, or a repair built from bad
neighbours (2024-01-01 16:28 in the rejected render: a missing 131 A band filled
from two static frames came out as static). This reads the JPEGs themselves.
Each composite holds six band panels and the composite in the middle
(`merger_FITS.composite_from_arrays`). For each of the seven, on luminance:
* **far_field** -- 99th percentile beyond 1.6 solar radii. The colour maps clip
the quiet far field to black, so bars, blocks, ghost discs and static light it.
* **noise** -- spread of the high-pass residual there: static's signature.
* **spike** -- how far the panel stands out of line with the slots either side,
per pixel ``max(0, |x - (prev+next)/2| - |next-prev|/2)``. Zero for a ramp or
a step, so flare onsets and rotation pass; a one-frame defect or a bad repair
does not. Needs both neighbours one cadence away.
A frame is flagged when any panel exceeds its limit (`LIMITS`). The flagged
frames are listed per stream, worst first, with contact sheets for review.
"""
import argparse
import datetime
import json
import os
import sys
from multiprocessing import Pool
import numpy as np
from PIL import Image, ImageDraw
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import merger_FITS
from make_comparison_video import build_timeline, parse_periods, stream_frames
from suvi import paths
#: Panel order: the six bands as merger_FITS lays them out, then the composite.
PANELS = tuple(name.lstrip("0").rstrip("A") for name in merger_FITS.image_names) + ("composite",)
#: Trimmed band size, and the shrunken copies along both sides.
FRAME_W = 1280 - 2 * merger_FITS.TRIM_X
FRAME_H = 1280 - 2 * merger_FITS.TRIM_Y
PANEL_W, PANEL_H = FRAME_W // 3, FRAME_H // 3
CANVAS = (FRAME_W + 2 * PANEL_W, FRAME_H)
#: Largest solar radius in a 1280-pixel frame (perihelion, 2.5"/px), and where
#: the far field starts, as in `detectors.field_v1`.
SUN_RADIUS = 392
FAR_FIELD_RADIUS = 1.6
#: Rows of the composite under the title; its timestamp changes every frame.
TITLE_ROWS = 60
#: Downsampling for the spike test: JPEG blocks and grain average out.
SPIKE_BIN = 4
#: Per-panel limits on luminance (0..1). Calibrated on the rejected v7_s128
#: GOES-16 render over 2024-01-01..07, 05-08..10 and 10-03..05 (4,680
#: composites) against the 46 slots holding frames the stored judgment rejects
#: but that render's detection passed. 38 were flagged -- static at noise
#: 0.02-0.06, bars and blocks at far field 0.2-1.0, the rest at spike 0.02 or
#: more; the other 8 are 94 A frames the series check rejects that look normal in
#: the panel. The one other flag was a real bad repair (16:28 above). Unflagged
#: frames, X-class flares included, peaked at far field 0.004, noise 0.002 and
#: spike 0.006.
LIMITS = {"far_field": 0.15, "noise": 0.005, "spike": 0.015}
METRICS = tuple(LIMITS)
def panel_boxes():
"""(x0, y0, x1, y1) of each panel in `PANELS` order."""
boxes = []
for index in range(6):
x0 = (index % 2) * (CANVAS[0] - PANEL_W)
y0 = (index // 2) * PANEL_H
boxes.append((x0, y0, x0 + PANEL_W, y0 + PANEL_H))
boxes.append((PANEL_W, 0, PANEL_W + FRAME_W, FRAME_H))
return tuple(boxes)
def far_field_masks():
"""Boolean far-field mask per panel, in `PANELS` order."""
masks = []
for x0, y0, x1, y1 in panel_boxes():
height, width = y1 - y0, x1 - x0
scale = width / FRAME_W
yy, xx = np.mgrid[0:height, 0:width]
radius = np.hypot(xx - (width - 1) / 2, yy - (height - 1) / 2)
mask = radius > FAR_FIELD_RADIUS * SUN_RADIUS * scale
if (x0, y0) == (PANEL_W, 0):
mask[:TITLE_ROWS] = False
masks.append(mask)
return tuple(masks)
_MASKS = []
def _box_blur(image):
padded = np.pad(image, 1, mode="edge")
height, width = image.shape
return sum(padded[i:i + height, j:j + width]
for i in range(3) for j in range(3)) / 9.0
def _bin(image):
height = image.shape[0] // SPIKE_BIN * SPIKE_BIN
width = image.shape[1] // SPIKE_BIN * SPIKE_BIN
return image[:height, :width].reshape(
height // SPIKE_BIN, SPIKE_BIN, width // SPIKE_BIN, SPIKE_BIN).mean(axis=(1, 3))
def measure(path):
"""(far_field, noise) per panel and the binned panels for the spike test, or
None for an unreadable or wrongly sized composite."""
if not _MASKS:
_MASKS.extend(far_field_masks())
try:
with Image.open(path) as image:
if image.size != CANVAS:
return None
luminance = np.asarray(image.convert("L"), dtype=np.float32) / 255.0
except OSError:
return None
levels, binned = [], []
for (x0, y0, x1, y1), mask in zip(panel_boxes(), _MASKS):
panel = luminance[y0:y1, x0:x1]
if (x0, y0) == (PANEL_W, 0):
panel = panel.copy()
panel[:TITLE_ROWS] = 0.0 # the title is the same blank in all three
far = panel[mask]
residual = (panel - _box_blur(panel))[mask]
levels.append((float(np.percentile(far, 99)), float(residual.std())))
binned.append(_bin(panel))
return levels, binned
def spike(previous, current, following):
"""Mean out-of-line excess of `current` against its two neighbours."""
midpoint = (previous + following) / 2.0
excess = np.abs(current - midpoint) - np.abs(following - previous) / 2.0
return float(np.maximum(excess, 0.0).mean())
def scan(frames, timeline, workers=2):
"""{time: {"far_field": [...7], "noise": [...7], "spike": [...7] or None}}
for every slot of `timeline` with a readable composite."""
wanted = [when for when in timeline if when in frames]
results = {}
window = [] # last three (time, binned)
with Pool(workers) as pool:
measured = pool.imap(measure, [frames[when] for when in wanted], 8)
for when, outcome in zip(wanted, measured):
if outcome is None:
window.clear()
continue
levels, binned = outcome
results[when] = {"far_field": [level[0] for level in levels],
"noise": [level[1] for level in levels],
"spike": None}
window = (window + [(when, binned)])[-3:]
if len(window) == 3:
(t0, a), (t1, b), (t2, c) = window
if t1 - t0 == paths.CADENCE and t2 - t1 == paths.CADENCE:
results[t1]["spike"] = [spike(x, y, z) for x, y, z in zip(a, b, c)]
return results
def flag(results, limits=LIMITS):
"""Flagged frames, worst first: (time, severity, [(panel, metric, value)])."""
flagged = []
for when, values in results.items():
hits = []
for metric, limit in limits.items():
for panel, value in zip(PANELS, values[metric] or ()):
if value > limit:
hits.append((panel, metric, value))
if hits:
severity = max(value / limits[metric] for _, metric, value in hits)
flagged.append((when, severity, sorted(hits, key=lambda h: -h[2] / limits[h[1]])))
flagged.sort(key=lambda item: (-item[1], item[0]))
return flagged
def stream_name(directory):
"""'.../compare_v8/new-detection/learned/goes16' -> 'new-detection_learned_goes16'."""
parts = os.path.normpath(os.path.abspath(directory)).split(os.sep)
return "_".join(parts[-3:])
def _stamp(when):
return datetime.datetime.fromtimestamp(when, datetime.timezone.utc).strftime("%Y-%m-%d %H:%M")
def contact_sheets(flagged, frames, out_prefix, columns=4, rows=6, limit=48):
"""Thumbnails of the worst flagged frames, offending panels outlined.
Returns the paths written."""
boxes = dict(zip(PANELS, panel_boxes()))
scale = 4
tile_w, tile_h = CANVAS[0] // scale, CANVAS[1] // scale
chosen = flagged[:limit]
per_sheet = columns * rows
written = []
for start in range(0, len(chosen), per_sheet):
members = chosen[start:start + per_sheet]
sheet_rows = (len(members) + columns - 1) // columns
sheet = Image.new("RGB", (tile_w * columns, tile_h * sheet_rows))
for index, (when, severity, hits) in enumerate(members):
with Image.open(frames[when]) as image:
tile = image.convert("RGB").resize((tile_w, tile_h))
draw = ImageDraw.Draw(tile)
for panel in {hit[0] for hit in hits}:
x0, y0, x1, y1 = (v // scale for v in boxes[panel])
draw.rectangle((x0, y0, x1 - 1, y1 - 1), outline=(255, 0, 0), width=2)
panel, metric, value = hits[0]
draw.rectangle((0, tile_h - 14, tile_w, tile_h), fill=(0, 0, 0))
draw.text((3, tile_h - 13), f"{_stamp(when)} {panel} {metric} {value:.3f}",
fill=(255, 255, 0))
sheet.paste(tile, (tile_w * (index % columns), tile_h * (index // columns)))
path = f"{out_prefix}_{start // per_sheet + 1:02d}.jpg"
sheet.save(path, quality=85)
written.append(path)
return written
def report(name, results, flagged, limits=LIMITS):
"""The JSON record of one stream's scan."""
return {
"stream": name,
"limits": limits,
"frames": len(results),
"flagged": [{"time": when, "utc": _stamp(when), "severity": round(severity, 2),
"hits": [{"panel": p, "metric": m, "value": round(v, 4)}
for p, m, v in hits]}
for when, severity, hits in flagged],
}
def main(argv=None):
parser = argparse.ArgumentParser(description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter)
parser.add_argument("streams", nargs="+", help="directories of Composite-*.jpg")
parser.add_argument("--out", required=True, help="report and contact-sheet directory")
parser.add_argument("--periods", default=None,
help="comma-separated UTC date ranges START..END (end exclusive)")
parser.add_argument("--workers", type=int, default=2)
parser.add_argument("--sheet", type=int, default=48,
help="worst flagged frames per stream to put on contact sheets")
args = parser.parse_args(argv)
periods = parse_periods(args.periods) if args.periods else None
names = [stream_name(directory) for directory in args.streams]
if len(set(names)) != len(names):
raise SystemExit(f"streams must differ in their last three path parts: {names}")
os.makedirs(args.out, exist_ok=True)
summary = []
for directory, name in zip(args.streams, names):
frames = stream_frames(directory)
if not frames:
raise SystemExit(f"{directory}: no composites")
results = scan(frames, build_timeline({name: frames}, periods), args.workers)
flagged = flag(results)
with open(os.path.join(args.out, f"{name}.json"), "w") as handle:
json.dump(report(name, results, flagged), handle, indent=1)
sheets = contact_sheets(flagged, frames, os.path.join(args.out, name),
limit=args.sheet)
by_panel = {}
for _, _, hits in flagged:
for panel in {hit[0] for hit in hits}:
by_panel[panel] = by_panel.get(panel, 0) + 1
summary.append((name, len(results), len(flagged), by_panel, len(sheets)))
print(f"{name}: {len(results)} composites, {len(flagged)} flagged", flush=True)
for when, severity, hits in flagged[:10]:
panel, metric, value = hits[0]
print(f" {_stamp(when)} x{severity:.1f} {panel} {metric} {value:.3f}"
+ (f" (+{len(hits) - 1} more)" if len(hits) > 1 else ""))
print(f"\n{'stream':<40} {'composites':>10} {'flagged':>8} by panel")
for name, count, flagged, by_panel, sheets in summary:
panels = ", ".join(f"{p} {by_panel[p]}" for p in PANELS if p in by_panel)
print(f"{name:<40} {count:>10} {flagged:>8} {panels}")
print(f"\nreports and contact sheets in {args.out}")
return 0
if __name__ == "__main__":
sys.exit(main())