Mike0021 commited on
Commit
22a050c
·
verified ·
1 Parent(s): 64a9a53

Terrain round 32: water repaint skips water-coloured ground, colour map keeps callers' colours, small-ground preview cameras, running elapsed time

Browse files
Files changed (3) hide show
  1. app/llms.txt +4 -2
  2. app/server.py +10 -3
  3. projtex/terrain.py +70 -11
app/llms.txt CHANGED
@@ -204,8 +204,10 @@ Request (JSON; only the ground and a prompt are required):
204
  (paths get an even surface automatically). "from_above" (optional) says how a layer looks in the colour map when
205
  that differs from its close-up; forests and woods default to their tree crowns. If you place your own 3D trees,
206
  make the forest layer their floor: from_above "pine-needle forest floor, no tree crowns" and a brown colour (a
207
- green patch is painted as crowns); then no trees are painted anywhere. Presets (when layers is omitted, chosen
208
- from the prompt's words): alpine, temperate, desert, arctic, winter, volcanic (GET /api lists them).
 
 
209
  - uv: planar (default: terrain.glb gets top-down UVs) | keep (the colour map is baked into your own UVs).
210
  - backend: qwen-turbo (default) | qwen-space | mock (free dry run). Cost: layers + 1 + tiles images (2048: about
211
  15 images, ~2 min of GPU quota and ~2-3 min in all; 4096: ~31 images).
 
204
  (paths get an even surface automatically). "from_above" (optional) says how a layer looks in the colour map when
205
  that differs from its close-up; forests and woods default to their tree crowns. If you place your own 3D trees,
206
  make the forest layer their floor: from_above "pine-needle forest floor, no tree crowns" and a brown colour (a
207
+ green patch is painted as crowns); then no trees are painted anywhere. A layer's "color" [r, g, b] is kept:
208
+ the colour map is turned to its hue (the image model drifts, e.g. red autumn leaves toward olive). Presets (when
209
+ layers is omitted, chosen from the prompt's words): alpine, temperate, desert, arctic, winter, volcanic (GET /api
210
+ lists them).
211
  - uv: planar (default: terrain.glb gets top-down UVs) | keep (the colour map is baked into your own UVs).
212
  - backend: qwen-turbo (default) | qwen-space | mock (free dry run). Cost: layers + 1 + tiles images (2048: about
213
  15 images, ~2 min of GPU quota and ~2-3 min in all; 4096: ~31 images).
app/server.py CHANGED
@@ -51,6 +51,12 @@ def identity_from(headers, token: str | None = None) -> Identity:
51
  return Identity(token=tok or None, ip_token=headers.get("x-ip-token") or None)
52
 
53
 
 
 
 
 
 
 
54
  def terrain_status(job, base: str, events: int = 12) -> dict:
55
  """The status of a terrain job: progress, the files, what each layer covers, and what to do next."""
56
  url = f"{base}/api/jobs/{job.id}"
@@ -63,7 +69,7 @@ def terrain_status(job, base: str, events: int = 12) -> dict:
63
  "job_id": job.id, "kind": "terrain", "state": job.state, "operation": job.op, "progress": job.progress,
64
  "backend": job.params.get("backend"),
65
  "quota_identity": job.identity.kind if BACKENDS[job.params["backend"]]["uses_quota"] else "not needed",
66
- "elapsed_seconds": round(job.updated - job.created, 1), "files": files, "error": job.error,
67
  "recent_events": job.events[-events:],
68
  }
69
  warnings = list(job.result.get("warnings") or []) + list(st.get("warnings") or [])
@@ -211,7 +217,7 @@ def status_payload(job, base: str, events: int = 12) -> dict:
211
  "job_id": job.id, "state": job.state, "operation": job.op, "progress": job.progress,
212
  "backend": job.params.get("backend"), "mode": job.params.get("mode"),
213
  "quota_identity": job.identity.kind if BACKENDS[job.params["backend"]]["uses_quota"] else "not needed",
214
- "elapsed_seconds": round(job.updated - job.created, 1), "summary": summary, "files": files,
215
  "views": views, "error": job.error, "recent_events": job.events[-events:],
