125 lines
4.7 KiB
Python
125 lines
4.7 KiB
Python
# imaging.py
|
|
#
|
|
# Small, shared OpenCV helpers for illumination-robust color work,
|
|
# used by both calibration.py (finding the monitor) and
|
|
# detection.py (finding the white stickers).
|
|
#
|
|
# Both work in CIELAB rather than HSV, for one concrete reason: LAB
|
|
# separates brightness (L) from true color (a, b) far more cleanly
|
|
# than HSV does. A glare highlight, a shadow, a dimmer corner of the
|
|
# hall, or the camera's own auto white-balance drifting under
|
|
# exhibition lighting - all of these move L a lot while barely
|
|
# moving a/b. That is exactly the property needed to answer "is
|
|
# this green?" or "is this a neutral white/gray?" in a way that
|
|
# stays true regardless of how bright, dim, or unevenly lit that
|
|
# particular pixel happens to be right now - which is the real
|
|
# source of "the camera doesn't quite see what the monitor is
|
|
# actually showing": most of that gap is a brightness/exposure
|
|
# effect, not a pure color-chemistry one, and LAB is built to shrug
|
|
# that off.
|
|
|
|
import cv2
|
|
import numpy as np
|
|
|
|
|
|
def to_lab(frame_bgr):
|
|
"""
|
|
(L, a, b), each a 2D uint8 array in OpenCV's 0-255 LAB
|
|
encoding: L is lightness (0=black, 255=white), a is the
|
|
green(low)-red(high) axis centered at 128, b is the
|
|
blue(low)-yellow(high) axis centered at 128.
|
|
"""
|
|
lab = cv2.cvtColor(frame_bgr, cv2.COLOR_BGR2LAB)
|
|
|
|
return lab[:, :, 0], lab[:, :, 1], lab[:, :, 2]
|
|
|
|
|
|
def chroma(a_channel, b_channel, reference_a=128.0, reference_b=128.0):
|
|
"""
|
|
How colorful a pixel is, independent of how bright or dark it
|
|
is - near 0 at the reference point, larger for anything further
|
|
away from it in LAB's roughly perceptually-even a/b plane.
|
|
Unlike HSV saturation (a ratio that gets noisy and unstable for
|
|
dark pixels, since it is normalized by brightness itself), this
|
|
stays meaningful whether a pixel is in a bright spot or a
|
|
shadowed corner.
|
|
|
|
The reference defaults to (128, 128) - LAB's own neutral gray -
|
|
but callers that have a measured "what does a KNOWN-white
|
|
surface look like to this camera right now" point (see
|
|
Calibration.white_reference) should pass it in instead. Under
|
|
colored ambient light even genuinely white paper is NOT neutral
|
|
gray to the camera - that is unavoidable physics, not a flaw in
|
|
the camera - so testing distance from true neutral would wrongly
|
|
reject it. Testing distance from "whatever white looks like
|
|
right now" sidesteps that entirely, the same way a photographer
|
|
custom white-balances off a gray card instead of trusting a
|
|
fixed assumption about what "white" means.
|
|
"""
|
|
da = a_channel.astype(np.float32) - reference_a
|
|
db = b_channel.astype(np.float32) - reference_b
|
|
|
|
return np.sqrt(da ** 2 + db ** 2)
|
|
|
|
|
|
def green_dominance(a_channel):
|
|
"""
|
|
How far into "green" territory a pixel sits on LAB's red-green
|
|
axis, clipped to [0, 255]. Green is the LOW end of the a
|
|
channel (a < 128), so this is just how far below 128 the pixel
|
|
is. Using this one, roughly illumination-independent channel
|
|
instead of comparing raw R/G/B brightness is what lets the
|
|
calibration pattern still be found once the camera's own white
|
|
balance has shifted its recorded color away from what the
|
|
monitor is actually displaying.
|
|
"""
|
|
return np.clip(
|
|
128 - a_channel.astype(np.int16),
|
|
0,
|
|
255,
|
|
).astype(np.uint8)
|
|
|
|
|
|
def fill_holes(mask):
|
|
"""
|
|
Fills any fully-enclosed hole in a binary mask (0/255) - e.g. a
|
|
glare highlight punching a small gap in the middle of an
|
|
otherwise solid colored region, or a reflection splitting a
|
|
sticker's blob in two. Standard flood-fill-from-the-border
|
|
trick: anything NOT reachable from the mask's own border by
|
|
walking through zero pixels must be an enclosed hole, so it
|
|
gets filled in. A real gap that reaches all the way out to the
|
|
mask's edge (e.g. a person actually standing in front of part
|
|
of the pattern) stays untouched, since it IS reachable from the
|
|
border.
|
|
"""
|
|
height, width = mask.shape[:2]
|
|
|
|
# Padding with a guaranteed-zero 1px border first means the
|
|
# flood-fill seed point is always background, even if the real
|
|
# mask happens to already be foreground right at image corner
|
|
# (0, 0) - otherwise floodFill would treat that corner's own
|
|
# color as the "background" to flood, filling nothing at all.
|
|
padded = cv2.copyMakeBorder(
|
|
mask,
|
|
1,
|
|
1,
|
|
1,
|
|
1,
|
|
cv2.BORDER_CONSTANT,
|
|
value=0,
|
|
)
|
|
|
|
flood = padded.copy()
|
|
|
|
flood_fill_mask = np.zeros(
|
|
(height + 4, width + 4),
|
|
np.uint8,
|
|
)
|
|
|
|
cv2.floodFill(flood, flood_fill_mask, (0, 0), 255)
|
|
|
|
flood = flood[1:-1, 1:-1]
|
|
|
|
return mask | cv2.bitwise_not(flood)
|