OverviewFilesCommitsIssuesDocsReleasesPeers

terrain-lookdev commits / e3a8e36

lookdev: the erosion band was sitting on top of the model, not under it
Reported as "the erosion filter seems to be acting on too high a layer", and it was, for two reasons. BAND. I defaulted the coarsest gully to 900 m. #46 §4 measured that the model still has real structure down to 120-240 m, so the erosion band 900 -> 56 m overlapped three octaves the model already owns and the two competed. Default is now 240 m, and the panel warns above 400. RESOLUTION, which is the real one. A 30 m/px tile has a 60 m Nyquist, so on the model's own grid the filter CANNOT produce anything finer than 60 m -- there is nowhere to put it. Narrowing the band alone just squeezes it against that floor. The filter is analytic and point-evaluable, so the limit is the grid, not the filter. e_upsample carries the tile to a finer grid first (x2 -> 15 m/px, x4 -> 7.5 m/px) and the filter then generates genuine detail down to 15 m. The upsample claims no information -- the model has none below 60 m -- it is only a carrier, and bilinear_up says so in its docstring. Measured on a 3.8 km crop at 7.5 m/px, band 240 -> 15 m: sd 213.6 -> 260.3 m, mean |change| 54.5 m, and the model's ridges and catchments are pixel-for-pixel where they were. The derived readout now shows the grid Nyquist against the finest requested octave and warns when the band drops below it, since that is the same aliasing trap voxel_size guards on the erosion panel. Cost: 2048^2 is 17 s on the CPU shim. Fine for lookdev, and the reason the real version wants the GPU sampler binding (#46 §3). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
e3a8e365eb18db21dc519d063d67a4b64ee41f3e
by plankatron <noreply@anthropic.com> · 2026-08-31T17:20:52.000Z

Changed files (2) +46 -7

