Spaces:
Running on Zero
Light unlit scenes with daylight, colour assets from thumbnails, hint at the next tier
Browse filesFrom the "green apple on the table" pre-push test:
- No glowing verb meant no sun and a near-black picture. Unlit scenes now
get soft seeded daylight, and the ledger asks "what light?" (default:
daylight), the same way the implied ground is surfaced. Lit scenes keep
their exact seeded configuration.
- SimReady assets rendered grey because textures are skipped. The colour now
comes from the mean of the 256 px thumbnail (backdrop left out), and the
thumbnail is a public dataset URL instead of a local Space path.
- base_color read trimesh's default dark grey (102) as a real colour, so
every uncoloured mesh rendered murky; it now falls back to light grey.
- "which <word>?" questions carry on_reject, e.g. 101 Objaverse tables
behind the one SimReady table.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- SPEC.md +11 -0
- vli/assets.py +32 -5
- vli/grammar.py +10 -2
- vli/pipeline.py +17 -1
- vli/scene.py +4 -2
- vli/simplify.py +2 -1
|
@@ -52,6 +52,17 @@ comes back with a **mini HTML page** the model can read, edit and build on.
|
|
| 52 |
nominal default (an `unresolved` decision the user can set).
|
| 53 |
- Light comes from the verb, not from a special word: whatever "shone" becomes the
|
| 54 |
light source and sits in the sky along the light direction.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 55 |
- The ground implied by "sat" (with no stated object) is a support plane created by
|
| 56 |
the relation. It is not a typed word, so it needs no asset. To exist, a thing needs
|
| 57 |
a reality to exist in: reading skips this, but visualising exposes it. So the
|
|
|
|
| 52 |
nominal default (an `unresolved` decision the user can set).
|
| 53 |
- Light comes from the verb, not from a special word: whatever "shone" becomes the
|
| 54 |
light source and sits in the sky along the light direction.
|
| 55 |
+
- When nothing shines, the picture still needs light: seeing needs light as sitting
|
| 56 |
+
needs a ground. The scene gets soft daylight from no object (`sun.daylight: true`,
|
| 57 |
+
seeded direction, shadows on) and `unresolved` asks "what light?" (default:
|
| 58 |
+
daylight; rephrase with "<noun> shone/glowed" to make an object the light).
|
| 59 |
+
- SimReady geometry comes without textures, so the colour is the mean of the asset's
|
| 60 |
+
256 px thumbnail (backdrop and transparent pixels left out), stored as `item.color`.
|
| 61 |
+
`item.thumbnail` is a public dataset URL, so the chat can show and compare assets.
|
| 62 |
+
Uncoloured meshes render neutral light grey.
|
| 63 |
+
- A "which <word>?" question carries `on_reject`: what rejecting the tier would offer
|
| 64 |
+
(e.g. `{"tier": "objaverse", "candidates": 101, "offered": 9}`), since those
|
| 65 |
+
alternatives are invisible until the tier is rejected.
|
| 66 |
- The ground implied by "sat" (with no stated object) is a support plane created by
|
| 67 |
the relation. It is not a typed word, so it needs no asset. To exist, a thing needs
|
| 68 |
a reality to exist in: reading skips this, but visualising exposes it. So the
|
|
@@ -8,6 +8,7 @@ import gzip
|
|
| 8 |
import json
|
| 9 |
import os
|
| 10 |
from functools import lru_cache
|
|
|
|
| 11 |
|
| 12 |
import numpy as np
|
| 13 |
import trimesh
|
|
@@ -112,8 +113,33 @@ def load_simready(cand):
|
|
| 112 |
roots = sorted(glob.glob(os.path.join(folder, "simready_usd", "*.usd*")))
|
| 113 |
if not roots:
|
| 114 |
raise UnusableAsset("no root USD file")
|
| 115 |
-
thumbs = glob.glob(os.path.join(folder, "simready_usd", ".thumbs", "256x256", "*.usd.png"))
|
| 116 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 117 |
|
| 118 |
|
| 119 |
# ---------------- objaverse (GLB, no real-world scale) ----------------
|
|
@@ -133,7 +159,7 @@ def load_objaverse(cand):
|
|
| 133 |
mesh = _flatten(trimesh.load(path, force="scene"))
|
| 134 |
if len(mesh.faces) == 0:
|
| 135 |
raise UnusableAsset("empty mesh")
|
| 136 |
-
return _normalize(mesh), {"license": lic, "metric": False, "thumbnail": None}
|
| 137 |
|
| 138 |
|
| 139 |
# ---------------- construction tiers ----------------
|
|
@@ -151,7 +177,7 @@ def load_primitive(cand, word):
|
|
| 151 |
mesh = trimesh.creation.box([4 * h, 0.01, 4 * h])
|
| 152 |
else:
|
| 153 |
mesh = trimesh.creation.box([h, h, h])
|
| 154 |
-
return _normalize(mesh), {"license": None, "metric": False, "thumbnail": None}
|
| 155 |
|
| 156 |
|
| 157 |
def load_generation(cand, word):
|
|
@@ -196,7 +222,8 @@ def settle(item):
|
|
| 196 |
raise UnusableAsset(f"no usable {dec['tier']} asset for '{item['word']}' ({e})")
|
| 197 |
dec["chosen"] = dec["alternatives"].pop(0)
|
| 198 |
item["license"] = info["license"]
|
| 199 |
-
item["thumbnail"] = info["thumbnail"]
|
|
|
|
| 200 |
height = item["height_m"]
|
| 201 |
native = float(mesh.extents[1]) or 1.0
|
| 202 |
if height["source"] == "user":
|
|
|
|
| 8 |
import json
|
| 9 |
import os
|
| 10 |
from functools import lru_cache
|
| 11 |
+
from urllib.parse import quote
|
| 12 |
|
| 13 |
import numpy as np
|
| 14 |
import trimesh
|
|
|
|
| 113 |
roots = sorted(glob.glob(os.path.join(folder, "simready_usd", "*.usd*")))
|
| 114 |
if not roots:
|
| 115 |
raise UnusableAsset("no root USD file")
|
| 116 |
+
thumbs = sorted(glob.glob(os.path.join(folder, "simready_usd", ".thumbs", "256x256", "*.usd.png")))
|
| 117 |
+
mesh, url, color = _usd_mesh(roots[0]), None, None
|
| 118 |
+
if thumbs:
|
| 119 |
+
rel = "/".join([cand["name"], "simready_usd", ".thumbs", "256x256", os.path.basename(thumbs[0])])
|
| 120 |
+
url = f"https://huggingface.co/datasets/{cand['repo']}/resolve/{cand['revision']}/{quote(rel)}"
|
| 121 |
+
color = thumbnail_color(thumbs[0])
|
| 122 |
+
if color is not None: # geometry comes without textures; the thumbnail still knows the colour
|
| 123 |
+
mesh.visual = trimesh.visual.ColorVisuals(mesh, face_colors=np.tile(color, (len(mesh.faces), 1)))
|
| 124 |
+
return mesh, {"license": lic, "metric": True, "thumbnail": url,
|
| 125 |
+
"color": None if color is None else "#%02x%02x%02x" % tuple(color[:3])}
|
| 126 |
+
|
| 127 |
+
|
| 128 |
+
def thumbnail_color(path):
|
| 129 |
+
"""Mean RGBA of the object in a thumbnail: transparent pixels, or pixels close to the
|
| 130 |
+
border colour (the backdrop), are left out. None when nothing is left."""
|
| 131 |
+
from PIL import Image
|
| 132 |
+
|
| 133 |
+
img = np.asarray(Image.open(path).convert("RGBA").resize((64, 64)), dtype=np.float64)
|
| 134 |
+
rgb, alpha = img[..., :3], img[..., 3]
|
| 135 |
+
if (alpha < 128).any():
|
| 136 |
+
mask = alpha >= 128
|
| 137 |
+
else:
|
| 138 |
+
border = np.concatenate([rgb[0], rgb[-1], rgb[:, 0], rgb[:, -1]])
|
| 139 |
+
mask = np.linalg.norm(rgb - np.median(border, axis=0), axis=-1) > 30
|
| 140 |
+
if mask.sum() < 20:
|
| 141 |
+
return None
|
| 142 |
+
return np.r_[rgb[mask].mean(axis=0), 255].astype(np.uint8)
|
| 143 |
|
| 144 |
|
| 145 |
# ---------------- objaverse (GLB, no real-world scale) ----------------
|
|
|
|
| 159 |
mesh = _flatten(trimesh.load(path, force="scene"))
|
| 160 |
if len(mesh.faces) == 0:
|
| 161 |
raise UnusableAsset("empty mesh")
|
| 162 |
+
return _normalize(mesh), {"license": lic, "metric": False, "thumbnail": None, "color": None}
|
| 163 |
|
| 164 |
|
| 165 |
# ---------------- construction tiers ----------------
|
|
|
|
| 177 |
mesh = trimesh.creation.box([4 * h, 0.01, 4 * h])
|
| 178 |
else:
|
| 179 |
mesh = trimesh.creation.box([h, h, h])
|
| 180 |
+
return _normalize(mesh), {"license": None, "metric": False, "thumbnail": None, "color": None}
|
| 181 |
|
| 182 |
|
| 183 |
def load_generation(cand, word):
|
|
|
|
| 222 |
raise UnusableAsset(f"no usable {dec['tier']} asset for '{item['word']}' ({e})")
|
| 223 |
dec["chosen"] = dec["alternatives"].pop(0)
|
| 224 |
item["license"] = info["license"]
|
| 225 |
+
item["thumbnail"] = info["thumbnail"] # a public URL, so the chat can show and compare it
|
| 226 |
+
item["color"] = info["color"]
|
| 227 |
height = item["height_m"]
|
| 228 |
native = float(mesh.extents[1]) or 1.0
|
| 229 |
if height["source"] == "user":
|
|
@@ -95,6 +95,9 @@ def classify(word):
|
|
| 95 |
|
| 96 |
# ---------------- ledger ----------------
|
| 97 |
|
|
|
|
|
|
|
|
|
|
| 98 |
def primitive_candidate(shape="box"):
|
| 99 |
return {"source": "primitive", "shape": shape}
|
| 100 |
|
|
@@ -108,7 +111,7 @@ def _object_decision(word, shape=None):
|
|
| 108 |
for tier in ASSET_TIERS:
|
| 109 |
cands = found.get(tier, [])
|
| 110 |
if cands:
|
| 111 |
-
return {"tier": tier, "chosen": cands[0], "alternatives": cands[1:
|
| 112 |
"rejected_tiers": [], "skipped": []}
|
| 113 |
raise ValueError(f"'{word}' has no asset")
|
| 114 |
|
|
@@ -122,7 +125,7 @@ def next_tier(decision, word):
|
|
| 122 |
cands = found.get(tier, [])
|
| 123 |
if not cands:
|
| 124 |
continue
|
| 125 |
-
return {"tier": tier, "chosen": cands[0], "alternatives": cands[1:
|
| 126 |
if tier == "primitive":
|
| 127 |
return {"tier": tier, "chosen": primitive_candidate(), "alternatives": []}
|
| 128 |
raise ValueError(f"'{word}' has no tier left after {decision['tier']}: generation is not "
|
|
@@ -225,6 +228,11 @@ def build_ledger(sentence, primitives=None):
|
|
| 225 |
for q in unresolved:
|
| 226 |
if q["about"] is None:
|
| 227 |
q["question"] = "determiner without a noun"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 228 |
return {"version": 1, "sentence": sentence, "primitives": primitives,
|
| 229 |
"tokens": tokens, "items": items, "unresolved": unresolved}
|
| 230 |
|
|
|
|
| 95 |
|
| 96 |
# ---------------- ledger ----------------
|
| 97 |
|
| 98 |
+
POOL = 9 # candidates taken from a tier: the chosen one plus alternatives
|
| 99 |
+
|
| 100 |
+
|
| 101 |
def primitive_candidate(shape="box"):
|
| 102 |
return {"source": "primitive", "shape": shape}
|
| 103 |
|
|
|
|
| 111 |
for tier in ASSET_TIERS:
|
| 112 |
cands = found.get(tier, [])
|
| 113 |
if cands:
|
| 114 |
+
return {"tier": tier, "chosen": cands[0], "alternatives": cands[1:POOL],
|
| 115 |
"rejected_tiers": [], "skipped": []}
|
| 116 |
raise ValueError(f"'{word}' has no asset")
|
| 117 |
|
|
|
|
| 125 |
cands = found.get(tier, [])
|
| 126 |
if not cands:
|
| 127 |
continue
|
| 128 |
+
return {"tier": tier, "chosen": cands[0], "alternatives": cands[1:POOL]}
|
| 129 |
if tier == "primitive":
|
| 130 |
return {"tier": tier, "chosen": primitive_candidate(), "alternatives": []}
|
| 131 |
raise ValueError(f"'{word}' has no tier left after {decision['tier']}: generation is not "
|
|
|
|
| 228 |
for q in unresolved:
|
| 229 |
if q["about"] is None:
|
| 230 |
q["question"] = "determiner without a noun"
|
| 231 |
+
if not any(i.get("emits_light") for i in items):
|
| 232 |
+
# Seeing needs light, as sitting needs a ground: unstated, so surfaced like the ground.
|
| 233 |
+
unresolved.append({"id": "q_light", "token": None, "about": "light", "question": "what light?",
|
| 234 |
+
"default_chosen": "daylight", "implied": True,
|
| 235 |
+
"alternatives": ["rephrase: '<noun> shone' or '<noun> glowed'"]})
|
| 236 |
return {"version": 1, "sentence": sentence, "primitives": primitives,
|
| 237 |
"tokens": tokens, "items": items, "unresolved": unresolved}
|
| 238 |
|
|
@@ -8,7 +8,8 @@ import os
|
|
| 8 |
import tempfile
|
| 9 |
|
| 10 |
from . import assets, html, scene
|
| 11 |
-
from .grammar import build_ledger, candidate_label, next_tier, parse_primitives, primitive_candidate
|
|
|
|
| 12 |
|
| 13 |
|
| 14 |
def _workdir():
|
|
@@ -21,6 +22,20 @@ def _option_label(cand):
|
|
| 21 |
return f"{label} [{cand['uid'][:6]}]" if cand.get("uid") else label
|
| 22 |
|
| 23 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 24 |
def settle_all(ledger):
|
| 25 |
"""Load every object's mesh; unusable candidates are skipped and recorded."""
|
| 26 |
meshes = {}
|
|
@@ -34,6 +49,7 @@ def settle_all(ledger):
|
|
| 34 |
if about and about["kind"] == "object":
|
| 35 |
q["default_chosen"] = _option_label(about["decision"]["chosen"])
|
| 36 |
q["alternatives"] = [_option_label(c) for c in about["decision"]["alternatives"]]
|
|
|
|
| 37 |
return meshes
|
| 38 |
|
| 39 |
|
|
|
|
| 8 |
import tempfile
|
| 9 |
|
| 10 |
from . import assets, html, scene
|
| 11 |
+
from .grammar import POOL, build_ledger, candidate_label, next_tier, parse_primitives, primitive_candidate
|
| 12 |
+
from .lexicon import ASSET_TIERS, LEXICON
|
| 13 |
|
| 14 |
|
| 15 |
def _workdir():
|
|
|
|
| 22 |
return f"{label} [{cand['uid'][:6]}]" if cand.get("uid") else label
|
| 23 |
|
| 24 |
|
| 25 |
+
def _on_reject(item):
|
| 26 |
+
"""What rejecting this item's tier would offer: alternatives in the next asset tier
|
| 27 |
+
are invisible in the question until then."""
|
| 28 |
+
dec = item["decision"]
|
| 29 |
+
if dec["tier"] not in ASSET_TIERS:
|
| 30 |
+
return None
|
| 31 |
+
found = LEXICON.lookup(item["word"])
|
| 32 |
+
for tier in ASSET_TIERS[ASSET_TIERS.index(dec["tier"]) + 1:]:
|
| 33 |
+
n = len(found.get(tier, []))
|
| 34 |
+
if n:
|
| 35 |
+
return {"tier": tier, "candidates": n, "offered": min(n, POOL)}
|
| 36 |
+
return {"tier": "primitive"}
|
| 37 |
+
|
| 38 |
+
|
| 39 |
def settle_all(ledger):
|
| 40 |
"""Load every object's mesh; unusable candidates are skipped and recorded."""
|
| 41 |
meshes = {}
|
|
|
|
| 49 |
if about and about["kind"] == "object":
|
| 50 |
q["default_chosen"] = _option_label(about["decision"]["chosen"])
|
| 51 |
q["alternatives"] = [_option_label(c) for c in about["decision"]["alternatives"]]
|
| 52 |
+
q["on_reject"] = _on_reject(about)
|
| 53 |
return meshes
|
| 54 |
|
| 55 |
|
|
@@ -45,11 +45,13 @@ def configure(ledger, meshes, seed=0):
|
|
| 45 |
for p in place.values():
|
| 46 |
p["position"][0] -= x / 2
|
| 47 |
|
| 48 |
-
sun = None
|
| 49 |
if lights:
|
| 50 |
sun = {"id": lights[0]["id"], "elevation_deg": float(rng.uniform(25, 65)),
|
| 51 |
"azimuth_deg": float(rng.uniform(-150, 150)),
|
| 52 |
"shadows": any(r["relation"] == "lights" for r in relations)}
|
|
|
|
|
|
|
|
|
|
| 53 |
|
| 54 |
def height(oid):
|
| 55 |
return float(meshes[oid].extents[1])
|
|
@@ -88,7 +90,7 @@ def configure(ledger, meshes, seed=0):
|
|
| 88 |
elif rel == "faces":
|
| 89 |
place[a]["yaw_deg"] = math.degrees(math.atan2(pb[0] - pa[0], pb[2] - pa[2]))
|
| 90 |
|
| 91 |
-
if
|
| 92 |
reach = max([abs(c) + _half_width(meshes[o]) for o, p in place.items()
|
| 93 |
for c in (p["position"][0], p["position"][2])] + [0.3])
|
| 94 |
for it in lights:
|
|
|
|
| 45 |
for p in place.values():
|
| 46 |
p["position"][0] -= x / 2
|
| 47 |
|
|
|
|
| 48 |
if lights:
|
| 49 |
sun = {"id": lights[0]["id"], "elevation_deg": float(rng.uniform(25, 65)),
|
| 50 |
"azimuth_deg": float(rng.uniform(-150, 150)),
|
| 51 |
"shadows": any(r["relation"] == "lights" for r in relations)}
|
| 52 |
+
else: # nothing shines: soft daylight from no object (the ledger asks "what light?")
|
| 53 |
+
sun = {"id": None, "elevation_deg": float(rng.uniform(35, 60)),
|
| 54 |
+
"azimuth_deg": float(rng.uniform(-150, 150)), "shadows": True, "daylight": True}
|
| 55 |
|
| 56 |
def height(oid):
|
| 57 |
return float(meshes[oid].extents[1])
|
|
|
|
| 90 |
elif rel == "faces":
|
| 91 |
place[a]["yaw_deg"] = math.degrees(math.atan2(pb[0] - pa[0], pb[2] - pa[2]))
|
| 92 |
|
| 93 |
+
if lights: # whatever shone sits in the sky along the light direction
|
| 94 |
reach = max([abs(c) + _half_width(meshes[o]) for o, p in place.items()
|
| 95 |
for c in (p["position"][0], p["position"][2])] + [0.3])
|
| 96 |
for it in lights:
|
|
@@ -12,7 +12,8 @@ FACE_LEVELS = [20000, 5000, 1000, 250, 60]
|
|
| 12 |
def base_color(mesh):
|
| 13 |
"""Mean RGBA of a mesh's colours, or neutral grey."""
|
| 14 |
vis = getattr(mesh, "visual", None)
|
| 15 |
-
|
|
|
|
| 16 |
try:
|
| 17 |
cols = vis.vertex_colors if vis.kind == "vertex" else vis.face_colors
|
| 18 |
if cols is not None and len(cols):
|
|
|
|
| 12 |
def base_color(mesh):
|
| 13 |
"""Mean RGBA of a mesh's colours, or neutral grey."""
|
| 14 |
vis = getattr(mesh, "visual", None)
|
| 15 |
+
# an uncoloured mesh still reports trimesh's default dark grey, so ask whether colours are defined
|
| 16 |
+
if isinstance(vis, trimesh.visual.ColorVisuals) and vis.defined:
|
| 17 |
try:
|
| 18 |
cols = vis.vertex_colors if vis.kind == "vertex" else vis.face_colors
|
| 19 |
if cols is not None and len(cols):
|