Spaces:
Sleeping
Sleeping
File size: 15,990 Bytes
d70361b | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 | """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())
|