Spaces:
Running
Running
Terrain round 30: drawn layers with rules stay in their shapes, forest-floor swatches, water repaint only on low gentle ground
Browse files- app/llms.txt +11 -11
- projtex/terrain.py +46 -7
app/llms.txt
CHANGED
|
@@ -193,17 +193,17 @@ Request (JSON; only the ground and a prompt are required):
|
|
| 193 |
20 % of the layer's area in natural patches (dirt in grass). paths/areas are ground coordinates in metres:
|
| 194 |
[x, z] of glTF / three.js; from Blender pass "axes": "z-up" and give [x, y]. A layer with only paths or areas
|
| 195 |
exists only there (roads, fields); with rules too, the rules apply inside them.
|
| 196 |
-
The layer prompt describes the ground close up (it becomes a 1024 px photo texture of tile_m metres
|
| 197 |
-
prompt
|
| 198 |
-
|
| 199 |
-
|
| 200 |
-
|
| 201 |
-
|
| 202 |
-
|
| 203 |
-
|
| 204 |
-
|
| 205 |
-
|
| 206 |
-
/api lists them).
|
| 207 |
- uv: planar (default: terrain.glb gets top-down UVs) | keep (the colour map is baked into your own UVs).
|
| 208 |
- backend: qwen-turbo (default) | qwen-space | mock (free dry run). Cost: layers + 1 + tiles images (2048: about
|
| 209 |
15 images, ~2 min of GPU quota and ~2-3 min in all; 4096: ~31 images).
|
|
|
|
| 193 |
20 % of the layer's area in natural patches (dirt in grass). paths/areas are ground coordinates in metres:
|
| 194 |
[x, z] of glTF / three.js; from Blender pass "axes": "z-up" and give [x, y]. A layer with only paths or areas
|
| 195 |
exists only there (roads, fields); with rules too, the rules apply inside them.
|
| 196 |
+
The layer prompt describes the ground close up (it becomes a 1024 px photo texture of tile_m metres, made from
|
| 197 |
+
that prompt alone: name the material, "groomed snow piste", not "groomed piste"); the main prompt describes the
|
| 198 |
+
place as a whole: climate, season, region. The colour map shows only the ground: buildings named in the prompt are
|
| 199 |
+
left out (put them in your scene as 3D models; texture them with POST /api/jobs and give the ground around them as
|
| 200 |
+
a layer area, e.g. a yard), and roads or streams drawn by a layer's paths follow your points, not the prompt's
|
| 201 |
+
words. Trees and bushes appear only when the prompt or a layer asks for them, and with a forest layer only inside
|
| 202 |
+
it (ask for "scattered trees" or "hedgerows" to get them elsewhere). The texture repeats in fixed directions, so
|
| 203 |
+
describe surfaces, not structures with a direction: "packed gravel", not "a lane with two ruts and a grass strip"
|
| 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. Presets (when layers is omitted,
|
| 206 |
+
chosen from the prompt's words): alpine, temperate, desert, arctic, winter, volcanic (GET /api lists them).
|
| 207 |
- uv: planar (default: terrain.glb gets top-down UVs) | keep (the colour map is baked into your own UVs).
|
| 208 |
- backend: qwen-turbo (default) | qwen-space | mock (free dry run). Cost: layers + 1 + tiles images (2048: about
|
| 209 |
15 images, ~2 min of GPU quota and ~2-3 min in all; 4096: ~31 images).
|
projtex/terrain.py
CHANGED
|
@@ -305,8 +305,9 @@ def splat_weights(maps: dict, layers: list[dict], seed: int = 0) -> np.ndarray:
|
|
| 305 |
W = np.zeros((len(layers), size, size), np.float32)
|
| 306 |
for i, L in enumerate(layers):
|
| 307 |
w = np.ones((size, size), np.float32)
|
| 308 |
-
if i and
|
| 309 |
-
w[:] = 0 # drawn only where its shapes are
|
|
|
|
| 310 |
elif i:
|
| 311 |
br = float(L.get("breakup", 0.5))
|
| 312 |
nz = fbm(size, 6, 5, seed * 1000 + 17 * i) if br > 0 else 0.0
|
|
@@ -890,6 +891,35 @@ def wooded(layer: dict) -> bool:
|
|
| 890 |
return bool(re.search(_WOODED, text, re.I))
|
| 891 |
|
| 892 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 893 |
def guide_colour(layer: dict) -> np.ndarray:
|
| 894 |
"""A layer's colour in the overview's guide: the given colour, else canopy green for trees (the guide must
|
| 895 |
agree with the legend: a forest floor's brown under "pine tree crowns" made the model ignore the guide), else
|
|
@@ -1127,11 +1157,14 @@ _WATER_LAYER = r"\b(?:water|rivers?|streams?|creeks?|brooks?|ponds?|lakes?|sea|o
|
|
| 1127 |
|
| 1128 |
|
| 1129 |
def dry_painted_water(img: np.ndarray, H: np.ndarray, level: float, W: np.ndarray, layers: list[dict],
|
| 1130 |
-
relief: float, water_rgb) -> tuple[np.ndarray, int]:
|
| 1131 |
"""Water the image model painted on ground above the caller's water plane, repainted in the colour its layers
|
| 1132 |
have elsewhere in the image. Measured (round 29): an 18 m pond in an 800 m desert was painted 33 to 52 times
|
| 1133 |
its area whatever the prompt said; under the caller's water plane the extra became dark blue "water" on dry
|
| 1134 |
-
sand.
|
|
|
|
|
|
|
|
|
|
| 1135 |
import cv2
|
| 1136 |
import re
|
| 1137 |
|
|
@@ -1153,7 +1186,9 @@ def dry_painted_water(img: np.ndarray, H: np.ndarray, level: float, W: np.ndarra
|
|
| 1153 |
refs = [np.median(L[(w > 0.6) & plain], 0) if ((w > 0.6) & plain).sum() >= 30 else fallback for w in W]
|
| 1154 |
E = np.einsum("lhw,lc->hwc", W, np.stack(refs).astype(np.float32))
|
| 1155 |
de = np.linalg.norm(L - E, axis=-1)
|
| 1156 |
-
|
|
|
|
|
|
|
| 1157 |
keep = [i for i, Lr in enumerate(layers) if re.search(_WATER_LAYER, Lr.get("name", "").replace("_", " "), re.I)]
|
| 1158 |
if keep:
|
| 1159 |
wet &= W[keep].sum(0) < 0.3
|
|
@@ -1492,7 +1527,10 @@ class Terrain:
|
|
| 1492 |
seed = self._seed("layer:" + L["name"], new_seeds)
|
| 1493 |
col = np.asarray(L["color"], np.float32) / 255 if L.get("color") else np.asarray(default_colour(L))
|
| 1494 |
path = bool(L.get("paths"))
|
| 1495 |
-
|
|
|
|
|
|
|
|
|
|
| 1496 |
if path: # the texture repeats in fixed directions: ruts or edge lines would cross the road
|
| 1497 |
prompt += (" Only the road surface material itself, even all over: no edges, verges, ruts, lines or "
|
| 1498 |
"stripes running across it.")
|
|
@@ -1573,7 +1611,8 @@ class Terrain:
|
|
| 1573 |
H = resample(np.load(self.root / "mesh" / "height.npy").astype(np.float32), ov.shape[0])
|
| 1574 |
fixed, n = dry_painted_water(ov, H, float(self.prm["water_level"]), self.weights(ov.shape[0]),
|
| 1575 |
self.state["layers"], self.state["relief_m"],
|
| 1576 |
-
water_look(self.prm.get("prompt", ""))[0]
|
|
|
|
| 1577 |
if n:
|
| 1578 |
_save(self.root / "macro" / "overview_raw.png", ov)
|
| 1579 |
ov = fixed
|
|
|
|
| 305 |
W = np.zeros((len(layers), size, size), np.float32)
|
| 306 |
for i, L in enumerate(layers):
|
| 307 |
w = np.ones((size, size), np.float32)
|
| 308 |
+
if i and (L.get("paths") or L.get("areas")):
|
| 309 |
+
w[:] = 0 # drawn only where its shapes are, its rules applied there (Terrain.plan); round 30: with
|
| 310 |
+
# rules too it also covered every slope its rules allow, 38 % of a fjord for a 0.6 % village green
|
| 311 |
elif i:
|
| 312 |
br = float(L.get("breakup", 0.5))
|
| 313 |
nz = fbm(size, 6, 5, seed * 1000 + 17 * i) if br > 0 else 0.0
|
|
|
|
| 891 |
return bool(re.search(_WOODED, text, re.I))
|
| 892 |
|
| 893 |
|
| 894 |
+
_TREE_PARTS = (r"\b(?:trunks?|crowns?|canop(?:y|ies)|trees?|branch(?:es)?|foliage|woodlands?|woods|forests?|groves?|"
|
| 895 |
+
r"copses?|thickets?|jungles?)\b(?!\s+floor)")
|
| 896 |
+
_WOOD_NOUN = r"\b(?:woodlands?|woods|forests?|groves?|copses?|thickets?|jungles?|orchards?)\b"
|
| 897 |
+
|
| 898 |
+
|
| 899 |
+
def floor_words(prompt: str) -> str:
|
| 900 |
+
"""What a wooded layer's detail texture shows: the ground under the trees. A prompt describing the trees
|
| 901 |
+
("dense Nordic birch woodland: slender white birch trunks, bright fresh green canopy, ...") gave a swatch of a
|
| 902 |
+
whole wood seen from afar, tiled on the ground every 8 m (round 30); asking for "the forest floor under" it kept
|
| 903 |
+
the trees. Clauses about the trees are dropped; the ground clauses stay, after the wood's name."""
|
| 904 |
+
import re
|
| 905 |
+
|
| 906 |
+
clauses = [c.strip() for c in re.split(r"[,;:.]", prompt or "") if c.strip()]
|
| 907 |
+
trees = [c for c in clauses if re.search(_TREE_PARTS, c, re.I)]
|
| 908 |
+
if not trees:
|
| 909 |
+
return prompt
|
| 910 |
+
ground = []
|
| 911 |
+
for c in clauses: # "gnarled olive trees on dry red earth with stones": the earth stays
|
| 912 |
+
tail = re.search(r"\b(?:on|over|among|in)\s+(.+)$", c, re.I) if c in trees else None
|
| 913 |
+
if c not in trees or (tail and not re.search(_TREE_PARTS, tail.group(1), re.I)):
|
| 914 |
+
ground.append(c if c not in trees else tail.group(1))
|
| 915 |
+
# the wood's name: its noun and up to three words before it ("dense Nordic birch woodland")
|
| 916 |
+
m = re.search(r"\b((?:(?!(?:in|on|of|a|an|the|with|and|trees?)\b)[\w-]+\s+){0,3})" + _WOOD_NOUN, " ".join(trees),
|
| 917 |
+
re.I)
|
| 918 |
+
wood = m.group(0).strip() if m else "woodland"
|
| 919 |
+
return (f"forest floor of {'an' if wood[0].lower() in 'aeiou' else 'a'} {wood}: "
|
| 920 |
+
+ (", ".join(ground) if ground else "fallen leaves or needles, twigs and moss"))
|
| 921 |
+
|
| 922 |
+
|
| 923 |
def guide_colour(layer: dict) -> np.ndarray:
|
| 924 |
"""A layer's colour in the overview's guide: the given colour, else canopy green for trees (the guide must
|
| 925 |
agree with the legend: a forest floor's brown under "pine tree crowns" made the model ignore the guide), else
|
|
|
|
| 1157 |
|
| 1158 |
|
| 1159 |
def dry_painted_water(img: np.ndarray, H: np.ndarray, level: float, W: np.ndarray, layers: list[dict],
|
| 1160 |
+
relief: float, water_rgb, cell=(1.0, 1.0)) -> tuple[np.ndarray, int]:
|
| 1161 |
"""Water the image model painted on ground above the caller's water plane, repainted in the colour its layers
|
| 1162 |
have elsewhere in the image. Measured (round 29): an 18 m pond in an 800 m desert was painted 33 to 52 times
|
| 1163 |
its area whatever the prompt said; under the caller's water plane the extra became dark blue "water" on dry
|
| 1164 |
+
sand. Only gentle ground near the level counts (under 30 degrees, within max(10 m, 2 % of the relief)): shaded
|
| 1165 |
+
fjord cliffs hundreds of metres up are as dark and blue as water (round 30; the oasis's extra water lay on
|
| 1166 |
+
slopes up to 30 degrees, at most 6 m up). ``H`` and ``W`` are at the image's size, ``cell`` its metres per pixel
|
| 1167 |
+
(along Z, along X); returns the image and the number of pixels repainted."""
|
| 1168 |
import cv2
|
| 1169 |
import re
|
| 1170 |
|
|
|
|
| 1186 |
refs = [np.median(L[(w > 0.6) & plain], 0) if ((w > 0.6) & plain).sum() >= 30 else fallback for w in W]
|
| 1187 |
E = np.einsum("lhw,lc->hwc", W, np.stack(refs).astype(np.float32))
|
| 1188 |
de = np.linalg.norm(L - E, axis=-1)
|
| 1189 |
+
gz, gx = np.gradient(H.astype(np.float64), float(cell[0]), float(cell[1]))
|
| 1190 |
+
low = (np.degrees(np.arctan(np.hypot(gx, gz))) < 30) & (H < level + max(10.0, 0.02 * relief))
|
| 1191 |
+
wet = dry & low & (dw < de) & (dw < np.maximum(12.0, 0.5 * np.linalg.norm(E - mu, axis=-1)))
|
| 1192 |
keep = [i for i, Lr in enumerate(layers) if re.search(_WATER_LAYER, Lr.get("name", "").replace("_", " "), re.I)]
|
| 1193 |
if keep:
|
| 1194 |
wet &= W[keep].sum(0) < 0.3
|
|
|
|
| 1527 |
seed = self._seed("layer:" + L["name"], new_seeds)
|
| 1528 |
col = np.asarray(L["color"], np.float32) / 255 if L.get("color") else np.asarray(default_colour(L))
|
| 1529 |
path = bool(L.get("paths"))
|
| 1530 |
+
words = surface_words(L["prompt"]) if path else floor_words(L["prompt"]) if wooded(L) else L["prompt"]
|
| 1531 |
+
if words != L["prompt"]:
|
| 1532 |
+
L["detail_prompt"] = words
|
| 1533 |
+
prompt = SWATCH_PROMPT.format(m=L["tile_m"], p=words)
|
| 1534 |
if path: # the texture repeats in fixed directions: ruts or edge lines would cross the road
|
| 1535 |
prompt += (" Only the road surface material itself, even all over: no edges, verges, ruts, lines or "
|
| 1536 |
"stripes running across it.")
|
|
|
|
| 1611 |
H = resample(np.load(self.root / "mesh" / "height.npy").astype(np.float32), ov.shape[0])
|
| 1612 |
fixed, n = dry_painted_water(ov, H, float(self.prm["water_level"]), self.weights(ov.shape[0]),
|
| 1613 |
self.state["layers"], self.state["relief_m"],
|
| 1614 |
+
water_look(self.prm.get("prompt", ""))[0],
|
| 1615 |
+
(self.state["depth_m"] / H.shape[0], self.state["width_m"] / H.shape[1]))
|
| 1616 |
if n:
|
| 1617 |
_save(self.root / "macro" / "overview_raw.png", ov)
|
| 1618 |
ov = fixed
|