OverviewFilesCommitsIssuesDocsReleasesPeers

terrain-lookdev / gate_heightmap.py

128 lines · python ref:
#!/usr/bin/env python3
"""
GATE: the erosion filter running on a diffusion heightfield, in GLSL.

Three rows, and the second and third exist so the first cannot pass by accident.

  IDENTITY   strength 0, no height offset, no detail layer. The surface the
             renderer finds MUST equal the input heightfield. This is the check
             erode_heightmap.cpp's --check does for the CPU shim, moved to the
             GPU path: a unit error in the metres -> filter-space conversion
             cannot survive it, and that conversion is exactly where #45 lost a
             session to a 60x factor.

  CONTROL    the same run at the REAL strength must NOT match. An identity gate
             that passes whatever you feed it is measuring nothing; this shows
             it discriminates.

  SEAM       the eroded field's step across an internal tile join must be no
             worse than the steps just inside the neighbouring tiles. This is
             the one that would catch the per-tile normalisation bug -- and it
             needs a block of at least 2x2 tiles to be able to fail at all,
             which is why stitch_block defaults to 4.

    python gate_heightmap.py /tmp/block.hblk --bin /tmp/erosion_render2
"""
import argparse, os, subprocess, sys
import numpy as np
from stitch_block import read_hblk

DC = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "dc-terrain"))
TILE_PX = 256


def run(binpath, hblk, out, extra):
    cmd = [binpath, "--heightmap", hblk, "--out", out] + extra
    r = subprocess.run(cmd, cwd=DC, capture_output=True, text=True)
    if r.returncode != 0:
        print(r.stdout, r.stderr); raise SystemExit(f"renderer failed: {cmd}")
    return r.stdout


def load(out, n):
    a = np.fromfile(out, dtype="<f4", count=n * n).reshape(n, n)
    return a.astype(np.float64)


def main():
    ap = argparse.ArgumentParser()
    ap.add_argument("hblk"); ap.add_argument("--bin", default="/tmp/erosion_render2")
    ap.add_argument("--strength", default="0.15")
    ap.add_argument("--tol", type=float, default=1.0, help="identity tolerance, metres")
    a = ap.parse_args()

    arr, meta = read_hblk(a.hblk)
    n = meta["side"] - 2
    ref = arr[1:-1, 1:-1].astype(np.float64)          # interior = what is rendered
    print(f"block {meta['side']}x{meta['side']} px, rendering interior {n}x{n}"
          f"  ({n*meta['mpp']/1000:.2f} km)\n")

    # ---- IDENTITY ---------------------------------------------------------
    run(a.bin, a.hblk, "/tmp/gate_id.f32",
        ["--set", "en_strength", "0", "--set", "en_height_offset_a", "0",
         "--set", "en_height_offset_b", "0", "--set", "detail_scale", "0"])
    idf = load("/tmp/gate_id.f32", n)
    d = np.abs(idf - ref)
    id_ok = d.max() <= a.tol
    print(f"  IDENTITY  maxdiff {d.max():8.3f} m   rmse {np.sqrt((d**2).mean()):8.4f} m"
          f"   {'PASS' if id_ok else 'FAIL'}")

    # ---- CONTROL ----------------------------------------------------------
    run(a.bin, a.hblk, "/tmp/gate_er.f32",
        ["--set", "en_strength", a.strength, "--set", "detail_scale", "0"])
    er = load("/tmp/gate_er.f32", n)
    de = np.abs(er - ref)
    ctl_ok = de.max() > a.tol
    print(f"  CONTROL   strength {a.strength}: maxdiff {de.max():8.1f} m   "
          f"rmse {np.sqrt((de**2).mean()):7.1f} m   "
          f"{'DIFFERS (gate discriminates)' if ctl_ok else 'IDENTICAL -- GATE IS BLIND'}")

    # ---- SEAM -------------------------------------------------------------
    # MEAN SIGNED step across the join, not mean absolute.
    #
    # The first version of this check used mean |step| and was BLIND: a 60 m
    # per-tile offset injected on purpose moved it from 1.007 to 1.088 against
    # a 1.5 threshold, because the terrain's own pixel-to-pixel roughness is
    # far larger than the seam and drowns it. A normalisation error is a
    # CONSISTENT SIGNED jump; natural roughness averages to about zero. So the
    # signed mean isolates exactly the thing being looked for, and is compared
    # against the spread of the same statistic at nearby interior columns.
    def col_signed_step(f, b):
        return float(np.mean(f[:, b + 1] - f[:, b]))

    bounds = [k * TILE_PX - 1 for k in range(1, n // TILE_PX)]
    if not bounds:
        print("  SEAM      SKIPPED -- block is 1 tile wide, this check cannot fail")
        seam_ok = False
    else:
        worst_z, rows = 0.0, []
        for b in bounds:
            at = col_signed_step(er, b)
            near = [col_signed_step(er, c) for c in range(b - 24, b + 25)
                    if abs(c - b) > 2 and 0 <= c < n - 1]
            sd = float(np.std(near)) or 1e-9
            z = abs(at) / sd
            worst_z = max(worst_z, z)
            rows.append((b, at, sd, z))
        for b, at, sd, z in rows:
            print(f"  SEAM      col {b:>5}: signed step {at:+8.3f} m   "
                  f"neighbours sd {sd:6.3f} m   z {z:6.2f}")
        seam_ok = bool(worst_z < 5.0)
        print(f"            worst z {worst_z:.2f}   "
              f"{'PASS (<5)' if seam_ok else 'FAIL -- the join is a systematic step'}")

    print()
    if not ctl_ok:
        print("VERDICT: the control did not move, so IDENTITY proves nothing."); return 2
    if not id_ok:
        print("VERDICT: units are WRONG. Do not trust any eroded field."); return 1
    if not seam_ok:
        print("VERDICT: identity holds but the tile joins are visible."); return 1
    print("VERDICT: units exact, erosion active, joins invisible. The shared "
          "wrapper's heightmap path is correct on the GPU.")
    return 0


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