ecg-rectifier / training /shadows.py
pashaalex's picture
ECG photo rectifier: code, ONNX models, examples, training scripts
9cc92da verified
Raw History Blame Contribute Delete
9.24 kB
"""Sharp shadows drawn on the FLAT texture before rendering (v5), bent together with the grid.
Modes: "crumple" (Voronoi facets with Lambert brightness), "folds" (2-4 broken crease lines, or the
real fold lines of the geometry, with valley / ridge shading), "cast" (hard shadow of a hand, phone
or ruler). The result is a multiplier map ~0.2..1.15 with blurred borders.
"""
import math
import cv2
import numpy as np
MODES = ("none", "crumple", "folds", "cast")
MODE_P = (0.2, 0.35, 0.3, 0.15)
EXTRA_CAST_P = 0.15 # a cast shadow on top of crumple / folds
def _u(rng, lo, hi):
return float(rng.uniform(lo, hi))
def _facet_brightness(rng, light, max_tilt_deg, ambient):
"""Lambert brightness of a facet whose normal is tilted from +z by up
to max_tilt_deg, relative to a flat facet (1.0)."""
tilt = math.radians(_u(rng, 0, max_tilt_deg))
az = _u(rng, 0, 2 * math.pi)
n = np.array([math.sin(tilt) * math.cos(az), math.sin(tilt) * math.sin(az), math.cos(tilt)])
lam = max(0.0, float(n @ light))
flat = max(1e-3, float(light[2]))
return ambient + (1 - ambient) * lam / flat
def _random_light(rng):
elev = math.radians(_u(rng, 25, 75)) # above the sheet
az = _u(rng, 0, 2 * math.pi)
return np.array([math.cos(elev) * math.cos(az), math.cos(elev) * math.sin(az), math.sin(elev)])
def _soften(shade, rng, ppm):
sigma_mm = math.exp(_u(rng, math.log(0.05), math.log(0.8)))
sigma = sigma_mm * ppm
if sigma > 0.4:
shade = cv2.GaussianBlur(shade, (0, 0), sigma)
return shade, sigma_mm
def _smooth_noise(rng, h, w, cell_px, amplitude):
field = rng.normal(0.0, 1.0, (h // cell_px + 2, w // cell_px + 2)).astype(np.float32)
field = cv2.resize(field, (w, h), interpolation=cv2.INTER_CUBIC)
field /= max(1e-6, float(field.std()))
return amplitude * field
def crumple(rng, h, w, ppm):
size_mm = _u(rng, 8, 45) # typical facet size
W, H = w / ppm, h / ppm
n = int(np.clip(W * H / size_mm ** 2 * _u(rng, 0.8, 1.2), 4, 600))
margin = 3 * size_mm
rect = (int(-margin * ppm), int(-margin * ppm), int((W + 2 * margin) * ppm), int((H + 2 * margin) * ppm))
sub = cv2.Subdiv2D(rect)
pts = np.stack([rng.uniform(-margin / 2, W + margin / 2, n), rng.uniform(-margin / 2, H + margin / 2, n)], 1)
for x, y in pts * ppm:
sub.insert((float(x), float(y)))
facets, _ = sub.getVoronoiFacetList([])
light = _random_light(rng)
max_tilt = _u(rng, 15, 45)
ambient = _u(rng, 0.3, 0.65)
shade = np.ones((h, w), np.float32)
values = []
for poly in facets:
b = _facet_brightness(rng, light, max_tilt, ambient)
values.append(b)
cv2.fillPoly(shade, [np.round(poly * 16).astype(np.int32)], float(b), cv2.LINE_AA, shift=4)
# facets are slightly curved: a weak gradient inside each one
shade *= 1.0 + _smooth_noise(rng, h, w, max(4, int(size_mm * ppm / 2)), _u(rng, 0.0, 0.05))
shade, soft = _soften(shade, rng, ppm)
return shade, dict(mode="crumple", facet_mm=size_mm, facets=len(facets), max_tilt_deg=max_tilt,
ambient=ambient, soft_mm=soft)
def _crease_polyline(rng, W, H, straight=None):
"""Broken line across the sheet, extended far beyond it, in mm.
straight = (s0, angle_deg): a straight line (U cos a + V sin a = s0)."""
big = 3 * (W + H)
if straight is not None:
s0, ang = straight
a = math.radians(ang)
n = np.array([math.cos(a), math.sin(a)])
d = np.array([-n[1], n[0]])
p = n * s0
return np.array([p - big * d, p + big * d])
p = np.array([_u(rng, 0.15, 0.85) * W, _u(rng, 0.15, 0.85) * H])
ang = _u(rng, 0, math.pi)
pts = [p]
# walk both ways from p; 0-2 kinks on each side, inside the sheet
for direction in (1.0, -1.0):
q, a = p.copy(), ang
side = []
for _ in range(int(rng.integers(0, 3))):
step = _u(rng, 0.15, 0.45) * max(W, H)
q = q + direction * step * np.array([math.cos(a), math.sin(a)])
side.append(q.copy())
a += math.radians(_u(rng, 8, 35)) * rng.choice([-1, 1])
q = q + direction * big * np.array([math.cos(a), math.sin(a)])
side.append(q)
pts = pts + side if direction > 0 else side[::-1] + pts
return np.array(pts)
def _side_mask(poly_px, h, w):
"""1 on the left side of the (far-extended) polyline, 0 on the right."""
d0 = poly_px[1] - poly_px[0]
d1 = poly_px[-1] - poly_px[-2]
big = 10 * (h + w)
left0 = np.array([d0[1], -d0[0]]) / max(1e-9, np.hypot(*d0))
left1 = np.array([d1[1], -d1[0]]) / max(1e-9, np.hypot(*d1))
polygon = np.concatenate([poly_px, [poly_px[-1] + big * left1, poly_px[0] + big * left0]])
mask = np.zeros((h, w), np.float32)
cv2.fillPoly(mask, [np.round(polygon * 16).astype(np.int64).astype(np.int32)], 1.0, cv2.LINE_AA, shift=4)
return mask
def folds(rng, h, w, ppm, fold_lines=None):
"""fold_lines: [(s0 mm, angle deg), ...] real folds of the geometry, or None."""
W, H = w / ppm, h / ppm
if fold_lines:
lines = [_crease_polyline(rng, W, H, straight=f) for f in fold_lines]
else:
lines = [_crease_polyline(rng, W, H) for _ in range(int(rng.integers(2, 5)))]
light = _random_light(rng)
ambient = _u(rng, 0.3, 0.65)
max_tilt = _u(rng, 20, 55)
shade = np.ones((h, w), np.float32)
# region brightness: product of per-crease side factors (each crease tilts one side)
for poly in lines:
poly_px = np.clip(poly * ppm, -1e6, 1e6)
side = _side_mask(poly_px, h, w)
b = _facet_brightness(rng, light, max_tilt, ambient)
shade *= 1.0 + (b - 1.0) * side
# valley: a dark ramp on one side; ridge: a thin highlight
line_img = np.full((h, w), 255, np.uint8)
cv2.polylines(line_img, [np.round(poly_px).astype(np.int32)], False, 0, 1)
dist = cv2.distanceTransform(line_img, cv2.DIST_L2, cv2.DIST_MASK_PRECISE) / ppm
if rng.random() < 0.7:
width = _u(rng, 2, 30)
depth = _u(rng, 0.15, 0.55)
which = side if rng.random() < 0.5 else 1.0 - side
shade *= 1.0 - depth * np.exp(-dist / width) * which
if rng.random() < 0.3:
shade *= 1.0 + _u(rng, 0.05, 0.15) * np.exp(-0.5 * (dist / _u(rng, 0.2, 0.8)) ** 2)
shade, soft = _soften(shade, rng, ppm)
return shade, dict(mode="folds", creases=len(lines), real_folds=bool(fold_lines), ambient=ambient,
max_tilt_deg=max_tilt, soft_mm=soft)
def cast(rng, h, w, ppm):
W, H = w / ppm, h / ppm
shade = np.ones((h, w), np.float32)
count = int(rng.integers(1, 3))
for _ in range(count):
c = np.array([_u(rng, -0.1, 1.1) * W, _u(rng, -0.1, 1.1) * H])
kind = str(rng.choice(["blob", "bar"]))
if kind == "bar": # ruler / pen / phone edge
a = _u(rng, 0, math.pi)
d = np.array([math.cos(a), math.sin(a)])
nrm = np.array([-d[1], d[0]])
L, T = _u(rng, 0.5, 2.0) * max(W, H), _u(rng, 5, 60)
poly = np.array([c - L * d - T * nrm, c + L * d - T * nrm, c + L * d + T * nrm, c - L * d + T * nrm])
else: # hand-like irregular polygon
k = int(rng.integers(5, 10))
r = _u(rng, 0.15, 0.45) * min(W, H)
ang = np.sort(rng.uniform(0, 2 * math.pi, k))
rad = r * rng.uniform(0.5, 1.3, k)
poly = c + np.stack([rad * np.cos(ang), rad * np.sin(ang)], 1)
mask = np.zeros((h, w), np.float32)
cv2.fillPoly(mask, [np.round(poly * ppm * 16).astype(np.int32)], 1.0, cv2.LINE_AA, shift=4)
shade *= 1.0 - _u(rng, 0.25, 0.65) * mask
shade, soft = _soften(shade, rng, ppm)
return shade, dict(mode="cast", objects=count, soft_mm=soft)
def shadow_map(rng, h, w, ppm, fold_lines=None):
"""Multiplier [h, w] float32 (or None for "none") and info."""
mode = str(rng.choice(MODES, p=MODE_P))
if mode == "none":
return None, dict(mode="none")
if mode == "crumple":
shade, info = crumple(rng, h, w, ppm)
elif mode == "folds":
use_real = fold_lines and rng.random() < 0.7
shade, info = folds(rng, h, w, ppm, fold_lines if use_real else None)
else:
shade, info = cast(rng, h, w, ppm)
if mode != "cast" and rng.random() < EXTRA_CAST_P:
extra, extra_info = cast(rng, h, w, ppm)
shade *= extra
info["cast"] = extra_info
tint = np.array([_u(rng, 1.0, 1.06), 1.0, _u(rng, 0.94, 1.0)], np.float32) # shadows slightly bluish
info["tint"] = tint.tolist()
return np.clip(shade, 0.2, 1.15).astype(np.float32), info
def apply(texture_bgr, shade, tint=(1.0, 1.0, 1.0)):
"""texture uint8 [h, w, 3] x shade -> uint8; the tint acts in proportion to the darkening."""
tex = texture_bgr.astype(np.float32)
dark = (1.0 - np.clip(shade, 0, 1))[..., None]
t = np.asarray(tint, np.float32)[None, None, :]
out = tex * shade[..., None] * (1.0 + (t - 1.0) * dark)
return np.clip(out, 0, 255).astype(np.uint8)