216
  }
217
  last = dict(job.result.get("last") or {})
@@ -544,7 +550,8 @@ class TerrainLayer(BaseModel):
544
  breakup: float | None = Field(None, ge=0, le=1, description="How irregular the layer's edges are (default 0.5)")
545
  tile_m: float = Field(4.0, gt=0.2, le=50, description="Size in metres of one repeat of the detail texture")
546
  color: list[float] | None = Field(None, description="Optional [r, g, b] 0-255 the layer shows in the overview "
547
- "guide (default: its detail texture's mean colour)")
 
548
  triplanar: bool | None = Field(None, description="Project the detail texture from the sides on steep slopes "
549
  "(default: layers whose rules start at 30 degrees or more)")
550
  from_above: str | None = Field(None, description="How the layer looks from high above in the colour map, if "
 
51
  return Identity(token=tok or None, ip_token=headers.get("x-ip-token") or None)
52
 
53
 
54
+ def elapsed(job) -> float:
55
+ """Seconds since the job was created: up to now while it runs (the last event's time stood still through a
56
+ long step, round 32), else up to its last event."""
57
+ return round((time.time() if job.state in ("queued", "running") else job.updated) - job.created, 1)
58
+
59
+
60
  def terrain_status(job, base: str, events: int = 12) -> dict:
61
  """The status of a terrain job: progress, the files, what each layer covers, and what to do next."""
62
  url = f"{base}/api/jobs/{job.id}"
 
69
  "job_id": job.id, "kind": "terrain", "state": job.state, "operation": job.op, "progress": job.progress,
70
  "backend": job.params.get("backend"),
71
  "quota_identity": job.identity.kind if BACKENDS[job.params["backend"]]["uses_quota"] else "not needed",
72
+ "elapsed_seconds": elapsed(job), "files": files, "error": job.error,
73
  "recent_events": job.events[-events:],
74
  }
75
  warnings = list(job.result.get("warnings") or []) + list(st.get("warnings") or [])
 
217
  "job_id": job.id, "state": job.state, "operation": job.op, "progress": job.progress,
218
  "backend": job.params.get("backend"), "mode": job.params.get("mode"),
219
  "quota_identity": job.identity.kind if BACKENDS[job.params["backend"]]["uses_quota"] else "not needed",
220
+ "elapsed_seconds": elapsed(job), "summary": summary, "files": files,
221
  "views": views, "error": job.error, "recent_events": job.events[-events:],
222
  }
223
  last = dict(job.result.get("last") or {})
 
550
  breakup: float | None = Field(None, ge=0, le=1, description="How irregular the layer's edges are (default 0.5)")
551
  tile_m: float = Field(4.0, gt=0.2, le=50, description="Size in metres of one repeat of the detail texture")
552
  color: list[float] | None = Field(None, description="Optional [r, g, b] 0-255 the layer shows in the overview "
553
+ "guide (default: its detail texture's mean colour); the "
554
+ "colour map is turned to its hue")
555
  triplanar: bool | None = Field(None, description="Project the detail texture from the sides on steep slopes "
556
  "(default: layers whose rules start at 30 degrees or more)")
