OverviewFilesCommitsIssuesDocsReleasesPeers

terrain-lookdev commits / f37cafb

lookdev: crossfade two conditioning worlds — the only place seeds can blend
"Can we blend seeds at large regions?" splits in two, because seed does two jobs. The LATENT seed cannot be blended. Noise is drawn position-addressed as standard_normal(seed, i*TILE_STRIDE, j*TILE_STRIDE) (world_pipeline.py:1230), and one global field is exactly what buys seam-freedom — 0.00 m over 108 adjacent tile pairs. Two regions on different seeds draw uncorrelated noise and the 16 px overlap window averages them into mush. change_seed() also clears every cached tile and rebuilds, so it was never a per-region knob. The CONDITIONING can, and it is the half that carries regional character: the latent seed only decides WHICH ridge goes where, conditioning decides what KIND of place it is. Measured, over 41 cut positions, blending across 16 cells (123 km — one block width): channel blend z hard-cut z elevation -0.10 1.24 temperature 0.07 1.76 temp std -0.04 2.03 precipitation -0.00 1.02 precip var -0.09 1.95 A 123 km band is enough. 492 km FAILS, but as an artefact: the band overlaps the transect ends so the "outside" baseline is contaminated. BLENDS RAW, FINALIZES ONCE. finalize_synthetic_map applies temp += lapse(precip)*max(0,elev) against each world's OWN elevation, so a convex blend of two finalized maps carries two inconsistent corrections — measured, corr(temp,elev) fell to -0.098 from -0.431 and -0.149, i.e. mountains in the transition band stopped being cold. sample_raw and finalize are exposed as attributes on the returned callable (synthetic_map.py:269-270). I wrote a patch to add them before checking, and it duplicated code already in HEAD. ⚠ UPSTREAM: _load_stats_cache() takes no arguments, so the quantile stats on disk are reused for ANY frequency_mult even though _compute_map_stats depends on it. World B (mult 2.5) is normalised with mult-1.0 quantiles, which is why it shows corr(temp,elev) = +0.069 — mountains warmer. Do not tune frequency_mult without addressing this. ⚠ synthetic_map.py uses RELATIVE paths for the rasters AND the stats cache. Imported from another directory it silently misses the cache and recomputes quantiles over the full Earth rasters, which does not finish in ten minutes. chdir first. THE STATISTIC TOOK FOUR TRIES, each caught by the control rather than shipped: max/median could not see a cut against spiky fBm; localising to a band found a natural spike instead of the cut (identical 7.57 for blend and hard cut); an off-by-one read the step AFTER the join, wholly inside world B; and one cut position is a sample of one. The criterion is now relative — the control must be detectable, and the blend well below it — because two worlds from the same quantile distribution are often already close, so a hard cut costs only ~2σ and no absolute threshold works.
f37cafba9752ce878c764370abe772574346bed8
by plankatron <fleeky@gmail.com> · 2026-09-02T15:56:14.000Z

Changed files (1) +210 -0

