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