Files
ball_game/calibration.py
2026-10-31 23:04:47 +03:30

1580 lines
45 KiB
Python

# calibration.py
import math
import cv2
import numpy as np
import imaging
from config import (
SCREEN_WIDTH,
SCREEN_HEIGHT,
CALIBRATION_TARGET_HUE_DEGREES,
CALIBRATION_TARGET_HUE_TOLERANCE_DEGREES,
CALIBRATION_MIN_TARGET_SATURATION,
CALIBRATION_MIN_TARGET_VALUE,
CALIBRATION_ADAPTIVE_HUE_ENABLED,
CALIBRATION_ADAPTIVE_HUE_BLEND,
CALIBRATION_ADAPTIVE_MAX_DRIFT_DEGREES,
CALIBRATION_WHITE_ADAPTIVE_OFFSET,
CALIBRATION_WHITE_MIN_ABSOLUTE_LIGHTNESS,
CALIBRATION_WHITE_MAX_GREEN_DOMINANCE,
CALIBRATION_WHITE_REFERENCE_BLEND,
CALIBRATION_WHITE_REFERENCE_MAX_OFFSET,
CALIBRATION_MIN_SCREEN_AREA_RATIO,
CALIBRATION_MAX_SCREEN_AREA_RATIO,
CALIBRATION_MIN_OUTER_TARGET_RATIO,
CALIBRATION_MIN_INNER_WHITE_RATIO,
CALIBRATION_MIN_FULL_TARGET_RATIO,
CALIBRATION_MIN_QUALITY,
CALIBRATION_REQUIRED_STABLE_FRAMES,
CALIBRATION_MAX_CORNER_JUMP_RATIO,
CALIBRATION_SMOOTHING_ALPHA,
CALIBRATION_MASK_CLOSE_KERNEL,
CALIBRATION_OUTER_BORDER_SIZE,
CALIBRATION_INNER_BORDER_SIZE,
)
class Calibration:
"""
Detects the monitor in two phases, matching what Renderer draws
in each:
1. NOT YET CALIBRATED: the whole screen is filled solid pink
(Renderer.draw_full_calibration_screen). We just look for
the single largest, best-shaped, (almost) entirely-pink
quad in the camera frame - the simplest, most unambiguous
target possible for the very first lock-on.
2. CALIBRATED (locked on): the screen instead shows a
saturated green outer ring with a white inner ring around
a black interior (Renderer.draw_calibration_border), drawn
every frame during gameplay so the system can keep quietly
re-verifying the monitor's position without interrupting
play, while leaving the black interior free for the ball
and the detected obstacles.
Compared to the previous four-marker approach this is far more
robust in a busy, noisy exhibition environment:
- it's one large, high-contrast shape instead of four tiny
independent ones, so a person briefly walking in front of
part of the screen doesn't break detection the way losing
one out of four small markers would;
- requiring BOTH the green AND white ring to be present, in
the right relative position, makes it very unlikely a
random green/white object or a reflection in the room gets
mistaken for the screen;
- once locked, calibration is only re-verified/refined by
new stable detections and is never silently dropped just
because a frame or two failed - it only clears on an
explicit reset() call (bound to the "C" key in main.py).
Color detection is NOT a single fixed HSV threshold (see
config.py's "Target-color detection" section for the full
reasoning). In short: a loose, wide hue pre-filter rules out
everything nowhere near green, and a per-frame Otsu threshold
on green-dominance (G compared to R and B) then finds the exact
cutoff that best separates "clearly green" from "not" IN THE
CURRENT frame - so it keeps adapting to the room's lighting
instead of needing a fixed brightness/saturation number that
only ever matched the lighting at setup time. On top of that,
once locked on, the system slowly learns the actual hue it is
seeing and re-centers the pre-filter on it, bounded so it can
never drift onto some unrelated color.
Public API is intentionally identical to the previous
marker-based Calibration class, so main.py needs no changes
beyond how the pattern itself is drawn.
"""
def __init__(self):
self.matrix = None
self.history = []
self.hue_history = []
self.previous_points = None
self.reference_points = None
# What hue (0-360 degrees) the camera is ACTUALLY showing
# for the calibration green right now, learned on the fly.
# None until the first successful lock, and never reset by
# reset() below - a "C" key reset is about re-finding the
# screen's position, not about the room's lighting having
# changed, so there is no reason to throw this away with it.
self._learned_hue_center = None
# What the camera ACTUALLY records for the white inner ring
# right now, as a (a*, b*) offset from LAB neutral (128,
# 128) - i.e. exactly what a photographer gets by custom
# white-balancing off a gray card, except sampled
# continuously off the ring that is drawn every single
# frame anyway. detection.py uses this to judge how "white"
# a candidate sticker really is relative to what white
# currently looks like to this camera, instead of relative
# to an absolute assumption that colored ambient light can
# never actually hold true. Also never cleared by reset().
self._white_reference_ab = None
self.failed_frame_count = 0
# ========================================================
# Public API
# ========================================================
def is_calibrated(self):
return (
self.matrix is not None
and np.isfinite(self.matrix).all()
)
def reset(self):
self.matrix = None
self.history = []
self.hue_history = []
self.previous_points = None
self.reference_points = None
self.failed_frame_count = 0
def update(self, frame):
if frame is None:
return self.is_calibrated()
if frame.ndim != 3:
return self.is_calibrated()
# Before we're locked on, the screen is filled solid pink
# (Renderer.draw_full_calibration_screen) - look for one
# big pink quad. Once locked on, the screen instead shows
# the pink/white border ring around a black interior
# (Renderer.draw_calibration_border) - look for that
# specific pattern instead, which also lets us tell a real
# match from a random pink object/reflection in the room.
if self.is_calibrated():
candidate = self._find_best_border_candidate(frame)
else:
candidate = self._find_best_full_screen_candidate(
frame
)
if candidate is None:
self._handle_failed_detection()
return self.is_calibrated()
self.failed_frame_count = 0
self._update_white_reference(candidate.get("white_ab"))
points = candidate["points"]
hue = candidate.get("hue_degrees")
if self._stable_against_previous(
points,
frame.shape,
):
self.history.append(points)
self.hue_history.append(hue)
if (
len(self.history)
> CALIBRATION_REQUIRED_STABLE_FRAMES
):
self.history.pop(0)
self.hue_history.pop(0)
else:
self.history = [points]
self.hue_history = [hue]
self.previous_points = points.copy()
if (
len(self.history)
>= CALIBRATION_REQUIRED_STABLE_FRAMES
):
self._accept_or_refine(frame.shape)
return self.is_calibrated()
def transform_frame(self, frame, scale=1.0):
"""
Warp the camera frame into screen space. With scale < 1 the
result is a proportionally smaller (SCREEN_WIDTH*scale by
SCREEN_HEIGHT*scale) image of the same screen - the warp is
composed with the scaling, so it's a single cheap pass and
much faster than warping full size and shrinking after.
"""
if not self.is_calibrated():
raise RuntimeError(
"Calibration has not been completed."
)
matrix = self.matrix
if scale != 1.0:
matrix = (
np.diag([scale, scale, 1.0]) @ matrix
)
return cv2.warpPerspective(
frame,
matrix,
(
int(round(SCREEN_WIDTH * scale)),
int(round(SCREEN_HEIGHT * scale)),
),
flags=cv2.INTER_LINEAR,
borderMode=cv2.BORDER_CONSTANT,
borderValue=(0, 0, 0),
)
def transform_point(self, x, y):
if not self.is_calibrated():
raise RuntimeError(
"Calibration has not been completed."
)
point = np.float32(
[[[x, y]]]
)
transformed = cv2.perspectiveTransform(
point,
self.matrix,
)
return tuple(transformed[0][0])
# ========================================================
# Candidate detection
# ========================================================
def _find_best_border_candidate(self, frame):
hsv = cv2.cvtColor(
frame,
cv2.COLOR_BGR2HSV,
)
lightness, a_channel, b_channel = imaging.to_lab(frame)
target_mask = self._make_target_mask(frame, hsv)
white_mask = self._make_white_mask(frame)
contours, _ = cv2.findContours(
target_mask,
cv2.RETR_EXTERNAL,
cv2.CHAIN_APPROX_SIMPLE,
)
outer_depth = float(CALIBRATION_OUTER_BORDER_SIZE)
inner_depth = float(
CALIBRATION_OUTER_BORDER_SIZE
+ CALIBRATION_INNER_BORDER_SIZE
)
best = None
for contour in contours:
quad = self._quad_from_contour(
contour,
frame.shape,
)
if quad is None:
continue
quality = self._quad_quality(
quad,
frame.shape,
)
if quality <= 0:
continue
target_ratio = self._band_ratio(
target_mask,
quad,
0.0,
outer_depth,
)
if (
target_ratio
< CALIBRATION_MIN_OUTER_TARGET_RATIO
):
continue
white_ratio = self._band_ratio(
white_mask,
quad,
outer_depth,
inner_depth,
)
if (
white_ratio
< CALIBRATION_MIN_INNER_WHITE_RATIO
):
continue
score = (
quality * 0.45
+ target_ratio * 0.30
+ white_ratio * 0.25
)
if score < CALIBRATION_MIN_QUALITY:
continue
if (
best is None
or score > best["score"]
):
best = {
"points": quad,
"score": float(score),
"quality": float(quality),
"target_ratio": float(target_ratio),
"white_ratio": float(white_ratio),
"hue_degrees": self._band_mean_hue(
hsv,
target_mask,
quad,
0.0,
outer_depth,
),
"white_ab": self._band_mean_ab(
a_channel,
b_channel,
white_mask,
quad,
outer_depth,
inner_depth,
),
}
return best
def _find_best_full_screen_candidate(self, frame):
"""
Used only before we're locked on, while the monitor is
showing one solid green fill (no white ring, no black
interior). We just need the largest, best-shaped green quad
whose own interior is (almost) entirely green - there's no
separate inner-ring check needed since the "ring" is the
whole screen.
"""
hsv = cv2.cvtColor(
frame,
cv2.COLOR_BGR2HSV,
)
target_mask = self._make_target_mask(frame, hsv)
contours, _ = cv2.findContours(
target_mask,
cv2.RETR_EXTERNAL,
cv2.CHAIN_APPROX_SIMPLE,
)
best = None
for contour in contours:
quad = self._quad_from_contour(
contour,
frame.shape,
)
if quad is None:
continue
quality = self._quad_quality(
quad,
frame.shape,
)
if quality <= 0:
continue
fill_ratio = self._full_quad_ratio(
target_mask,
quad,
)
if (
fill_ratio
< CALIBRATION_MIN_FULL_TARGET_RATIO
):
continue
score = (
quality * 0.5
+ fill_ratio * 0.5
)
if score < CALIBRATION_MIN_QUALITY:
continue
if (
best is None
or score > best["score"]
):
best = {
"points": quad,
"score": float(score),
"quality": float(quality),
"target_ratio": float(fill_ratio),
"white_ratio": 1.0,
"hue_degrees": self._quad_mean_hue(
hsv,
target_mask,
quad,
),
}
return best
def _quad_from_contour(
self,
contour,
frame_shape,
):
frame_area = (
frame_shape[0]
* frame_shape[1]
)
area = cv2.contourArea(contour)
if area <= 0:
return None
area_ratio = area / frame_area
if area_ratio < CALIBRATION_MIN_SCREEN_AREA_RATIO:
return None
if area_ratio > CALIBRATION_MAX_SCREEN_AREA_RATIO:
return None
perimeter = cv2.arcLength(
contour,
True,
)
if perimeter <= 0:
return None
for epsilon_ratio in (
0.005,
0.008,
0.012,
0.018,
0.025,
0.035,
):
approx = cv2.approxPolyDP(
contour,
epsilon_ratio * perimeter,
True,
)
if len(approx) != 4:
continue
points = approx.reshape(
4, 2
).astype(np.float32)
if not cv2.isContourConvex(
points.reshape((-1, 1, 2))
):
continue
return self._order_points(points)
return None
# ========================================================
# Color masks
# ========================================================
def _make_target_mask(self, frame_bgr, hsv):
"""
Finds the calibration green robustly under unpredictable,
possibly-shifting exhibition lighting, by combining a loose
hue pre-filter with a per-frame auto-threshold on a
brightness-INVARIANT color signal, rather than one fixed
HSV box (see config.py's "Target-color detection" section
and the class docstring for the full reasoning).
"""
pre_filter = self._make_hue_prefilter_mask(hsv)
if cv2.countNonZero(pre_filter) < 64:
# Nothing even loosely green-ish in view - there is no
# meaningful split for Otsu to find, so don't ask it to
# invent one out of noise.
return self._clean_mask(pre_filter)
# The "how green is this pixel" signal is measured on LAB's
# a* axis (green-red), NOT by comparing raw R/G/B levels.
# LAB deliberately separates brightness (L) from true color
# (a, b), so a* barely moves when a glare highlight, a
# shadow, or the camera's own auto-exposure/white-balance
# changes how BRIGHT a pixel looks - which is most of what
# "the camera doesn't quite see what the monitor shows"
# actually is. Comparing raw channel levels (as a plain
# "G minus R/B" measure would) conflates color with
# brightness and is far more easily fooled by exactly that.
_lightness, a_channel, _b_channel = imaging.to_lab(
frame_bgr
)
dominance = imaging.green_dominance(a_channel)
# Otsu needs to see BOTH classes to find a meaningful split
# point - "pattern green" and "everything else" - so it is
# run over the WHOLE frame's dominance histogram (that is
# genuinely bimodal: a bright green cluster from the
# pattern vs. a large, much less green-dominant cluster for
# the room/people/background). Restricting it to only the
# pixels the loose pre-filter already approved would feed
# Otsu an almost-uniform population with no real second
# class to split against, which produces a meaningless
# threshold instead of an adaptive one.
threshold, _ = cv2.threshold(
dominance,
0,
255,
cv2.THRESH_BINARY + cv2.THRESH_OTSU,
)
strong = cv2.inRange(
dominance,
int(threshold),
255,
)
# The hue pre-filter is then applied on top as a precision
# pass: it rules out anything that passed the brightness-
# based Otsu split but isn't actually the right hue (e.g. a
# bright white highlight, which has near-zero dominance
# anyway, or a saturated cyan/yellow object, which does
# not).
mask = cv2.bitwise_and(pre_filter, strong)
mask = self._clean_mask(mask)
# A specular glare spot on the monitor's glass can punch a
# small hole clean through the middle of the pattern, or
# even a thin streak that splits it into two separate
# blobs. Filling any fully-enclosed hole (see
# imaging.fill_holes) repairs exactly that without touching
# a real gap caused by, say, a person actually standing in
# front of part of the screen (which stays open to the
# mask's edge and so is left alone).
return imaging.fill_holes(mask)
def _make_hue_prefilter_mask(self, hsv):
center = self._effective_hue_center()
mask = np.zeros(
hsv.shape[:2],
dtype=np.uint8,
)
for lower_h, upper_h in self._hue_ranges(
center,
CALIBRATION_TARGET_HUE_TOLERANCE_DEGREES,
):
current = cv2.inRange(
hsv,
np.array(
[
lower_h,
CALIBRATION_MIN_TARGET_SATURATION,
CALIBRATION_MIN_TARGET_VALUE,
],
dtype=np.uint8,
),
np.array(
[upper_h, 255, 255],
dtype=np.uint8,
),
)
mask = cv2.bitwise_or(mask, current)
return mask
@staticmethod
def _hue_ranges(center_degrees, tolerance_degrees):
"""
A (center +/- tolerance) window in standard 0-360 degree
hue, converted to OpenCV's 0-179 H channel and split into
one or two (lower, upper) ranges so a window that wraps
past 0/360 (relevant for colors near red, not for the green
this is tuned for, but kept general) still works correctly.
"""
raw_low = center_degrees - tolerance_degrees
raw_high = center_degrees + tolerance_degrees
if raw_low < 0.0:
spans = [
(0.0, raw_high),
(raw_low + 360.0, 360.0),
]
elif raw_high > 360.0:
spans = [
(raw_low, 360.0),
(0.0, raw_high - 360.0),
]
else:
spans = [(raw_low, raw_high)]
return [
(
int(round(lo / 2.0)),
int(round(hi / 2.0)),
)
for lo, hi in spans
]
def _make_white_mask(self, frame_bgr):
"""
Finds the white inner ring the same way detection.py finds
white stickers (see its module docstring): LOCALLY adaptive
brightness rather than one fixed V floor for the whole frame
(so a ring sitting in a dimmer part of the hall is still
found), and LAB chroma rather than HSV saturation for "is
this actually neutral" (stable across the whole brightness
range, unlike HSV's saturation ratio). A fixed absolute HSV
box here was exactly as fragile under real exhibition
lighting as the old fixed box was for stickers.
"""
lightness, a_channel, _b_channel = imaging.to_lab(
frame_bgr
)
block = self._white_ring_adaptive_block_size(
lightness.shape
)
local = cv2.adaptiveThreshold(
lightness,
255,
cv2.ADAPTIVE_THRESH_GAUSSIAN_C,
cv2.THRESH_BINARY,
block,
-CALIBRATION_WHITE_ADAPTIVE_OFFSET,
)
mask = local & cv2.inRange(
lightness,
CALIBRATION_WHITE_MIN_ABSOLUTE_LIGHTNESS,
255,
)
# The only other bright thing near the white ring is the
# outer green ring itself, bleeding in right at the band
# boundary - so it is enough to exclude anything clearly
# GREEN-dominant (one-sided), rather than pinning an exact
# "how white" chroma number. A strong ambient color cast can
# push even genuinely white paper's overall chroma quite
# far (see config.py's comment), but it does not make white
# look GREEN - a one-sided test stays reliable under warm,
# cool, or any other cast a two-sided chroma ceiling cannot
# universally cover.
mask &= cv2.inRange(
imaging.green_dominance(a_channel),
0,
CALIBRATION_WHITE_MAX_GREEN_DOMINANCE,
)
kernel = cv2.getStructuringElement(
cv2.MORPH_RECT,
(9, 9),
)
return cv2.morphologyEx(
mask,
cv2.MORPH_CLOSE,
kernel,
)
@staticmethod
def _white_ring_adaptive_block_size(shape):
# Tied to the frame's own size (calibration has no learned
# "ring thickness" the way detection.py learns sticker
# size), large enough that the sampled neighborhood is
# mostly the ring's dark/green surroundings rather than the
# ring's own pixels.
size = int(round(min(shape[:2]) * 0.05))
size = max(15, size)
if size % 2 == 0:
size += 1
return size
def _clean_mask(self, mask):
open_kernel = cv2.getStructuringElement(
cv2.MORPH_RECT,
(5, 5),
)
close_kernel = cv2.getStructuringElement(
cv2.MORPH_RECT,
(
CALIBRATION_MASK_CLOSE_KERNEL,
CALIBRATION_MASK_CLOSE_KERNEL,
),
)
mask = cv2.morphologyEx(
mask,
cv2.MORPH_OPEN,
open_kernel,
)
mask = cv2.morphologyEx(
mask,
cv2.MORPH_CLOSE,
close_kernel,
)
return mask
# ========================================================
# Full-quad sampling (used during the solid-pink full-screen
# calibration phase, before we're locked on)
# ========================================================
def _full_quad_ratio(self, mask, points):
polygon = points.astype(np.int32)
fill = np.zeros_like(mask)
cv2.fillPoly(
fill,
[polygon],
255,
)
total = cv2.countNonZero(fill)
if total == 0:
return 0.0
inside = cv2.bitwise_and(
mask,
fill,
)
return cv2.countNonZero(inside) / total
# ========================================================
# Band sampling (used to confirm the pink outer / white inner
# ring at the right relative depth, instead of trusting the
# outer pink contour alone)
# ========================================================
# points is always [top_left, top_right, bottom_right,
# bottom_left] (see _order_points), so edge 0 (top) and edge 2
# (bottom) run the screen's WIDTH and edge 1 (right) and edge 3
# (left) run its HEIGHT. An absolute screen-space pixel depth
# (e.g. CALIBRATION_OUTER_BORDER_SIZE) converts to that edge's
# own camera-space pixels via (edge's camera length / that
# edge's TRUE screen-space length) - using the same reference
# (SCREEN_WIDTH) for every edge, as if top/bottom and left/right
# were interchangeable, silently undersizes the left/right band
# whenever the screen isn't exactly square, which in practice is
# always: a 1920x1080 screen's left/right edges are only
# 1080/1920 as long as its top/bottom edges, so a band meant to
# be the same physical width on every side would come out
# visibly too shallow on the sides, easily shallow enough to
# land entirely short of where the white ring actually starts.
_EDGE_REFERENCE_LENGTH = (
SCREEN_WIDTH,
SCREEN_HEIGHT,
SCREEN_WIDTH,
SCREEN_HEIGHT,
)
def _band_ratio(
self,
mask,
points,
depth_start_px,
depth_end_px,
):
center = np.mean(points, axis=0)
values = []
for i in range(4):
p1 = points[i]
p2 = points[(i + 1) % 4]
edge = p2 - p1
length = np.linalg.norm(edge)
if length < 1:
continue
reference = self._EDGE_REFERENCE_LENGTH[i]
depth_start = (
depth_start_px * length / reference
)
depth_end = (
depth_end_px * length / reference
)
if depth_end <= depth_start:
continue
normal = np.array(
[-edge[1], edge[0]],
dtype=np.float32,
)
normal_length = np.linalg.norm(normal)
if normal_length < 1e-6:
continue
normal /= normal_length
midpoint = (p1 + p2) * 0.5
if (
np.dot(normal, center - midpoint)
< 0
):
normal = -normal
a = p1 + normal * depth_start
b = p2 + normal * depth_start
c = p2 + normal * depth_end
d = p1 + normal * depth_end
polygon = np.array(
[a, b, c, d],
dtype=np.int32,
)
band = np.zeros_like(mask)
cv2.fillPoly(
band,
[polygon],
255,
)
total = cv2.countNonZero(band)
if total == 0:
continue
inside = cv2.bitwise_and(
mask,
band,
)
values.append(
cv2.countNonZero(inside) / total
)
if not values:
return 0.0
return float(np.mean(values))
def _band_polygon_mask(
self,
points,
depth_start_px,
depth_end_px,
shape,
):
"""
Same ring-band geometry as _band_ratio above (including the
same per-edge reference-length correction), but returned as
a single combined mask (union of the 4 edge trapezoids)
instead of an averaged ratio - used for pooling pixels
together (e.g. to measure their mean hue) rather than just
counting them.
"""
center = np.mean(points, axis=0)
mask = np.zeros(shape, dtype=np.uint8)
drew_any = False
for i in range(4):
p1 = points[i]
p2 = points[(i + 1) % 4]
edge = p2 - p1
length = np.linalg.norm(edge)
if length < 1:
continue
reference = self._EDGE_REFERENCE_LENGTH[i]
depth_start = (
depth_start_px * length / reference
)
depth_end = (
depth_end_px * length / reference
)
if depth_end <= depth_start:
continue
normal = np.array(
[-edge[1], edge[0]],
dtype=np.float32,
)
normal_length = np.linalg.norm(normal)
if normal_length < 1e-6:
continue
normal /= normal_length
midpoint = (p1 + p2) * 0.5
if (
np.dot(normal, center - midpoint)
< 0
):
normal = -normal
a = p1 + normal * depth_start
b = p2 + normal * depth_start
c = p2 + normal * depth_end
d = p1 + normal * depth_end
polygon = np.array(
[a, b, c, d],
dtype=np.int32,
)
cv2.fillPoly(
mask,
[polygon],
255,
)
drew_any = True
return mask if drew_any else None
def _band_mean_hue(
self,
hsv,
color_mask,
points,
depth_start_px,
depth_end_px,
):
"""
Mean hue (0-360 degrees) of the pixels that are BOTH inside
the given ring band AND already matched by color_mask - the
measurement used to learn what hue the camera is actually
recording for the calibration green right now (see
Calibration._update_learned_hue).
"""
band = self._band_polygon_mask(
points,
depth_start_px,
depth_end_px,
hsv.shape[:2],
)
if band is None:
return None
return self._masked_mean_hue(
hsv,
cv2.bitwise_and(color_mask, band),
)
def _quad_mean_hue(self, hsv, color_mask, points):
polygon = points.astype(np.int32)
fill = np.zeros(hsv.shape[:2], dtype=np.uint8)
cv2.fillPoly(fill, [polygon], 255)
return self._masked_mean_hue(
hsv,
cv2.bitwise_and(color_mask, fill),
)
def _band_mean_ab(
self,
a_channel,
b_channel,
color_mask,
points,
depth_start_px,
depth_end_px,
):
"""
Mean LAB (a*, b*) of the pixels that are BOTH inside the
given ring band AND already matched by color_mask - used to
learn what the camera currently records for a KNOWN-white
surface (see white_reference / _update_white_reference).
"""
band = self._band_polygon_mask(
points,
depth_start_px,
depth_end_px,
a_channel.shape[:2],
)
if band is None:
return None
combined = cv2.bitwise_and(color_mask, band)
if cv2.countNonZero(combined) < 32:
return None
mean_a = cv2.mean(a_channel, mask=combined)[0]
mean_b = cv2.mean(b_channel, mask=combined)[0]
return float(mean_a), float(mean_b)
@staticmethod
def _masked_mean_hue(hsv, mask):
# A minimum pixel count keeps a tiny sliver of matched
# pixels (e.g. a stray reflection) from producing a
# confident-looking but meaningless hue measurement.
if cv2.countNonZero(mask) < 32:
return None
mean_hue_cv_units = cv2.mean(
hsv[:, :, 0],
mask=mask,
)[0]
return float(mean_hue_cv_units) * 2.0
# ========================================================
# Live white reference ("gray card" for the sticker detector)
# ========================================================
def white_reference(self):
"""
(a*, b*) in LAB's 0-255 encoding (128 = neutral) that the
camera is CURRENTLY recording for the calibration ring's
white band, or None before enough samples have come in.
detection.py measures how "white" a candidate sticker is
relative to this, instead of relative to absolute LAB
neutral - exactly like a photographer custom white-balancing
off a gray card, except this one is sampled continuously off
the ring that is drawn on the monitor every single frame
anyway, so it tracks the room's actual current lighting
automatically rather than needing to be re-tuned on-site.
"""
return self._white_reference_ab
def _update_white_reference(self, measured_ab):
if measured_ab is None:
return
measured_a, measured_b = measured_ab
if self._white_reference_ab is None:
self._white_reference_ab = (measured_a, measured_b)
return
current_a, current_b = self._white_reference_ab
blended_a = (
current_a
+ CALIBRATION_WHITE_REFERENCE_BLEND
* (measured_a - current_a)
)
blended_b = (
current_b
+ CALIBRATION_WHITE_REFERENCE_BLEND
* (measured_b - current_b)
)
# A real color cast from room lighting is a gentle, physical
# effect - it should never push "white" wildly far from
# actual neutral. Clamping the offset's magnitude keeps a
# run of bad samples (someone's bright shirt briefly
# overlapping the band, say) from teaching the sticker
# detector a wildly wrong idea of white.
offset_a = blended_a - 128.0
offset_b = blended_b - 128.0
magnitude = math.hypot(offset_a, offset_b)
limit = CALIBRATION_WHITE_REFERENCE_MAX_OFFSET
if magnitude > limit:
scale = limit / magnitude
offset_a *= scale
offset_b *= scale
self._white_reference_ab = (
128.0 + offset_a,
128.0 + offset_b,
)
# ========================================================
# Hue learning
# ========================================================
#
# Slowly re-centers the loose hue pre-filter on whatever hue
# the camera is ACTUALLY recording for the calibration green,
# instead of forever trusting the single fixed
# CALIBRATION_TARGET_HUE_DEGREES value from config.py. This is
# what lets the system track a slow lighting change over a long
# show without needing to be re-tuned on-site - while
# ADAPTIVE_MAX_DRIFT_DEGREES keeps it from ever wandering far
# enough to start matching some unrelated color.
def _effective_hue_center(self):
if (
CALIBRATION_ADAPTIVE_HUE_ENABLED
and self._learned_hue_center is not None
):
return self._learned_hue_center
return CALIBRATION_TARGET_HUE_DEGREES
def _update_learned_hue(self, measured_hue_degrees):
if measured_hue_degrees is None:
return
if not CALIBRATION_ADAPTIVE_HUE_ENABLED:
return
current = (
self._learned_hue_center
if self._learned_hue_center is not None
else CALIBRATION_TARGET_HUE_DEGREES
)
blended = self._circular_blend_degrees(
current,
measured_hue_degrees,
CALIBRATION_ADAPTIVE_HUE_BLEND,
period=360.0,
)
drift = self._circular_diff_degrees(
blended,
CALIBRATION_TARGET_HUE_DEGREES,
period=360.0,
)
max_drift = CALIBRATION_ADAPTIVE_MAX_DRIFT_DEGREES
if abs(drift) > max_drift:
blended = (
CALIBRATION_TARGET_HUE_DEGREES
+ math.copysign(max_drift, drift)
) % 360.0
self._learned_hue_center = blended
@staticmethod
def _representative_hue(samples):
"""
Circular mean (vector average, not a plain arithmetic mean
- hue wraps around, so naively averaging e.g. 350 degrees
and 10 degrees must give 0, not 180) of a list of hue
samples in degrees, skipping any that are None. Returns
None if nothing usable is in the list.
"""
valid = [
value
for value in samples
if value is not None
]
if not valid:
return None
radians = np.radians(valid)
mean_sin = float(np.mean(np.sin(radians)))
mean_cos = float(np.mean(np.cos(radians)))
return float(
np.degrees(
np.arctan2(mean_sin, mean_cos)
)
% 360.0
)
@staticmethod
def _circular_diff_degrees(a, b, period):
"""Shortest signed a-b distance on a circle of this period."""
return (
(a - b + period / 2.0) % period
) - period / 2.0
@classmethod
def _circular_blend_degrees(cls, old, new, alpha, period):
diff = cls._circular_diff_degrees(
new,
old,
period,
)
return (old + alpha * diff) % period
# ========================================================
# Geometry / quality scoring
# ========================================================
def _quad_quality(
self,
points,
frame_shape,
):
height, width = frame_shape[:2]
frame_area = width * height
area = self._polygon_area(points)
area_ratio = area / frame_area
if area_ratio < CALIBRATION_MIN_SCREEN_AREA_RATIO:
return 0.0
if area_ratio > CALIBRATION_MAX_SCREEN_AREA_RATIO:
return 0.0
contour = points.reshape(
(-1, 1, 2)
).astype(np.float32)
if not cv2.isContourConvex(contour):
return 0.0
sides = self._side_lengths(points)
if min(sides) < 50:
return 0.0
top_bottom = (
min(sides[0], sides[2])
/ max(sides[0], sides[2])
)
left_right = (
min(sides[1], sides[3])
/ max(sides[1], sides[3])
)
side_quality = (
top_bottom + left_right
) / 2.0
angle_quality = 1.0
for i in range(4):
p1 = points[i - 1]
p2 = points[i]
p3 = points[(i + 1) % 4]
v1 = p1 - p2
v2 = p3 - p2
n1 = np.linalg.norm(v1)
n2 = np.linalg.norm(v2)
if n1 < 1e-6 or n2 < 1e-6:
return 0.0
cos_angle = np.clip(
np.dot(v1, v2) / (n1 * n2),
-1.0,
1.0,
)
angle = np.degrees(
np.arccos(cos_angle)
)
if angle < 45 or angle > 135:
angle_quality *= 0.3
elif angle < 60 or angle > 120:
angle_quality *= 0.7
area_quality = min(
area_ratio / 0.40,
1.0,
)
return float(
area_quality * 0.35
+ side_quality * 0.35
+ angle_quality * 0.30
)
@staticmethod
def _polygon_area(points):
return abs(
cv2.contourArea(
points.astype(np.float32)
)
)
@staticmethod
def _side_lengths(points):
return [
np.linalg.norm(points[1] - points[0]),
np.linalg.norm(points[2] - points[1]),
np.linalg.norm(points[3] - points[2]),
np.linalg.norm(points[0] - points[3]),
]
@staticmethod
def _order_points(points):
points = np.asarray(
points,
dtype=np.float32,
)
s = points.sum(axis=1)
d = np.diff(points, axis=1).reshape(-1)
top_left = points[np.argmin(s)]
top_right = points[np.argmin(d)]
bottom_right = points[np.argmax(s)]
bottom_left = points[np.argmax(d)]
return np.array(
[top_left, top_right, bottom_right, bottom_left],
dtype=np.float32,
)
# ========================================================
# Temporal stability
# ========================================================
def _stable_against_previous(
self,
points,
frame_shape,
):
if self.previous_points is None:
return True
height, width = frame_shape[:2]
diagonal = np.sqrt(
width * width + height * height
)
distance = self._corners_distance(
points,
self.previous_points,
)
return (
distance
<= diagonal * CALIBRATION_MAX_CORNER_JUMP_RATIO
)
@staticmethod
def _corners_distance(a, b):
return float(
np.mean(
np.linalg.norm(a - b, axis=1)
)
)
# ========================================================
# Accepting / refining calibration
# ========================================================
def _accept_or_refine(self, frame_shape):
final_points = self._order_points(
np.median(
np.stack(self.history, axis=0),
axis=0,
)
)
quality = self._quad_quality(
final_points,
frame_shape,
)
if quality < CALIBRATION_MIN_QUALITY:
return
area_ratio = self._polygon_area(
final_points
) / (frame_shape[0] * frame_shape[1])
if area_ratio < CALIBRATION_MIN_SCREEN_AREA_RATIO:
return
if self.reference_points is not None:
final_points = self._smooth(
self.reference_points,
final_points,
)
matrix = self._calculate_matrix(
final_points
)
if matrix is None:
return
self.matrix = matrix
self.reference_points = final_points
self._update_learned_hue(
self._representative_hue(self.hue_history)
)
@staticmethod
def _smooth(old_points, new_points):
alpha = CALIBRATION_SMOOTHING_ALPHA
return (
old_points * (1.0 - alpha)
+ new_points * alpha
).astype(np.float32)
def _calculate_matrix(self, points):
destination = np.float32(
[
[0, 0],
[SCREEN_WIDTH, 0],
[SCREEN_WIDTH, SCREEN_HEIGHT],
[0, SCREEN_HEIGHT],
]
)
try:
matrix = cv2.getPerspectiveTransform(
points.astype(np.float32),
destination,
)
except cv2.error:
return None
if matrix is None:
return None
if not np.isfinite(matrix).all():
return None
return matrix
# ========================================================
# Failure handling
# ========================================================
def _handle_failed_detection(self):
self.failed_frame_count += 1
self.history = []
self.hue_history = []
self.previous_points = None
# ========================================================
# Debugging
# ========================================================
def debug_draw(self, frame):
"""
Returns an annotated copy of `frame` showing the best
currently-detected candidate (if any), its quality/color
scores, the hue center currently being searched around
(handy for seeing the adaptive learning track the room's
actual lighting), and whether calibration is locked. Useful
for tuning config.py's hue/saturation/value floors on-site
against your real camera and lighting.
"""
output = frame.copy()
if self.is_calibrated():
candidate = self._find_best_border_candidate(frame)
else:
candidate = self._find_best_full_screen_candidate(
frame
)
if candidate is not None:
points = candidate["points"].astype(
np.int32
)
cv2.polylines(
output,
[points.reshape((-1, 1, 2))],
True,
(0, 255, 0),
3,
)
text = (
f"quality={candidate['quality']*100:.0f}% "
f"green={candidate['target_ratio']*100:.0f}% "
f"white={candidate['white_ratio']*100:.0f}% "
f"stable={len(self.history)}/"
f"{CALIBRATION_REQUIRED_STABLE_FRAMES}"
)
else:
text = "searching..."
status = (
"CALIBRATED"
if self.is_calibrated()
else "NOT CALIBRATED"
)
hue_line = (
f"hue center ~{self._effective_hue_center():.0f} deg "
f"(anchor {CALIBRATION_TARGET_HUE_DEGREES} deg)"
)
cv2.putText(
output,
f"{status} | {text}",
(20, 30),
cv2.FONT_HERSHEY_SIMPLEX,
0.7,
(0, 255, 0),
2,
cv2.LINE_AA,
)
cv2.putText(
output,
hue_line,
(20, 58),
cv2.FONT_HERSHEY_SIMPLEX,
0.6,
(0, 255, 0),
2,
cv2.LINE_AA,
)
return output