557
  from_above: str | None = Field(None, description="How the layer looks from high above in the colour map, if "
projtex/terrain.py CHANGED
@@ -1053,6 +1053,44 @@ def _lab(rgb) -> np.ndarray:
1053
  return cv2.cvtColor(np.asarray(rgb, np.float32).reshape(1, 1, 3), cv2.COLOR_RGB2LAB)[0, 0]
1054
 
1055
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1056
  def lift_darks(colors: np.ndarray, floor: float = 18.0, knee: float = 30.0) -> np.ndarray:
1057
  """Guide colours (sRGB 0..1) darker than ``knee`` L* pressed into ``floor``..``knee``, their order kept: flat
1058
  near-black reads as ink drawn on the picture, not as ground (round 31: black lava flows, L* 6, were painted
@@ -1257,14 +1295,18 @@ _WATER_LAYER = r"\b(?:water|rivers?|streams?|creeks?|brooks?|ponds?|lakes?|sea|o
1257
 
1258
 
1259
  def dry_painted_water(img: np.ndarray, H: np.ndarray, level: float, W: np.ndarray, layers: list[dict],
1260
- relief: float, water_rgb, cell=(1.0, 1.0)) -> tuple[np.ndarray, int]:
 
1261
  """Water the image model painted on ground above the caller's water plane, repainted in the colour its layers
1262
  have elsewhere in the image. Measured (round 29): an 18 m pond in an 800 m desert was painted 33 to 52 times
1263
  its area whatever the prompt said; under the caller's water plane the extra became dark blue "water" on dry
1264
  sand. Only gentle ground near the level counts (under 30 degrees, within max(10 m, 2 % of the relief)): shaded
1265
  fjord cliffs hundreds of metres up are as dark and blue as water (round 30; the oasis's extra water lay on
1266
- slopes up to 30 degrees, at most 6 m up). ``H`` and ``W`` are at the image's size, ``cell`` its metres per pixel
1267
- (along Z, along X); returns the image and the number of pixels repainted."""
 
 
 
1268
  import cv2
1269
  import re
1270
 
@@ -1283,12 +1325,17 @@ def dry_painted_water(img: np.ndarray, H: np.ndarray, level: float, W: np.ndarra
1283
  fallback = np.median(L[plain], 0) if plain.sum() >= 30 else ground
1284
  if fallback is None:
1285
  return img, 0
1286
- refs = [np.median(L[(w > 0.6) & plain], 0) if ((w > 0.6) & plain).sum() >= 30 else fallback for w in W]
 
 
 
1287
  E = np.einsum("lhw,lc->hwc", W, np.stack(refs).astype(np.float32))
 
1288
  de = np.linalg.norm(L - E, axis=-1)
1289
  gz, gx = np.gradient(H.astype(np.float64), float(cell[0]), float(cell[1]))
1290
  low = (np.degrees(np.arctan(np.hypot(gx, gz))) < 30) & (H < level + max(10.0, 0.02 * relief))
1291
  wet = dry & low & (dw < de) & (dw < np.maximum(12.0, 0.5 * np.linalg.norm(E - mu, axis=-1)))
 
1292
  keep = [i for i, Lr in enumerate(layers) if re.search(_WATER_LAYER, Lr.get("name", "").replace("_", " "), re.I)]
1293
  if keep:
1294
  wet &= W[keep].sum(0) < 0.3
@@ -1716,7 +1763,8 @@ class Terrain:
1716
  fixed, n = dry_painted_water(ov, H, float(self.prm["water_level"]), self.weights(ov.shape[0]),
1717
  self.state["layers"], self.state["relief_m"],
1718
  water_look(self.prm.get("prompt", ""))[0],
1719
- (self.state["depth_m"] / H.shape[0], self.state["width_m"] / H.shape[1]))
 
1720
  if n:
1721
  _save(self.root / "macro" / "overview_raw.png", ov)
1722
  ov = fixed
@@ -1790,6 +1838,12 @@ class Terrain:
1790
  tiles = {(i, j): _load(d / f"tile_{i}_{j}.png") for i in range(lay["n"]) for j in range(lay["n"])}
1791
  macro = merge_tiles(tiles, lay, ov)
1792
  F = macro.shape[0]
 
 
 
 
 
 
1793
  _save(self.root / "macro" / "macro.jpg", macro, quality=92)
1794
  self.state["macro_file"] = "macro/macro.jpg"
1795
  (x0, _), (x1, _) = self.bounds
@@ -1797,7 +1851,6 @@ class Terrain:
1797
  self.state["macro_soft_file"] = "macro/macro_soft.jpg"
1798
  # colour modulation for engines that multiply splatted layers by a colour map (Unity/Godot style):
1799
  # lowpass(macro) / lowpass(sum of w_i * layer mean), linear, stored halved (0.5 = x1)
1800
- W = self.weights()
1801
  means = np.stack([np.asarray(L.get("mean_linear") or [0.2, 0.2, 0.2], np.float32)
1802
  for L in self.state["layers"]])
1803
  S = W.shape[1]
@@ -1955,19 +2008,25 @@ class Terrain:
1955
  if drawn:
1956
  near_drawn = ndi.uniform_filter(W[drawn].sum(0), max(3, S // 30))
1957
  score = score + 0.8 * near_drawn / max(float(near_drawn.max()), 1e-6)
 
 
1958
  wl = self.prm.get("water_level")
1959
  if wl is not None: # look at the land, not at the bed of the caller's lake (round 28)
1960
  Hs = resample(H, S)
1961
- score[Hs < float(wl) + max(0.3, 0.002 * self.state["relief_m"])] = -1e9
1962
- m = S // 8
1963
- score[:m], score[-m:], score[:, :m], score[:, -m:] = -1e9, -1e9, -1e9, -1e9
 
 
 
1964
  r, cc = np.unravel_index(int(np.argmax(score)), score.shape)
1965
  px, pz = x0 + (cc + 0.5) / S * (x1 - x0), z0 + (r + 0.5) / S * (z1 - z0)
1966
  toward = np.array([c[0] - px, c[2] - pz])
1967
  toward = toward / (np.linalg.norm(toward) + 1e-9)
1968
- for name, dd, hh, fov in (("mid", float(np.clip(0.12 * span, 40, 350)), 0.45, 50),
1969
- ("near", float(np.clip(0.004 * span, 4, 12)), 0.4, 60)):
1970
  ex, ez = px - toward[0] * dd, pz - toward[1] * dd
 
1971
  ey = max(self._ground_y(H, ex, ez), self._ground_y(H, px, pz), float(wl) if wl is not None else -1e18) \
1972
  + hh * dd
1973
  tgt = [px, self._ground_y(H, px, pz), pz]
 
1053
  return cv2.cvtColor(np.asarray(rgb, np.float32).reshape(1, 1, 3), cv2.COLOR_RGB2LAB)[0, 0]
1054
 
1055
 
1056
+ def match_colours(img: np.ndarray, W: np.ndarray, layers: list[dict], keep: np.ndarray | None = None,
1057
+ chroma: float = 0.5, cap: float = 15.0) -> np.ndarray:
1058
+ """The colour map with each layer the caller gave a ``color`` turned toward that colour's hue (and half way to
1059
+ its chroma, at most ``cap`` in a*b*), lightness and texture as painted. The image model drifts toward its idea
1060
+ of the place: October leaf litter asked for in rust (126, 74, 38) came back olive brown, a* 19 -> 6, and red
1061
+ blueberry scrub olive, hue 55 -> 94 degrees (round 32): the tester recoloured the materials by hand.
1062
+ ``W`` and ``keep`` (0..1, where to change it: not the caller's water) are at the splat map's size; the shift is
1063
+ measured there and resized to the image."""
1064
+ import cv2
1065
+
1066
+ S = W.shape[1]
1067
+ lab = lambda a: cv2.cvtColor(np.clip(a, 0, 1).astype(np.float32), cv2.COLOR_RGB2LAB) # noqa: E731
1068
+ small = lab(cv2.resize(img, (S, S), interpolation=cv2.INTER_AREA) if img.shape[0] != S else img)
1069
+ shift = np.zeros((S, S, 2), np.float32)
1070
+ inner = np.ones((S, S), bool) if keep is None else keep > 0.5
1071
+ for i, L in enumerate(layers):
1072
+ if not L.get("color"):
1073
+ continue
1074
+ m = cv2.erode(((W[i] > 0.7) & inner).astype(np.uint8), np.ones((5, 5), np.uint8)) > 0
1075
+ if m.sum() < max(100, 1e-4 * m.size):
1076
+ continue
1077
+ ab = small[m][:, 1:].mean(0)
1078
+ want = _lab(guide_colour(L))[1:]
1079
+ c, wc = float(np.hypot(*ab)), float(np.hypot(*want))
1080
+ tc = c + chroma * (wc - c)
1081
+ target = want * tc / wc if wc > 3 else ab * tc / max(c, 1e-6) # a grey has no hue to turn to
1082
+ d = target - ab
1083
+ d *= min(1.0, cap / max(float(np.linalg.norm(d)), 1e-6))
1084
+ shift += W[i][..., None] * d.astype(np.float32)
1085
+ if keep is not None:
1086
+ shift *= keep[..., None]
1087
+ if not shift.any():
1088
+ return img
1089
+ out = lab(img)
1090
+ out[..., 1:] += cv2.resize(shift, img.shape[1::-1], interpolation=cv2.INTER_LINEAR) if img.shape[0] != S else shift
1091
+ return np.clip(cv2.cvtColor(out, cv2.COLOR_LAB2RGB), 0, 1)
1092
+
1093
+
1094
  def lift_darks(colors: np.ndarray, floor: float = 18.0, knee: float = 30.0) -> np.ndarray:
1095
  """Guide colours (sRGB 0..1) darker than ``knee`` L* pressed into ``floor``..``knee``, their order kept: flat
1096
  near-black reads as ink drawn on the picture, not as ground (round 31: black lava flows, L* 6, were painted
 
1295
 
1296
 
1297
  def dry_painted_water(img: np.ndarray, H: np.ndarray, level: float, W: np.ndarray, layers: list[dict],
1298
+ relief: float, water_rgb, cell=(1.0, 1.0), guide: np.ndarray | None = None
1299
+ ) -> tuple[np.ndarray, int]:
1300
  """Water the image model painted on ground above the caller's water plane, repainted in the colour its layers
1301
  have elsewhere in the image. Measured (round 29): an 18 m pond in an 800 m desert was painted 33 to 52 times
1302
  its area whatever the prompt said; under the caller's water plane the extra became dark blue "water" on dry
1303
  sand. Only gentle ground near the level counts (under 30 degrees, within max(10 m, 2 % of the relief)): shaded
1304
  fjord cliffs hundreds of metres up are as dark and blue as water (round 30; the oasis's extra water lay on
1305
+ slopes up to 30 degrees, at most 6 m up). Nor does ground asked for in a colour like water's (``guide``, the
1306
+ colour map the model was given): Iceland's black sand and lava were within 12 of it, and repainting them in
1307
+ the only colour left, the glacier's, ringed the lava with white (round 32). ``H``, ``W`` and ``guide`` are at
1308
+ the image's size, ``cell`` its metres per pixel (along Z, along X); returns the image and the number of
1309
+ pixels repainted."""
1310
  import cv2
1311
  import re
1312
 
 
1325
  fallback = np.median(L[plain], 0) if plain.sum() >= 30 else ground
1326
  if fallback is None:
1327
  return img, 0
1328
+ G = None if guide is None else lab(guide)
1329
+ asked = [np.median(G[w > 0.6], 0) if G is not None and (w > 0.6).sum() >= 30 else None for w in W]
1330
+ refs = [np.median(L[(w > 0.6) & plain], 0) if ((w > 0.6) & plain).sum() >= 30 else
1331
+ a if a is not None else fallback for w, a in zip(W, asked)]
1332
  E = np.einsum("lhw,lc->hwc", W, np.stack(refs).astype(np.float32))
1333
+ A = np.einsum("lhw,lc->hwc", W, np.stack([r if a is None else a for a, r in zip(asked, refs)]).astype(np.float32))
1334
  de = np.linalg.norm(L - E, axis=-1)
1335
  gz, gx = np.gradient(H.astype(np.float64), float(cell[0]), float(cell[1]))
1336
  low = (np.degrees(np.arctan(np.hypot(gx, gz))) < 30) & (H < level + max(10.0, 0.02 * relief))
1337
  wet = dry & low & (dw < de) & (dw < np.maximum(12.0, 0.5 * np.linalg.norm(E - mu, axis=-1)))
1338
+ wet &= np.linalg.norm(A - mu, axis=-1) > 20 # ground asked for in a colour like water's: cannot tell them apart
1339
  keep = [i for i, Lr in enumerate(layers) if re.search(_WATER_LAYER, Lr.get("name", "").replace("_", " "), re.I)]
1340
  if keep:
1341
  wet &= W[keep].sum(0) < 0.3
 
1763
  fixed, n = dry_painted_water(ov, H, float(self.prm["water_level"]), self.weights(ov.shape[0]),
1764
  self.state["layers"], self.state["relief_m"],
1765
  water_look(self.prm.get("prompt", ""))[0],
1766
+ (self.state["depth_m"] / H.shape[0], self.state["width_m"] / H.shape[1]),
1767
+ guide=resample(g, ov.shape[0]))
1768
  if n:
1769
  _save(self.root / "macro" / "overview_raw.png", ov)
1770
  ov = fixed
 
1838
  tiles = {(i, j): _load(d / f"tile_{i}_{j}.png") for i in range(lay["n"]) for j in range(lay["n"])}
1839
  macro = merge_tiles(tiles, lay, ov)
1840
  F = macro.shape[0]
1841
+ W = self.weights()
1842
+ keep = None
1843
+ if self.prm.get("water_level") is not None: # the water painted below the caller's plane stays water
1844
+ H = resample(np.load(self.root / "mesh" / "height.npy").astype(np.float32), W.shape[1])
1845
+ keep = cv2.GaussianBlur((H >= float(self.prm["water_level"])).astype(np.float32), (0, 0), 1)
1846
+ macro = match_colours(macro, W, self.state["layers"], keep)
1847
  _save(self.root / "macro" / "macro.jpg", macro, quality=92)
1848
  self.state["macro_file"] = "macro/macro.jpg"
1849
  (x0, _), (x1, _) = self.bounds
 
1851
  self.state["macro_soft_file"] = "macro/macro_soft.jpg"
1852
  # colour modulation for engines that multiply splatted layers by a colour map (Unity/Godot style):
1853
  # lowpass(macro) / lowpass(sum of w_i * layer mean), linear, stored halved (0.5 = x1)
 
1854
  means = np.stack([np.asarray(L.get("mean_linear") or [0.2, 0.2, 0.2], np.float32)
1855
  for L in self.state["layers"]])
1856
  S = W.shape[1]
 
2008
  if drawn:
2009
  near_drawn = ndi.uniform_filter(W[drawn].sum(0), max(3, S // 30))
2010
  score = score + 0.8 * near_drawn / max(float(near_drawn.max()), 1e-6)
2011
+ m = S // 8
2012
+ score[:m], score[-m:], score[:, :m], score[:, -m:] = -1e9, -1e9, -1e9, -1e9
2013
  wl = self.prm.get("water_level")
2014
  if wl is not None: # look at the land, not at the bed of the caller's lake (round 28)
2015
  Hs = resample(H, S)
2016
+ # a zen garden's gravel was 6 cm above its pond's level: all of it "lake bed", and the views went to
2017
+ # the corner (round 32)
2018
+ for above in (max(0.3, 0.002 * self.state["relief_m"]), 0.0):
2019
+ if (score[Hs >= float(wl) + above] > -1e8).any():
2020
+ score[Hs < float(wl) + above] = -1e9
2021
+ break
2022
  r, cc = np.unravel_index(int(np.argmax(score)), score.shape)
2023
  px, pz = x0 + (cc + 0.5) / S * (x1 - x0), z0 + (r + 0.5) / S * (z1 - z0)
2024
  toward = np.array([c[0] - px, c[2] - pz])
2025
  toward = toward / (np.linalg.norm(toward) + 1e-9)
2026
+ for name, dd, hh, fov in (("mid", float(np.clip(0.12 * span, min(40, 0.3 * span), 350)), 0.45, 50),
2027
+ ("near", float(np.clip(0.004 * span, min(4, 0.1 * span), 12)), 0.4, 60)):
2028
  ex, ez = px - toward[0] * dd, pz - toward[1] * dd
2029
+ ex, ez = np.clip(ex, x0, x1), np.clip(ez, z0, z1) # over the ground, not beyond its edge
2030
  ey = max(self._ground_y(H, ex, ez), self._ground_y(H, px, pz), float(wl) if wl is not None else -1e18) \
2031
  + hh * dd
2032
  tgt = [px, self._ground_y(H, px, pz), pz]