Resolving Panel Detection Failures in Calibration
The calibration run stops with “no panel found in frame 0004”, or — worse — completes, having found something. On inspection the detected region is a patch of bright gravel behind the operator, and every reflectance value in the survey has been scaled by its radiance.
Panel detection is a small step with outsized consequences, because its output is a single number that multiplies the entire dataset. This page covers making it robust, giving it a manual fallback, and checking that what it found is actually the panel. It is part of the diagnostic sequence in troubleshooting multispectral and thermal failures, and it feeds the measurement described in applying reflectance panel calibration in Python.
Why brightness alone is a poor detector
The standard approach thresholds on brightness and takes the largest bright region. It fails in both directions.
It misses the panel when the panel is not the brightest thing in frame. A grey panel at 0.5 reflectance photographed next to white gravel, a light-coloured vehicle, or a patch of sky in a tilted frame is not the brightest region, and a percentile threshold tuned for one site fails at the next.
It finds the wrong thing when something brighter is present. Concrete, painted lines, a reflective vest, the sky through a gap — all of them pass a brightness test and none of them has a known reflectance.
What distinguishes a panel is not its brightness but its geometry and uniformity: it is a quadrilateral of near-constant value, with sharp edges, occupying a substantial and predictable fraction of the frame. Detecting on those properties is both more reliable and easier to validate.
Figure 1 — Why the obvious detector is the wrong one, on a frame that is not unusual.
Minimal reproducible solution
import cv2
import numpy as np
def detect_panel(frame: np.ndarray, *, min_area_fraction: float = 0.02,
max_area_fraction: float = 0.5,
max_interior_cv: float = 0.06) -> dict:
"""Locate a reflectance panel by geometry and uniformity, not brightness.
Three filters in sequence: the region must be a convex quadrilateral, it
must occupy a plausible share of the frame, and its interior must be
uniform. Gravel fails the third, a vehicle the first, and the sky the
second — none of which a brightness test can distinguish.
"""
img = frame.astype(np.float32)
norm = np.clip((img - np.percentile(img, 1)) /
max(np.percentile(img, 99) - np.percentile(img, 1), 1e-6) * 255,
0, 255).astype(np.uint8)
blurred = cv2.GaussianBlur(norm, (7, 7), 0)
edges = cv2.Canny(blurred, 40, 120)
edges = cv2.dilate(edges, np.ones((3, 3), np.uint8), iterations=1)
contours, _ = cv2.findContours(edges, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
frame_area = img.size
candidates = []
for c in contours:
area = cv2.contourArea(c)
if not (min_area_fraction * frame_area < area < max_area_fraction * frame_area):
continue
approx = cv2.approxPolyDP(c, 0.03 * cv2.arcLength(c, True), True)
if len(approx) != 4 or not cv2.isContourConvex(approx):
continue
mask = np.zeros(img.shape, np.uint8)
cv2.drawContours(mask, [approx], -1, 1, cv2.FILLED)
interior = cv2.erode(mask, np.ones((21, 21), np.uint8)).astype(bool)
if interior.sum() < 500:
continue
values = img[interior]
cv = float(values.std() / max(values.mean(), 1e-6))
if cv > max_interior_cv:
continue
candidates.append({"corners": approx.reshape(-1, 2), "area": float(area),
"interior_cv": cv, "median": float(np.median(values)),
"pixels": int(interior.sum())})
if not candidates:
return {"found": False,
"note": "no uniform quadrilateral of plausible size — supply corners manually"}
best = min(candidates, key=lambda c: c["interior_cv"])
return {"found": True, **best, "candidates": len(candidates)}
Selecting the most uniform candidate rather than the largest or brightest is the choice that makes this robust. A panel is manufactured to be uniform; almost nothing else in a field scene is.
The manual fallback
Automatic detection will fail on some frames, and a pipeline that stops there costs a whole flight. A manual fallback — four corner coordinates supplied once per flight — keeps the run going.
import numpy as np
def measure_from_corners(frame: np.ndarray, corners: np.ndarray,
*, erode_px: int = 15,
keep_percentile: tuple = (20, 80)) -> dict:
"""Measure a panel from manually supplied corners, with the same guards."""
import cv2
mask = np.zeros(frame.shape, np.uint8)
cv2.fillConvexPoly(mask, corners.astype(np.int32), 1)
interior = cv2.erode(mask, np.ones((erode_px * 2 + 1,) * 2, np.uint8)).astype(bool)
values = frame[interior]
if values.size < 500:
raise ValueError("too few interior pixels; check the supplied corners")
lo, hi = np.percentile(values, keep_percentile)
core = values[(values >= lo) & (values <= hi)]
return {"radiance": float(np.median(core)), "pixels": int(core.size),
"interior_cv": float(core.std() / max(core.mean(), 1e-6)),
"source": "manual corners"}
Recording source distinguishes a frame measured automatically from one measured by hand, which matters when a calibration is later questioned.
Figure 3 — Four checks, and detection tuning helps with only one of them.
Edge-case matrix
| Situation | Symptom | Handling |
|---|---|---|
| Panel not the brightest object | Detector picks something else | Geometric detection |
| Panel too small in frame | Rejected on area | Re-shoot closer, or lower the minimum |
| Panel partly out of frame | Not a quadrilateral | Reject; use the other panel capture |
| Panel in shadow | Found, but low radiance | Uniformity passes; the reconciliation check catches it |
| Strong specular glint | Interior uniformity fails | Percentile window in the measurement |
| Panel on a textured surface | Edges unclear | Place it on a plain background |
| Multiple panels in frame | Several candidates | Take the most uniform, log the count |
| Panel saturated | Uniform and clipped | Explicit saturation check |
The saturated case deserves its own guard, because a clipped panel is perfectly uniform and passes every geometric test:
import numpy as np
def saturation_guard(values: np.ndarray, *, ceiling: int = 65000,
max_fraction: float = 0.001) -> None:
"""Reject a panel measurement containing clipped pixels."""
frac = float(np.count_nonzero(values >= ceiling) / max(values.size, 1))
if frac > max_fraction:
raise ValueError(f"{frac:.1%} of the panel interior is saturated — "
"re-shoot at a lower exposure; this measurement is unusable")
Verification snippet
import numpy as np
def validate_detection(measurement: dict, *, expected_reflectance: float,
frame_median: float) -> dict:
"""Is the detected region plausibly the panel we think it is?"""
problems = []
ratio = measurement["radiance"] / max(frame_median, 1e-6)
if measurement["interior_cv"] > 0.06:
problems.append(f"interior variation {measurement['interior_cv']:.3f} is high "
"for a manufactured panel")
if ratio < 1.2:
problems.append("the detected region is barely brighter than the scene — "
"it may not be the panel")
if ratio > 12:
problems.append("the detected region is extremely bright — possibly the sky "
"or a specular surface")
if measurement["pixels"] < 2000:
problems.append(f"only {measurement['pixels']} usable interior pixels")
return {"brightness_ratio": float(ratio), "problems": problems,
"usable": not problems,
"expected_reflectance": expected_reflectance}
The brightness ratio is a sanity check rather than a detector, and that distinction is the point: brightness is poor at finding a panel and perfectly good at confirming that a geometrically detected region is plausible.
Figure 2 — One number separates the panel from every other candidate in a typical frame.
Making detection unnecessary
The most reliable panel detection is the one that has almost nothing to do. Three capture habits reduce the problem to a formality.
Flag the panel frames at capture. Most rigs record a marker, or the operator can note the frame numbers. Searching two known frames instead of a whole flight removes every false positive from the rest of the survey at a stroke.
Fill a good share of the frame. A panel occupying two percent of the image has a small interior after erosion and is easily confused; one occupying a fifth is unmistakable to any detector. Moving closer costs nothing.
Use a plain background. A panel placed on short grass or a clean apron has sharp, complete edges; one placed on gravel or in long vegetation has broken edges that defeat contour detection. A folded groundsheet solves it permanently.
None of these needs new equipment, and together they turn panel detection from a recurring failure into a step that has never failed.
When to escalate
- Detection fails on every frame of a flight. The panel was probably not photographed, or was photographed too small to use. Check the capture before adjusting thresholds.
- A detected panel passes every check and the reflectance still comes out wrong. Compare the two panel captures against each other; a shadowed one passes uniformity and reconciliation catches it.
- The site has a large uniform bright surface. A white membrane roof can beat a panel on every geometric test. Constrain the search to frames flagged as panel captures rather than searching the whole flight.