Spaces:
Running on Zero
Running on Zero
kwabs22
Claude Opus 5.5
Light unlit scenes with daylight, colour assets from thumbnails, hint at the next tier
98ca16a Download vli/scene.py from KwabsHug/VisualLearningIdea: direct link, hf CLI and curl.
- Browser
- Download file 9.53 kB
-
https://huggingface.co/spaces/KwabsHug/VisualLearningIdea/resolve/main/vli/scene.py
- Command line
-
hf download hf://spaces/KwabsHug/VisualLearningIdea/vli/scene.py
-
curl -L -o scene.py https://huggingface.co/spaces/KwabsHug/VisualLearningIdea/resolve/main/vli/scene.py
9.53 kB
| """Configuration side: once the serial ledger is resolved, any placement that | |
| satisfies its relations is valid. The seed picks one configuration. | |
| """ | |
| import math | |
| import matplotlib | |
| matplotlib.use("Agg") | |
| import matplotlib.pyplot as plt # noqa: E402 | |
| import numpy as np # noqa: E402 | |
| import trimesh # noqa: E402 | |
| from mpl_toolkits.mplot3d.art3d import Poly3DCollection # noqa: E402 | |
| from scipy.spatial import ConvexHull # noqa: E402 | |
| from .simplify import base_color, decimate # noqa: E402 | |
| GAP = 0.1 | |
| GLOW = [255, 214, 107, 255] | |
| RENDER_FACE_BUDGET = 3000 | |
| def _half_width(mesh): | |
| return 0.5 * max(mesh.extents[0], mesh.extents[2]) | |
| def sun_direction(sun): | |
| el, az = math.radians(sun["elevation_deg"]), math.radians(sun["azimuth_deg"]) | |
| return np.array([math.cos(el) * math.sin(az), math.sin(el), math.cos(el) * math.cos(az)]) | |
| def configure(ledger, meshes, seed=0): | |
| """Place every object so the ledger's relations hold. Returns a JSON-able dict.""" | |
| rng = np.random.default_rng(seed) | |
| items = ledger["items"] | |
| relations = [i for i in items if i["kind"] == "relation"] | |
| lights = [i for i in items if i.get("emits_light")] | |
| objects = [i for i in items if i["kind"] == "object" and not i.get("emits_light")] | |
| place, x = {}, 0.0 | |
| for it in objects: # unrelated objects stand in a loose row | |
| hw = _half_width(meshes[it["id"]]) | |
| place[it["id"]] = {"position": [x + hw, 0.0, float(rng.uniform(-0.5, 0.5) * hw)], | |
| "yaw_deg": float(rng.uniform(-180, 180))} | |
| x += 2 * hw + 4 * GAP | |
| for p in place.values(): | |
| p["position"][0] -= x / 2 | |
| if lights: | |
| sun = {"id": lights[0]["id"], "elevation_deg": float(rng.uniform(25, 65)), | |
| "azimuth_deg": float(rng.uniform(-150, 150)), | |
| "shadows": any(r["relation"] == "lights" for r in relations)} | |
| else: # nothing shines: soft daylight from no object (the ledger asks "what light?") | |
| sun = {"id": None, "elevation_deg": float(rng.uniform(35, 60)), | |
| "azimuth_deg": float(rng.uniform(-150, 150)), "shadows": True, "daylight": True} | |
| def height(oid): | |
| return float(meshes[oid].extents[1]) | |
| for _ in range(2): # second pass lets chains (a on b on c) settle | |
| for r in relations: | |
| a, b, rel = r["subject"], r["object"], r["relation"] | |
| if a not in place: | |
| continue | |
| pa = place[a]["position"] | |
| if b not in place: # environment object: ground, sun, scene | |
| if rel == "on": | |
| pa[1] = 0.0 | |
| elif rel == "faces" and sun and b == sun["id"]: | |
| place[a]["yaw_deg"] = sun["azimuth_deg"] | |
| continue | |
| pb = place[b]["position"] | |
| hwa, hwb = _half_width(meshes[a]), _half_width(meshes[b]) | |
| if rel == "on": | |
| slack = max(0.0, hwb - hwa) | |
| ang = rng.uniform(0, 2 * math.pi) | |
| pa[0], pa[2] = pb[0] + slack * math.cos(ang) * 0.5, pb[2] + slack * math.sin(ang) * 0.5 | |
| pa[1] = pb[1] + height(b) | |
| elif rel == "under": | |
| pa[0], pa[1], pa[2] = pb[0], 0.0, pb[2] | |
| elif rel in ("beside", "near"): | |
| side = rng.choice([-1, 1]) | |
| gap = GAP if rel == "beside" else 3 * GAP + hwa | |
| pa[0], pa[1], pa[2] = pb[0] + side * (hwa + hwb + gap), pb[1], pb[2] | |
| elif rel in ("behind", "in_front_of"): | |
| sign = -1 if rel == "behind" else 1 | |
| pa[0], pa[1], pa[2] = pb[0], pb[1], pb[2] + sign * (hwa + hwb + GAP) | |
| elif rel == "above": | |
| pa[0], pa[2] = pb[0], pb[2] | |
| pa[1] = pb[1] + height(b) + 0.3 * max(height(a), height(b)) | |
| elif rel == "faces": | |
| place[a]["yaw_deg"] = math.degrees(math.atan2(pb[0] - pa[0], pb[2] - pa[2])) | |
| if lights: # whatever shone sits in the sky along the light direction | |
| reach = max([abs(c) + _half_width(meshes[o]) for o, p in place.items() | |
| for c in (p["position"][0], p["position"][2])] + [0.3]) | |
| for it in lights: | |
| # shadows use the direction; distance only frames the view | |
| pos = sun_direction(sun) * (1.5 * reach + 2 * _half_width(meshes[it["id"]])) | |
| place[it["id"]] = {"position": [float(c) for c in pos], "yaw_deg": 0.0, "light": True} | |
| return {"seed": seed, "placements": place, "sun": sun} | |
| def transform(p): | |
| t = trimesh.transformations.rotation_matrix(math.radians(p["yaw_deg"]), [0, 1, 0]) | |
| t[:3, 3] = p["position"] | |
| return t | |
| def _ground(ledger, placed): | |
| if not any(i["kind"] == "environment" and i["role"] == "plane" for i in ledger["items"]): | |
| return None | |
| lights = {i["id"] for i in ledger["items"] if i.get("emits_light")} | |
| reach = max([np.abs(m.bounds[:, [0, 2]]).max() for oid, m in placed.items() if oid not in lights] + [0.3]) | |
| g = trimesh.creation.cylinder(radius=reach * 1.6, height=0.01, sections=48) | |
| g.apply_transform(trimesh.transformations.rotation_matrix(math.pi / 2, [1, 0, 0])) | |
| g.apply_translation([0, -0.005, 0]) | |
| g.visual = trimesh.visual.ColorVisuals(g, face_colors=np.tile([77, 124, 58, 255], (len(g.faces), 1))) | |
| return g | |
| def placed_meshes(ledger, meshes, config): | |
| out = {} | |
| for oid, p in config["placements"].items(): | |
| m = meshes[oid].copy() | |
| m.apply_transform(transform(p)) | |
| if p.get("light"): # a light source glows: warm, flat colour | |
| m.visual = trimesh.visual.ColorVisuals(m, face_colors=np.tile(GLOW, (len(m.faces), 1))) | |
| out[oid] = m | |
| return out | |
| def export_glb(ledger, meshes, config, path): | |
| placed = placed_meshes(ledger, meshes, config) | |
| scene = trimesh.Scene() | |
| for oid, m in placed.items(): | |
| scene.add_geometry(m, node_name=oid) | |
| ground = _ground(ledger, placed) | |
| if ground is not None: | |
| scene.add_geometry(ground, node_name="ground") | |
| scene.export(path) | |
| return path | |
| # ---------------- picture (headless, for people and for models) ---------------- | |
| def _sky(sun): | |
| if not sun: | |
| return "#2a3346" | |
| t = min(1.0, max(0.0, (sun["elevation_deg"] - 5) / 40)) # low sun -> warm sky | |
| low, high = np.array([0.95, 0.62, 0.38]), np.array([0.53, 0.71, 0.88]) | |
| return tuple(low + (high - low) * t) | |
| def _to_axes(v): | |
| """Y-up scene coords -> matplotlib Z-up axes (a proper rotation).""" | |
| return np.stack([v[..., 0], -v[..., 2], v[..., 1]], axis=-1) | |
| def _shade(mesh, light, color, glow=False): | |
| if glow: | |
| k = np.ones(len(mesh.faces)) | |
| else: # two-sided: Objaverse meshes often have flipped faces | |
| k = 0.45 + 0.55 * np.abs(mesh.face_normals @ light) | |
| rgb = np.clip(color[:3] / 255.0 * k[:, None], 0, 1) | |
| return np.c_[rgb, np.ones(len(rgb))] | |
| def render(meshes_by_id, path, sun=None, ground=None, title=None): | |
| light = sun_direction(sun) if sun else np.array([0.4, 0.8, 0.45]) / np.linalg.norm([0.4, 0.8, 0.45]) | |
| fig = plt.figure(figsize=(6.4, 4.8), dpi=110) | |
| fig.patch.set_facecolor(_sky(sun)) | |
| ax = fig.add_subplot(projection="3d", computed_zorder=False) | |
| ax.set_facecolor(_sky(sun)) | |
| pts = [] | |
| if ground is not None: | |
| v = ground.vertices | |
| top = v[(v[:, 1] > -0.005) & (np.hypot(v[:, 0], v[:, 2]) > 1e-6)] # top cap rim | |
| ring = top[np.argsort(np.arctan2(top[:, 2], top[:, 0]))] | |
| ax.add_collection3d(Poly3DCollection([_to_axes(ring)], facecolors=[(0.30, 0.49, 0.23, 1)], zorder=0)) | |
| pts.append(ring) | |
| if sun and sun.get("shadows") and light[1] > 0.05: | |
| for oid, m in meshes_by_id.items(): | |
| if oid == sun["id"]: | |
| continue | |
| v = m.vertices if len(m.vertices) < 4000 else m.vertices[:: len(m.vertices) // 4000 + 1] | |
| flat = v - np.outer(v[:, 1] / light[1], light) | |
| try: | |
| hull = ConvexHull(flat[:, [0, 2]]) | |
| except Exception: | |
| continue | |
| poly = flat[hull.vertices] | |
| poly[:, 1] = 0.002 | |
| ax.add_collection3d(Poly3DCollection([_to_axes(poly)], facecolors=[(0, 0, 0, 0.35)], zorder=1)) | |
| tris, colors = [], [] | |
| for oid, m in meshes_by_id.items(): | |
| color = base_color(m) | |
| r = decimate(m, RENDER_FACE_BUDGET) if len(m.faces) > RENDER_FACE_BUDGET else m | |
| tris.append(r.triangles) | |
| colors.append(_shade(r, light, color, glow=bool(sun) and oid == sun["id"])) | |
| pts.append(r.vertices) | |
| if tris: | |
| ax.add_collection3d(Poly3DCollection(_to_axes(np.concatenate(tris)), facecolors=np.concatenate(colors), | |
| linewidths=0, zorder=2)) | |
| allp = _to_axes(np.concatenate(pts)) if pts else np.zeros((1, 3)) | |
| lo, hi = allp.min(axis=0), allp.max(axis=0) | |
| span = max((hi - lo).max(), 1e-3) | |
| mid = (lo + hi) / 2 | |
| ax.set_xlim(mid[0] - span / 2, mid[0] + span / 2) | |
| ax.set_ylim(mid[1] - span / 2, mid[1] + span / 2) | |
| ax.set_zlim(max(lo[2], mid[2] - span / 2), max(lo[2], mid[2] - span / 2) + span) | |
| ax.set_box_aspect((1, 1, 1)) | |
| ax.view_init(elev=22, azim=-60) | |
| ax.set_axis_off() | |
| if title: | |
| ax.set_title(title, color="white", fontsize=10) | |
| fig.savefig(path, facecolor=fig.get_facecolor(), bbox_inches="tight") | |
| plt.close(fig) | |
| return path | |
| def render_scene(ledger, meshes, config, path): | |
| placed = placed_meshes(ledger, meshes, config) | |
| return render(placed, path, sun=config["sun"], ground=_ground(ledger, placed)) | |