1580 lines
45 KiB
Python
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 |