# 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)