index.html +13 -3
……@@ -200,7 +200,8 @@ const GROUPS = {
200200 ["noise_lacunarity",1.87,1.2,3,0.01],["gn_scale",3,0.1,40,0.1],
201201 ["gn_gain",0.1,0,0.9,0.01],["gn_detail",3,1,8,1],
202202 ["voxel_size",1,0.25,512,0.25]],
203 erode: [["e_gully_metres",900,30,20000,10],["e_strength",0.08,0,1,0.005],
203 erode: [["e_upsample",2,1,4,1],
204 ["e_gully_metres",240,30,20000,10],["e_strength",0.08,0,1,0.005],
204205 ["e_octaves",5,1,10,1],["e_gain",0.5,0.2,0.9,0.01],
205206 ["e_cell_scale",0.7,0.05,4,0.01],["e_rounding_input",0.1,0.005,4,0.005],
206207 ["e_offset",0,-2,1,0.01]],
……@@ -288,9 +289,18 @@ function derived(k) {
288289 case "voxel_size": return {txt: `band-limit; at ${num("mpp")} m/px use \u2265 ${2*num("mpp")} m`,
289290 warn: num("voxel_size") < 2 * num("mpp")};
290291 case "rounding_input": return {txt: "runevision's input-feature-size control; pinned at 0.1 in the engine"};
292 case "e_upsample": return {txt: `filter runs at ${(30/num("e_upsample")).toFixed(2)} m/px, ` +
293 `Nyquist ${(60/num("e_upsample")).toFixed(0)} m ` +
294 `(the model itself stops at 60 m)`};
291295 case "e_gully_metres":
292 case "e_octaves": return {txt: band(num("e_gully_metres"), 2.0, num("e_octaves")) +
293 " (diffusion's Nyquist is 60 m)"};
296 case "e_octaves": {
297 const fin = num("e_gully_metres") / Math.pow(2, num("e_octaves") - 1);
298 const nyq = 60 / num("e_upsample");
299 return {txt: band(num("e_gully_metres"), 2.0, num("e_octaves")) +
300 (fin < nyq ? ` below the ${nyq.toFixed(0)} m grid Nyquist — aliases`
301 : ` grid Nyquist ${nyq.toFixed(0)} m`),
302 warn: fin < nyq || num("e_gully_metres") > 400};
303 }
294304 case "e_strength": return {txt: "relative to the input tile's own relief; 0.22 is runevision's, far too strong here"};
295305 case "e_offset": return {txt: "DC bias x magnitude. -0.65 is the reference; it shifts a 2 km tile by ~800 m, so 0 for lookdev"};
296306 case "assumed_slope_mix":return {txt: num("assumed_slope_mix") >= 1
server.py +33 -4
……@@ -96,9 +96,29 @@ ERODE_KNOBS = {
9696 "e_rounding_input": ("f", 0.005, 4.0),
9797 "e_offset": ("f", -2.0, 1.0),
9898 "e_vscale": ("f", 0.0, 20000.0),
99 # Run the filter on a FINER grid than the model's. The model has nothing
100 # below its 60 m Nyquist, so upsampling adds no information -- it is a
101 # carrier. The filter is analytic and point-evaluable, so it genuinely
102 # generates detail there. Without this the erosion band is floored at 60 m
103 # and competes with the model instead of extending it.
104 "e_upsample": ("i", 1, 4),
99105}
100106
101107
108def bilinear_up(h, f):
109 """Nearest-honest upsample: no information is claimed, only carried."""
110 if f <= 1:
111 return h
112 n = h.shape[0]
113 t = np.linspace(0, n - 1, n * f)
114 i0 = np.clip(t.astype(int), 0, n - 2)
115 fr = (t - i0)
116 a = h[np.ix_(i0, i0)]; b = h[np.ix_(i0 + 1, i0)]
117 c = h[np.ix_(i0, i0 + 1)]; d = h[np.ix_(i0 + 1, i0 + 1)]
118 fy = fr[:, None]; fx = fr[None, :]
119 return a * (1 - fy) * (1 - fx) + b * fy * (1 - fx) + c * (1 - fy) * fx + d * fy * fx
120
121
102122def erode_field(h, mpp, args):
103123 """Run erode_heightmap on an in-memory field. Returns (height, ridge)."""
104124 import tempfile
……@@ -120,6 +140,8 @@ def erode_field(h, mpp, args):
120140 ("e_cell_scale", "--cell-scale"),
121141 ("e_rounding_input", "--rounding-input"),
122142 ("e_offset", "--offset"), ("e_vscale", "--vscale")):
143 if k == "e_upsample":
144 continue
123145 v = gv(k)
124146 if v is not None:
125147 cmd += [flag, repr(v)]
……@@ -307,10 +329,14 @@ def api_diffusion():
307329 i1 = int(float(a.get("i1", 0))); j1 = int(float(a.get("j1", 2048)))
308330 h, climate = fetch_diffusion(app.config["TD_URL"], i1, j1, n, a.get("seed"))
309331 extra = {"X-Source": "terrain-diffusion", "X-Mpp": "30"}
332 mpp_d = 30.0
310333 if a.get("erode") in ("1", "true", "on"):
311 h, _ridge = erode_field(h, 30.0, a)
334 f = int(_clamp(float(a.get("e_upsample", 1)), 1, 4))
335 h = bilinear_up(h, f); mpp_d = 30.0 / f
336 h, _ridge = erode_field(h, mpp_d, a)
312337 extra["X-Source"] = "terrain-diffusion + erosion_filter"
313 png, lo, hi = colourise(h, 30.0, a.get("view", "shaded-height"))
338 extra["X-Mpp"] = repr(mpp_d)
339 png, lo, hi = colourise(h, mpp_d, a.get("view", "shaded-height"))
314340 if climate is not None:
315341 extra["X-Climate"] = (f"{climate[...,0].mean():.1f}C,"
316342 f"{climate[...,1].mean():.0f},"
……@@ -369,15 +395,18 @@ def api_diffusion_bin():
369395 i1 = int(float(a.get("i1", 0))); j1 = int(float(a.get("j1", 2048)))
370396 h, climate = fetch_diffusion(app.config["TD_URL"], i1, j1, n, a.get("seed"))
371397 extra = {"X-Source": "terrain-diffusion"}
398 mpp_d = 30.0
372399 if a.get("erode") in ("1", "true", "on"):
373 h, _ridge = erode_field(h, 30.0, a)
400 f = int(_clamp(float(a.get("e_upsample", 1)), 1, 4))
401 h = bilinear_up(h, f); mpp_d = 30.0 / f
402 h, _ridge = erode_field(h, mpp_d, a)
374403 extra["X-Source"] = "terrain-diffusion + erosion_filter"
375404 if climate is not None:
376405 extra["X-Climate"] = (f"{climate[...,0].mean():.1f}C,"
377406 f"{climate[...,1].mean():.0f},"
378407 f"{climate[...,2].mean():.0f}mm,"
379408 f"{climate[...,3].mean():.0f}%")
380 return _raw_response(h, 30.0, extra)
409 return _raw_response(h, mpp_d, extra)
381410 except Exception as e:
382411 return Response(str(e), status=400, mimetype="text/plain")
383412