Ridge baker: near plane opaque to the horizon, occlusion-matched far planes, near-cover check; rebake Vienna (#165)
Some checks failed
Rat dev preview / Build dev preview (push) Has been cancelled

This commit is contained in:
Nibu 2026-11-14 12:52:20 +00:00 • committed by Void
commit b30ddac052
43 changed files with 141 additions and 47 deletions

View file

@ -2,6 +2,6 @@
"schema": 1,
"default": "vienna",
"places": {
"vienna": "vienna-b464e375319f"
"vienna": "vienna-12e733dc9f09"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 111 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 110 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 112 KiB

View file

@ -1,7 +1,7 @@
{
"schema": 1,
"baker_version": 1,
"bake_id": "b464e375319f",
"bake_id": "12e733dc9f09",
"place": {
"id": "vienna",
"name": "Vienna, looking SW to Rax/Schneeberg",
@ -34,27 +34,27 @@
"planes": {
"far": {
"file": "dawn-far.webp",
"bytes": 89842,
"bytes": 76030,
"w": 1280,
"h": 600
},
"mid": {
"file": "dawn-mid.webp",
"bytes": 58444,
"bytes": 53926,
"w": 1280,
"h": 600
},
"near": {
"file": "dawn-near.webp",
"bytes": 112950,
"bytes": 113422,
"w": 1280,
"h": 600
}
},
"bytes": 261236,
"bytes": 243378,
"luma": {
"min": 0.02173927318967578,
"max": 0.2143920269061395
"max": 0.2161143719823614
}
},
{
@ -67,27 +67,27 @@
"planes": {
"far": {
"file": "morning-far.webp",
"bytes": 91090,
"bytes": 77330,
"w": 1280,
"h": 600
},
"mid": {
"file": "morning-mid.webp",
"bytes": 63476,
"bytes": 58076,
"w": 1280,
"h": 600
},
"near": {
"file": "morning-near.webp",
"bytes": 119778,
"bytes": 120478,
"w": 1280,
"h": 600
}
},
"bytes": 274344,
"bytes": 255884,
"luma": {
"min": 0.023307705502610833,
"max": 0.5954759807347093
"max": 0.5966843818121018
}
},
{
@ -100,27 +100,27 @@
"planes": {
"far": {
"file": "noon-far.webp",
"bytes": 92266,
"bytes": 78634,
"w": 1280,
"h": 600
},
"mid": {
"file": "noon-mid.webp",
"bytes": 64134,
"bytes": 58684,
"w": 1280,
"h": 600
},
"near": {
"file": "noon-near.webp",
"bytes": 122270,
"bytes": 123036,
"w": 1280,
"h": 600
}
},
"bytes": 278670,
"bytes": 260354,
"luma": {
"min": 0.028025005749671317,
"max": 0.7071301021405179
"min": 0.028024916825206435,
"max": 0.7110067687110413
}
},
{
@ -133,26 +133,26 @@
"planes": {
"far": {
"file": "golden-far.webp",
"bytes": 89676,
"bytes": 76038,
"w": 1280,
"h": 600
},
"mid": {
"file": "golden-mid.webp",
"bytes": 59462,
"bytes": 54686,
"w": 1280,
"h": 600
},
"near": {
"file": "golden-near.webp",
"bytes": 114342,
"bytes": 114944,
"w": 1280,
"h": 600
}
},
"bytes": 263480,
"bytes": 245668,
"luma": {
"min": 0.02402914320144737,
"min": 0.02139437660612246,
"max": 0.2869579287555448
}
},
@ -166,27 +166,27 @@
"planes": {
"far": {
"file": "dusk-far.webp",
"bytes": 89124,
"bytes": 75552,
"w": 1280,
"h": 600
},
"mid": {
"file": "dusk-mid.webp",
"bytes": 56656,
"bytes": 52176,
"w": 1280,
"h": 600
},
"near": {
"file": "dusk-near.webp",
"bytes": 111564,
"bytes": 112176,
"w": 1280,
"h": 600
}
},
"bytes": 257344,
"bytes": 239904,
"luma": {
"min": 0.018028911802351475,
"max": 0.18841340359158804
"max": 0.18804654725014905
}
},
{
@ -199,27 +199,27 @@
"planes": {
"far": {
"file": "night-far.webp",
"bytes": 88064,
"bytes": 74732,
"w": 1280,
"h": 600
},
"mid": {
"file": "night-mid.webp",
"bytes": 54654,
"bytes": 50192,
"w": 1280,
"h": 600
},
"near": {
"file": "night-near.webp",
"bytes": 110050,
"bytes": 110780,
"w": 1280,
"h": 600
}
},
"bytes": 252768,
"bytes": 235704,
"luma": {
"min": 0.012974147057371975,
"max": 0.14175859785553058
"max": 0.14255636176898956
}
}
],

Binary file not shown.

After

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 118 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 73 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 108 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 77 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 120 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 88 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 110 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 87 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 109 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 88 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 112 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 89 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 117 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 108 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 119 KiB

View file

@ -27,7 +27,7 @@ Files are `Copernicus_DSM_COG_10_<N|S>dd_00_<E|W>ddd_00_DEM.tif`; the list below
## Planes
`relight_planes` renders every layer twice, with haze colour 0 and 1; the relight is affine in the haze colour, so the difference is each pixel's haze share. That share becomes transparency and the sky behind supplies the haze. Far is slot 0 plus the floor, near is the last slot, mid is every slot between (a place needs at least 3). Colour is exact over the frame's own haze colour; over another sky it drifts by the difference between the two. Stacked planes leak: each plane's haze is alpha over whatever is behind it, so a nearer plane's haze veils the farther ridge instead of the sky, and the farther ridge shows through it. Over a flat sky the stack differs from the opaque frame by 4.9/255 over the whole frame (Vienna noon), but that is 21.8/255 mean over the 22.6% of pixels where two planes overlap and 0.004/255 everywhere else; the tests bound the frame at 6/255 and the overlap at 23/255. The spec's 2/255 was not pursued: it needs a different decomposition than independent haze-as-alpha planes.
`relight_planes` renders every layer twice, with haze colour 0 and 1; the relight is affine in the haze colour, so the difference is each pixel's haze share. That share becomes transparency and the sky behind supplies the haze. Far is slot 0 plus the floor, near is the last slot, mid is every slot between (a place needs at least 3). Colour is exact over the frame's own haze colour; over another sky it drifts by the difference between the two. Stacked planes would leak: each plane's haze is alpha over whatever is behind it, so a nearer plane's haze would veil the farther ridge instead of the sky, and the farther ridge would show through it (a slanted seam through Vienna's near plane). `relight_planes` therefore scales a farther plane's alpha, near to far, to what the nearer layers leave open in the opaque frame (their transmittance over the stack's), looked up as the most open pixel within `OCCLUDE_MARGIN_PX` columns and eased by a Gaussian, because the runtime slides the planes against each other and a hidden pixel must stay hidden after the slide. The nearest layer is the ground in front of the eye: `bake_layer(near=True)` keeps its body opaque down to the horizon (its dissolve starts there), where the farther layers keep theirs (the haze arches). `export.check_near_cover` refuses, at `write_bake`, a near plane with a fully transparent column or a hole between a column's crest and the horizon row, in every frame; the farther planes may have empty columns. Over a flat sky the stack differs from the opaque frame by 1.8/255 over the whole frame (Vienna noon; 4.9 before the knock-out), but that is 11.2/255 mean over the 16.4% of pixels where two planes overlap and 0.005/255 everywhere else; the tests bound the frame at 3/255 and the overlap at 12.5/255. The spec's 2/255 pin is met on the frame; the overlap keeps the margin's soft blob.
Manifest `luma` is the min and max relative luminance (WCAG, linear 0..1) of the three planes composited far, mid, near over a flat mid-grey sky, over the pixels whose composite coverage `1 - prod(1 - alpha)` is at least 0.5. No plane pixel is opaque (haze is alpha), so no per-plane solid set exists; the composite is what the runtime shows.

View file

@ -25,6 +25,8 @@ DECODE_BYTES_MAX = 20_000_000 # the six planes of a frame pair decoded
WEBP_QUALITY = 80
LUMA_SKY = 0.5 # the flat mid-grey sky the planes are composited over for the luma probe
LUMA_COVER = 0.5 # composite coverage (1 - prod(1 - alpha)) at or above which a pixel is measured
NEAR_CREST_ALPHA = 64 # alpha (0..255) at which a near-plane pixel counts as the ridge's body: the crest of a column is its first such row
NEAR_HOLE_ALPHA = 24 # alpha below which a pixel under the crest is a hole (the sky would show through the ground in front of the eye)
SHEET_SKY = (0.80, 0.86, 0.94) # the flat sky of the contact sheet
ATTRIBUTION = dict(
credit_line='Contains modified Copernicus data: DEM © DLR e.V. 2010-2014 and © Airbus Defence and Space GmbH 2014-2018 provided under COPERNICUS by the European Union and ESA; all rights reserved.',
@ -99,6 +101,14 @@ def luma_range(decoded):
c = img[m]; lin = np.where(c <= 0.04045, c / 12.92, ((c + 0.055) / 1.055) ** 2.4); y = lin @ np.array([0.2126, 0.7152, 0.0722])
return float(y.min()), float(y.max())
def check_near_cover(near, horizon_row):
"""Refuse a near plane (RGBA uint8) that does not cover the ground in front of the eye: every column must have a crest (an alpha >= NEAR_CREST_ALPHA row), and from the crest down to the horizon row no pixel may be a hole (alpha < NEAR_HOLE_ALPHA).
A transparent column, or a wedge of holes under a crest, shows the sky through the foreground; the farther planes may leave columns empty (a place with no ridge at that distance), the nearest may not."""
a = near[..., 3]; h, w = a.shape; crest = np.argmax(a >= NEAR_CREST_ALPHA, axis=0); none = ~(a >= NEAR_CREST_ALPHA).any(0)
if none.any(): raise ValueError(f'near plane: {int(none.sum())} fully transparent columns, first at x={int(np.argmax(none))}')
rows = np.arange(h)[:, None]; hole = (a < NEAR_HOLE_ALPHA) & (rows >= crest[None, :]) & (rows < int(horizon_row))
if hole.any(): x = int(np.argmax(hole.any(0))); raise ValueError(f'near plane: {int(hole.any(0).sum())} columns have a hole between the crest and the horizon, first at x={x} row {int(np.argmax(hole[:, x]))}')
def check_budget(frame_bytes, plane_w, plane_h):
"""Refuse a bake the runtime could not hold: any two adjacent frames (night with dawn too) above PAIR_BYTES_MAX, or the planes of a frame pair decoding above DECODE_BYTES_MAX."""
if plane_w > PLANE_W_MAX: raise ValueError(f'plane width {plane_w} above {PLANE_W_MAX}')
@ -123,6 +133,7 @@ def write_bake(out, place, view, suns, frames_webp, default=None):
h, w = shapes.pop()
if h != view['plane_h']: raise ValueError(f"the planes are {h} rows, the view says {view['plane_h']}")
check_budget([sum(sizes[k].values()) for k in KEYS], w, h)
for k in KEYS: check_near_cover(dec[k][-1], view['horizon_row'])
bid = bake_id(frames_webp); name = f"{place['id']}-{bid}"; out = Path(out); out.mkdir(parents=True, exist_ok=True); dest = out / name
frames = []
for k in KEYS:

View file

@ -28,6 +28,7 @@ COLW = math.radians(360.0 / W)
R_EARTH, KREF = 6371000.0, 0.13
PROM_M, NEAR_CAP, MINW, LINK_D, LINK_A, LOOK = 25.0, 4000.0, 50, 0.45, 0.25, 4
FOREGROUND_M = 5000.0 # a chain nearer than this that is narrower than MINW is kept only where no kept foreground chain covers its columns (the nearest layer must cover every column; a lone stub beside a long near ridge is an arch)
FADE_AT = 0.6 # a layer's body is opaque down to this share of its foot length, then smoothsteps to nothing at the foot
TAPER = 8 # minimum columns over which a chain end dies into its valley (D's 8; round 1 used a fixed 20 AND borrowed the far end's value: the spike)
TAPER_K, TAPER_MAX = 30.0, 72 # a taller end falls over more columns: ceil(30 columns per degree of drop), 8..72: at the authored 1.5-4x exaggeration an end leaves the ridge at about 45 deg or less, not a wall
def taper_len(drop_deg): return np.clip(np.ceil(np.asarray(drop_deg) * TAPER_K), TAPER, TAPER_MAX)
@ -304,7 +305,7 @@ def face_field(hd, hh, ang, lo, pd, ph, vd, vh):
b_ = np.maximum(2 * COLW * Dp, 1.0); la = np.where(np.abs(a_) > 3 * b_, 0.0, np.clip((c_ - a_ * sl) / b_, -1.5, 1.5)) # neighbours at very different distances are an occlusion edge, not a surface: no tilt
return Z.astype(np.float32), Dp, blurw(sl, 2.0).astype(np.float32), blurw(la, 2.0).astype(np.float32), f
def bake_layer(S, rank, far):
def bake_layer(S, rank, far, near=False):
b = S['slot']; base = RAMP[b]; reg = int(round((rank - 2) * 1.5)); R_ = lambda v: np.roll(v, reg, axis=-1); px = 1.0 / PXDEG_V
s_ref = P.get('s_ref_deg', 8.0)
ang = blurw(S['top'], 2.5) + (pn1(38, 100 + b) - 0.5) * 6 * px + (pn1(7, 200 + b) - 0.5) * 2 * px; ang = R_(ang) # edge wobble authored in pixels
@ -315,8 +316,9 @@ def bake_layer(S, rank, far):
m = np.clip(toppx / 1.6 + 0.5, 0, 1) * np.clip(blurw((wt > 0).astype(float), 1.0) * 1.5, 0, 1)[None, :] # a layer is drawn only where a chain exists (or tapers)
rise_px = np.clip((ang - blurw(lo, 12.0)) * PXDEG_V, 1, None) # foot length from a smoothed valley: no per-column curtains
foot = np.clip(0.85 * rise_px, 28 * np.clip(wt, 0, 1), 210).clip(1) # a tapering end carries no 28 px curtain: the body shrinks with the chain's weight, so a chain end leaves no stub
if near: foot = np.maximum(foot, np.clip(ang * PXDEG_V, 0, None) / FADE_AT) # the nearest layer is the ground in front of the eye: its body stays opaque down to the horizon, whatever the strip's own valley says; it dissolves only below that (above the horizon a dissolved body shows the sky under a crest, and the valley angle jumps where the winning chain changes, so the sky showed as a wedge with a hard edge)
depthpx = np.clip(toppx / foot[None], 0, 1)
fade = np.clip((depthpx - 0.6) / 0.4, 0, 1); fade = fade * fade * (3 - 2 * fade); alpha = (m * (1 - fade)).astype(np.float32) # the body dissolves at its foot: what is behind (other layers, floor, sky) shows, so a column boundary leaves no wall down the frame
fade = np.clip((depthpx - FADE_AT) / (1 - FADE_AT), 0, 1); fade = fade * fade * (3 - 2 * fade); alpha = (m * (1 - fade)).astype(np.float32) # the body dissolves at its foot: what is behind (other layers, floor, sky) shows, so a column boundary leaves no wall down the frame
w = (depthpx ** 0.8 * 0.92).astype(np.float32) # weight of the foot colour (floor/haze mix, decided at relight)
face = np.broadcast_to((base * 1.12).astype(np.float32), (H, W, 3)).copy()
Z, Dp, sl, la, f = face_field(hd, hhist, ang, lo, pd_, ph, vd_, vh)
@ -354,7 +356,7 @@ def bake_layer(S, rank, far):
return dict(m=m.astype(np.float32), a=alpha, face=face, w=w, dpx=dpx, ca=ca, ha=ha, rb=rb, mot=mot.astype(np.float32), sl=sl, la=la, ink=ink, far=far, dist=S['dist'], rank=rank, ang=ang, lo=lo, spts=spts)
def bake(slots):
return [bake_layer(S, rank, float(np.clip((math.log(S['dist']) - math.log(2000)) / (math.log(70000) - math.log(2000)), 0, 1))) for rank, S in enumerate(slots)]
return [bake_layer(S, rank, float(np.clip((math.log(S['dist']) - math.log(2000)) / (math.log(70000) - math.log(2000)), 0, 1)), near=rank == len(slots) - 1) for rank, S in enumerate(slots)]
# ====================================================================== cast shadows (sun-dependent): DEM march toward the sun from each layer's face samples
SH_STEPS = 40.0 * 1.1 ** np.arange(0, 66) # 40 m .. 20 km, geometric
@ -495,6 +497,12 @@ def group_slots(n):
if n < 3: raise ValueError(f"a place needs at least 3 layer slots for the far/mid/near planes, this one has {n}")
return dict(far=[0], mid=list(range(1, n - 1)), near=[n - 1])
OCCLUDE_MARGIN_PX = 48 # the runtime slides the planes against each other by up to this much; a farther plane is hidden only where the nearer layers cover it at every offset within it, so a slide never uncovers a hole
def most_open(seen):
"""The most light any offset within OCCLUDE_MARGIN_PX lets through `seen` (the nearer layers' transmittance), eased over the same distance so the knock-out has no edge of its own: a max filter along the columns, then a Gaussian of sigma OCCLUDE_MARGIN_PX / 2; the window's ends repeat their edge column."""
m = OCCLUDE_MARGIN_PX; r = 2 * m; pad = np.pad(seen, ((0, 0), (r, r)), mode='edge')
return np.clip(blurw(np.lib.stride_tricks.sliding_window_view(pad, 2 * m + 1, axis=1).max(-1), m / 2.0)[:, m:-m], 0, 1)
def relight_planes(layers, az, el, paperf, grain, fmot, shadows=True, ref=None):
"""The frame as three RGBA planes (window columns, float 0..1, straight alpha) with no sky drawn. Every pixel of relight is affine in the haze colour c: out = X + c Y
(the haze enters only as the foot mix and the final Beer-Lambert mix). Render each layer and the floor with c = 0 (X) and c = 1 (X + Y), composite each group over
@ -506,7 +514,8 @@ def relight_planes(layers, az, el, paperf, grain, fmot, shadows=True, ref=None):
for c in (0.0, 1.0):
hc = np.full((W, 3), c, np.float32); F = (floor_img(st, hc, fmot) * k).astype(np.float32)
runs.append((F, [relight_layer(Ly, st, hc, F, sh3) * k for Ly, sh3 in zip(layers, shs)]))
for name, idx in group_slots(len(layers)).items():
groups = group_slots(len(layers)); names = list(groups); seen = {}
for name, idx in groups.items():
img = [np.where(below[..., None], r[0], 0.0) if name == 'far' else np.zeros_like(r[0]) for r in runs]; cov = np.broadcast_to(below, (H, W)).astype(np.float32) if name == 'far' else np.zeros((H, W), np.float32)
for i in idx:
a = layers[i]['a'][..., None]
@ -515,7 +524,11 @@ def relight_planes(layers, az, el, paperf, grain, fmot, shadows=True, ref=None):
X = win(img[0]); Yc = win(img[1] - img[0]); Y = Yc.mean(-1); alpha = np.clip(win(cov) - Y, 0, 1)
P = X + ref_w[None] * (Yc - Y[..., None]) # premultiplied colour: exact over the reference haze, per channel
col = np.where(alpha[..., None] > 1e-4, P / np.maximum(alpha, 1e-4)[..., None], 0.0) * win(grain)[..., None]
seen[name] = win(np.prod([1 - layers[i]['a'] for i in idx], axis=0))
out[name] = np.concatenate([np.clip(col, 0, 1), alpha[..., None]], -1).astype(np.float32)
for i in range(len(names) - 2, -1, -1): # near to far: the opaque frame lets a farther plane through only what the nearer layers leave open (their transmittance `seen`), but the stacked planes let it through by their alpha, which also lost the haze share to transparency; the farther plane's alpha is brought to the opaque frame's
nearer = names[i + 1:]; left = most_open(np.prod([seen[n] for n in nearer], axis=0)); got = np.prod([1 - out[n][..., 3] for n in nearer], axis=0)
out[names[i]][..., 3] *= np.clip(left / np.maximum(got, 1e-3), 0, 1)
return out
def statics():

View file

@ -73,6 +73,25 @@ M = {
'attribution dem_source names the 90 m product': ('export.py', "dem_source='Copernicus DEM GLO-30 (30 m)'", "dem_source='Copernicus DEM GLO-90 (90 m)'"),
'attribution url over plain http': ('export.py', "url='https://spacedata", "url='http://spacedata"),
'attribution url blanked': ('export.py', "url='https://spacedata.copernicus.eu/collections/copernicus-digital-elevation-model')", "url='')"),
'knock-out removed: the farther planes show through the nearer': ('ridge.py', "out[names[i]][..., 3] *= np.clip(left / np.maximum(got, 1e-3), 0, 1)", "out[names[i]][..., 3] *= 1.0"),
'knock-out ignores the nearer planes\' own alpha': ('ridge.py', "out[names[i]][..., 3] *= np.clip(left / np.maximum(got, 1e-3), 0, 1)", "out[names[i]][..., 3] *= np.clip(left, 0, 1)"),
'knock-out margin filter is a min (hides what the slide uncovers)': ('ridge.py', 'sliding_window_view(pad, 2 * m + 1, axis=1).max(-1), m / 2.0)', 'sliding_window_view(pad, 2 * m + 1, axis=1).min(-1), m / 2.0)'),
'knock-out has a hard edge (no easing)': ('ridge.py', 'return np.clip(blurw(np.lib.stride_tricks.sliding_window_view(pad, 2 * m + 1, axis=1).max(-1), m / 2.0)[:, m:-m], 0, 1)', 'return np.clip(np.lib.stride_tricks.sliding_window_view(pad, 2 * m + 1, axis=1).max(-1)[:, m:-m], 0, 1)'),
'knock-out margin of one column': ('ridge.py', 'OCCLUDE_MARGIN_PX = 48 ', 'OCCLUDE_MARGIN_PX = 1 '),
'knock-out hides the nearer plane from the farther one, not the reverse': ('ridge.py', 'nearer = names[i + 1:]; left', 'nearer = names[:i]; left'),
'near foot floor removed: the near body dissolves above the horizon': ('ridge.py', 'if near: foot = np.maximum(foot,', 'if False: foot = np.maximum(foot,'),
'near foot floor starts the fade at the foot, not at the horizon': ('ridge.py', 'np.clip(ang * PXDEG_V, 0, None) / FADE_AT)', 'np.clip(ang * PXDEG_V, 0, None) / 1.0)'),
'near flag passed to every layer': ('ridge.py', 'near=rank == len(slots) - 1)', 'near=True)'),
'near flag passed to no layer': ('ridge.py', 'near=rank == len(slots) - 1)', 'near=False)'),
'near flag on the first layer, not the last': ('ridge.py', 'near=rank == len(slots) - 1)', 'near=rank == 0)'),
'near cover never checked at the writer': ('export.py', "for k in KEYS: check_near_cover(dec[k][-1], view['horizon_row'])", "pass"),
'near cover checked on the mid plane': ('export.py', "check_near_cover(dec[k][-1], view['horizon_row'])", "check_near_cover(dec[k][1], view['horizon_row'])"),
'near cover checked on noon only': ('export.py', "for k in KEYS: check_near_cover(", "for k in ('noon',): check_near_cover("),
'near cover hole threshold of zero': ('export.py', 'NEAR_HOLE_ALPHA = 24 ', 'NEAR_HOLE_ALPHA = 0 '),
'near cover looks below the horizon too': ('export.py', '& (rows < int(horizon_row))', '& (rows < h)'),
'near cover crest threshold above any alpha': ('export.py', 'NEAR_CREST_ALPHA = 64 ', 'NEAR_CREST_ALPHA = 256 '),
'near cover lets a transparent column through': ('export.py', 'if none.any(): raise ValueError', 'if False: raise ValueError'),
'near cover looks only above the crest row': ('export.py', '(rows >= crest[None, :]) & (rows < int(horizon_row))', '(rows >= crest[None, :] + 400) & (rows < int(horizon_row))'),
'relight is not deterministic (noise in the planes)': ('ridge.py', 'P = X + ref_w[None] * (Yc - Y[..., None])', 'P = X + ref_w[None] * (Yc - Y[..., None]) + np.random.random() * 0.05'),
}

View file

@ -388,12 +388,17 @@ def _over(planes, sky):
img = np.broadcast_to(np.array(sky), planes[export.PLANES[0]].shape[:2] + (3,)).copy()
for n in export.PLANES: a = planes[n]; img = img * (1 - a[..., 3:]) + a[..., :3] * a[..., 3:]
return img
def _open_to(L, first):
"""Pixels (window columns) that the layers from index `first` on leave open: no coverage within OCCLUDE_MARGIN_PX columns, so the plane's knock-out does not touch them."""
if first >= len(L): return np.ones((ridge.H, ridge.WIN), bool)
cov = ridge.win(1 - np.prod([1 - Ly['a'] for Ly in L[first:]], axis=0)); m = ridge.OCCLUDE_MARGIN_PX; pad = np.pad(cov, ((0, 0), (m, m)), mode='edge')
return np.lib.stride_tricks.sliding_window_view(pad, 2 * m + 1, axis=1).max(-1) < 1e-6
def test_the_far_plane_over_a_flat_sky_equals_the_opaque_relight_of_its_group():
"""The affine haze split is exact for one group: the far plane (floor + slot 0) over one flat colour equals relight of slot 0 alone with that colour as sky and haze (grain off, no shadows) within 1/255 mean and 4/255 max.
The planes carry no sky: above the ridge their alpha is 0."""
r, L = _three_slot_layers(); f = _statics_flat_grain(); sun = (200.0, 40.0); sky = export.SHEET_SKY
planes = ridge.relight_planes(L, *sun, *f, shadows=False, ref=sky); img = _over(dict(far=planes['far'], mid=0 * planes['mid'], near=0 * planes['near']), sky)
want = ridge.win(ridge.relight(L[:1], *sun, *f, shadows=False, flat=sky)); d = np.abs(img - want) * 255
want = ridge.win(ridge.relight(L[:1], *sun, *f, shadows=False, flat=sky)); d = np.abs(img - want) * 255; d = d[_open_to(L, 1)] # a nearer layer hides the far plane where it covers (test_a_farther_plane_is_hidden_behind_the_nearer_layers_that_cover_it)
assert d.mean() < 1.0 and d.max() < 4.0, ('the far plane over a flat sky differs from its opaque relight', d.mean(), d.max())
assert planes['far'][0, :, 3].max() < 0.01 and planes['near'][0, :, 3].max() < 0.01, 'the top row (sky) is not transparent'
_VIENNA = {}
@ -405,15 +410,60 @@ def _vienna_noon():
_VIENNA['err'] = np.abs(_over(planes, sky) - ridge.win(ridge.relight(L, sun['az'], sun['el'], *f, shadows=False, flat=sky))).mean(-1) * 255; _VIENNA['planes'] = planes; ridge.P = saved
return _VIENNA['err'], _VIENNA['planes']
def test_stacked_planes_stay_close_to_the_opaque_relight_on_vienna_noon():
"""Real Vienna bake at noon, three planes over a flat sky vs relight(flat=that sky): the stacking loss (a nearer group's haze veils the farther plane instead of the sky) measured 4.9/255 mean over the frame; the bound is 6,
so a split that loses colour or alpha shows. The spec's 2/255 pin was not pursued (reported as a deviation); the loss is concentrated where planes overlap, see the next test."""
err, _ = _vienna_noon(); assert err.mean() < 6.0, ('stacked planes drift from the opaque relight', err.mean())
"""Real Vienna bake at noon, three planes over a flat sky vs relight(flat=that sky): the stacking loss (a nearer group's haze veils the farther plane instead of the sky) measured 4.9/255 mean over the frame before the farther planes were knocked out
behind the nearer layers, 1.8 after; the bound is 3, so a split that loses colour or alpha, or a knock-out that stops working, shows. The spec's 2/255 pin is met with 0.2 to spare; the loss is concentrated where planes overlap, see the next test."""
err, _ = _vienna_noon(); assert err.mean() < 3.0, ('stacked planes drift from the opaque relight', err.mean())
def test_stacked_planes_error_over_the_overlap_region_is_the_measured_leak():
"""Where a nearer plane covers a farther one (two or more planes with alpha above 1/100) the farther ridge shows through the nearer plane's haze: measured 21.8/255 mean over 22.6% of the frame on Vienna noon, bound 23.
Outside the overlap the planes are exact (0.004/255). The honest figure; it is the price of independent haze-as-alpha planes."""
"""Where a nearer plane covers a farther one (two or more planes with alpha above 1/100) the rest of the leak sits: measured 11.2/255 mean over 16.4% of the frame on Vienna noon (21.8 over 22.6% before the knock-out), bound 12.5. It is what the 48 px knock-out margin
and the easing leave visible (a soft darker blob beside the near plane's wedge), and the dissolving feet where a nearer layer is only partly opaque. Outside the overlap the planes are exact (0.005/255)."""
err, planes = _vienna_noon(); ov = sum((planes[n][..., 3] > 0.01).astype(int) for n in export.PLANES) >= 2
assert 0.15 < ov.mean() < 0.30, ('the overlap region is not the 20-ish percent the bound was measured on', ov.mean())
assert err[ov].mean() < 23.0, ('overlap-region error', err[ov].mean()); assert err[~ov].mean() < 0.1, ('error outside the overlap', err[~ov].mean())
assert 0.10 < ov.mean() < 0.25, ('the overlap region is not the 16-ish percent the bound was measured on', ov.mean())
assert err[ov].mean() < 12.5, ('overlap-region error', err[ov].mean()); assert err[~ov].mean() < 0.1, ('error outside the overlap', err[~ov].mean())
def test_a_farther_plane_is_hidden_behind_the_nearer_layers_that_cover_it():
"""The opaque frame never shows a farther ridge through a nearer layer, but the stacked planes did (the nearer plane's haze share became transparency and the farther ridge showed in it: a slanted seam through the near plane on Vienna).
Right answer on the 3-slot fixture: where the nearer layers are opaque for OCCLUDE_MARGIN_PX columns around a pixel, the mid and far planes are transparent there; where nothing nearer is within the margin, the planes are exactly what they were without the knock-out;
and the knock-out eases in (a pixel just inside the covered region keeps most of its alpha), so no edge of its own is drawn."""
r, L = _three_slot_layers(); f = _statics_flat_grain(); on = ridge.relight_planes(L, 200.0, 40.0, *f, shadows=False); keep = ridge.most_open; ridge.most_open = lambda seen: np.ones_like(seen)
try: off = ridge.relight_planes(L, 200.0, 40.0, *f, shadows=False)
finally: ridge.most_open = keep
m = ridge.OCCLUDE_MARGIN_PX
for name, first in (('mid', 2), ('far', 1)):
cov = ridge.win(1 - np.prod([1 - Ly['a'] for Ly in L[first:]], axis=0)); reach = lambda w: np.lib.stride_tricks.sliding_window_view(np.pad(cov, ((0, 0), (w, w)), mode='edge'), 2 * w + 1, axis=1).min(-1)
deep = reach(3 * m) > 0.999 # the max filter looks m columns out and the easing another 1.5 m
assert deep.sum() > 2000 and (off[name][..., 3][deep] > 0.1).sum() > 500, (name, 'the fixture has no farther body under a nearer layer to hide')
assert on[name][..., 3][deep].max() < 0.03, (name, 'a farther plane shows through the layer in front of it', float(on[name][..., 3][deep].max()))
free = _open_to(L, first); assert np.array_equal(on[name][..., 3][free], off[name][..., 3][free]) and free.sum() > 2000, (name, 'the knock-out reached pixels no nearer layer covers')
edge = (cov > 0.999) & (reach(2) < 0.5) & (off[name][..., 3] > 0.1); assert edge.sum() > 50 and (on[name][..., 3][edge] / off[name][..., 3][edge]).mean() > 0.4, (name, 'the knock-out has a hard edge at the margin')
def test_the_nearest_layer_is_opaque_down_to_the_horizon_and_the_others_keep_their_dissolve():
"""Right answer: the nearest layer's alpha is 1 from its crest to the horizon row, whatever its strip's valley angle says (above the horizon a dissolved body shows the sky under a crest: the wedge of Vienna's near plane, whose valley angle jumps where the winning chain changes);
a farther layer keeps its dissolve (the far blue ridges fade into the haze above the horizon by design). Fixture: a 400 m block at 3 km (crest 5.7 deg, foot 210 px but 380 px to the horizon) and a 4000 m block at 20 km (a crest above the near one, so it is a ridge of its own)."""
r, L = _bake_view(lambda dk, az: np.where(in_arc(az, 100, 140) & (dk >= 3000) & (dk < 3600), 400.0, np.where(in_arc(az, 100, 140) & (dk >= 20000) & (dk < 24000), 4000.0, 0.0)), 120.0)
c = col_of(120); hz = int(np.argmax(ridge.rowang < 0)); assert len(L) == 2, [Ly['dist'] for Ly in L]
for Ly, solid in ((L[-1], True), (L[0], False)):
a = Ly['a'][:, c]; crest = int(np.argmax(a > 0.5)); body = a[crest + 3:hz]; assert body.size > 20, ('fixture crest is not above the horizon', crest, hz)
assert (body.min() > 0.999) if solid else (body.min() < 0.5), ('nearest' if solid else 'farther', 'layer alpha between crest and horizon', float(body.min()))
def test_near_plane_must_cover_every_column_and_leave_no_hole_under_its_crest():
"""check_near_cover: a near plane passes when every column has a crest and nothing between the crest and the horizon row is a hole; it refuses a fully transparent column, and a hole under a crest (the wedge: alpha 0 at the plane's right edge, rows 230-480 with a hard edge, in the committed Vienna set).
Holes below the horizon are the foot dissolving into the floor and pass."""
ok = np.zeros((60, 40, 4), np.uint8); ok[10:, :, 3] = 200; export.check_near_cover(ok, 30.0); export.check_near_cover(ok, 59.0)
cols = ok.copy(); cols[:, 33:, 3] = 0; wedge = ok.copy(); wedge[20:50, 30:, 3] = 0; below = ok.copy(); below[35:, 30:, 3] = 0
export.check_near_cover(below, 30.0)
for bad, why in ((cols, 'transparent'), (wedge, 'hole')):
try: export.check_near_cover(bad, 40.0)
except ValueError as e: assert why in str(e), (why, str(e))
else: assert False, ('a near plane with a ' + why + ' was accepted')
export.check_near_cover(ok, 30.0)
def test_write_bake_refuses_a_near_plane_with_a_wedge_and_lets_the_other_planes_have_empty_columns():
"""The refusal is at the writer, on the decoded near plane of every frame; the far and mid planes may have transparent columns (a place with no ridge at that distance in part of the window)."""
d = tempfile.mkdtemp()
try:
place, view, suns, planes = _fixture_bake()
for k in export.KEYS: planes[k]['mid'][:, 200:260, 3] = 0
export.write_bake(d + '/ok', place, view, suns, _encode_all(planes))
for k, ys, xs in (('dawn', slice(20, 80), slice(1200, 1280)), ('night', slice(0, 600), slice(600, 700))):
bad = {kk: {n: np.array(v) for n, v in vv.items()} for kk, vv in planes.items()}; bad[k]['near'][ys, xs, 3] = 0
_refuses(d + '/bad', place, view, suns, _encode_all(bad), 'a near plane with a transparent wedge in frame %s was written' % k)
finally: shutil.rmtree(d)
def test_plane_grouping_far_mid_near_and_refuses_fewer_than_three_slots():
"""far = slot 0, near = the last slot, mid = every slot between (the union); a 3-slot place fills all three planes; fewer than 3 slots are refused."""
assert ridge.group_slots(3) == dict(far=[0], mid=[1], near=[2]) and ridge.group_slots(6) == dict(far=[0], mid=[1, 2, 3, 4], near=[5])
@ -434,11 +484,12 @@ def test_straight_alpha_planes_unpremultiply_the_composite():
for name, i in (('mid', 1), ('near', 2)):
got = _over({n: (pl[n] if n == name else 0 * pl[n]) for n in export.PLANES}, sky)[:top]; want = ridge.win(ridge.relight(L[i:i + 1], *sun, *f, shadows=False, flat=sky))[:top]
body = pl[name][:top, :, 3] > 0.3; assert body.sum() > 1000 and pl[name][:top, :, 3][body].mean() < 0.97, (name, 'the fixture has no translucent body to test')
d = np.abs(got - want) * 255; assert d.mean() < 0.5 and d.max() < 4.0, (name, 'plane over a flat sky differs from its slot relight: colour is premultiplied or the haze split is wrong', d.mean(), d.max())
d = (np.abs(got - want) * 255)[_open_to(L, i + 1)[:top]]; assert d.mean() < 0.5 and d.max() < 4.0, (name, 'plane over a flat sky differs from its slot relight: colour is premultiplied or the haze split is wrong', d.mean(), d.max())
def _encode_all(planes): return {k: {n: export.encode(planes[k][n]) for n in export.PLANES} for k in export.KEYS}
def _fixture_bake():
r, L = _three_slot_layers(); f = _statics_flat_grain(); suns = {k: dict(az=100.0 + 20 * i, el=-10.0 + 15 * i, hour_utc=float(i)) for i, k in enumerate(export.KEYS)}
planes = {k: ridge.relight_planes(L, suns[k]['az'], max(suns[k]['el'], 5.0) if k != 'night' else 5.0, *f, shadows=False) for k in export.KEYS}
for k in export.KEYS: planes[k]['near'][..., 3] = np.maximum(planes[k]['near'][..., 3], 0.6) # the writer refuses a near plane with an empty column: the fixture's nearest ridge covers only 40 degrees, so its plane is lifted to cover the window
place = dict(id='synthetic', name='Synthetic', lat=48.0, lon=16.0, altitude_km=0.1, tz_h=2, tz_name='CEST', date='2026-09-23')
view = dict(look_az_deg=120.0, plane_h=ridge.H, px_per_deg_h=ridge.HPX_DEG, px_per_deg_v=ridge.PXDEG_V, horizon_row=100.0, sky_row=24.0, near_row=396.0)
return place, view, suns, planes