OverviewFilesCommitsIssuesDocsReleasesPeers

terrain-lookdev / crossfade_seeds.py

211 lines · python ref:
#!/usr/bin/env python3
"""
CROSSFADE TWO WORLDS — in the CONDITIONING, which is the only place it works.

"Can we blend seeds at large regions?" splits in two, because seed does two
jobs:

  LATENT seed  — cannot be blended. Noise is drawn position-addressed as
                 standard_normal(seed, i*TILE_STRIDE, j*TILE_STRIDE)
                 (world_pipeline.py:1230). 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.

  CONDITIONING — blendable, continuous, and the half that carries regional
                 character: elevation prior, temperature, seasonality,
                 precipitation, variability. The latent seed only decides WHICH
                 ridge goes where; conditioning decides what KIND of place it is.

So this blends two synthetic conditioning maps with different frequency_mult
(biome scale) and different seeds (layout), across a smooth band, and asks
whether the result is continuous.

THE CONTROL IS A HARD SWITCH at the same place. The statistic must REJECT it.
A continuity test that cannot see an abrupt cut is measuring nothing — the
whole question is whether blending beats switching (#50 section 6).

⚠ BLENDS THE FINALIZED FIELDS, not the raw Perlin. finalize_synthetic_map
applies the lapse coupling (temp += lapse*max(0,elev)) inside each factory, so
a convex blend of two finalized maps only approximately preserves that
relationship. The run measures corr(temp, elev) in A, in B and in the blend, so
the size of that approximation is reported rather than assumed.

⚠ UPSTREAM SHARP EDGE: _load_stats_cache() takes no arguments, so the quantile
stats cached on disk are reused for ANY frequency_mult even though
_compute_map_stats(frequency_mult, ...) depends on it. Two factories with
different frequency_mult therefore share stats. Fine for a shape prototype;
not fine if you tune frequency_mult and expect the distribution to follow.

    python crossfade_seeds.py
"""
import argparse, os, sys
import numpy as np

# ⚠ synthetic_map.py uses RELATIVE paths ("data/global/...") for the WorldClim
# and ETOPO rasters AND for the stats cache. Imported from elsewhere it silently
# misses the cache and recomputes quantiles over the full Earth rasters — which
# does not finish in ten minutes. chdir first.
TD = "/storage/projects/code/godot/terrain-diffusion"
sys.path.insert(0, TD)
os.chdir(TD)
from terrain_diffusion.inference.synthetic_map import make_synthetic_map_factory

NAMES = ["elevation", "temperature", "temp std", "precipitation", "precip var"]


def seam_z(sig, cut, band):
    """How exceptional is the step AT the seam, against steps elsewhere?

    ⚠ THE FIRST VERSION OF THIS USED max/median OVER THE WHOLE TRANSECT and
    could not reject a hard cut: fBm conditioning is intrinsically spiky (one
    world alone scored 9.4-18.8), so a single extra step does not stand out
    against that baseline. The probe said so and refused to report — the
    statistic was the bug, not the blend.

    Localising fixes it, and mirrors gate_heightmap's seam test: take the step
    at the join in units of the spread of steps AWAY from the join. A hard cut
    is a large outlier there by construction; a smooth blend is not.
    """
    d = np.abs(np.diff(sig))
    lo, hi = cut - band, cut + band
    outside = np.r_[d[:max(lo, 1)], d[min(hi, len(d) - 1):]]
    if outside.size < 8:
        return float("nan")
    med = float(np.median(outside))
    sd = float(np.std(outside))
    if sd <= 0:
        return float("inf")
    # THE STEP EXACTLY AT THE JOIN, not the largest step near it. Taking the
    # max over the band found a natural fBm spike instead of the cut and
    # returned the SAME value for the blend and the hard cut (7.57 vs 7.57) —
    # the second statistic the control rejected before it could mislead.
    # ⚠ OFF BY ONE, and it hid the cut completely. d[i] = |sig[i+1] - sig[i]|,
    # and with world A below `cut` and world B at and above it, the jump is
    # d[cut-1]. Reading d[cut] samples a step wholly inside world B, which is
    # why a hard cut scored z = -0.54 and looked seamless.
    at_cut = float(d[min(max(cut - 1, 0), len(d) - 1)])
    return (at_cut - med) / sd


