kwabs22 Claude Opus 5.5 commited on
Commit
98ca16a
·
1 Parent(s): e1d8e60

Light unlit scenes with daylight, colour assets from thumbnails, hint at the next tier

Browse files

From 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>

Files changed (6) hide show
  1. SPEC.md +11 -0
  2. vli/assets.py +32 -5
  3. vli/grammar.py +10 -2
  4. vli/pipeline.py +17 -1
  5. vli/scene.py +4 -2
  6. vli/simplify.py +2 -1
SPEC.md CHANGED
@@ -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
vli/assets.py CHANGED
@@ -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
- return _usd_mesh(roots[0]), {"license": lic, "metric": True, "thumbnail": thumbs[0] if thumbs else None}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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":
vli/grammar.py CHANGED
@@ -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:9],
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:9]}
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
 
vli/pipeline.py CHANGED
@@ -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
 
vli/scene.py CHANGED
@@ -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 sun: # whatever shone sits in the sky along the light direction
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:
vli/simplify.py CHANGED
@@ -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
- if isinstance(vis, trimesh.visual.ColorVisuals):
 
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):