crossfade_seeds.py +210 -0
……@@ -0,0 +1,210 @@
1#!/usr/bin/env python3
2"""
3CROSSFADE TWO WORLDS — in the CONDITIONING, which is the only place it works.
4
5"Can we blend seeds at large regions?" splits in two, because seed does two
6jobs:
7
8 LATENT seed — cannot be blended. Noise is drawn position-addressed as
9 standard_normal(seed, i*TILE_STRIDE, j*TILE_STRIDE)
10 (world_pipeline.py:1230). One global field is exactly what
11 buys seam-freedom (0.00 m over 108 adjacent tile pairs). Two
12 regions on different seeds draw uncorrelated noise and the
13 16 px overlap window averages them into mush.
14
15 CONDITIONING — blendable, continuous, and the half that carries regional
16 character: elevation prior, temperature, seasonality,
17 precipitation, variability. The latent seed only decides WHICH
18 ridge goes where; conditioning decides what KIND of place it is.
19
20So this blends two synthetic conditioning maps with different frequency_mult
21(biome scale) and different seeds (layout), across a smooth band, and asks
22whether the result is continuous.
23
24THE CONTROL IS A HARD SWITCH at the same place. The statistic must REJECT it.
25A continuity test that cannot see an abrupt cut is measuring nothing — the
26whole question is whether blending beats switching (#50 section 6).
27
28⚠ BLENDS THE FINALIZED FIELDS, not the raw Perlin. finalize_synthetic_map
29applies the lapse coupling (temp += lapse*max(0,elev)) inside each factory, so
30a convex blend of two finalized maps only approximately preserves that
31relationship. The run measures corr(temp, elev) in A, in B and in the blend, so
32the size of that approximation is reported rather than assumed.
33
34⚠ UPSTREAM SHARP EDGE: _load_stats_cache() takes no arguments, so the quantile
35stats cached on disk are reused for ANY frequency_mult even though
36_compute_map_stats(frequency_mult, ...) depends on it. Two factories with
37different frequency_mult therefore share stats. Fine for a shape prototype;
38not fine if you tune frequency_mult and expect the distribution to follow.
39
40 python crossfade_seeds.py
41"""
42import argparse, os, sys
43import numpy as np
44
45# ⚠ synthetic_map.py uses RELATIVE paths ("data/global/...") for the WorldClim
46# and ETOPO rasters AND for the stats cache. Imported from elsewhere it silently
47# misses the cache and recomputes quantiles over the full Earth rasters — which
48# does not finish in ten minutes. chdir first.
49TD = "/storage/projects/code/godot/terrain-diffusion"
50sys.path.insert(0, TD)
51os.chdir(TD)
52from terrain_diffusion.inference.synthetic_map import make_synthetic_map_factory
53
54NAMES = ["elevation", "temperature", "temp std", "precipitation", "precip var"]
55
56
57def seam_z(sig, cut, band):
58 """How exceptional is the step AT the seam, against steps elsewhere?
59
60 ⚠ THE FIRST VERSION OF THIS USED max/median OVER THE WHOLE TRANSECT and
61 could not reject a hard cut: fBm conditioning is intrinsically spiky (one
62 world alone scored 9.4-18.8), so a single extra step does not stand out
63 against that baseline. The probe said so and refused to report — the
64 statistic was the bug, not the blend.
65
66 Localising fixes it, and mirrors gate_heightmap's seam test: take the step
67 at the join in units of the spread of steps AWAY from the join. A hard cut
68 is a large outlier there by construction; a smooth blend is not.
69 """
70 d = np.abs(np.diff(sig))
71 lo, hi = cut - band, cut + band
72 outside = np.r_[d[:max(lo, 1)], d[min(hi, len(d) - 1):]]
73 if outside.size < 8:
74 return float("nan")
75 med = float(np.median(outside))
76 sd = float(np.std(outside))
77 if sd <= 0:
78 return float("inf")
79 # THE STEP EXACTLY AT THE JOIN, not the largest step near it. Taking the
80 # max over the band found a natural fBm spike instead of the cut and
81 # returned the SAME value for the blend and the hard cut (7.57 vs 7.57) —
82 # the second statistic the control rejected before it could mislead.
83 # ⚠ OFF BY ONE, and it hid the cut completely. d[i] = |sig[i+1] - sig[i]|,
84 # and with world A below `cut` and world B at and above it, the jump is
85 # d[cut-1]. Reading d[cut] samples a step wholly inside world B, which is
86 # why a hard cut scored z = -0.54 and looked seamless.
87 at_cut = float(d[min(max(cut - 1, 0), len(d) - 1)])
88 return (at_cut - med) / sd
89
90
91def main():
92 ap = argparse.ArgumentParser()
93 ap.add_argument("--n", type=int, default=768, help="transect length, conditioning cells")
94 ap.add_argument("--rows", type=int, default=8)
95 ap.add_argument("--band", type=int, default=192, help="crossfade width, cells")
96 ap.add_argument("--cuts", type=int, default=41,
97 help="how many cut positions to sweep")
98 ap.add_argument("--z-max", type=float, default=6.0,
99 help="per-channel flag threshold (advisory)")
100 ap.add_argument("--min-control-z", type=float, default=1.0,
101 help="a hard cut must score at least this to be a control")
102 a = ap.parse_args()
103
104 print("building two conditioning worlds")
105 fa = make_synthetic_map_factory(frequency_mult=[1.0]*5, seed=1234)
106 fb = make_synthetic_map_factory(frequency_mult=[2.5]*5, seed=777)
107
108 # RAW, then finalize ONCE on the blend.
109 #
110 # sample_full_synthetic_map finalizes each world separately, and finalize
111 # applies the lapse coupling temp += lapse(precip)*max(0,elev) against THAT
112 # world's own elevation. A convex blend of two finalized maps therefore
113 # carries two inconsistent corrections: measured, corr(temp, elev) fell to
114 # -0.098 from -0.431 (A) and -0.149 (B) — mountains in the transition band
115 # stopped being cold.
116 #
117 # sample_raw and finalize are already exposed as attributes on the returned
118 # callable (synthetic_map.py:269-270). I wrote a patch to add them before
119 # checking, and it was a duplicate of code already in HEAD.
120 A = np.asarray(fa.sample_raw(0, 0, a.rows, a.n), dtype=np.float64)
121 B = np.asarray(fb.sample_raw(0, 0, a.rows, a.n), dtype=np.float64)
122 FINALIZE = fa.finalize
123 print(f" A frequency_mult 1.0 seed 1234 B frequency_mult 2.5 seed 777")
124 print(f" sampled {A.shape} conditioning cells\n")
125
126 # smooth weight: 0 on the left third, 1 on the right, smoothstep across band
127 x = np.arange(a.n, dtype=np.float64)
128 c = a.n * 0.5
129 t = np.clip((x - (c - a.band / 2)) / a.band, 0.0, 1.0)
130 w_soft = t * t * (3 - 2 * t)
131 w_hard = (x >= c).astype(np.float64) # the CONTROL
132
133 # MANY CUT POSITIONS, not one. A single cut is a sample of one: where the
134 # two worlds happen to agree, a hard cut leaves no step and the control
135 # looks seamless (precipitation scored z 2.46 that way). Sweeping the cut
136 # across the transect and reporting the MEDIAN is what makes the comparison
137 # about blending rather than about one lucky x.
138 cuts = np.linspace(a.band, a.n - a.band, a.cuts).astype(int)
139 print(f"{'channel':>15} {'blend z':>18} {'hard-cut z':>18} (median over "
140 f"{len(cuts)} cut positions)")
141 fails, soft_med, hard_med = [], [], []
142 for ch in range(5):
143 sa = A[ch].mean(axis=0); sb = B[ch].mean(axis=0)
144 zs_all, zh_all = [], []
145 for cut in cuts:
146 t = np.clip((x - (cut - a.band / 2)) / a.band, 0.0, 1.0)
147 ws = t * t * (3 - 2 * t)
148 wh = (x >= cut).astype(np.float64)
149 zs_all.append(seam_z((1 - ws) * sa + ws * sb, int(cut), a.band))
150 zh_all.append(seam_z((1 - wh) * sa + wh * sb, int(cut), a.band))
151 zs = float(np.nanmedian(zs_all)); zh = float(np.nanmedian(zh_all))
152 soft_med.append(zs); hard_med.append(zh)
153 flag = ""
154 if not np.isfinite(zs) or zs > a.z_max:
155 fails.append(NAMES[ch]); flag = " <-- STEPS"
156 print(f"{NAMES[ch]:>15} {zs:>18.2f} {zh:>18.2f}{flag}")
157 hard_z, soft_z = hard_med, soft_med
158
159 # THE CRITERION IS RELATIVE, and it took four wrong statistics to see why.
160 #
161 # An absolute z threshold does not work here: two worlds drawn from the SAME
162 # quantile distribution are often already close at any given x, so a hard cut
163 # between them costs only ~2 sigma. Setting z_max = 6 meant the control could
164 # never trip, and the probe correctly refused to report rather than printing
165 # a green line it had not earned.
166 #
167 # The question was never "is the blend smooth in absolute terms" — it is
168 # "does blending remove the step that switching creates". So: the control
169 # must be DETECTABLE, and the blend must be well below it.
170 print()
171 hard_worst = float(np.median(hard_z))
172 blend_worst = float(np.max(np.abs(soft_z)))
173 if hard_worst < a.min_control_z:
174 print(f"CONTROL NOT DETECTABLE (median hard-cut z {hard_worst:.2f} < "
175 f"{a.min_control_z}).")
176 print("A hard cut leaves no measurable step, so there is nothing for the")
177 print("blend to improve on and this run proves nothing.")
178 return 2
179 print(f"control (hard cut, same positions) is detectable at median z "
180 f"{hard_worst:.2f} — the test discriminates")
181 if blend_worst > 0.5 * hard_worst:
182 print(f"\nVERDICT: the blend ({blend_worst:.2f}) is not clearly better than\n"
183 f" switching ({hard_worst:.2f}). Widen --band.")
184 return 1
185
186 # Does the lapse coupling survive the blend?
187 # Blend RAW, then finalize once — so the lapse coupling is applied to the
188 # blended elevation and precipitation rather than inherited from two.
189 raw_blend = np.stack([(1 - w_soft) * A[c].mean(0) + w_soft * B[c].mean(0)
190 for c in range(5)])
191 blend = np.asarray(FINALIZE(raw_blend[:, None, :]), dtype=np.float64)[:, 0, :]
192 fA = np.asarray(FINALIZE(A.mean(axis=1)[:, None, :]), dtype=np.float64)[:, 0, :]
193 fB = np.asarray(FINALIZE(B.mean(axis=1)[:, None, :]), dtype=np.float64)[:, 0, :]
194 print(f"\ncorr(temperature, elevation) — negative means the lapse rate holds")
195 print(f" world A {np.corrcoef(fA[1], fA[0])[0,1]:+.3f}"
196 f" world B {np.corrcoef(fB[1], fB[0])[0,1]:+.3f}"
197 f" blend {np.corrcoef(blend[1], blend[0])[0,1]:+.3f}"
198 f" (all finalized the same way)")
199
200 print(f"\nVERDICT: crossfading two conditioning worlds over {a.band} cells "
201 f"({a.band * 7.68:.0f} km)\n"
202 f" leaves a seam of z {blend_worst:.2f}, against {hard_worst:.2f} "
203 f"for switching between\n"
204 f" them at the same places. Two worlds CAN be blended, in the\n"
205 f" conditioning, under ONE latent seed.")
206 return 0
207
208
209if __name__ == "__main__":
210 sys.exit(main())