"""Shadow detection and removal (relighting) for aerial / drone imagery. Distinct from the shadow *suppression* in ``detection_engine`` — that only stops shadow-only differences being reported as change, and needs a before/after pair. This module works on a **single image**: it finds shadowed pixels and restores their true surface appearance, so downstream detection compares like with like even when two dates were flown at different times of day. Pipeline -------- 1. ``no_data_mask`` — pure-black orthomosaic borders are excluded up front. They are not shadows and must never be "restored" (that would fabricate image content where the sensor recorded none). 2. ``shadow_index`` — per-pixel likelihood combining darkness with the blue shift of skylight-only illumination. 3. ``detect_shadows`` — Otsu seeds + hysteresis so faint/small shadows attached to confident ones survive, then a per-component check that separates true shadows from genuinely dark objects (dark tarp, asphalt, water). 4. ``remove_shadows`` — per-component linear illumination transfer against the surrounding lit ring, feathered across the penumbra so no seams or halos remain. The linear transfer is deliberate: scaling preserves the texture and relative colour inside the region, where an inpaint/fill would erase the very detail the detector needs. """ from __future__ import annotations import logging from dataclasses import dataclass, field from typing import Optional, Tuple import cv2 import numpy as np logger = logging.getLogger(__name__) # A pixel is treated as sensor no-data when every channel is at/near zero. NO_DATA_MAX = 8 # Shadows rarely exceed this share of a scene; above it the index is likely # tracking a dark surface (or a night image) rather than cast shadow. MAX_SHADOW_FRACTION = 0.60 @dataclass class ShadowResult: """Outcome of a shadow detect/remove pass.""" mask: np.ndarray # uint8 0/255, final shadow mask index: np.ndarray # float32 [0,1] shadow likelihood image: Optional[np.ndarray] = None # relit RGB (None for detect-only) shadow_fraction: float = 0.0 components: int = 0 rejected_dark_objects: int = 0 no_data_fraction: float = 0.0 notes: list = field(default_factory=list) def to_json(self) -> dict: return { "shadowFraction": round(float(self.shadow_fraction), 4), "components": int(self.components), "rejectedDarkObjects": int(self.rejected_dark_objects), "noDataFraction": round(float(self.no_data_fraction), 4), "notes": list(self.notes), } # --------------------------------------------------------------------------- # 1. No-data # --------------------------------------------------------------------------- def no_data_mask(img: np.ndarray) -> np.ndarray: """Pixels the sensor never recorded (black orthomosaic border), as 0/255. Rotated orthomosaics are padded with pure black. Those pixels satisfy every "is it dark?" test but contain no surface to restore, so they are held out of both detection and removal. """ dark_all = np.all(img <= NO_DATA_MAX, axis=2) m = (dark_all.astype(np.uint8)) * 255 # Keep only regions connected to the frame edge; an interior black object # (tarp, deep shade) is real image content, not padding. h, w = m.shape flood = m.copy() ff_mask = np.zeros((h + 2, w + 2), np.uint8) for seed in ((0, 0), (w - 1, 0), (0, h - 1), (w - 1, h - 1)): if flood[seed[1], seed[0]] == 255: cv2.floodFill(flood, ff_mask, seed, 128) return ((flood == 128).astype(np.uint8)) * 255 # --------------------------------------------------------------------------- # 2. Shadow likelihood # --------------------------------------------------------------------------- def shadow_index(img: np.ndarray) -> np.ndarray: """Per-pixel shadow likelihood in [0, 1]. Combines two independent cues so neither alone dominates: * darkness — shadows lose direct sun, so luminance drops; * blue shift — a shadowed surface is lit by sky only, which is bluer than sunlight, so blue rises *relative* to overall intensity. The ratio cue is what separates shadow from an intrinsically dark surface: black tarp is dark in every channel, shadowed concrete is dark *and* bluer. """ f = img.astype(np.float32) + 1.0 intensity = f.mean(axis=2) blue_ratio = f[:, :, 2] / intensity # >1 where blue dominates lab = cv2.cvtColor(img, cv2.COLOR_RGB2LAB) L = lab[:, :, 0].astype(np.float32) / 255.0 # Normalise against the scene's own lit level (95th percentile) so the cue # is exposure-independent: "dark relative to this image", not an absolute. lit_level = max(float(np.percentile(L[L > 0.02], 95)) if np.any(L > 0.02) else 1.0, 1e-3) darkness = np.clip(1.0 - L / lit_level, 0, 1) blueness = np.clip((blue_ratio - 1.0) / 0.25, 0, 1) # Weighted toward darkness (the primary signal) with blueness confirming. idx = 0.65 * darkness + 0.35 * darkness * blueness idx = cv2.GaussianBlur(idx, (0, 0), 1.5) return np.clip(idx, 0, 1).astype(np.float32) # --------------------------------------------------------------------------- # 3. Detection: seeds -> hysteresis -> dark-object rejection # --------------------------------------------------------------------------- def _ring_of(comp: np.ndarray, width: int) -> np.ndarray: """Lit collar just outside a component, used as its relighting reference.""" k = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (width * 2 + 1,) * 2) return cv2.subtract(cv2.dilate(comp, k), comp) def _is_true_shadow(img: np.ndarray, comp: np.ndarray, ring: np.ndarray) -> bool: """Same material, just unlit (shadow) — or a genuinely dark object? A shadow keeps its surface's hue and texture and merely loses light, so against its lit surround it shows a large luminance drop but a similar chromatic character. A dark object differs in *kind*: its colour is dark in its own right, and it is often flat (a tarp has little internal texture). """ # Judge the component's *core*: hysteresis + closing pull transition pixels # from the lit surround into the blob, and those inflate the apparent # texture of an otherwise flat object (a black tarp measured edge-to-edge # looks "textured" purely because of its own boundary). core = cv2.erode(comp, cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (5, 5))) if int((core > 0).sum()) < 24: core = comp # thin/small shape — nothing left to erode to comp_px = core > 0 ring_px = ring > 0 if comp_px.sum() < 24 or ring_px.sum() < 24: return True # too small to judge; darkness cue already fired lab = cv2.cvtColor(img, cv2.COLOR_RGB2LAB).astype(np.float32) L, a_, b_ = lab[:, :, 0], lab[:, :, 1], lab[:, :, 2] l_comp, l_ring = float(L[comp_px].mean()), float(L[ring_px].mean()) if l_ring - l_comp < 6.0: return False # barely darker than its surround — not a cast shadow # Chromaticity distance: shadow shifts mainly in L, an object also in a*/b*. chroma_shift = float(np.hypot(a_[comp_px].mean() - a_[ring_px].mean(), b_[comp_px].mean() - b_[ring_px].mean())) # Texture retention: shadowed ground keeps structure once contrast-normalised. tex_comp = float(L[comp_px].std()) relative_tex = tex_comp / max(float(L[ring_px].std()), 1e-3) # Very dark + chromatically distinct + flat => a dark surface, not shadow. if l_comp < 45 and chroma_shift > 9.0 and relative_tex < 0.55: return False # Extremely flat and near-black regardless of chroma (fresh tarp / water). if l_comp < 28 and tex_comp < 4.0: return False return True def detect_shadows( img: np.ndarray, *, strength: float = 1.0, min_area: int = 40, ) -> ShadowResult: """Build a shadow mask covering faint, small and irregular shadows. ``strength`` (0.5–1.5) scales sensitivity: >1 admits fainter shadows, <1 is conservative. Hysteresis keeps weak pixels only where they are contiguous with a confident seed, which is what lets soft penumbra and thin shadow fingers survive without the threshold also swallowing every dim surface. """ notes: list[str] = [] nodata = no_data_mask(img) valid = nodata == 0 idx = shadow_index(img) scored = idx[valid] if scored.size == 0: return ShadowResult(mask=np.zeros(img.shape[:2], np.uint8), index=idx, notes=["image is entirely no-data"]) # Otsu on the valid pixels gives a scene-adaptive seed threshold. otsu, _ = cv2.threshold((scored * 255).astype(np.uint8), 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU) high = float(np.clip(otsu / 255.0 / max(strength, 1e-3), 0.05, 0.95)) low = high * 0.72 # penumbra / faint shadow band seeds = ((idx >= high) & valid).astype(np.uint8) weak = ((idx >= low) & valid).astype(np.uint8) if seeds.sum() == 0: return ShadowResult(mask=np.zeros(img.shape[:2], np.uint8), index=idx, no_data_fraction=float((nodata > 0).mean()), notes=["no shadow seeds above threshold"]) # Hysteresis: keep weak components containing at least one seed. n, labels = cv2.connectedComponents(weak, connectivity=8) keep = np.unique(labels[seeds > 0]) grown = np.isin(labels, keep[keep > 0]).astype(np.uint8) * 255 grown = cv2.morphologyEx( grown, cv2.MORPH_CLOSE, cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (5, 5))) # Per-component adjudication (requirement: analyse context before removing). n2, lab2, stats, _ = cv2.connectedComponentsWithStats( (grown > 0).astype(np.uint8), connectivity=8) final = np.zeros(img.shape[:2], np.uint8) kept = rejected = 0 for i in range(1, n2): if stats[i, cv2.CC_STAT_AREA] < min_area: continue comp = ((lab2 == i).astype(np.uint8)) * 255 ring = cv2.bitwise_and(_ring_of(comp, 9), (valid.astype(np.uint8)) * 255) ring = cv2.bitwise_and(ring, cv2.bitwise_not(grown)) # lit reference only if _is_true_shadow(img, comp, ring): final[comp > 0] = 255 kept += 1 else: rejected += 1 frac = float((final > 0).mean()) if frac > MAX_SHADOW_FRACTION: notes.append( f"shadow mask covers {frac:.0%} of the frame — likely a dark or " "low-light scene rather than cast shadow; lower `strength` if the " "result looks over-brightened") if rejected: notes.append(f"{rejected} dark region(s) kept as objects, not shadows") return ShadowResult(mask=final, index=idx, shadow_fraction=frac, components=kept, rejected_dark_objects=rejected, no_data_fraction=float((nodata > 0).mean()), notes=notes) # --------------------------------------------------------------------------- # 4. Removal by illumination compensation # --------------------------------------------------------------------------- def _feather(mask: np.ndarray, width: int = 9) -> np.ndarray: """Soft 0→1 alpha that ramps across the penumbra, avoiding hard seams.""" dist = cv2.distanceTransform((mask > 0).astype(np.uint8), cv2.DIST_L2, 3) alpha = np.clip(dist / max(width, 1), 0, 1) return cv2.GaussianBlur(alpha.astype(np.float32), (0, 0), width / 3.0) def remove_shadows( img: np.ndarray, result: Optional[ShadowResult] = None, *, strength: float = 1.0, max_gain: float = 3.2, ) -> ShadowResult: """Relight shadowed pixels to match their surrounding lit surface. Each component is corrected independently against its own lit ring, because a shadow on grass and a shadow on concrete need different corrections. The transfer matches both mean and spread per channel, so texture and colour survive; ``max_gain`` caps amplification so deep shade turns into plausible surface rather than amplified sensor noise. """ res = result if result is not None else detect_shadows(img, strength=strength) out = img.astype(np.float32).copy() if res.mask is None or not np.any(res.mask): res.image = img.copy() return res nodata = no_data_mask(img) lit_ref = cv2.bitwise_and(cv2.bitwise_not(res.mask), cv2.bitwise_not(nodata)) n, labels, stats, _ = cv2.connectedComponentsWithStats( (res.mask > 0).astype(np.uint8), connectivity=8) corrected = 0 for i in range(1, n): area = stats[i, cv2.CC_STAT_AREA] if area < 12: continue comp = ((labels == i).astype(np.uint8)) * 255 # Widen the search until the ring holds enough lit reference pixels. ring = None for width in (9, 15, 24, 36): cand = cv2.bitwise_and(_ring_of(comp, width), lit_ref) if int((cand > 0).sum()) >= max(40, area // 12): ring = cand break if ring is None or not np.any(ring): continue cpx, rpx = comp > 0, ring > 0 alpha = _feather(comp)[..., None] for c in range(3): ch = out[:, :, c] m_s, s_s = float(ch[cpx].mean()), float(ch[cpx].std()) m_l, s_l = float(ch[rpx].mean()), float(ch[rpx].std()) # Losing light compresses a surface's contrast, so restoring it can # only expand: never allow gain < 1, which would flatten the very # texture the correction is meant to bring back. (A component # spanning several materials can otherwise out-vary its own lit # ring and get squashed.) gain = float(np.clip(s_l / max(s_s, 1.0), 1.0, max_gain)) adjusted = (ch - m_s) * gain + m_l # Feathered blend: full correction in the umbra, easing to none at # the boundary so the penumbra transitions without a visible edge. ch[:] = ch * (1 - alpha[..., 0]) + adjusted * alpha[..., 0] corrected += 1 out = np.clip(out, 0, 255).astype(np.uint8) out[nodata > 0] = img[nodata > 0] # never invent content in no-data padding res.image = out res.notes.append(f"relit {corrected} shadow component(s)") return res def remove_shadows_from_image(img: np.ndarray, *, strength: float = 1.0): """Convenience one-shot: returns ``(relit_image, ShadowResult)``.""" res = remove_shadows(img, strength=strength) return res.image, res def _main(argv=None) -> int: import argparse from PIL import Image ap = argparse.ArgumentParser(description="Detect and remove shadows in an image.") ap.add_argument("image") ap.add_argument("--out", help="write the relit image here") ap.add_argument("--mask-out", help="write the shadow mask here") ap.add_argument("--strength", type=float, default=1.0, help="0.5 conservative … 1.5 aggressive (default 1.0)") args = ap.parse_args(argv) logging.basicConfig(level=logging.INFO, format="%(message)s") img = np.array(Image.open(args.image).convert("RGB")) relit, res = remove_shadows_from_image(img, strength=args.strength) print(f"shadow fraction : {res.shadow_fraction:.1%}") print(f"components : {res.components}") print(f"dark objects kept: {res.rejected_dark_objects}") print(f"no-data : {res.no_data_fraction:.1%}") for note in res.notes: print(f"note : {note}") if args.mask_out: Image.fromarray(res.mask).save(args.mask_out) print(f"wrote {args.mask_out}") if args.out: Image.fromarray(relit).save(args.out) print(f"wrote {args.out}") return 0 if __name__ == "__main__": raise SystemExit(_main())