def main():
    ap = argparse.ArgumentParser()
    ap.add_argument("--n", type=int, default=768, help="transect length, conditioning cells")
    ap.add_argument("--rows", type=int, default=8)
    ap.add_argument("--band", type=int, default=192, help="crossfade width, cells")
    ap.add_argument("--cuts", type=int, default=41,
                    help="how many cut positions to sweep")
    ap.add_argument("--z-max", type=float, default=6.0,
                    help="per-channel flag threshold (advisory)")
    ap.add_argument("--min-control-z", type=float, default=1.0,
                    help="a hard cut must score at least this to be a control")
    a = ap.parse_args()

    print("building two conditioning worlds")
    fa = make_synthetic_map_factory(frequency_mult=[1.0]*5, seed=1234)
    fb = make_synthetic_map_factory(frequency_mult=[2.5]*5, seed=777)

    # RAW, then finalize ONCE on the blend.
    #
    # sample_full_synthetic_map finalizes each world separately, and finalize
    # applies the lapse coupling temp += lapse(precip)*max(0,elev) against THAT
    # world's own elevation. A convex blend of two finalized maps therefore
    # carries two inconsistent corrections: measured, corr(temp, elev) fell to
    # -0.098 from -0.431 (A) and -0.149 (B) — mountains in the transition band
    # stopped being cold.
    #
    # sample_raw and finalize are already exposed as attributes on the returned
    # callable (synthetic_map.py:269-270). I wrote a patch to add them before
    # checking, and it was a duplicate of code already in HEAD.
    A = np.asarray(fa.sample_raw(0, 0, a.rows, a.n), dtype=np.float64)
    B = np.asarray(fb.sample_raw(0, 0, a.rows, a.n), dtype=np.float64)
    FINALIZE = fa.finalize
    print(f"  A frequency_mult 1.0 seed 1234   B frequency_mult 2.5 seed 777")
    print(f"  sampled {A.shape} conditioning cells\n")

    # smooth weight: 0 on the left third, 1 on the right, smoothstep across band
    x = np.arange(a.n, dtype=np.float64)
    c = a.n * 0.5
    t = np.clip((x - (c - a.band / 2)) / a.band, 0.0, 1.0)
    w_soft = t * t * (3 - 2 * t)
    w_hard = (x >= c).astype(np.float64)          # the CONTROL

    # MANY CUT POSITIONS, not one. A single cut is a sample of one: where the
    # two worlds happen to agree, a hard cut leaves no step and the control
    # looks seamless (precipitation scored z 2.46 that way). Sweeping the cut
    # across the transect and reporting the MEDIAN is what makes the comparison
    # about blending rather than about one lucky x.
    cuts = np.linspace(a.band, a.n - a.band, a.cuts).astype(int)
    print(f"{'channel':>15} {'blend z':>18} {'hard-cut z':>18}   (median over "
          f"{len(cuts)} cut positions)")
    fails, soft_med, hard_med = [], [], []
    for ch in range(5):
        sa = A[ch].mean(axis=0); sb = B[ch].mean(axis=0)
        zs_all, zh_all = [], []
        for cut in cuts:
            t = np.clip((x - (cut - a.band / 2)) / a.band, 0.0, 1.0)
            ws = t * t * (3 - 2 * t)
            wh = (x >= cut).astype(np.float64)
            zs_all.append(seam_z((1 - ws) * sa + ws * sb, int(cut), a.band))
            zh_all.append(seam_z((1 - wh) * sa + wh * sb, int(cut), a.band))
        zs = float(np.nanmedian(zs_all)); zh = float(np.nanmedian(zh_all))
        soft_med.append(zs); hard_med.append(zh)
        flag = ""
        if not np.isfinite(zs) or zs > a.z_max:
            fails.append(NAMES[ch]); flag = "  <-- STEPS"
        print(f"{NAMES[ch]:>15} {zs:>18.2f} {zh:>18.2f}{flag}")
    hard_z, soft_z = hard_med, soft_med

    # THE CRITERION IS RELATIVE, and it took four wrong statistics to see why.
    #
    # An absolute z threshold does not work here: two worlds drawn from the SAME
    # quantile distribution are often already close at any given x, so a hard cut
    # between them costs only ~2 sigma. Setting z_max = 6 meant the control could
    # never trip, and the probe correctly refused to report rather than printing
    # a green line it had not earned.
    #
    # The question was never "is the blend smooth in absolute terms" — it is
    # "does blending remove the step that switching creates". So: the control
    # must be DETECTABLE, and the blend must be well below it.
    print()
    hard_worst = float(np.median(hard_z))
    blend_worst = float(np.max(np.abs(soft_z)))
    if hard_worst < a.min_control_z:
        print(f"CONTROL NOT DETECTABLE (median hard-cut z {hard_worst:.2f} < "
              f"{a.min_control_z}).")
        print("A hard cut leaves no measurable step, so there is nothing for the")
        print("blend to improve on and this run proves nothing.")
        return 2
    print(f"control (hard cut, same positions) is detectable at median z "
          f"{hard_worst:.2f} — the test discriminates")
    if blend_worst > 0.5 * hard_worst:
        print(f"\nVERDICT: the blend ({blend_worst:.2f}) is not clearly better than\n"
              f"         switching ({hard_worst:.2f}). Widen --band.")
        return 1

    # Does the lapse coupling survive the blend?
    # Blend RAW, then finalize once — so the lapse coupling is applied to the
    # blended elevation and precipitation rather than inherited from two.
    raw_blend = np.stack([(1 - w_soft) * A[c].mean(0) + w_soft * B[c].mean(0)
                          for c in range(5)])
    blend = np.asarray(FINALIZE(raw_blend[:, None, :]), dtype=np.float64)[:, 0, :]
    fA = np.asarray(FINALIZE(A.mean(axis=1)[:, None, :]), dtype=np.float64)[:, 0, :]
    fB = np.asarray(FINALIZE(B.mean(axis=1)[:, None, :]), dtype=np.float64)[:, 0, :]
    print(f"\ncorr(temperature, elevation) — negative means the lapse rate holds")
    print(f"  world A {np.corrcoef(fA[1], fA[0])[0,1]:+.3f}"
          f"   world B {np.corrcoef(fB[1], fB[0])[0,1]:+.3f}"
          f"   blend  {np.corrcoef(blend[1], blend[0])[0,1]:+.3f}"
          f"   (all finalized the same way)")

    print(f"\nVERDICT: crossfading two conditioning worlds over {a.band} cells "
          f"({a.band * 7.68:.0f} km)\n"
          f"         leaves a seam of z {blend_worst:.2f}, against {hard_worst:.2f} "
          f"for switching between\n"
          f"         them at the same places. Two worlds CAN be blended, in the\n"
          f"         conditioning, under ONE latent seed.")
    return 0


if __name__ == "__main__":
    sys.exit(main())