| … | … | @@ -145,7 +145,18 @@ async function poll() { |
| 145 | 145 | // the queue explodes (343 deep on first load). Below MIN_PX the tile is drawn |
| 146 | 146 | // as a placeholder and never requested; that band is exactly where the coarse |
| 147 | 147 | // stage (7.7 km/px, cheap) belongs, and is the next thing to wire in. |
| 148 | const MIN_PX = 48; | |
| 148 | // TWO limits, and the first run had neither working. | |
| 149 | // | |
| 150 | // MIN_PX: below this a tile is a thumbnail and generating 30 m/px for it is | |
| 151 | // waste. 48 was far too permissive -- at 97 px on screen the gate never fired | |
| 152 | // and the page queued all 220 visible tiles plus a ring: 445 deep, at ~11 s | |
| 153 | // each, 84 minutes of GPU from ONE view change. | |
| 154 | // | |
| 155 | // MAX_QUEUE: never ask for more than can be delivered while you are still | |
| 156 | // looking at it. One tile at a time is by design, so a queue longer than this | |
| 157 | // is a promise the page cannot keep -- and it is stale the moment you pan. | |
| 158 | const MIN_PX = 110; | |
| 159 | const MAX_QUEUE = 12; | |
| 149 | 160 | const onScreen = TILE_PX * view.scale; |
| 150 | 161 | if (onScreen < MIN_PX) { |
| 151 | 162 | try { |
| … | … | @@ -160,11 +171,17 @@ async function poll() { |
| 160 | 171 | for (let dy = -rings; dy <= rings; dy++) |
| 161 | 172 | for (let dx = -rings; dx <= rings; dx++) want.add((tx+dx) + "," + (ty+dy)); |
| 162 | 173 | } |
| 163 | const list = [...want].join(";"); | |
| 164 | 174 | const ctx_ = [Math.floor(view.x / TILE_PX), Math.floor(view.y / TILE_PX)]; |
| 175 | // Sort by distance from the view centre and ask only for the nearest few. | |
| 176 | const sorted = [...want].map(k => { | |
| 177 | const [x, y] = k.split(",").map(Number); | |
| 178 | return {k, d: (x - ctx_[0]) ** 2 + (y - ctx_[1]) ** 2}; | |
| 179 | }).sort((a, b) => a.d - b.d); | |
| 180 | const list = sorted.map(o => o.k).join(";"); // state for all | |
| 181 | const ask = sorted.slice(0, MAX_QUEUE).map(o => o.k).join(";"); // generate a few | |
| 165 | 182 | try { |
| 166 | 183 | if (!STOPPED) { |
| 167 | await fetch(`/api/tiles/request?tiles=${encodeURIComponent(list)}` + | |
| 184 | await fetch(`/api/tiles/request?tiles=${encodeURIComponent(ask)}` + | |
| 168 | 185 | `&cx=${ctx_[0]}&cy=${ctx_[1]}`); |
| 169 | 186 | } |
| 170 | 187 | const r = await fetch(`/api/tiles/state?tiles=${encodeURIComponent(list)}`); |
| … | … | @@ -201,7 +218,7 @@ function hud() { |
| 201 | 218 | |
| 202 | 219 | <span class="k">generated </span>${s.generated||0} tiles <span class="k">=</span> ${(s.km2||0).toFixed(0)} km² |
| 203 | 220 | <span class="k">avg gen </span>${(s.avg_gen_s||0).toFixed(2)} s/tile ${(s.km2_per_s||0).toFixed(1)} km²/s |
| 204 | <span class="k">queue </span><span class="${(s.queue||0)>0?'w':'k'}">${s.queue||0}</span> <span class="k">busy</span> <span class="a">${(s.busy||[]).join(" ")||"—"}</span> | |
| 221 | <span class="k">queue </span><span class="${(s.queue||0)>12?'w':'k'}">${s.queue||0}</span><span class="k"> / cap 12</span> <span class="k">busy</span> <span class="a">${(s.busy||[]).join(" ")||"—"}</span> | |
| 205 | 222 | <span class="k">cache </span>${s.disk_tiles||0} on disk, ${s.mem_tiles||0} in memory |
| 206 | 223 | <span class="k">hits </span>${s.from_disk||0} disk, ${s.from_mem||0} mem${(s.errors||0)?`\n<span class="w">errors ${s.errors}</span>`:""}`; |
| 207 | 224 | } |
| … | … | @@ -272,6 +272,33 @@ def to_png(rgb): |
| 272 | 272 | return buf.getvalue() |
| 273 | 273 | |
| 274 | 274 | |
| 275 | def colourise_array(h, mpp, view, vmin, vmax): | |
| 276 | """The colour computation without the PNG encode, so a caller can crop an | |
| 277 | apron off before encoding.""" | |
| 278 | finite = h[np.isfinite(h)] | |
| 279 | lo = float(vmin) if vmin is not None else (float(finite.min()) if finite.size else 0.0) | |
| 280 | hi = float(vmax) if vmax is not None else (float(finite.max()) if finite.size else 1.0) | |
| 281 | if hi <= lo: | |
| 282 | hi = lo + 1.0 | |
| 283 | if view == "slope": | |
| 284 | s = np.clip(slope_map(h, mpp) / 60.0, 0, 1) | |
| 285 | rgb = np.stack([s, 1.0 - s * 0.65, 1.0 - s], axis=-1) | |
| 286 | elif view == "height": | |
| 287 | t = np.nan_to_num(np.clip((h - lo) / (hi - lo), 0, 1)) | |
| 288 | rgb = np.stack([0.15 + 0.85 * t, 0.25 + 0.6 * t, 0.35 + 0.3 * (1 - t)], axis=-1) | |
| 289 | else: | |
| 290 | sh = hillshade(h, mpp) | |
| 291 | if view == "shaded-height": | |
| 292 | t = np.nan_to_num(np.clip((h - lo) / (hi - lo), 0, 1)) | |
| 293 | base = np.stack([0.20 + 0.75 * t, 0.30 + 0.55 * t, | |
| 294 | 0.38 + 0.22 * (1 - t)], axis=-1) | |
| 295 | rgb = base * (0.35 + 0.65 * sh[..., None]) | |
| 296 | else: | |
| 297 | rgb = np.repeat(sh[..., None], 3, axis=-1) | |
| 298 | rgb = np.where(np.isfinite(h)[..., None], rgb, 0.0) | |
| 299 | return (np.clip(rgb, 0, 1) * 255).astype(np.uint8) | |
| 300 | ||
| 301 | ||
| 275 | 302 | def colourise(h, mpp, view, vmin=None, vmax=None): |
| 276 | 303 | """view: shade | height | slope | shaded-height |
| 277 | 304 | |
| … | … | @@ -476,9 +503,13 @@ def api_tile(): |
| 476 | 503 | # reads as terrain discontinuity -- the same misread as upstream #19. |
| 477 | 504 | vmin = float(request.args.get("vmin", -4000)) |
| 478 | 505 | vmax = float(request.args.get("vmax", 4000)) |
| 479 | png, _lo, _hi = colourise(a.astype(np.float64), 30.0, | |
| 480 | request.args.get("view", "shaded-height"), | |
| 481 | vmin, vmax) | |
| 506 | # Shade WITH the apron, then crop it. Shading the bare tile is what put | |
| 507 | # a seam on every join even though the elevation is continuous. | |
| 508 | full = a.astype(np.float64) | |
| 509 | shaded = colourise_array(full, 30.0, request.args.get("view", "shaded-height"), | |
| 510 | vmin, vmax) | |
| 511 | ap = tiles().APRON | |
| 512 | png = to_png(shaded[ap:-ap, ap:-ap] if ap else shaded) | |
| 482 | 513 | r = Response(png, mimetype="image/png") |
| 483 | 514 | r.headers["Cache-Control"] = "public, max-age=3600" |
| 484 | 515 | r.headers["X-Tile"] = f"{tx},{ty}" |
| … | … | @@ -57,8 +57,19 @@ class TileStore: |
| 57 | 57 | self._worker.start() |
| 58 | 58 | |
| 59 | 59 | # ---- addressing -------------------------------------------------------- |
| 60 | # Tiles are stored with a 1 px APRON on every side. | |
| 61 | # | |
| 62 | # np.gradient falls back to ONE-SIDED differences at an array edge, so a | |
| 63 | # tile shaded on its own has its outer row and column lit differently from | |
| 64 | # its interior -- and adjacent tiles then mismatch exactly at the join. The | |
| 65 | # elevation has no seam (measured: join step 6.25 m vs inner 6.14 m, ratio | |
| 66 | # 1.02x); the seam was entirely in the shading. Shade the apron, crop it off. | |
| 67 | # | |
| 68 | # v2 filename, so the old apron-less cache is ignored rather than deleted. | |
| 69 | APRON = 1 | |
| 70 | ||
| 60 | 71 | def path(self, tx, ty): |
| 61 | return os.path.join(self.dir, f"t_{self.tile_px}_{tx}_{ty}.npy") | |
| 72 | return os.path.join(self.dir, f"t2_{self.tile_px}_{tx}_{ty}.npy") | |
| 62 | 73 | |
| 63 | 74 | def state(self, tx, ty): |
| 64 | 75 | k = (tx, ty) |
| … | … | @@ -118,7 +129,8 @@ class TileStore: |
| 118 | 129 | t0 = time.time() |
| 119 | 130 | try: |
| 120 | 131 | n = self.tile_px |
| 121 | e, _c = self.fetch(tx * n, ty * n, n) | |
| 132 | a_ = self.APRON | |
| 133 | e, _c = self.fetch(tx * n - a_, ty * n - a_, n + 2 * a_) | |
| 122 | 134 | a = np.clip(e, -32768, 32767).astype(np.int16) |
| 123 | 135 | np.save(self.path(tx, ty), a) |
| 124 | 136 | self._mem[(tx, ty)] = a |
| … | … | @@ -140,7 +152,7 @@ class TileStore: |
| 140 | 152 | s["mem_tiles"] = len(self._mem) |
| 141 | 153 | try: |
| 142 | 154 | s["disk_tiles"] = len([f for f in os.listdir(self.dir) |
| 143 | if f.startswith(f"t_{self.tile_px}_")]) | |
| 155 | if f.startswith(f"t2_{self.tile_px}_")]) | |
| 144 | 156 | except OSError: |
| 145 | 157 | s["disk_tiles"] = 0 |
| 146 | 158 | s["avg_gen_s"] = (s["gen_seconds"] / s["generated"]) if s["generated"] else 0.0 |