⏳ This skill is pending AI review.
Scores will appear once the review pipeline completes.
gaussian-splat-to-blender-nodes
Reconstruct Gaussian splat scenes as editable native Blender Geometry Nodes meshes and Shader Node materials. Use for splat-to-procedural reconstruction, matched-camera analysis, semantic scene decomposition, detailed environment recreation, native-scene audits, optional hybrid capture-texture mapping onto reconstructed surfaces, and reference-versus-Blender comparison videos. This is an agent-guided measured workflow, not automatic splat-to-mesh conversion.
Choose how to use this skill
You do not need every option. Choose the path your AI client supports. The stable page stays the same; versioned files are immutable.
1. Native installer
This listing has no registered native installer command. Use the complete package or source fallback below, depending on what your client supports.
Do not guess an installer command or replace an existing version without reviewing the diff.
2. Complete package recommended
Download the ZIP when available. It includes SKILL.md plus the references, security notes and version metadata.
No complete ProSkills package is published for this listing yet.3. Prompt-only
Copy the prompt above when the agent can read the stable page or when you want to adopt the workflow without installing a skill.
Need only the instruction file?
Download SKILL.md only if your client requires a single file. The complete ZIP is safer for a full installation because it preserves the references and release context.
No path installs or executes anything by itself. Your agent still needs access to the project files. Before updating, compare the installed version and review the diff.
// RATINGS
// README
World to World Mapping
Gaussian splat to Blender nodes — reconstruct geometry, then optionally map captured appearance.
A project-independent agent skill and toolkit for measured reconstruction with native Geometry Nodes, Shader Nodes and calibrated multi-view texture projection. Built from the Garden study, in the spirit of video-to-blender and shader-skills.
Watch the release video — grey/textured reveals and a matched 360° comparison · Read the skill · Reusable prompts · Hybrid mapping guide
The video shows the accepted Garden c26b hybrid experiment. Its mapped table and paving
are baked evaluated meshes with packed appearance textures; other reconstructed parts retain
node-based construction. The source scan and fitted hybrid assets are not bundled. See
media provenance and limitations. The installation name remains
gaussian-splat-to-blender-nodes.
What it does
This is an agent-guided reconstruction workflow, not a one-click splat-to-mesh converter. The splat provides observations; the output describes objects through meaningful geometry, parameters and materials that you can edit in Blender.
Splat reference → measured frame + cameras → semantic scene parts
→ Geometry Nodes + Shader Nodes → native audit + visual comparison
→ optional calibrated appearance mapping → hybrid review + packed blend
| Included | Purpose |
|---|---|
| Installable agent skill | Acquisition, registration, construction, detail and candid critique |
| Project-agnostic prompt pack | Full brief, whole-scene capture, part contracts, detail, critique and delivery |
| Asset inspector | SHA-256 and limited PLY / SPLAT / KSPLAT metadata |
| Shared camera tools | Validated coordinates and repeatable world/scan orbit manifests |
| Native-scene audit | Remove the reference in memory; compare mesh and instance signatures |
| Blender renderer | Sequential rendering of a shared camera manifest |
| Still/video comparison | Strict frame pairing, labels and hashed evidence; optional FFmpeg MP4 |
| Synthetic starter | Small editable GN table; no scan download required |
| Hybrid mapping tools | UV preparation, depth/object visibility, robust projection, material-aware fallback and packed assembly |
| Hybrid prompt/config | Per-project objects, material slots, cameras, source masks and appearance mode |
| Garden study | Seven procedural parts, parameters and vendored node-building helpers |
No automatic semantic segmentation, universal splat decoder or reference renderer is bundled. An audit pass proves specific structural checks; it does not prove visual resemblance.
Install
Agent Skills
npx skills add gokayfem/gaussian-splat-to-blender-nodes
Claude Code plugin
/plugin marketplace add gokayfem/gaussian-splat-to-blender-nodes
/plugin install gaussian-splat-to-blender-nodes@gaussian-splat-to-blender-nodes
Manual / Codex
git clone https://github.com/gokayfem/gaussian-splat-to-blender-nodes.git
cd gaussian-splat-to-blender-nodes
mkdir -p ~/.codex/skills
cp -R skills/gaussian-splat-to-blender-nodes ~/.codex/skills/
For Claude project-local use, copy the skill into .claude/skills/. See
adapters for other agents. A skill-only install includes tools and the
starter; clone the full repository for Garden.
Try it without a scan
Requires Python 3.10+ and Blender 5.0+. Local integration verification used Blender
5.0.1; validate other versions before relying on them. Substitute your Blender executable
for blender if it is not on PATH.
S=skills/gaussian-splat-to-blender-nodes
blender -b --factory-startup --python-exit-code 1 \
--python "$S/templates/starter.py" -- --out outputs/starter.blend
blender -b --factory-startup outputs/starter.blend --python-exit-code 1 \
--python "$S/scripts/audit_blend.py" -- --out outputs/starter-audit.json
python3 "$S/scripts/cameras.py" --frames 4 --radius 3 --target 0 0 0.5 \
--width 640 --height 480 --out outputs/starter-cameras.json
blender -b --factory-startup outputs/starter.blend --python-exit-code 1 \
--python "$S/scripts/render_manifest.py" -- \
--manifest outputs/starter-cameras.json --out outputs/starter-views
Width and Depth controls change the tabletop and supports. The starter is a pipeline test, not a reconstructed asset. The audit deletes its hidden synthetic reference point in memory and leaves the saved file unchanged.
Reconstruct your scene
Use the full prompting guide, starting with the copy-ready project brief. It generalizes the actual structure used for the study: source selection and whole-scene capture, shared measurements/cameras, scoped part builders, integrated blockout, detail correction and final evidence. It works for other scene types and with a single agent.
The capture guide explains how repeatable reference views came from the original splat: viewer/asset identity, exact camera and canvas settings, settled captures, shared world coordinates, coverage views, analysis maps and matched close-ups. This is reference capture, not splat training.
For a shorter starting message, give your agent an authorized source, intended views and a quality target:
Use gaussian-splat-to-blender-nodes. Reconstruct this scene as editable native Blender geometry and materials. First fingerprint the asset, establish a measured coordinate frame, match the reference camera and identify the major objects. Build an integrated coarse checkpoint before fine detail. Review side, opposite and detail views; be candid about mismatches. Deliver source, parameters, blend and scan-removal evidence.
Start with the skill workflow. Use the part contract, detail pass and review prompt as the project progresses. For a turntable, follow the matched 360° guide. Reference frames must come from a compatible viewer using the same camera manifest; the Blender renderer handles the native side.
Map captured appearance onto your reconstruction
Use the hybrid mapping prompt when captured appearance is part of the intended result. Supply your own reconstructed scene and calibrated captures, then fill the configuration template. Objects, materials, cameras, visibility masks and rejection rules are configuration inputs; the implementation contains no required Garden objects or fitted scene coordinates.
The step-by-step guide
covers the three commands: prepare → project → assemble. Install requirements.txt for
the NumPy/SciPy/Pillow projector. Blender prepares evaluated UV surfaces and visibility maps;
ordinary Python maps the captures; Blender packs the results onto selected mesh copies.
Unse
// HOW IT'S BUILT
// FILES
28 files shipped with this skill. Inline text is the catalog copy. The upstream link is the source repository.
skills/gaussian-splat-to-blender-nodes/SKILL.md7.6 KB2a6f6518483aupstream ↗
--- name: gaussian-splat-to-blender-nodes description: Reconstruct Gaussian splat scenes as editable native Blender Geometry Nodes meshes and Shader Node materials. Use for splat-to-procedural reconstruction, matched-camera analysis, semantic scene decomposition, detailed environment recreation, native-scene audits, optional hybrid capture-texture mapping onto reconstructed surfaces, and reference-versus-Blender comparison videos. This is an agent-guided measured workflow, not automatic splat-to-mesh conversion. license: MIT --- # World to World Mapping — Gaussian Splat to Blender Nodes Build an editable scene that explains the reference through geometry, materials and light. Use the splat as measurement and visual evidence. Deliver native source, parameters and honest multi-view comparisons. Default to procedural materials. When captured appearance is requested, use the optional hybrid workflow below and disclose its baked-surface limits. ## Prompting a complete project For a full-scene task, use the [prompting workflow](references/prompting-workflow.md) and [master project brief](templates/prompts/project-brief.md). They supply the actual brief, capture, part-contract, detail, review and delivery prompt structure with project-specific values left to measure. Read [whole-scene capture](references/whole-scene-capture.md) when building the reference package: it explains the source-viewer, camera, coverage and close-up capture procedure. These prompts work with one agent; delegation and recurring review are optional, separately authorized modes. ## Start from evidence 1. Inspect current artifact versions and preserve user work. 2. Record asset URL, author/dataset, usage terms, byte size and SHA-256. Viewer software licensing does not establish dataset rights. Keep scans and decoded arrays local. 3. Identify the actual container and producer. `scripts/inspect_asset.py` fingerprints assets and reads limited metadata; it does not decode Gaussians into surfaces. 4. Establish one scan-to-world transform, scale, handedness and camera convention. Save position, target, up, vertical FOV and dimensions. Register stable landmarks first. 5. Read [acquisition and cameras](references/acquisition-and-cameras.md). ## Reconstruct coarse to fine 1. List major objects and occlusions. Separate measurements, estimates and invented hidden surfaces. Prioritize silhouette and spatial relationships. 2. Create collections: `GS_REFERENCE` (hidden from rendering), `SCENE_PROCEDURAL` (or a project-specific equivalent), `CAMERAS`, `LIGHTING`, and `QA`. 3. Use empty mesh hosts with live GN modifiers. Build meaningful primitives, profiles, curves, branches and instances. Document fitted control points. Instancing every Gaussian as the beauty scene is not procedural reconstruction. 4. Expose semantic controls with units, bounds and descriptions. Seed stochastic placement. Organize substantial graphs into documented subgroups and labeled frames. 5. Use Shader Nodes with part-local coordinates and physical feature scales. Splat color contains illumination; it is not automatically material albedo. 6. Produce an integrated matched-camera checkpoint before adding expensive detail. 7. Read [procedural construction](references/procedural-construction.md). Build joints, thickness, bevels, apertures, branch hierarchy and edge wear before micro-noise. ## Detail without inventing fidelity More polygons, random noise or instances do not establish similarity. Require visible meso-detail at the intended distance and fine detail in native-resolution crops. Preserve improvements in newer versions. Omissions in isolated part tests are not missing integrated objects. Inspect the current project's identity-defining features and intended viewing scale. Rough source edges and tiny seams need not block a ready-for-review result. Prioritize visible structure, material assignment and multi-view fidelity over numerical residuals. ## Optional hybrid captured appearance When the requested mode includes captured textures, read [hybrid projection](references/hybrid-projection.md) and use the [hybrid prompt](templates/prompts/hybrid-projection.md) with [projection.json](templates/projection.json). Prepare reconstructed UV mesh copies in Blender, project registered captures with depth/object/source-mask rejection, then assemble packed appearance/mix images with original-material fallback. Per-project configuration supplies all objects, material slots, cameras, world bounds and rejection rules. Mapped copies are baked evaluated meshes, not live generated GN hosts. Preserve the source and rebake after geometry/UV/frame changes. Captured radiance is an unlit appearance mode; irradiance-divided albedo is only an approximation. Check wrong-object appearance, broad ghosts/stretching, large color mismatch and missing coverage in full matched views. Rank up to three current material issues; fewer or none is fine. Do not import another scene's thresholds or restart edge polishing without a materially visible defect. ## Verify what actually exists 1. Build with `--python-exit-code 1`. Fail on missing parts, node warnings and invalid links. 2. Run `scripts/audit_blend.py` in a disposable background process. It removes the scan in memory and compares evaluated mesh/instance signatures. It never saves the blend. Apply it to live procedural collections; it intentionally rejects baked hybrid hosts. Verify hybrid packed images, UV/slot wiring, fallback and complementary renders separately. 3. Render with the reference removed. Inspect clay and beauty views, opposite/side/overview cameras and detail crops. Both renderers must use one shared camera manifest. 4. Record exact source/output hashes. An audit of an old blend does not validate a new one. 5. Read [validation and critique](references/validation-and-critique.md). Rank up to three materially visible differences with region, correction and verification view. Separate actual observations, hypotheses and agent-reported metrics. 6. If pixel-level independence matters, compare renders before/after scan removal too. The structural audit does not test shader, driver or compositor side effects. ## Compare stills and motion `scripts/cameras.py` produces world/scan orbit poses. Render `world` poses with `scripts/render_manifest.py`; render `scan` poses in the chosen reference viewer. The reference side is viewer-specific and is not automated here. `scripts/compare.py` pairs images and optionally encodes an MP4 with FFmpeg. See [comparison videos](references/comparison-videos.md). Preserve framing and source labels. Never invent missing reference angles. Serialize expensive renders and use immutable outputs. ## Tools and delivery Resolve paths relative to this skill directory. Test Blender without a scan using `templates/starter.py`. Basic helpers use the standard library; comparisons need Pillow, and hybrid projection needs NumPy, SciPy and Pillow. MP4 encoding requires FFmpeg/libx264. Blender tools target 5.0+; report the version actually tested. Deliver the editable blend, deterministic source/parameters, camera manifests, provenance, audit JSON and comparisons. Report remaining mismatches and unsupported views. Do not promise perfect reconstruction or declare completion from script success. The full repository includes an experimental `prompts/garden` builder and pinned nodekit from `gokayfem/video-to-blender`. A skill-only install contains the portable tools and starter; clone the repository for Garden. Do not schedule reviews, contact other agents, publish assets or start API jobs implicitly. Honor user authorization and stop queued work when asked to stop.skills/gaussian-splat-to-blender-nodes/scripts/audit_blend.py7.5 KBd7a79ef6eb79upstream ↗
"""Audit native Geometry Nodes objects and remove GS_REFERENCE in memory. Run in a disposable background Blender process with --python-exit-code 1. Never saves the loaded .blend. Signatures cover evaluated mesh vertices/topology, material assignments and instance transforms. This is structural evidence, not a render-equivalence test or a guarantee that a scene resembles its reference. """ import argparse from array import array import hashlib import json from pathlib import Path import struct import sys import bpy def mesh_signature(obj): me = obj.to_mesh() if me is None: return None try: h = hashlib.sha256() for items, prop, code, stride in ((me.vertices, "co", "f", 3), (me.loops, "vertex_index", "i", 1), (me.polygons, "loop_total", "i", 1), (me.polygons, "material_index", "i", 1)): a = array(code, [0]) * (len(items)*stride) items.foreach_get(prop, a) h.update(a.tobytes()) h.update(json.dumps([m.name if m else None for m in me.materials]).encode()) return dict(vertices=len(me.vertices), faces=len(me.polygons), sha256=h.hexdigest()) finally: obj.to_mesh_clear() def snapshot(names): bpy.context.view_layer.update() dg = bpy.context.evaluated_depsgraph_get() objects, instances, cache = {}, [], {} for name in sorted(names): ob = bpy.data.objects.get(name) if ob is None: objects[name] = None continue evaluated = ob.evaluated_get(dg) objects[name] = dict(mesh=mesh_signature(evaluated), matrix=[list(row) for row in ob.matrix_world]) for inst in dg.object_instances: if not inst.is_instance or not inst.parent or inst.parent.original.name not in names: continue ob = inst.object key = ob.as_pointer() if key not in cache: cache[key] = mesh_signature(ob) h = hashlib.sha256(json.dumps(cache[key], sort_keys=True).encode()) h.update(struct.pack("<16d", *(x for row in inst.matrix_world for x in row))) instances.append(h.hexdigest()) return dict(objects=objects, instance_count=len(instances), instances_sha256=hashlib.sha256("".join(sorted(instances)).encode()).hexdigest()) def audit(collection, reference): coll = bpy.data.collections.get(collection) if coll is None or not coll.all_objects: raise ValueError(f"Missing/empty procedural collection: {collection}") objects = list(coll.all_objects) names = {o.name for o in objects} failures, warnings, inventory = [], [], [] for ob in objects: mods = [m for m in ob.modifiers if m.type == "NODES" and m.node_group and m.show_render and m.show_viewport] if ob.type != "MESH" or not mods: failures.append(f"{ob.name}: expected mesh object with enabled Geometry Nodes") if ob.type == "MESH" and len(ob.data.vertices): failures.append(f"{ob.name}: base mesh is nonempty; inspect whether geometry was baked/imported") inventory.append(dict(name=ob.name, base_vertices=len(ob.data.vertices) if ob.type == "MESH" else None, node_groups=[m.node_group.name for m in mods])) for m in mods: for w in getattr(m, "node_warnings", []): failures.append(f"{ob.name}: {w.message}") refs = bpy.data.collections.get(reference) if refs and not refs.hide_render: failures.append(f"{reference} collection is not hidden from rendering") reference_names = {ob.name for ob in refs.all_objects} if refs else set() if names & reference_names: raise ValueError("Reference and procedural collections share objects; refusing destructive test") groups = [g for g in bpy.data.node_groups if g.bl_idname == "GeometryNodeTree"] for g in groups: for link in g.links: if not link.is_valid: failures.append(f"{g.name}: invalid node link") for n in g.nodes: if n.bl_idname in ("GeometryNodeObjectInfo", "GeometryNodeCollectionInfo"): for socket in n.inputs: value = getattr(socket, "default_value", None) if isinstance(value, bpy.types.Object) and value.name in reference_names: warnings.append(f"{g.name}/{n.name}: references scan object {value.name}") if isinstance(value, bpy.types.Collection) and value.name == reference: warnings.append(f"{g.name}/{n.name}: references scan collection") materials = [m for m in bpy.data.materials if m.users] for m in materials: if not m.node_tree: failures.append(f"{m.name}: no Shader Nodes") image_nodes = sorted({f"{g.name}/{n.name}" for g in [*bpy.data.node_groups, *(m.node_tree for m in materials if m.node_tree)] for n in g.nodes if n.bl_idname in ("ShaderNodeTexImage", "ShaderNodeTexEnvironment")}) if image_nodes: warnings.append("Image textures need provenance/manual review; they are not inherently scan-free") before = snapshot(names) if refs: for ob in list(refs.all_objects): bpy.data.objects.remove(ob, do_unlink=True) bpy.data.collections.remove(refs, do_unlink=True) # Blender can retain the previous evaluated GN result after an ID is deleted. # Invalidate graph/object caches explicitly before the independence comparison. for group in bpy.data.node_groups: group.update_tag() for ob in bpy.data.objects: ob.update_tag(refresh={"DATA"}) after = snapshot(names) if before != after: failures.append("Evaluated geometry/instances changed after removing the reference") mesh_vertices = sum((v.get("mesh") or {}).get("vertices", 0) for v in after["objects"].values() if v) if mesh_vertices == 0 and after["instance_count"] == 0: failures.append("Procedural collection evaluates to no meshes or instances") return dict(blender_version=bpy.app.version_string, collection=collection, reference=reference, reference_objects_removed=len(reference_names), inventory=inventory, geometry_node_groups=len(groups), shader_materials=len(materials), image_nodes=image_nodes, before=before, after=after, failures=failures, warnings=warnings, passed=not failures, limits="Does not compare rendered pixels, materials before/after, animation, or prove visual fidelity") def main(): p = argparse.ArgumentParser(description=__doc__) p.add_argument("--collection", default="GARDEN_PROCEDURAL") p.add_argument("--reference", default="GS_REFERENCE") p.add_argument("--out", required=True, type=Path) a = p.parse_args(sys.argv[sys.argv.index("--")+1:] if "--" in sys.argv else []) r = audit(a.collection, a.reference) source = Path(bpy.data.filepath) if source.is_file(): h = hashlib.sha256() with source.open("rb") as f: for chunk in iter(lambda: f.read(1024*1024), b""): h.update(chunk) r["blend"] = dict(filename=source.name, sha256=h.hexdigest(), mtime_ns=source.stat().st_mtime_ns) a.out.parent.mkdir(parents=True, exist_ok=True) a.out.write_text(json.dumps(r, indent=2)+"\n") print(json.dumps({k: r[k] for k in ("passed", "failures", "warnings", "geometry_node_groups", "shader_materials")})) if not r["passed"]: raise RuntimeError("Native scene audit failed; see JSON report") if __name__ == "__main__": main()skills/gaussian-splat-to-blender-nodes/scripts/cameras.py4.6 KB88333a66286eupstream ↗
#!/usr/bin/env python3 """Create one orbit manifest for a Z-up Blender scene and its scan-space reference. World points = scale * R * (scan points - origin). No third-party dependencies. """ import argparse import json import math from pathlib import Path def dot(a, b): return sum(x * y for x, y in zip(a, b)) def cross(a, b): return [a[1]*b[2]-a[2]*b[1], a[2]*b[0]-a[0]*b[2], a[0]*b[1]-a[1]*b[0]] def unit(v): n = math.sqrt(dot(v, v)) if not math.isfinite(n) or n < 1e-12: raise ValueError("Degenerate camera direction") return [x/n for x in v] def vector(v): if len(v) != 3 or not all(math.isfinite(float(x)) for x in v): raise ValueError("Expected three finite coordinates") return [float(x) for x in v] def validate_frame(f): r = f["R_scan_to_world"] vector(f["origin_scan"]) s = f["scale_m_per_unit"] if not math.isfinite(s) or s <= 0 or len(r) != 3: raise ValueError("Frame requires positive scale and a 3x3 proper rotation") for row in r: vector(row) for i in range(3): for j in range(3): if abs(dot(r[i], r[j]) - (i == j)) > 1e-5: raise ValueError("Rotation is not orthonormal") if abs(dot(r[0], cross(r[1], r[2])) - 1) > 1e-5: raise ValueError("Rotation reflects handedness; determinant must be +1") return f def transform(v, frame, to_scan=False, direction=False): validate_frame(frame) v = vector(v) r, o, s = (frame[k] for k in ("R_scan_to_world", "origin_scan", "scale_m_per_unit")) if to_scan: v = v if direction else [x/s for x in v] out = [sum(r[j][i]*v[j] for j in range(3)) for i in range(3)] return out if direction else [out[i]+o[i] for i in range(3)] v = v if direction else [v[i]-o[i] for i in range(3)] out = [dot(row, v) for row in r] return out if direction else [s*x for x in out] def validate_camera(c): p, t, u = (vector(c[k]) for k in ("position", "look_at", "up")) z = unit([p[i]-t[i] for i in range(3)]) unit(cross(u, z)) if not 0 < c["fov_y_deg"] < 179: raise ValueError("Vertical FOV must be in (0, 179) degrees") if any(type(c[k]) is not int or c[k] <= 0 for k in ("width", "height")): raise ValueError("Image dimensions must be positive integers") return c def camera_to_scan(c, frame): validate_camera(c) return dict(c, position=transform(c["position"], frame, True), look_at=transform(c["look_at"], frame, True), up=unit(transform(c["up"], frame, True, True))) def orbit(target, radius, elevation, frames, width, height, fov, start=0): target = vector(target) if not math.isfinite(radius) or radius <= 0 or frames < 2 or not -89 < elevation < 89: raise ValueError("Use radius > 0, at least 2 frames and elevation between -89 and 89") if not math.isfinite(start): raise ValueError("Start azimuth must be finite") e = math.radians(elevation) cameras = {} for i in range(frames): a = math.radians(start + 360*i/frames) d = [math.cos(e)*math.cos(a), math.cos(e)*math.sin(a), math.sin(e)] cameras[f"{i:06d}"] = validate_camera(dict( position=[target[j]+radius*d[j] for j in range(3)], look_at=target, up=[0, 0, 1], fov_y_deg=fov, width=width, height=height)) return cameras def main(): p = argparse.ArgumentParser(description=__doc__) p.add_argument("--out", required=True, type=Path) p.add_argument("--frame", type=Path, help="Optional scan-to-world similarity transform JSON") p.add_argument("--target", type=float, nargs=3, default=[0, 0, 0.7]) p.add_argument("--radius", type=float, default=3) p.add_argument("--elevation", type=float, default=30) p.add_argument("--start", type=float, default=0) p.add_argument("--frames", type=int, default=240) p.add_argument("--fps", type=int, default=24) p.add_argument("--width", type=int, default=640) p.add_argument("--height", type=int, default=640) p.add_argument("--fov", type=float, default=50) a = p.parse_args() if a.fps <= 0: p.error("fps must be positive") world = orbit(a.target, a.radius, a.elevation, a.frames, a.width, a.height, a.fov, a.start) result = dict(schema_version=1, fps=a.fps, loop=True, world=world) if a.frame: f = validate_frame(json.loads(a.frame.read_text())) result.update(frame=f, scan={k: camera_to_scan(c, f) for k, c in world.items()}) a.out.parent.mkdir(parents=True, exist_ok=True) a.out.write_text(json.dumps(result, indent=2) + "\n") print(f"Wrote {len(world)} poses to {a.out}; closing pose is not duplicated") if __name__ == "__main__": main()skills/gaussian-splat-to-blender-nodes/scripts/compare.py3.7 KBc3214ae4b094upstream ↗
#!/usr/bin/env python3 """Compare equal-sized, identically named PNG pairs without changing their framing. Use directories for a numbered sequence and --video to encode with FFmpeg. No independent crops, auto-alignment, frame interpolation or fabricated missing views. """ import argparse import json import subprocess from pathlib import Path from PIL import Image, ImageDraw from inspect_asset import sha256 def pairs(left, right): left, right = Path(left), Path(right) if left.is_file() and right.is_file(): return [(left, right)] if not left.is_dir() or not right.is_dir(): raise ValueError("Provide two image files or two frame directories") a = {p.name: p for p in left.glob("*.png")} b = {p.name: p for p in right.glob("*.png")} if not a or a.keys() != b.keys(): raise ValueError(f"Frame sets differ or are empty: left-only={sorted(a.keys()-b.keys())[:5]}, right-only={sorted(b.keys()-a.keys())[:5]}") return [(a[k], b[k]) for k in sorted(a)] def compose(left, right, labels=("Gaussian splat reference", "Native Blender nodes")): with Image.open(left) as a, Image.open(right) as b: if a.size != b.size: raise ValueError(f"Mismatched image sizes: {a.size} vs {b.size}") w, h = a.size out = Image.new("RGB", (w*2, h+32), (19, 23, 27)) out.paste(a.convert("RGB"), (0, 32)) out.paste(b.convert("RGB"), (w, 32)) d = ImageDraw.Draw(out) d.text((10, 9), labels[0], fill="white") d.text((w+10, 9), labels[1], fill="white") return out def main(): p = argparse.ArgumentParser(description=__doc__) p.add_argument("left", type=Path) p.add_argument("right", type=Path) p.add_argument("--out", required=True, type=Path, help="New directory for comparisons and evidence.json") p.add_argument("--video", action="store_true", help="Write comparison.mp4 using FFmpeg/libx264") p.add_argument("--fps", type=int, default=24) p.add_argument("--labels", nargs=2, default=["Gaussian splat reference", "Native Blender nodes"]) a = p.parse_args() if a.fps <= 0: p.error("fps must be positive") pp = pairs(a.left, a.right) # Validate every pair before writing, so a mixed-size sequence cannot encode silently. sizes = set() for l, r in pp: with Image.open(l) as il, Image.open(r) as ir: if il.size != ir.size: p.error(f"Pair size mismatch: {l.name}") sizes.add(il.size) if len(sizes) != 1: p.error("Image dimensions vary across the sequence") a.out.mkdir(parents=True, exist_ok=False) evidence = [] for i, (l, r) in enumerate(pp): dest = a.out / f"{i:06d}.png" compose(l, r, a.labels).save(dest) evidence.append(dict(frame=i, source_name=l.name, left_sha256=sha256(l), right_sha256=sha256(r), comparison_sha256=sha256(dest))) record = dict(fps=a.fps, frames=len(pp), labels=a.labels, source_dimensions=list(next(iter(sizes))), camera_match="Must be verified against the shared camera manifest; equal image size is not proof", images=evidence) if a.video: dest = a.out / "comparison.mp4" subprocess.run(["ffmpeg", "-hide_banner", "-loglevel", "error", "-n", "-framerate", str(a.fps), "-i", str(a.out/"%06d.png"), "-vf", "pad=ceil(iw/2)*2:ceil(ih/2)*2", "-c:v", "libx264", "-crf", "18", "-pix_fmt", "yuv420p", "-movflags", "+faststart", str(dest)], check=True) record["video"] = dict(filename=dest.name, sha256=sha256(dest)) (a.out/"evidence.json").write_text(json.dumps(record, indent=2)+"\n") print(f"Wrote {len(pp)} paired frames to {a.out}") if __name__ == "__main__": main()skills/gaussian-splat-to-blender-nodes/scripts/inspect_asset.py4.3 KB1f0a45d5f4c2upstream ↗
#!/usr/bin/env python3 """Fingerprint a local asset and inspect common splat container metadata. This is a header/record-layout inspector, NOT a semantic decoder or a mesh converter. PLY body values and KSPLAT bucket/payload integrity are not validated. """ import argparse import hashlib import json import struct from pathlib import Path def sha256(path): h = hashlib.sha256() with open(path, "rb") as f: for chunk in iter(lambda: f.read(1024*1024), b""): h.update(chunk) return h.hexdigest() def inspect(path): path = Path(path) size = path.stat().st_size result = dict(filename=path.name, bytes=size, sha256=sha256(path)) suffix = path.suffix.lower() if suffix == ".ply": with path.open("rb") as f: header = bytearray() while len(header) < 1024*1024: line = f.readline(4096) if not line: raise ValueError("PLY header has no end_header") header.extend(line) if line.strip() == b"end_header": break else: raise ValueError("PLY header exceeds 1 MiB") lines = header.decode("ascii").splitlines() if lines[0] != "ply": raise ValueError("Invalid PLY magic") elements, current, fmt = [], None, None for line in lines[1:]: p = line.split() if not p: continue if p[0] == "format": if len(p) != 3 or p[1] not in ("ascii", "binary_little_endian", "binary_big_endian") or p[2] != "1.0": raise ValueError("Unsupported PLY format") fmt = p[1] elif p[0] == "element": if len(p) != 3 or int(p[2]) < 0: raise ValueError("Invalid PLY element count") current = dict(name=p[1], count=int(p[2]), properties=[]) elements.append(current) elif p[0] == "property": if current is None: raise ValueError("PLY property without element") current["properties"].append(" ".join(p[1:])) if fmt is None or not elements: raise ValueError("Incomplete PLY header") props = {p.split()[-1] for e in elements if e["name"] == "vertex" for p in e["properties"]} result.update(format="ply", encoding=fmt, header_bytes=len(header), elements=elements, gaussian_fields_present={k: k in props for k in ("opacity", "scale_0", "rot_0", "f_dc_0")}, validation="Header only; field conventions and body require the producer's decoder") elif suffix == ".splat": if size == 0 or size % 32: raise ValueError("Expected nonempty 32-byte records for the common .splat layout") result.update(format="splat", records=size//32, record_bytes=32, validation="Size only; .splat has no magic/version marker. Confirm producer/layout") elif suffix == ".ksplat": with path.open("rb") as f: h = f.read(4096) if len(h) < 4096 or h[:2] != b"\x00\x01": raise ValueError("Only GaussianSplats3D KSPLAT v0.1 headers are supported") max_sections, sections, max_splats, splats = struct.unpack_from("<IIII", h, 4) level = struct.unpack_from("<H", h, 20)[0] if not 0 < sections <= max_sections or splats > max_splats or level not in (0, 1, 2): raise ValueError("Inconsistent KSPLAT header") if size < 4096 + 1024*max_sections: raise ValueError("Truncated KSPLAT section headers") result.update(format="ksplat", version="0.1", sections=sections, splats=splats, compression_level=level, validation="Main header only; section records and payload not decoded") else: result.update(format="unknown", validation="Fingerprint only; use the producer's decoder") return result def main(): p = argparse.ArgumentParser(description=__doc__) p.add_argument("asset", type=Path) p.add_argument("--out", type=Path) a = p.parse_args() output = json.dumps(inspect(a.asset), indent=2) + "\n" if a.out: a.out.parent.mkdir(parents=True, exist_ok=True) a.out.write_text(output) print(output, end="") if __name__ == "__main__": main()skills/gaussian-splat-to-blender-nodes/scripts/project_textures.py5.3 KB132a6596c8f2upstream ↗
"""Project calibrated captures onto prepared UV surfaces. Run with ordinary Python. python project_textures.py --config mapping.json --prepared outputs/prepared --out outputs/mapped """ import argparse import json from pathlib import Path import numpy as np from PIL import Image from projection_core import finish_texture, project_surface from projection_config import load_config, sha256 def main(): parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--config", required=True, type=Path) parser.add_argument("--prepared", required=True, type=Path) parser.add_argument("--out", required=True, type=Path) parser.add_argument("--chunk-size", type=int, default=65536) args = parser.parse_args() if args.chunk_size <= 0: parser.error("chunk-size must be positive") cfg, root = load_config(args.config) prep = json.loads((args.prepared/"prepared.json").read_text()) if prep["config_sha256"] != sha256(args.config): raise ValueError("Config changed since preparation; create a new prepared checkpoint") for relative, digest in prep["files"].items(): if sha256(args.prepared/relative) != digest: raise ValueError(f"Prepared artifact changed: {relative}") captures, fingerprints = {}, {} for name, view in cfg["views"].items(): camera = view["camera"] shape = (camera["height"], camera["width"]) path = root/view["image"] with Image.open(path) as image: rgb = np.asarray(image.convert("RGB")) if rgb.shape[:2] != shape: raise ValueError(f"Capture dimensions do not match camera: {name}") fingerprints[name] = dict(image_sha256=sha256(path), source_valid_masks={}) captures[name] = dict(camera=camera, rgb=rgb, depth=np.load(args.prepared/f"depth/{name}.npy", mmap_mode="r"), ids=np.load(args.prepared/f"depth/{name}_id.npy", mmap_mode="r")) if any(captures[name][key].shape != shape for key in ("depth", "ids")): raise ValueError(f"Visibility-map shape mismatch: {name}") args.out.mkdir(parents=True, exist_ok=False) report = dict(schema_version=1, config_sha256=sha256(args.config), prepared_sha256=sha256(args.prepared/"prepared.json"), source_blend_sha256=prep["source_blend_sha256"], captures=fingerprints, targets={}) for target in cfg["targets"]: name = target["name"] fields = {k: np.load(args.prepared/f"{name}_{k}.npy", mmap_mode="r") for k in ("position", "normal", "valid", "material")} valid = fields["valid"] if valid.dtype != bool or fields["material"].shape != valid.shape or not valid.any() or fields["position"].shape != (*valid.shape, 3) or fields["normal"].shape != (*valid.shape, 3): raise ValueError(f"Invalid prepared surface arrays: {name}") if not np.isfinite(fields["position"][valid]).all() or not np.isfinite(fields["normal"][valid]).all(): raise ValueError(f"Non-finite prepared geometry: {name}") views = [] for key, view in cfg["views"].items(): item = dict(captures[key]) mask = view.get("source_valid_masks", {}).get(name) if mask: path = root/mask with Image.open(path) as image: allowed = np.asarray(image.convert("L")) >= 128 if allowed.shape != item["depth"].shape: raise ValueError(f"Source-mask dimensions do not match camera: {key}/{name}") item["allowed"] = allowed fingerprints[key]["source_valid_masks"][name] = sha256(path) views.append(item) color, state = project_surface(fields["position"], fields["normal"], valid, fields["material"], views, target, args.chunk_size) irr = np.load(args.prepared/f"{name}_irradiance.npy") if target.get("appearance") == "estimated_albedo" else None rgb, mix, padded = finish_texture(color, state, fields["material"], target, irr) Image.fromarray(np.rint(rgb*255).astype(np.uint8)).save(args.out/f"{name}_appearance.png") Image.fromarray(np.rint(mix*255).astype(np.uint8)).save(args.out/f"{name}_mix.png") Image.fromarray(padded).save(args.out/f"{name}_coverage.png") np.save(args.out/f"{name}_coverage.npy", padded) denominator = int(valid.sum()) report["targets"][name] = dict(appearance=target.get("appearance", "captured_radiance"), surface_texels=denominator, direct=float(np.count_nonzero(state == 1)/denominator), rejected_fallback=float(np.count_nonzero(state == 2)/denominator), unseen_fallback=float(np.count_nonzero(state == 3)/denominator), padding_texels=int(np.count_nonzero(padded == 4))) print(name, json.dumps(report["targets"][name]), flush=True) report["files"] = {p.name: sha256(p) for p in sorted(args.out.iterdir()) if p.is_file()} report["coverage_legend"] = {"0": "outside selected UV surface", "1": "direct capture", "2": "rejected source; procedural fallback", "3": "unseen; procedural fallback", "4": "UV padding"} (args.out/"projection.json").write_text(json.dumps(report, indent=2)+"\n") if __name__ == "__main__": main()skills/gaussian-splat-to-blender-nodes/scripts/projection_blender.py18 KB1e519894bc06upstream ↗
"""Prepare or assemble project-agnostic hybrid texture mapping in a disposable Blender process. blender -b scene.blend --python-exit-code 1 --python projection_blender.py -- prepare --config mapping.json --out prepared blender -b scene.blend --python-exit-code 1 --python projection_blender.py -- assemble --config mapping.json --prepared prepared --textures mapped --out hybrid.blend Source geometry is evaluated from reconstructed objects, never from a reference splat. Assembly saves baked copies and retains hidden source graphs; later GN edits require rebaking. """ import argparse import json import math from pathlib import Path import sys import bpy import numpy as np from mathutils import Matrix, Vector sys.path.insert(0, str(Path(__file__).resolve().parent)) from projection_config import load_config, sha256 def material(name): mat = bpy.data.materials.new(name) mat.use_nodes = True mat.node_tree.nodes.clear() out = mat.node_tree.nodes.new("ShaderNodeOutputMaterial") return mat, mat.node_tree, out def emission(name, value=None): mat, nt, out = material(name) shader = nt.nodes.new("ShaderNodeEmission") nt.links.new(shader.outputs[0], out.inputs["Surface"]) if value is not None: shader.inputs["Color"].default_value = (*value, 1) return mat, nt, shader def disable_reference(cfg): for name in cfg.get("reference_collections", ["GS_REFERENCE"]): col = bpy.data.collections.get(name) if col: col.hide_render = True col.hide_viewport = True def render_setup(device, samples): sc = bpy.context.scene sc.render.engine = "CYCLES" sc.cycles.device, sc.cycles.samples = device, samples sc.render.resolution_percentage = 100 sc.render.pixel_aspect_x = sc.render.pixel_aspect_y = 1 sc.render.use_border = False sc.render.film_transparent = True sc.render.use_compositing = False sc.render.use_sequencer = False if hasattr(sc.render, "use_motion_blur"): sc.render.use_motion_blur = False return sc def evaluated_copy(source, name): if source.type != "MESH": raise ValueError(f"Target must be a mesh host (including GN): {source.name}") # Realize GN instances before requesting the evaluated mesh component. ng = bpy.data.node_groups.new("Mapping Realize", "GeometryNodeTree") ng.interface.new_socket("Geometry", in_out="INPUT", socket_type="NodeSocketGeometry") ng.interface.new_socket("Geometry", in_out="OUTPUT", socket_type="NodeSocketGeometry") gi, go = ng.nodes.new("NodeGroupInput"), ng.nodes.new("NodeGroupOutput") realize = ng.nodes.new("GeometryNodeRealizeInstances") ng.links.new(gi.outputs[0], realize.inputs["Geometry"]) ng.links.new(realize.outputs[0], go.inputs[0]) mod = source.modifiers.new("Mapping temporary realize", "NODES"); mod.node_group = ng try: bpy.context.view_layer.update() mesh = bpy.data.meshes.new_from_object(source.evaluated_get(bpy.context.evaluated_depsgraph_get())) finally: source.modifiers.remove(mod) bpy.data.node_groups.remove(ng) if not mesh.polygons: raise ValueError(f"Target evaluates to no mesh faces: {source.name}") obj = bpy.data.objects.new(name, mesh) bpy.context.scene.collection.objects.link(obj) obj.matrix_world = source.matrix_world.copy() return obj def select(obj): for other in bpy.context.view_layer.objects: other.select_set(False) obj.hide_set(False); obj.hide_render = False obj.select_set(True); bpy.context.view_layer.objects.active = obj def bake(obj, mats, size, kind, samples): originals = list(obj.data.materials) indices = np.array([p.material_index for p in obj.data.polygons], np.int32) obj.data.materials.clear() for mat in mats: obj.data.materials.append(mat) obj.data.polygons.foreach_set("material_index", indices) image = bpy.data.images.new(f"MAP_{obj.name}_{kind}", size, size, float_buffer=True, alpha=True) image.colorspace_settings.name = "Non-Color" nodes = [] for mat in set(mats): tex = mat.node_tree.nodes.new("ShaderNodeTexImage"); tex.image = image mat.node_tree.nodes.active = tex; nodes.append((mat, tex)) select(obj) sc = bpy.context.scene sc.cycles.samples = samples if kind == "irradiance" else 1 sc.render.bake.margin = 0 if kind == "irradiance": sc.render.bake.use_pass_direct = True sc.render.bake.use_pass_indirect = True sc.render.bake.use_pass_color = False try: bpy.ops.object.bake(type="DIFFUSE" if kind == "irradiance" else "EMIT") array = np.array(image.pixels[:], np.float32).reshape(size, size, 4)[::-1, :, :3].copy() finally: for mat, tex in nodes: mat.node_tree.nodes.remove(tex) bpy.data.images.remove(image) obj.data.materials.clear() for mat in originals: obj.data.materials.append(mat) obj.data.polygons.foreach_set("material_index", indices) return array def camera_object(): obj = bpy.data.objects.new("MAP_camera", bpy.data.cameras.new("MAP_camera")) bpy.context.scene.collection.objects.link(obj) obj.data.sensor_fit = "VERTICAL" obj.data.clip_start, obj.data.clip_end = 0.001, 10000 return obj def set_camera(obj, c): pos, target, up = (Vector(c[k]) for k in ("position", "look_at", "up")) z = (pos-target).normalized(); x = up.cross(z).normalized(); y = z.cross(x) obj.matrix_world = Matrix(((x.x, y.x, z.x, pos.x), (x.y, y.y, z.y, pos.y), (x.z, y.z, z.z, pos.z), (0, 0, 0, 1))) obj.data.angle_y = math.radians(c["fov_y_deg"]) sc = bpy.context.scene; sc.camera = obj sc.render.resolution_x, sc.render.resolution_y = c["width"], c["height"] def visibility(cfg, out): sc = bpy.context.scene sc.cycles.samples = 1; sc.cycles.use_denoising = False sc.cycles.pixel_filter_type = "BOX"; sc.cycles.filter_width = 0.01 sc.world = None sc.render.image_settings.file_format = "OPEN_EXR"; sc.render.image_settings.color_depth = "32" ids = {t["object"]: t["object_id"] for t in cfg["targets"]} for obj in sc.objects: obj.pass_index = ids.get(obj.name, 32767) depth, nt, shader = emission("MAP_distance") geo = nt.nodes.new("ShaderNodeNewGeometry") origin = nt.nodes.new("ShaderNodeCombineXYZ") dist = nt.nodes.new("ShaderNodeVectorMath"); dist.operation = "DISTANCE" nt.links.new(geo.outputs["Position"], dist.inputs[0]); nt.links.new(origin.outputs[0], dist.inputs[1]) nt.links.new(dist.outputs["Value"], shader.inputs["Color"]) ident, it, ish = emission("MAP_object_ID") info = it.nodes.new("ShaderNodeObjectInfo") it.links.new(info.outputs["Object Index"], ish.inputs["Color"]) cam = camera_object() (out/"depth").mkdir() for name, view in cfg["views"].items(): c = view["camera"]; set_camera(cam, c) for i, value in enumerate(c["position"]): origin.inputs[i].default_value = value for suffix, mat in (("", depth), ("_id", ident)): bpy.context.view_layer.material_override = mat path = out/"depth"/f"{name}{suffix}.exr" sc.render.filepath = str(path) bpy.ops.render.render(write_still=True) image = bpy.data.images.load(str(path), check_existing=False) pixels = np.array(image.pixels[:], np.float32).reshape(c["height"], c["width"], 4)[::-1] values = np.where(pixels[..., 3] > 0.5, pixels[..., 0], 0 if suffix else np.inf) values = np.rint(values).astype(np.uint16) if suffix else values.astype(np.float32) np.save(out/"depth"/f"{name}{suffix}.npy", values) bpy.data.images.remove(image); path.unlink() print("VISIBILITY", name, flush=True) bpy.context.view_layer.material_override = None def prepare(args, cfg): args.out.mkdir(parents=True, exist_ok=False) sc = render_setup(args.device, args.samples) disable_reference(cfg) report = dict(schema_version=1, config_sha256=sha256(args.config), source_blend_sha256=sha256(bpy.data.filepath), blender_version=bpy.app.version_string, targets={}) copies = [] for target in cfg["targets"]: source = bpy.data.objects[target["object"]] if source.hide_render or not source.visible_get(): raise ValueError(f"Target is not visible in the current view layer: {source.name}") name = target["name"] obj = evaluated_copy(source, "MAP_"+name) copies.append(obj) source.hide_render = True select(obj) fallback_uv = next((uv.name for uv in obj.data.uv_layers if uv.active_render), None) if target.get("uv_mode", "unwrap") == "unwrap": obj.data.uv_layers.new(name="MappingUV") obj.data.uv_layers.active_index = len(obj.data.uv_layers)-1 bpy.ops.object.mode_set(mode="EDIT"); bpy.ops.mesh.select_all(action="SELECT") bpy.ops.uv.smart_project(angle_limit=math.radians(60), island_margin=0.003, scale_to_bounds=True) bpy.ops.object.mode_set(mode="OBJECT") elif target["uv_mode"] != "existing" or not obj.data.uv_layers.active: raise ValueError("uv_mode must be unwrap or existing with a valid nonoverlapping UV map") mapping_uv = obj.data.uv_layers.active.name obj.data.uv_layers.active.active_render = True if not obj.data.materials: fallback = bpy.data.materials.new("MAP neutral fallback"); fallback.use_nodes = True obj.data.materials.append(fallback) for i, mat in enumerate(obj.data.materials): if mat is None: # GN can retain an unused empty slot before its assigned materials. # Keep the slot index; removing it would remap face identities. mat = bpy.data.materials.new(f"MAP neutral slot {i}"); mat.use_nodes = True obj.data.materials[i] = mat if not mat.use_nodes: raise ValueError(f"Target needs node materials in every slot: {name}, slot {i}") count = len(obj.data.materials); size = target.get("texture_size", 1024) selected = target.get("material_slots", list(range(count))) if not selected or any(type(i) is not int or i < 0 or i >= count for i in selected): raise ValueError(f"Invalid material_slots: {name}") for field, socket in (("position", "Position"), ("normal", "True Normal")): mat, nt, shader = emission("MAP_"+field) geo = nt.nodes.new("ShaderNodeNewGeometry") nt.links.new(geo.outputs[socket], shader.inputs["Color"]) array = bake(obj, [mat]*count, size, field, args.samples) np.save(args.out/f"{name}_{field}.npy", array) mats = [emission("MAP_slot_"+str(i), (i+1,)*3)[0] for i in range(count)] indices = np.rint(bake(obj, mats, size, "material", args.samples)[..., 0]).astype(np.int32)-1 normals = np.load(args.out/f"{name}_normal.npy") valid = (np.linalg.norm(normals, axis=2) > 0.5) & np.isin(indices, selected) if not valid.any(): raise ValueError(f"No selected UV texels: {name}") np.save(args.out/f"{name}_material.npy", indices) np.save(args.out/f"{name}_valid.npy", valid) if target.get("appearance") == "estimated_albedo": white, nt, output = material("MAP_white_diffuse") diffuse = nt.nodes.new("ShaderNodeBsdfDiffuse") diffuse.inputs["Color"].default_value = (1, 1, 1, 1) nt.links.new(diffuse.outputs[0], output.inputs["Surface"]) np.save(args.out/f"{name}_irradiance.npy", bake(obj, [white]*count, size, "irradiance", args.samples)) report["targets"][name] = dict(object=obj.name, source_object=source.name, materials=[m.name for m in obj.data.materials], selected_slots=selected, mapping_uv=mapping_uv, fallback_uv=fallback_uv, polygons=len(obj.data.polygons), surface_texels=int(valid.sum())) obj.hide_render = True; source.hide_render = False bpy.data.libraries.write(str(args.out/"targets.blend"), set(copies), fake_user=True, path_remap="ABSOLUTE") visibility(cfg, args.out) report["files"] = {str(p.relative_to(args.out)): sha256(p) for p in sorted(args.out.rglob("*")) if p.is_file()} (args.out/"prepared.json").write_text(json.dumps(report, indent=2)+"\n") def assemble(args, cfg): prep = json.loads((args.prepared/"prepared.json").read_text()) mapped = json.loads((args.textures/"projection.json").read_text()) if args.out.exists() or args.out.resolve() == Path(bpy.data.filepath).resolve(): raise ValueError("Output must be a new .blend path; preserve the source") if prep["source_blend_sha256"] != sha256(bpy.data.filepath): raise ValueError("Source blend changed since preparation; rebake the new geometry") if sha256(args.config) != prep["config_sha256"] or mapped["prepared_sha256"] != sha256(args.prepared/"prepared.json"): raise ValueError("Config/prepared/mapped checkpoint mismatch") for directory, manifest in ((args.prepared, prep), (args.textures, mapped)): for name, digest in manifest["files"].items(): if sha256(directory/name) != digest: raise ValueError(f"Artifact changed: {name}") if mapped["config_sha256"] != prep["config_sha256"]: raise ValueError("Projection config mismatch") wanted = [prep["targets"][t["name"]]["object"] for t in cfg["targets"]] if any(name in bpy.data.objects for name in wanted): raise ValueError("Source already contains mapped targets; use the original frozen source") with bpy.data.libraries.load(str(args.prepared/"targets.blend")) as (src, dst): dst.objects = wanted disable_reference(cfg) collection = bpy.data.collections.new("MAPPED_SURFACES") bpy.context.scene.collection.children.link(collection) for target, obj in zip(cfg["targets"], dst.objects): name = target["name"] collection.objects.link(obj); obj.hide_render = False; obj.hide_viewport = False source = bpy.data.objects[target["object"]] source.hide_render = True; source.hide_viewport = True original_uv = prep["targets"][name]["fallback_uv"] if original_uv: obj.data.uv_layers[original_uv].active_render = True rgb = bpy.data.images.load(str(args.textures/f"{name}_appearance.png"), check_existing=False) rgb.colorspace_settings.name = "sRGB"; rgb.pack() mix = bpy.data.images.load(str(args.textures/f"{name}_mix.png"), check_existing=False) mix.colorspace_settings.name = "Non-Color"; mix.pack() for slot in prep["targets"][name]["selected_slots"]: mat = obj.data.materials[slot].copy(); mat.name += " [mapped]" obj.data.materials[slot] = mat; nt = mat.node_tree output = next(n for n in nt.nodes if n.type == "OUTPUT_MATERIAL" and n.is_active_output) if not output.inputs["Surface"].is_linked: raise ValueError(f"No fallback surface shader: {name}/{slot}") fallback = output.inputs["Surface"].links[0].from_socket tex = nt.nodes.new("ShaderNodeTexImage"); tex.image = rgb; tex.extension = "EXTEND" mask = nt.nodes.new("ShaderNodeTexImage"); mask.image = mix; mask.extension = "EXTEND" uv = nt.nodes.new("ShaderNodeUVMap"); uv.uv_map = prep["targets"][name]["mapping_uv"] nt.links.new(uv.outputs[0], tex.inputs["Vector"]); nt.links.new(uv.outputs[0], mask.inputs["Vector"]) settings = target.get("material_overrides", {}).get(str(slot), {}) scale = nt.nodes.new("ShaderNodeVectorMath"); scale.operation = "SCALE" scale.inputs["Scale"].default_value = settings.get("color_scale", 1.0) nt.links.new(tex.outputs["Color"], scale.inputs[0]) if target.get("appearance", "captured_radiance") == "captured_radiance": shader = nt.nodes.new("ShaderNodeEmission") nt.links.new(scale.outputs[0], shader.inputs["Color"]) else: shader = nt.nodes.new("ShaderNodeBsdfPrincipled") nt.links.new(scale.outputs[0], shader.inputs["Base Color"]) shader.inputs["Roughness"].default_value = settings.get("roughness", 0.5) shader.inputs["Specular IOR Level"].default_value = settings.get("specular", 0.5) blend = nt.nodes.new("ShaderNodeMixShader") nt.links.new(mask.outputs["Color"], blend.inputs[0]) nt.links.new(fallback, blend.inputs[1]); nt.links.new(shader.outputs[0], blend.inputs[2]) nt.links.new(blend.outputs[0], output.inputs["Surface"]) obj["mapping_source_object"] = source.name obj["mapping_editability"] = "Baked evaluated mesh; edits to source GN require rebaking" bpy.context.scene["mapping_manifest_sha256"] = sha256(args.textures/"projection.json") args.out.parent.mkdir(parents=True, exist_ok=True) bpy.ops.wm.save_as_mainfile(filepath=str(args.out.resolve()), compress=True) print("SAVED_HYBRID", args.out, flush=True) def main(): parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("action", choices=("prepare", "assemble")) parser.add_argument("--config", required=True, type=Path) parser.add_argument("--out", required=True, type=Path) parser.add_argument("--prepared", type=Path) parser.add_argument("--textures", type=Path) parser.add_argument("--device", choices=("CPU", "GPU"), default="CPU") parser.add_argument("--samples", type=int, default=32) args = parser.parse_args(sys.argv[sys.argv.index("--")+1:] if "--" in sys.argv else []) if not bpy.data.filepath or not Path(bpy.data.filepath).is_file(): parser.error("Open a saved reconstructed scene first") if args.samples < 1: parser.error("samples must be positive") cfg, _ = load_config(args.config) if args.action == "prepare": prepare(args, cfg) elif args.prepared and args.textures: assemble(args, cfg) else: parser.error("assemble requires --prepared and --textures") if __name__ == "__main__": main()skills/gaussian-splat-to-blender-nodes/scripts/projection_config.py6.3 KBf2c9fbd3a18eupstream ↗
"""Shared projection configuration and file fingerprints (standard library only).""" import hashlib import json import math from pathlib import Path import re from cameras import validate_camera def sha256(path): with Path(path).open("rb") as stream: digest = hashlib.sha256() for block in iter(lambda: stream.read(1024 * 1024), b""): digest.update(block) return digest.hexdigest() def load_config(path): path = Path(path).resolve() cfg = json.loads(path.read_text()) if cfg.get("schema_version") != 1 or not cfg.get("targets") or not cfg.get("views"): raise ValueError("schema_version=1, nonempty targets and views are required") names, objects, ids = set(), set(), set() for target in cfg["targets"]: name, obj, ident = target["name"], target["object"], target["object_id"] if not re.fullmatch(r"[A-Za-z0-9_-]+", name) or name in names or obj in objects: raise ValueError("Targets need unique safe names and unique source objects") if type(ident) is not int or not 1 <= ident <= 32766 or ident in ids: raise ValueError("Target object IDs must be unique integers in 1..32766") names.add(name); objects.add(obj); ids.add(ident) size = target.get("texture_size", 1024) if type(size) is not int or not 8 <= size <= 8192: raise ValueError("texture_size must be an integer in 8..8192") mode = target.get("appearance", "captured_radiance") if mode not in ("captured_radiance", "estimated_albedo"): raise ValueError("appearance must be captured_radiance or estimated_albedo") options(target) for rule in target.get("reject_rules", []): validate_rule(rule) if target.get("uv_mode", "unwrap") not in ("unwrap", "existing"): raise ValueError("uv_mode must be unwrap or existing") slots = target.get("material_slots") if slots is not None and (not slots or any(type(s) is not int or s < 0 for s in slots) or len(set(slots)) != len(slots)): raise ValueError("material_slots must be unique nonnegative integer indices") for slot, values in target.get("material_overrides", {}).items(): if not slot.isdigit() or (slots is not None and int(slot) not in slots): raise ValueError("Material overrides must name selected, zero-based material slots") for key, value in values.items(): if key not in ("robust_q", "color_scale", "roughness", "specular"): raise ValueError(f"Unknown material setting: {key}") if not math.isfinite(value) or value < 0 or (key in ("roughness", "specular") and value > 1): raise ValueError(f"Invalid material setting: {key}") for name, view in cfg["views"].items(): if not re.fullmatch(r"[A-Za-z0-9_-]+", name): raise ValueError("View names must be safe file stems") validate_camera(view["camera"]) if min(view["camera"][k] for k in ("width", "height")) < 3: raise ValueError("Projection captures must be at least 3x3 pixels") if not view.get("image"): raise ValueError("Every view needs a capture image path") if set(view.get("source_valid_masks", {})) - names: raise ValueError("source_valid_masks must name configured targets") return cfg, path.parent def options(target): p = dict(mode="weighted", power=12.0, min_cos=0.3, vertical_min_cos=0.3, depth_absolute=0.006, depth_relative=0.004, depth_erode=1, border=9, resolution_cap=0.0, edge_feather=0.0, robust_q=0.3, mix_feather=3.0, padding=8, irradiance_floor=0.35) unknown = set(target.get("projection", {})) - p.keys() if unknown: raise ValueError(f"Unknown projection options: {sorted(unknown)}") p.update(target.get("projection", {})) if p["mode"] not in ("weighted", "robust"): raise ValueError("Projection mode must be weighted or robust") for key in ("min_cos", "vertical_min_cos", "edge_feather"): if not math.isfinite(p[key]) or not 0 <= p[key] <= 1: raise ValueError(f"{key} must be in [0,1]") for key in ("depth_absolute", "depth_relative", "resolution_cap", "robust_q", "mix_feather"): if not math.isfinite(p[key]) or p[key] < 0: raise ValueError(f"{key} must be finite and nonnegative") if not 0 < p["power"] <= 32 or not 0 < p["irradiance_floor"] <= 1: raise ValueError("power must be in (0,32], irradiance_floor in (0,1]") for key in ("depth_erode", "border", "padding"): if type(p[key]) is not int or not 0 <= p[key] <= 255: raise ValueError(f"{key} must be an integer in 0..255") return p def validate_rule(rule): """Reject malformed local heuristics before starting any Blender bakes.""" allowed = {"bounds_min", "bounds_max", "rgb_min", "rgb_max", "channel_ratios", "min_sum"} if set(rule) - allowed: raise ValueError("Unknown rejection rule field") for key in ("bounds_min", "bounds_max", "rgb_min", "rgb_max"): if key not in rule and key.startswith("rgb"): continue value = rule.get(key, []) if len(value) != 3 or not all(math.isfinite(v) for v in value): raise ValueError(f"{key} must contain three finite numbers") if key.startswith("rgb") and not all(0 <= v <= 1 for v in value): raise ValueError(f"{key} must be in [0,1]") if any(lo > hi for lo, hi in zip(rule["bounds_min"], rule["bounds_max"])): raise ValueError("Rejection bounds must be ordered") for ratio in rule.get("channel_ratios", []): if len(ratio) != 3 or any(c not in ("r", "g", "b") for c in ratio[:2]): raise ValueError("Channel ratios require two channel names and a ratio") if not math.isfinite(ratio[2]) or ratio[2] <= 0: raise ValueError("Channel ratios must be finite and positive") if "min_sum" in rule: value = rule["min_sum"] channels = value.get("channels", []) if not channels or len(set(channels)) != len(channels) or any(c not in ("r", "g", "b") for c in channels): raise ValueError("min_sum requires unique RGB channels") if not math.isfinite(value["value"]) or not 0 <= value["value"] <= len(channels): raise ValueError("min_sum threshold is outside the channel sum range")skills/gaussian-splat-to-blender-nodes/scripts/projection_core.py8.6 KB251833fce3edupstream ↗
"""Project-independent capture projection, derived from the Garden c11b/c22s/c26b study. No Blender dependency. Coordinates and tolerances are in the configured world frame. Captured radiance is not clean albedo; source masks complement reconstructed depth. """ import math import numpy as np from scipy.ndimage import distance_transform_edt, gaussian_filter, maximum_filter, minimum_filter from projection_config import options def srgb_to_linear(c): return np.where(c <= 0.04045, c / 12.92, ((c + 0.055) / 1.055) ** 2.4) def linear_to_srgb(c): c = np.clip(c, 0, 1) return np.where(c <= 0.0031308, c * 12.92, 1.055 * c ** (1 / 2.4) - 0.055) def project(camera, points): pos, look, up = (np.asarray(camera[k], float) for k in ("position", "look_at", "up")) z = pos - look; z /= np.linalg.norm(z) x = np.cross(up, z); x /= np.linalg.norm(x) y = np.cross(z, x) d = points - pos distance = np.linalg.norm(d, axis=1) forward = -(d @ z) f = 0.5 * camera["height"] / math.tan(math.radians(camera["fov_y_deg"]) / 2) safe = np.where(np.abs(forward) > 1e-12, forward, 1e-12) u = camera["width"] / 2 + f * (d @ x) / safe v = camera["height"] / 2 - f * (d @ y) / safe return d, distance, forward, f, u, v def bilinear(image, u, v): h, w = image.shape[:2] u = np.clip(u - 0.5, 0, w - 1.001); v = np.clip(v - 0.5, 0, h - 1.001) x, y = u.astype(int), v.astype(int) a, b = (u - x)[:, None], (v - y)[:, None] return (image[y, x] * (1-a) * (1-b) + image[y, x+1] * a * (1-b) + image[y+1, x] * (1-a) * b + image[y+1, x+1] * a * b) def reject_samples(points, srgb, rules): """Explicit, optional world-region/color rules; never infer semantic labels from hue.""" rejected = np.zeros(len(points), bool) channels = {"r": 0, "g": 1, "b": 2} for rule in rules: # A bounded region is mandatory: a scene-specific fix must not filter an entire world. low, high = np.asarray(rule["bounds_min"], float), np.asarray(rule["bounds_max"], float) if low.shape != (3,) or high.shape != (3,) or not np.isfinite([low, high]).all() or (low > high).any(): raise ValueError("Rejection rules need finite, ordered 3D bounds") hit = ((points >= low) & (points <= high)).all(1) for left, right, ratio in rule.get("channel_ratios", []): if not math.isfinite(ratio) or ratio <= 0: raise ValueError("Channel ratios must be finite and positive") hit &= srgb[:, channels[left]] > ratio * srgb[:, channels[right]] if "rgb_min" in rule: hit &= (srgb >= np.asarray(rule["rgb_min"])).all(1) if "rgb_max" in rule: hit &= (srgb <= np.asarray(rule["rgb_max"])).all(1) if "min_sum" in rule: hit &= srgb[:, [channels[c] for c in rule["min_sum"]["channels"]]].sum(1) > rule["min_sum"]["value"] rejected |= hit return rejected def prepare_view(view, target, p): depth, ids = view["depth"], view["ids"] depth = minimum_filter(depth, size=max(p["depth_erode"], 1)) foreign = maximum_filter(ids != target["object_id"], size=max(p["border"], 1)) allowed = view.get("allowed") if allowed is not None: # Bilinear samples may touch four pixels; don't interpolate across a rejected pixel. allowed = minimum_filter(allowed, size=3) return dict(view, depth=depth, foreign=foreign, allowed=allowed, linear_rgb=srgb_to_linear(view["rgb"].astype(np.float32)/255)) def sample_view(points, normals, view, target, p): c = view["camera"] d, distance, forward, f, u, v = project(c, points) cosine = -(normals*d).sum(1) / np.maximum(distance, 1e-12) x, y = np.clip(u.astype(int), 0, c["width"]-1), np.clip(v.astype(int), 0, c["height"]-1) threshold = np.where(np.abs(normals[:, 2]) < 0.4, p["vertical_min_cos"], p["min_cos"]) valid = (forward > 0) & (u >= 1) & (u < c["width"]-1) & (v >= 1) & (v < c["height"]-1) valid &= cosine > threshold valid &= distance <= view["depth"][y, x] + p["depth_absolute"] + p["depth_relative"]*distance valid &= ~view["foreign"][y, x] observed_candidate = valid.copy() if view.get("allowed") is not None: valid &= view["allowed"][y, x] color = bilinear(view["linear_rgb"], u, v) valid &= ~reject_samples(points, linear_to_srgb(color), target.get("reject_rules", [])) resolution = f / np.maximum(distance, 1e-12) if p["resolution_cap"]: resolution = np.minimum(resolution, p["resolution_cap"]) weight = ((np.clip(cosine, 0, 1)**4)*resolution**2)**(p["power"]/2) if p["edge_feather"]: edge = np.minimum(np.minimum(u, c["width"]-u)/(c["width"]/2), np.minimum(v, c["height"]-v)/(c["height"]/2)) t = np.clip(edge/p["edge_feather"], 0, 1) weight *= t*t*(3-2*t) return np.where(valid, weight, 0), color, observed_candidate def blend_samples(weights, colors, mode, quantile): """c11b robust diffuse consensus: suppress bright outliers without selecting dark ghosts.""" if mode == "robust": lum = colors @ np.array([0.2126, 0.7152, 0.0722]) total = weights.sum(0) mu = (weights*lum).sum(0)/np.maximum(total, 1e-300) sd = np.sqrt(np.maximum((weights*lum*lum).sum(0)/np.maximum(total, 1e-300)-mu*mu, 0)) + 0.02 dark = lum < mu - np.where(quantile > 1, 3.0, 2.2)*sd weights = np.where(dark, 0, weights*np.exp(-3*np.maximum(lum-(mu-quantile*sd), 0)/sd)) total = weights.sum(0) color = (weights[..., None]*colors).sum(0)/np.maximum(total, 1e-300)[:, None] return color, total > 0 def project_surface(position, normal, valid, material_ids, views, target, chunk_size=65536): p = options(target) index = np.flatnonzero(valid) output = np.zeros((*valid.shape, 3), np.float32) state = np.zeros(valid.shape, np.uint8) prepared = [prepare_view(v, target, p) for v in views] for start in range(0, len(index), chunk_size): ix = index[start:start+chunk_size] points, normals = position.reshape(-1, 3)[ix], normal.reshape(-1, 3)[ix].astype(float) normals /= np.maximum(np.linalg.norm(normals, axis=1, keepdims=True), 1e-12) sampled = [sample_view(points, normals, v, target, p) for v in prepared] weights = np.array([s[0] for s in sampled]) colors = np.array([s[1] for s in sampled]) q = np.full(len(ix), p["robust_q"]) for slot, override in target.get("material_overrides", {}).items(): q[material_ids.ravel()[ix] == int(slot)] = override.get("robust_q", p["robust_q"]) color, covered = blend_samples(weights, colors, p["mode"], q) candidate = np.array([s[2] for s in sampled]).any(0) output.reshape(-1, 3)[ix] = color # 2 = observed candidate rejected to procedural fallback; 3 = unseen fallback. state.ravel()[ix] = np.where(covered, 1, np.where(candidate, 2, 3)) return output, state def finish_texture(color, state, material_ids, target, irradiance=None): """Feather only toward procedural fallback; never fill unseen faces with another object's color.""" p = options(target) direct = state == 1 if target.get("appearance", "captured_radiance") == "estimated_albedo": if irradiance is None or irradiance.shape != color.shape or not np.isfinite(irradiance).all(): raise ValueError("estimated_albedo needs a finite, same-size linear irradiance bake") e = irradiance.mean(2) lit = direct & (e > 0.05) if not lit.any(): raise ValueError("No lit direct texels for an irradiance estimate") floor = p["irradiance_floor"]*float(np.median(e[lit])) color = np.clip(color/np.maximum(e, floor)[..., None], 0, 0.9) mix = np.zeros(state.shape, np.float32) # Material-aware filtering preserves slate/wood and other real face-slot boundaries. for slot in np.unique(material_ids[state > 0]): surface = (material_ids == slot) & (state > 0) d = direct & surface if p["mix_feather"]: f = gaussian_filter(d.astype(float), p["mix_feather"])/np.maximum( gaussian_filter(surface.astype(float), p["mix_feather"]), 1e-6) mix[d] = np.clip(2*f[d]-0.5, 0, 1) else: mix[d] = 1 rgb = linear_to_srgb(color) if direct.any() and p["padding"]: distance, nearest = distance_transform_edt(~direct, return_indices=True) padding = (state == 0) & (distance <= p["padding"]) rgb[padding] = rgb[tuple(nearest[:, padding])] mix[padding] = mix[tuple(nearest[:, padding])] state = state.copy(); state[padding] = 4 return rgb, mix, stateskills/gaussian-splat-to-blender-nodes/scripts/render_manifest.py3.6 KB0f87a7d79c33upstream ↗
"""Render a shared camera manifest in Blender. Removes GS_REFERENCE in memory. One process renders sequentially. No scan renderer is bundled: export the manifest's scan cameras in the chosen reference viewer using identical dimensions and FOV. Does not save or overwrite the source .blend or change its lighting/color management. """ import argparse import hashlib import json from pathlib import Path import sys import bpy from mathutils import Matrix, Vector sys.path.insert(0, str(Path(__file__).resolve().parent)) from cameras import validate_camera def main(): p = argparse.ArgumentParser(description=__doc__) p.add_argument("--manifest", required=True, type=Path) p.add_argument("--out", required=True, type=Path) p.add_argument("--reference", default="GS_REFERENCE") p.add_argument("--views", help="Comma-separated names; default is every world camera") p.add_argument("--samples", type=int, default=32) p.add_argument("--engine", choices=["CYCLES", "BLENDER_EEVEE"], default="BLENDER_EEVEE") a = p.parse_args(sys.argv[sys.argv.index("--")+1:] if "--" in sys.argv else []) data = json.loads(a.manifest.read_text()) cameras = data["world"] chosen = a.views.split(",") if a.views else list(cameras) if a.samples <= 0 or not chosen: p.error("Positive samples and at least one camera required") for name in chosen: if not name or Path(name).name != name or name in (".", "..") or "\\" in name: p.error("Camera names must be safe file stems") validate_camera(cameras[name]) a.out.mkdir(parents=True, exist_ok=False) ref = bpy.data.collections.get(a.reference) if ref: for ob in list(ref.all_objects): bpy.data.objects.remove(ob, do_unlink=True) bpy.data.collections.remove(ref, do_unlink=True) sc = bpy.context.scene sc.render.engine = a.engine if a.engine == "CYCLES": sc.cycles.device, sc.cycles.samples, sc.cycles.use_denoising = "CPU", a.samples, True else: sc.eevee.taa_render_samples = a.samples cam = bpy.data.objects.new("QA_manifest_camera", bpy.data.cameras.new("QA_manifest_camera")) sc.collection.objects.link(cam) sc.camera = cam cam.data.sensor_fit = "VERTICAL" cam.data.clip_start, cam.data.clip_end = 0.01, 1000 sc.render.resolution_percentage = 100 sc.render.pixel_aspect_x = sc.render.pixel_aspect_y = 1 sc.render.use_border = False sc.render.image_settings.file_format = "PNG" sc.render.image_settings.color_mode = "RGB" record = dict(manifest_sha256=hashlib.sha256(a.manifest.read_bytes()).hexdigest(), engine=a.engine, samples=a.samples, view_transform=sc.view_settings.view_transform, views=[]) import math for name in chosen: c = cameras[name] pos, target, up = (Vector(c[k]) for k in ("position", "look_at", "up")) z = (pos-target).normalized() x = up.cross(z).normalized() y = z.cross(x) cam.matrix_world = Matrix(((x.x, y.x, z.x, pos.x), (x.y, y.y, z.y, pos.y), (x.z, y.z, z.z, pos.z), (0, 0, 0, 1))) cam.data.angle_y = math.radians(c["fov_y_deg"]) sc.render.resolution_x, sc.render.resolution_y = c["width"], c["height"] path = a.out / f"{name}.png" sc.render.filepath = str(path.resolve()) bpy.ops.render.render(write_still=True) record["views"].append(dict(name=name, camera=c, sha256=hashlib.sha256(path.read_bytes()).hexdigest())) (a.out/"render.json").write_text(json.dumps(record, indent=2)+"\n") print(f"RENDERED {name}", flush=True) if __name__ == "__main__": main()skills/gaussian-splat-to-blender-nodes/references/acquisition-and-cameras.md2.7 KBcc922f9c14cdupstream ↗
# Acquisition and camera registration For the complete source-viewer, coverage and capture sequence, read [whole-scene capture](whole-scene-capture.md) and use the [capture prompt](../templates/prompts/capture-and-registration.md). Record scene/author, asset and viewer URLs, retrieval date, bytes, SHA-256, producer/version, usage terms and conversions. Keep scans, decoded per-splat arrays and private session data local. Do not infer dataset rights from an MIT viewer. ## Formats Use the producer's decoder and confirm field semantics. Common 3DGS training PLY files contain log-scales, opacity logits, quaternions and spherical harmonics; PLY is only a container and other exporters differ. Do not exponentiate or sigmoid already-linear data. A common 32-byte `.splat` layout contains position, scale, RGBA and quantized rotation without higher-order SH. KSPLAT uses versioned compressed sections; SPZ is different. The inspector enumerates PLY headers, checks common `.splat` record size, and reads KSPLAT v0.1 main-header metadata. It does not validate PLY bodies/KSPLAT payloads, decode positions or infer semantic labels. Unknown formats receive a fingerprint only. Primary implementations: [GaussianSplats3D](https://github.com/mkkellogg/GaussianSplats3D) and [3D Gaussian Splatting](https://github.com/graphdeco-inria/gaussian-splatting). ## One frame, two renderers With column vectors, proper rotation R, scan origin o and positive metric scale s: ```text world_point = s * R * (scan_point - o) scan_point = transpose(R) * world_point / s + o world_direction = R * scan_direction ``` Directions/up receive no translation or metric scale. Check orthonormal rows and determinant +1. A reflection is not a rotation. Example `frame.json`: ```json { "scale_m_per_unit": 1.0, "origin_scan": [0, 0, 0], "R_scan_to_world": [[1, 0, 0], [0, 1, 0], [0, 0, 1]], "assumption": "Scale measured from a known physical dimension" } ``` World is right-handed, metres and Z-up. Blender cameras look along local -Z with +Y up. For position p, target t and up u, use z=normalize(p-t), x=normalize(cross(u,z)), y=cross(z,x) as matrix columns. Reject collinear up/view vectors. Record **vertical** FOV, dimensions and square pixel aspect. Account for browser viewport bounds: a cropped screenshot is not necessarily the whole render viewport. Register three dispersed landmarks, then verify another landmark and an opposite view. Check projected pixels before changing geometry. Lock the camera while correcting a part. Scale may be an assumption: Garden uses a table height of 0.74 m. Label estimates and inferred hidden surfaces. Splat floaters, transparent backsides and unseen regions can be capture artifacts. A camera manifest proves repeatability, not ground-truth calibration.skills/gaussian-splat-to-blender-nodes/references/comparison-videos.md2.1 KB24cc742aeab6upstream ↗
# Matched 360-degree comparison Run from the repository root; substitute the installed Blender executable if needed. ```sh S=skills/gaussian-splat-to-blender-nodes python3 "$S/scripts/cameras.py" --frame prompts/garden/data/frame.json \ --target 0 0 0.7 --radius 3 --elevation 30 --frames 240 --fps 24 \ --width 640 --height 640 --out outputs/orbit.json blender -b --factory-startup outputs/garden.blend --python-exit-code 1 \ --python "$S/scripts/render_manifest.py" -- \ --manifest outputs/orbit.json --out outputs/orbit-native --samples 32 ``` This produces 240 poses at fixed distance/elevation. The closing pose is not duplicated. The manifest's FPS defines duration; the Blender scene remains at its current frame. This is a static-scene camera orbit, not animation evaluation. Render the manifest's **scan** cameras in the reference viewer with exact position, target, up, vertical FOV, dimensions and six-digit PNG filenames. Record quality, SH degree, background and exposure. Disable auto-rotation and let the viewer settle. A universal reference renderer/browser driver is not included. Do not reframe either side, interpolate missing frames or approximate the reference orbit by eye. With limited scan coverage, select and label a supported arc instead of claiming 360°. ```sh python3 -m pip install -r requirements.txt python3 "$S/scripts/compare.py" outputs/orbit-reference outputs/orbit-native \ --out outputs/comparison --video --fps 24 ``` Requires FFmpeg with libx264 on PATH. Output: H.264/yuv420p, CRF 18, faststart, no audio. Pairing rejects missing names, inconsistent dimensions and existing output directories. Labels occupy a separate 32-pixel strip. The encoder pads at most one pixel at the right or bottom for even dimensions. It does not resize or auto-align the scene images. `evidence.json` records input/output hashes, frame count and FPS. Equal image sizes do not prove camera matching: verify manifests and landmark projections. Inspect quarter turns and the seam for clipping, missing frames, labels and changing occlusion. For a still pair, give two image files and omit `--video`.skills/gaussian-splat-to-blender-nodes/references/hybrid-projection.md12 KB5ef0bdf649b4upstream ↗
# Optional hybrid appearance mapping Use this mode when the user wants captured appearance on reconstructed surfaces. The procedural scene remains the geometric source. Selected surfaces become **baked evaluated meshes with packed textures**, while untouched parts retain their original node graphs. Keep the source blend and label that distinction. This is not automatic splat meshing, material recovery or a texture that follows later Geometry Nodes edits. ## Contents - [Inputs and configuration](#inputs-and-configuration) - [Run the pipeline](#run-the-pipeline) - [Visibility and fallback](#visibility-and-fallback) - [Appearance and material slots](#appearance-and-material-slots) - [Review and delivery](#review-and-delivery) ## Inputs and configuration Start from [the hybrid prompt](../templates/prompts/hybrid-projection.md) and copy [projection.json](../templates/projection.json) into the project. Its object name, cameras, paths and distances are **illustrative values to replace**, not fitted defaults for a scene. No Garden object list, coordinates, semantic labels, hue filters or texture files are built into the tools. Supply a saved reconstructed `.blend` and calibrated, opaque RGB sRGB PNG/JPEG captures from the chosen reference renderer. Composite transparent captures deliberately before use; the projector ignores alpha. Images must have the declared dimensions, with no lens distortion, crop, camera shift or UI overlay. Cameras use world-space position/look_at/up, vertical FOV in degrees, square pixels, and a top-left image origin. Register geometry in the same world frame. The tools do not estimate poses or run a splat viewer. See [whole-scene capture](whole-scene-capture.md). Configure: | Field | Meaning | |---|---| | `targets[].name` | Unique safe output stem | | `object` / `object_id` | Exact reconstructed Blender object name / unique integer 1–32766 | | `texture_size` | Square atlas size, 8–8192; start small and measure memory/time | | `material_slots` | Optional selected **zero-based evaluated face material slots**; omitted means all | | `uv_mode` | `unwrap` for a new smart unwrap; `existing` for an already valid nonoverlapping active UV map | | `appearance` | `captured_radiance` (unlit) or `estimated_albedo` (approximate lit surface) | | `views` | Named calibrated images and cameras; paths relative to the config file | | `source_valid_masks` | Per-view mapping from target name to grayscale source mask; white allows that surface | | `reference_collections` | Reference collections hidden during preparation/assembly; default `GS_REFERENCE` | Targets are mesh objects, including generating GN mesh hosts. Instances are realized on a copy; points/curves that do not evaluate to faces are not supported. Object transforms and actual material-slot identities are preserved. A missing slot gets a neutral fallback without renumbering faces. Existing non-node materials must be converted before preparation. Unwrapping adds a new mapping UV layer; existing layers and the original render UV selection are preserved for fallback shaders. Explicit mapping nodes use the new layer. Original fallback image dependencies are resolved to their source paths; only new appearance/mix images are packed automatically. Keep external fallback assets available or package them with the deliverable. Tune distances to the project's units. `depth_absolute` is world-space tolerance; `depth_relative` is dimensionless. `border` and `depth_erode` are pixel filter widths, `mix_feather` is an atlas-pixel Gaussian sigma, and `padding` is atlas pixels. Optional `resolution_cap` limits pixels per world unit and `edge_feather` fades camera-edge weights. `min_cos` rejects grazing views; `vertical_min_cos` applies where `abs(normal.z) < 0.4` in a Z-up Blender world. Leave these equal for orientation-independent weighting. All these controls belong to the configuration, not scene-specific code. ## Run the pipeline Use Python 3.10+ with `numpy`, `scipy` and `Pillow` (the repository's requirements include them), and Blender 5.0+ with its bundled NumPy. Tested versions are recorded in the repository validation notes. Run sequentially in disposable background processes; do not run scripts against an unsaved interactive scene. ```sh S=skills/gaussian-splat-to-blender-nodes # Freeze geometry, bake world positions/normals/material IDs and scene visibility. blender -b --factory-startup project/source.blend --python-exit-code 1 \ --python "$S/scripts/projection_blender.py" -- prepare \ --config project/mapping.json --out project/outputs/prepared-v01 --samples 32 # Project calibrated captures, reject contaminated samples, write textures + coverage. python3 "$S/scripts/project_textures.py" \ --config project/mapping.json --prepared project/outputs/prepared-v01 \ --out project/outputs/mapped-v01 # Assemble packed appearance/mix images on copies with the original shaders as fallback. blender -b --factory-startup project/source.blend --python-exit-code 1 \ --python "$S/scripts/projection_blender.py" -- assemble \ --config project/mapping.json --prepared project/outputs/prepared-v01 \ --textures project/outputs/mapped-v01 --out project/outputs/hybrid-v01.blend ``` Each output must be new. Preparation fingerprints the config and source blend; projection checks all prepared arrays and records capture/mask hashes; assembly verifies prepared and mapped artifacts. Changing the config, geometry, materials, transforms or lighting means a new prepared checkpoint. Unchanged captures may be reused. Reprojecting changed captures requires a new mapped directory. Reports describe the files actually used, not a mutable “latest” alias. Changing the source after assembly does not update the mapped copies. CPU is the default. `prepare --device GPU` requires the user's existing Cycles device setup; the tool does not alter device preferences. `--chunk-size` on the projector controls the texel batch size, not the full cached capture/atlas memory. Visibility needs two renders per view; each target needs three UV bakes, plus irradiance for estimated albedo. Pilot with a small atlas and a few views before scaling. ## Visibility and fallback Projection combines facing-angle/resolution weighting, reconstructed scene distance and object-ID rejection, and conservative object boundaries. Only the first opaque reconstructed surface is modeled by the visibility pass. Transparent/transmissive surfaces, volumes, animation, view-dependent splat geometry and overlapping UVs need a separate validated workflow; they are not solved here. **Reconstructed depth cannot detect an occluder that exists only in the source capture.** For example, if an unmodeled prop covers a surface in the scan, its pixels may be assigned to that surface. Add per-target source masks, a better aligned reconstruction, or another clean view. Masks use the same dimensions/camera as the capture. The allowed region is shrunk to avoid bilinear sampling through rejected pixels. When masks are impractical, `reject_rules` can explicitly reject samples in a bounded world region. A rule requires `bounds_min` and `bounds_max` vectors, and optionally `rgb_min`, `rgb_max`, `channel_ratios` (triples such as `["g", "r", 1.5]`) and `min_sum` (`{"channels": ["g", "b"], "value": 0.5}`). Color values use sRGB 0–1. Conditions within one rule are ANDed; multiple rules are ORed. An empty condition set rejects the entire bounded region. These are **user-configured heuristics**, not segmentation. Do not copy a study's hue/coordinate thresholds to another scene. Verify any such rejection from a second view so legitimate surface color is not erased. No acceptable sample means original-material fallback. The projector never copies nearby captured color into unseen faces. Filtering uses real material IDs; padding affects only pixels outside the selected UV surface. Keep adequate island spacing for the chosen padding and texture filtering. Inspect mapping at normal size before chasing subpixel UV artifacts. Coverage PNGs contain raw class values (0–4), so they look nearly black in an ordinary image viewer; display them with discrete colors when diagnosing a visible defect. NumPy copies and the legend are also saved: | Value | Interpretation | |---|---| | 0 | Outside the selected UV surface | | 1 | Direct accepted capture; edge texels may blend toward fallback | | 2 | Geometrically visible candidate rejected by a mask/rule; fallback | | 3 | No acceptable geometric visibility/facing sample; fallback | | 4 | Padding outside the selected surface | Percentages use selected valid UV texels, not scene area, image pixels or fidelity. The `*_mix.png` image is the actual shader blend weight; class 1 alone does not imply 100% mix. ## Appearance and material slots `weighted` averages in linear light. `robust` adds a diffuse-consensus heuristic that reduces bright view outliers and rejects sufficiently dark outliers. It can still blur details or choose the wrong view. `robust_q` controls that heuristic; it is not a measured quantile or an image-quality score. Preserve actual face/material separation instead of inferring it from “horizontal” or “vertical” normals. `material_overrides` is keyed by actual slot index as a string. Supported values are `robust_q`, `color_scale`, and, for estimated albedo, `roughness` and `specular`. Inspect `prepared.json` to see the evaluated slot names. Configure any needed overrides, then prepare a fresh checkpoint. Unselected slots retain their source materials. - **Captured radiance:** emission avoids applying scene light a second time. Captured shadows/highlights remain fixed, and the mapped object can contribute emitted light in Cycles. This is an appearance display mode, not a relightable material. - **Estimated albedo:** a white diffuse irradiance bake under the source scene's lighting divides out approximate illumination with a floor to limit shadow amplification. The captured image may include specular response, baked shadows and view dependence that this approximation cannot remove. It feeds a Principled shader and can still show double lighting. Neither mode recovers physical albedo. Compare unlit/lit diagnostics when a broad brightness mismatch appears; do not solve it by blindly increasing texture gains. ## Review and delivery First inspect matched reference, procedural/clay and hybrid hero plus opposite/oblique views at normal viewing size. Rank up to three material defects: wrong-object appearance on another surface, broad ghosts/stretching, large color/material errors or materially missing coverage. Use atlas/mask crops only to explain those defects. Separate geometry/camera mismatch from projection errors, and plausible cast shadows from false textures. Rough source boundaries and tiny seams alone need not block a scoped ready-for-review result. Reopen the saved hybrid and verify packed images, intended UV map/slot wiring, hidden source copies and fallback coverage. Audit live procedural collections separately: `audit_blend.py` intentionally rejects baked hosts and is **not** a hybrid acceptance test. The synthetic [integration procedure](../../../tests/README.md) tests the mapping pipeline in a full repository checkout; a skill-only install does not include repository tests. Remove reference data in a disposable copy and render representative views to establish independence from the original scan; packed appearance remains intentionally derived from it. Deliver exact source/config/texture/blend/report paths and hashes, matched views and the editability contract. Record source permissions, unobserved surfaces and lighting limits. A full 360° comparison must use the same poses and labels on all sides; do not synthesize missing reference evidence. Package once major scoped mismatches are addressed instead of running an unbounded edge-polish loop. The generalized implementation derives from the c11b/c22s/c26b study, but it does not reproduce that fitted scene without its local inputs.skills/gaussian-splat-to-blender-nodes/references/procedural-construction.md2.8 KBc8135cb00fe2upstream ↗
# Procedural reconstruction patterns | Subject | Construction | Verification | |---|---|---| | Round table | Radial plank wedges, separate slate disc, frame, braces and feet | Top, low side, rim crop | | Irregular vase | Radius profile, azimuth deformation, thick rim and inner wall | Side silhouette and mouth | | Dried arrangement | Stem hierarchy, broad fans, curled leaves and seedhead clusters | Hero outline, side and close-up | | Tree | Measured branch paths, taper, continuous forks, bark displacement | Backlit outline, fork crop | | Shrubs | Coarse occupancy controls, branches and oriented leaf families | Gaps, overlap and depth from two sides | | Paving | Measured slab layout, bevels/chips, recessed joints and constrained litter | Overview and grazing close-up | | Lawn/beds | Uneven density, blade clumps, bare patches and soil | Ground-level edge and overhead patches | | Pots/structures | Profiles, lips, soil surfaces, supports, stakes and contents | Paired views with correct occlusion | ## Spatial frequencies **Macro:** placement, silhouette, supports and canopy envelope. **Meso:** joinery, plank gaps, vase mouth, leaves, forks, slab edges and soil clumps. **Micro:** grain pores, scratches, roughness breakup and small bark fissures. Use geometry when a feature changes silhouette, parallax or shadows. Shading can carry small surface response. Micro-noise cannot compensate for absent meso-geometry. Fit structure with curves/parameters, then use seeded randomness inside bounded distributions. Do not make all foliage spheres or identical flat leaves. Use species-like profiles, curl/thickness where visible, branch-scale clustering and coherent orientation. Preserve negative spaces; indiscriminate density often makes matching worse. ## Materials and lighting Splat appearance includes captured illumination and view dependence. Sampling it as albedo then lighting it again can double shadows. Keep camera, color management and exposure fixed while testing one variable at a time. Convert sRGB picks to linear shader values. Use local coordinates for grain, bark and slate. Match grain direction and joint dirt before adding noise. Tune roughness independently of color. Check wet highlights, metallic response, black cavities and leaf translucency. Use neutral clay to isolate geometry/light errors. Match key direction, sky fill and contact shadows before applying a global tint. ## Editability Keep semantic JSON parameters, deterministic seeds, named groups and documented controls. A sparse fitted occupancy field is useful; the full scan as a hidden geometry source is not an independent reconstruction. Document required helper data. Keep instances until needed. Zero evaluated mesh vertices on an instancer does not mean empty output: inspect dependency-graph instances. A baked mesh with a pass-through GN modifier is not a native procedural builder.skills/gaussian-splat-to-blender-nodes/references/prompting-workflow.md7.3 KB3a358a223d1dupstream ↗
# Prompt structure and handoffs ## Contents - [Use the templates](#use-the-templates) - [The actual structure behind the study](#the-actual-structure-behind-the-study) - [Project inputs and artifacts](#project-inputs-and-artifacts) - [Stage exits and scope](#stage-exits-and-scope) ## Use the templates Start with [project-brief.md](../templates/prompts/project-brief.md). It contains the full assignment, resource context, representation rules, sequence, review contract and delivery bar. Send the narrower prompts only when their stage applies: | Prompt | When to use it | |---|---| | [Capture and registration](../templates/prompts/capture-and-registration.md) | Source discovery or reference capture/calibration is incomplete | | [Part contract](../templates/prompts/part-contract.md) | A component needs focused measurement/building under the shared scene contract | | [Detail pass](../templates/prompts/detail-pass.md) | Complete composition exists but construction/material detail remains weak | | [Review and correction](../templates/prompts/review-and-correction.md) | Fresh matched renders are ready for critique | | [Hybrid appearance mapping](../templates/prompts/hybrid-projection.md) | Captured appearance is requested on selected reconstructed surfaces | | [Comparison and delivery](../templates/prompts/comparison-and-delivery.md) | A frozen version needs final evidence or a comparison video | Replace `{{...}}` placeholders with project values. Blank measurements mean **measure and record uncertainty**, not fabricate values or stop at a questionnaire. Keep previous human choices; an already-approved reference does not need another approval cycle. If the desired scene has not been selected, collect candidates with actual previews and resolve that choice before building the wrong subject. ## The actual structure behind the study The original project used a short project instruction file pointing to a longer brief. The brief defined the entire environment, source identity, known tool/API differences, representation rules, measurement tasks, build sequence and live review outputs. A separate part contract froze coordinates/cameras and assigned module ownership. A later detail brief responded to inspected images, preserving earlier fixes while specifying construction and surface corrections. Review messages named current artifacts and concrete verification views. Reference capture was its own stage: a high-quality source was selected; the live viewer's camera and asset were identified; a local viewer rendered the untouched asset from explicit cameras; geometry analysis established a world frame and per-part parameters; reference contact sheets and close-ups were supplied to the builder. See [whole-scene capture](whole-scene-capture.md) for the method. The final orbit used a frozen native checkpoint and one shared camera path for both renderers. This prevents a long export from silently mixing versions. Several lessons are retained: integrate all major parts early, judge detail at native resolution, preserve improvements, serialize heavy renders and distinguish a working graph from a faithful image. The generic templates preserve these contracts without carrying over the study's object list, metric assumptions, exact view count, fixed iteration count, machine limits or blanket recurring-review permission. They are suitable for environments, rooms, streets, machinery, landscapes and object assemblies. ## Project inputs and artifacts Fill the master brief with the project root/name/prefix, reference source, selected view, whole-scene scope, must-keep elements, allowed omissions, Blender environment, output target and resource budget. Scope is spatial and semantic: define the surrounding context as well as the focal subject, and inventory small objects that materially establish identity. Suggested **generated project** layout (these are output conventions, not files bundled for every scene): ```text project/ PROJECT_BRIEF.md filled master prompt docs/REFERENCE.md inventory, source identity, coverage, uncertainty docs/COORDINATES.md frame, units, cameras, display conventions docs/PART_CONTRACT.md shared interfaces and ownership docs/DETAIL_PASS.md current region-specific targets docs/review-log.md timestamped evidence and corrections assets/source.json provenance; original binary remains local data/frame.json validated scan-to-world transform data/cameras.json {world: {...}, scan: {...}} from one definition data/capture-manifest.json image paths/hashes, camera IDs, viewer settings data/scene-inventory.json parts, relations, coverage and confidence data/params/<part>.json measurements and fitted controls scene/parts/<part>.py project builders build.py integrated deterministic entry point outputs/reference/<view>.png actual reference-render pixels outputs/derived/ local analysis, maps and decoder products outputs/checkpoints/<version>/ immutable blend, graph/audit and source identity outputs/review/<version>/ full comparisons, crops and status outputs/review/latest_status.json ``` Use project-relative artifact paths in prompts and reports. Include file hashes, camera IDs, version and mtime in review evidence; avoid user-specific filesystem paths or session logs in reusable/public prompts. Keep decoded attributes row-aligned with positions if analysis uses them. A frame change invalidates derived positions, part fits and affected reference captures; update those as one versioned change rather than letting parts drift independently. `GS_REFERENCE` is the default reference collection used by the tools. Use `SCENE_PROCEDURAL` as a generic native collection, or choose a project-specific name and pass it explicitly to `audit_blend.py --collection ...`. The shipped Garden example's default name is not a requirement for other projects. The build and capture adapters described by these prompts must be implemented for the chosen project; the toolkit supplies only the generic helpers documented in the README. ## Stage exits and scope | Stage | Continue when | If evidence is insufficient | |---|---|---| | Source | Identity and selected scene are established | Resolve source/scope, not a procedural guess | | Capture | Landmarks align; useful views cover the inventory | Add targeted views or record a coverage gap | | Blockout | All major masses exist in one native scene | Integrate missing parts before polishing | | Geometry/look | Same cameras show corrected structure/response | Change the highest-impact cause and verify again | | Detail | Actual close-ups show the claimed feature | Correct form/scale; do not upscale a small preview | | Delivery | Exact saved version has audit and visual evidence | Deliver as incomplete with explicit limits | These are evidence checks, not mandatory human approval gates. Work through stages already authorized by the user. Do not interpret “perfect replication” as a promise of an exact inverse solution or an unbounded budget. Agree practical scope when necessary and report uncertainty honestly. Optional part delegation and recurring external critique require their own authorization; a single agent can perform every stage with the same contracts.skills/gaussian-splat-to-blender-nodes/references/validation-and-critique.md3.4 KBd9f5f64a4e7fupstream ↗
# Validation and candid critique ## Evidence ladder 1. **Build:** exit status, parts, warnings and valid links. 2. **Native structure:** empty hosts, generating graphs, Shader Nodes and documented helpers. 3. **Independence:** delete GS_REFERENCE in memory and compare evaluated output; compare before/after beauty renders when render equivalence is required. 4. **Registration:** shared poses, FOV, dimensions and matching landmark projections. 5. **Fidelity:** whole-scene pairs, opposite/side views and native-resolution detail crops. 6. **Repeatability:** rebuild the source commit with identical parameters/seeds. The audit checks the named procedural collection at the current frame. It hashes evaluated mesh coordinates/topology, material assignments, instance geometry and transforms before and after deleting reference objects. It checks empty hosts, enabled GN modifiers, warnings, invalid links, material node trees and reference render hiding. Texture nodes and explicit scan dependencies are reported for manual inspection. It never saves the blend. It does not prove visual similarity or test shader/driver/compositor effects, animation, external dependencies, all non-mesh geometry or pixel equality. Run it in a disposable background process, never in the user's unsaved interactive scene. For hybrid delivery, apply the native audit only to untouched live-GN collections. Verify mapped baked copies separately: packed images, exact UV/material slots, original-material fallback, source/config hashes and complementary rendered views. See [hybrid projection](hybrid-projection.md). Captured textures are intentional in that mode; unlit radiance and approximate albedo must be labeled, not treated as clean relightable data. ## Review record Record UTC time, source commit, blend hash/mtime, render hashes, camera hash, engine, samples, dimensions, color management and reference identity. State integrated/isolated, clay/beauty scope. Keep outputs immutable. Example: > Observed: the hero's upper-right canopy is one opaque mass; the reference has three > openings and visible branches. Adjust the clump envelope and measured gaps before > increasing leaf density. Verify hero and right views plus the same foliage crop. > Hypothesis: excessive sky fill may also flatten depth; test after geometry. Rank up to three materially visible current mismatches; fewer or none is fine. Give region, evidence, correction and verification view. Start with framing/silhouettes, then construction/occlusion, material/light and detail. Preserve improvements; never repeat superseded defects. Avoid unsupported similarity scores and generic praise. Rough source edges and minor seams alone do not justify an unbounded polish loop; prioritize normal-size multi-view appearance. No new render means no new visual finding. Record the blocker without inventing critique or repeating requests already acknowledged. An agent's description is not image evidence. ## Completion and scope Deliver editable source/blend, provenance, cameras, native audit, multi-view comparisons and remaining limits. A beautiful generic scene is not close replication. Weakly captured backside views expose coverage limits, not correctness. Serialize expensive renders. Schedule recurring critique or message another agent only with explicit authorization and correct session identity. Honor stops; never restart a stopped reconstruction merely to write a report.skills/gaussian-splat-to-blender-nodes/references/whole-scene-capture.md8.1 KB50339ca6163eupstream ↗
# How the whole scene was captured for reconstruction This describes **reference-view capture from an existing splat**, not photographic acquisition or splat training. The study used the original asset as a stable measurement source and captured several views of it. A hero screenshot alone did not describe the whole environment. ## 1. Select the scene and preserve its identity The source-selection pass inspected real high-quality previews and distinguished a complex environment from isolated assets. Once the user selected the scene, its viewer, model URL, quality setting and approved screenshot became the reference contract. The exact downloaded binary was preserved with byte size/hash, container version and available SH metadata. For another project, use its already-selected asset if one exists. Otherwise compare scenes on scope, coverage, visible geometry and usable source quality. More splats alone is not a quality ranking. Verify a download is complete and actually a model, not an error document. Keep the source unchanged; derived conversions get separate paths and provenance. ## 2. Reproduce the reference renderer before modeling The study inspected the deployed viewer's asset and initial camera configuration, then rendered the untouched asset in a local GaussianSplats3D viewer. Its adapter used explicit position/target/up/FOV, a controlled canvas size and direct canvas PNG export. It disabled built-in camera controls and progressive loading for that capture path. Both the deployed and local reference images were inspected for alignment before trusting the local renderer. Generalize the **contract**, not that viewer's API or version: | Capture setting | What to record/control | |---|---| | Asset | Source hash, producer/container and conversion identity | | Viewer | Renderer/library version, quality/LOD and SH degree | | Camera | Position, target or rotation, up, vertical FOV, lens shift if present, clipping | | Canvas | Pixel width/height, pixel ratio, aspect, viewport crop and principal point | | Appearance | Background/alpha, exposure, output color space and tone mapping | | Readiness | Asset loading complete, camera fixed, renderer/sort updated and output settled | | Image | Camera ID, native dimensions, timestamp, file path and SHA-256 | The original adapter repeatedly updated/rendered after each camera change before exporting the canvas. That settling loop was a pragmatic implementation choice, not a universal timing guarantee. A new adapter should use available load/sort completion signals, bounded waiting and actual image checks. Record a timeout as a failed capture; do not accept a loading frame. Capture the rendering canvas where the tool permits it, or use its supported screenshot surface and account for the exact viewport. Do not include browser chrome, resize one side independently, or mistake device pixels for CSS pixels. A browser UI that cannot expose exact poses provides approximate registration; label that limitation rather than claiming a match. Adapter pseudocode (a specification, **not** bundled executable functions): ```text load_unchanged_asset_and_wait() for view in shared_manifest.scan: set_canvas_dimensions_and_pixel_ratio(view) set_camera_position_target_up_and_vertical_fov(view) update_camera_matrices_and_projection() wait_for_loading_and_sorting_then_render_until_settled(timeout) export_reference_png(view.id) verify_dimensions_and_record_hash_settings_and_readiness(view.id) ``` ## 3. Establish the shared world frame The study decoded positions and attributes with the source format's conventions, filtered candidate measurements by opacity, fitted a support plane, identified a stable central form, and defined an origin/axes/scale. Top-down color and height maps and screen projections helped locate surrounding objects. These were **analysis aids**, not replacement beauty renders. Choose appropriate evidence for a new scene: floors/walls, surveyed anchors, a measured dimension or known camera calibration. Plane fitting and robust shape fits can help; a lowest point layer need not be a floor, and opacity thresholds are scene/format dependent. Check outliers and uncertainty. State when metric scale is assumed rather than measured. Store one proper scan-to-world transform and derive both renderers' cameras from it. See [the coordinate conventions](acquisition-and-cameras.md). Keep all parts in that same frame. When decoding, preserve attribute alignment, scale/opacity encodings and quaternion order; a point projection is only a layout diagnostic and does not reproduce SH splat rendering. ## 4. Capture coverage, not just attractive angles The initial study set contained a hero, opposite and side views, an elevated overview, a low view and a focal detail. Later, matched close-ups targeted newly identified problems. Use the following roles as a starting coverage plan, not a fixed required number of cameras: | View role | What it reveals | |---|---| | Selected/hero | Composition, projected landmarks and the main silhouette | | Opposite + lateral | Hidden supports, depth, overlap and missing surrounding context | | Elevated/overview | Footprints, spacing, boundaries and repeated-layout structure | | Low/grazing | Contact, thickness, joints, support geometry and shadow depth | | Region close-ups | Meso/fine construction and material response at useful pixel scale | | Wider context | Background planes, peripheral objects and scene extent | Build an inventory across all views: foreground, middle ground, background, surfaces, structural supports, repeated populations, secondary props and transitions. Record each part's identifying features, coordinates/bounds, adjacency/occlusion and best verification views. Include small context-defining objects; a screen-area cutoff must not silently drop them. Mark regions as observed, partially observed or inferred. Create a labeled contact sheet and add views where the inventory has gaps. “Whole scene” means the requested environment and its visible relationships; a full orbit cannot recover uncaptured surfaces or turn blurry background splats into known geometry. ## 5. Match the camera before critiquing reconstruction Compare the local reference hero against the selected source view. Check several dispersed landmarks, their distances/occlusions, crop and aspect; verify another view too. Preserve any measured residual and its method. In the study this was a reference-versus-reference check, **not** proof that the native reconstruction matched. Lock the calibrated poses. Use exactly those world-space poses for Blender, with the same projection and pixel dimensions. Record display transforms instead of assuming a particular one is correct for every viewer. No independent zoom/crop or camera-specific object hiding to make a comparison look closer. Fix material, light and geometry as separate variables. ## 6. Capture additional detail and the final orbit For close-ups the study moved toward an observed region along a useful existing line of sight and stored the resulting camera, target, FOV and resolution. Both renderers then used that camera. This preserves familiar coverage better than freely orbiting into unseen sides, but it still requires checking for newly exposed holes. Render close-ups at actual evaluation resolution; enlarging a preview cannot reveal fine detail. Use identical pixel rectangles for reference/previous/current crops and retain the uncropped frames. For the comparison video, freeze a source checkpoint and share one orbit manifest between reference and native renderers. Inspect cardinal views and coverage before rendering the sequence. Record any start/end holds separately from unique camera poses. Keep frames paired by camera ID, remove the scan from native beauty, serialize heavy rendering and inspect the encoded output. See [the video procedure](comparison-videos.md). The public toolkit provides fingerprinting, camera math, a native renderer, auditing and pairing/encoding. It does **not** bundle the study's local viewer, automatic scene segmentation, format decoder or a universal screenshot driver. The [capture prompt](../templates/prompts/capture-and-registration.md) tells an agent what to implement and prove with the tools available for a new project.skills/gaussian-splat-to-blender-nodes/agents/openai.yaml346 Bac6da318f136upstream ↗
interface: display_name: "World to World Mapping" short_description: "Reconstruct splats with Blender nodes and texture mapping" default_prompt: "Use $gaussian-splat-to-blender-nodes to reconstruct my Gaussian splat reference as native Blender geometry and materials, starting with camera registration and an honest multi-view checkpoint."skills/gaussian-splat-to-blender-nodes/templates/projection.json1.2 KB9b3cc8081399upstream ↗
{ "schema_version": 1, "reference_collections": ["GS_REFERENCE"], "targets": [ { "name": "primary_surface", "object": "REPLACE_WITH_RECONSTRUCTED_OBJECT", "object_id": 1, "texture_size": 1024, "uv_mode": "unwrap", "appearance": "captured_radiance", "projection": { "mode": "robust", "power": 12, "min_cos": 0.3, "vertical_min_cos": 0.3, "depth_absolute": 0.006, "depth_relative": 0.004, "border": 9, "mix_feather": 3, "padding": 8 }, "material_overrides": {}, "reject_rules": [] } ], "views": { "hero": { "image": "captures/hero.png", "camera": { "position": [3, -4, 2], "look_at": [0, 0, 0.5], "up": [0, 0, 1], "fov_y_deg": 50, "width": 1280, "height": 720 }, "source_valid_masks": {} }, "opposite": { "image": "captures/opposite.png", "camera": { "position": [-3, 4, 2], "look_at": [0, 0, 0.5], "up": [0, 0, 1], "fov_y_deg": 50, "width": 1280, "height": 720 }, "source_valid_masks": {} } } }skills/gaussian-splat-to-blender-nodes/templates/prompts/capture-and-registration.md5.3 KB4bcade1eb63cupstream ↗
# Prompt: capture and measure the whole reference scene Use after the project brief when source evidence or calibration is incomplete. This is capture **from an existing splat**, not instructions to photograph or train a new dataset. --- Prepare the reference package for `{{PROJECT_NAME}}` in `{{PROJECT_ROOT}}` from `{{REFERENCE_SOURCE}}`, preserving the selected view `{{APPROVED_VIEW}}` and scope `{{WHOLE_SCENE_SCOPE}}`. Read the project's brief and the skill's whole-scene capture guide. ## Source identity and selection If a source is already selected, use it. Otherwise find high-quality scenes that actually cover the requested environment; compare real previews, source availability and capture coverage. Do not substitute isolated objects for a whole-scene request. Resolve the choice with the user when it is not established. Identify the actual model used by the selected viewer through supported inspection tools. Acquire it where authorized, preserve an unchanged master and record URL/provenance, usage terms, bytes/hash, producer, quality, compression and available SH metadata. A screenshot, an incomplete download and an HTML error document are not usable 3D inputs. ## Reference-render adapter Use an existing compatible viewer or implement a small project-local capture adapter if needed. Pin and record its version. The public toolkit does not provide a universal viewer or format decoder; do not claim otherwise. Prefer the real asset's original rendering semantics over a center-point preview for final reference comparisons. Discover and record camera position/rotation or target, up, vertical FOV, aspect, viewport, principal point/lens shift, clipping, pixel ratio, quality/SH and color/tone-mapping settings. Disable automatic camera motion for deterministic captures. Load the actual complete asset, apply a saved pose, update projection/matrices, wait for rendering/sorting to settle within a bounded timeout, then save the actual scene pixels. Inspect them for loading placeholders, holes and cropping. Report unsupported controls honestly. Export canvas PNGs when supported, or capture the supported rendering surface with exact viewport bounds. Do not include browser chrome or independently resize reference images. Record image dimensions, camera ID, settings, timestamp and hash in a capture manifest. ## World frame and measurements Decode only through a format-correct implementation. Check coordinate conventions, linear versus log scale, opacity versus logit, quaternion order and RGB/SH semantics. Keep original attributes aligned with positions. Validate decoded structure and projected landmarks. Fit a stable frame from appropriate scene anchors, planes or known dimensions. Reject floaters/outliers with documented filters; do not copy another scene's numerical thresholds. Store origin, proper rotation, positive scale, units and assumptions in `data/frame.json`. If absolute scale is unknowable, label the chosen scale explicitly. Where useful, create orthographic color/height/density maps and region-specific projections for measuring footprints, levels, spacing and extents. These are diagnostic maps, not reference beauty renders or final generated meshes. Record map bounds and pixel-to-world conversion so measurements can be reproduced. ## View coverage and registration Create one canonical camera definition with both world and scan poses. Match the selected hero first; compare the local reference render against the selected source screenshot using dispersed landmarks and exact crop/aspect. This is reference-versus-reference calibration, not evidence of reconstruction quality. Verify a second view before locking the frame. Build a useful coverage set: hero, opposite/lateral, elevated overview, low/contact views, wider context and focal details. Choose counts and positions based on the scene's actual visibility. Inspect every view; add targeted captures for important gaps and mark uncaptured or degraded regions. A 360° orbit does not guarantee valid source coverage. Inventory all major and identity-defining components, including supports, openings, peripheral objects, repeated populations and transitions. Record subparts, bounds/proportions, adjacency/occlusion, observed materials, distributions and confidence. Cross-check measurements between views; flag disagreement instead of averaging incompatible observations away. For close-ups, target a measured region along an already useful viewing direction when possible, store the new pose/FOV/resolution, and render it on both sides. Verify that the closer view still has reliable source coverage. Crops use the same pixel rectangle in both images; enlarging a preview is not a new detail capture. ## Hand off evidence Write source provenance, `docs/REFERENCE.md`, `docs/COORDINATES.md`, `data/frame.json`, `data/cameras.json`, `data/capture-manifest.json`, `data/scene-inventory.json`, the actual reference PNGs and a labeled contact sheet. Include uncertainty, source coverage limits, calibration residuals with their method, and adapter/decoder commands that actually ran. Keep the approved source immutable. Freeze the frame/cameras for builders and state which outputs must be regenerated if calibration changes. Then continue the authorized integrated blockout; do not remain indefinitely in infrastructure or claim capture is reconstruction.skills/gaussian-splat-to-blender-nodes/templates/prompts/comparison-and-delivery.md4.1 KB91f0ac4c27afupstream ↗
# Prompt: frozen-checkpoint comparison and handoff Use after the desired checkpoint is chosen. A comparison video can document an unfinished scene; exporting it is not acceptance of the reconstruction. --- Prepare the agreed deliverables for `{{PROJECT_NAME}}` from `{{CHECKPOINT}}`. Requested media: `{{MATCHED_STILLS_DETAILS_AND_OPTIONAL_ORBIT_SPEC}}`. Reference asset/camera package: `{{REFERENCE_PACKAGE}}`. Destination: `{{LOCAL_OUTPUT_DIRECTORY}}`. Publishing/uploading is separate unless explicitly included in the user's request. Freeze the source/parameters, cameras and exact saved blend in an immutable checkpoint. Record hashes, source identity, Blender version and render/color settings. Do not read a live file that another build may overwrite midway through export. Keep review and media outputs versioned so later corrections cannot silently replace the compared scene. Audit that exact blend in a disposable process with the actual procedural collection name. Check live GN generation, Shader Nodes, warnings/links and removal of `GS_REFERENCE`. Preserve legitimate sparse QA helpers that are not the scan. If required, render before/after scan removal too; a structural audit alone does not test every shader/compositor dependency. For a hybrid checkpoint, audit live procedural collections separately and follow the hybrid prompt for baked mapped surfaces. Reopen and verify packed images, UV/material wiring, fallback and source/config hashes. Record which surfaces require rebaking after edits. ## Matched stills and optional orbit Render the agreed canonical views and detail crops from the frozen version. Pair them with the same camera IDs, dimensions and projection in the reference renderer. Retain full frames, label sources and report remaining mismatches. For a hybrid release, include reference, clay/grey and textured variants from the same poses. Grey-to-textured reveals may mix those registered variants; label each representation and keep the full 360° comparison readable. If an orbit is requested, define one camera manifest: target, radius/path, height/elevation, start azimuth, vertical FOV, image dimensions, unique pose count and FPS. A full 360° orbit has no duplicated closing pose; any start/end holds are additional encoded frames and must be documented. Do not change canonical review cameras to manufacture the export path. Test several cardinal/important poses before the expensive run. Check reference coverage, clipping, framing, occlusions and lighting at those angles. If a full orbit exposes unreliable source regions, state the limitation; do not synthesize missing reference images or quietly replace the requested comparison with a supported-looking carousel. Render the same manifest on both sides, with the source removed from native beauty. Use the project's shared render queue/lock; coordinate a temporary hold on other expensive project renders when authorized. Never terminate unrelated jobs or delete their locks. Save frames with stable matching IDs. Validate counts, dimensions and correspondence before encoding. Use the bundled comparison tool or a documented equivalent. No independent crops/resizing, interpolation to conceal missing poses, source swaps or camera-specific hiding. Label reference/native and the checkpoint. Record output hash, codec, dimensions, FPS, unique poses, encoded frames, duration and holds. Decode-check the movie and visually inspect cardinal views, the seam and detail regions. Do not treat a successful encoder exit as visual QA. ## Final handoff Provide source/build commands, parameters/presets, native blend, camera/frame/provenance, actual validation results, control documentation, node screenshots and agreed media. Reopen the saved deliverable; verify representative controls affect geometry/materials and report any supported-range or resource limits. Distinguish verified structural properties, inspected visual differences and untested claims. Name remaining mismatches and uncertain/unseen surfaces. Avoid invented fidelity percentages or “perfect replication.” Preserve the last working artifacts and honor any user stop; completion of this export does not authorize restarting reconstruction or recurring review.skills/gaussian-splat-to-blender-nodes/templates/prompts/detail-pass.md3.7 KBd6b6600cbcf8upstream ↗
# Prompt: more construction detail and closer resemblance Use after an integrated checkpoint exists. Fill targets from actual current images, not from a generic list of decorative effects. Keep object names and mechanisms scene-specific. --- Continue `{{PROJECT_NAME}}` from `{{CURRENT_CHECKPOINT}}` using the locked frame and cameras. The current target needs substantially more **evidenced construction and surface detail**. Read the project brief, current status and latest review first. Preserve improvements in `{{ALREADY_IMPROVED_REGIONS}}`; do not repeat critique against superseded versions. Inspect `{{CURRENT_MATCHED_IMAGES}}` against `{{REFERENCE_IMAGES}}` and `{{PREVIOUS_MATCHED_IMAGES}}`. Confirm exact artifact versions and scope. If the latest parts are not integrated, obtain an integrated checkpoint before claiming whole-scene improvement. ## Prioritize visible causes Fill this table from what is actually visible; do not invent a diagnosis: | Priority / part / image region | Observed difference | Construction or shading correction | Verification views | |---|---|---|---| | `{{TARGET_1}}` | `{{EVIDENCE_1}}` | `{{CHANGE_1}}` | `{{VIEWS_1}}` | | `{{TARGET_2}}` | `{{EVIDENCE_2}}` | `{{CHANGE_2}}` | `{{VIEWS_2}}` | | `{{TARGET_3}}` | `{{EVIDENCE_3}}` | `{{CHANGE_3}}` | `{{VIEWS_3}}` | First correct spatial arrangement, envelope, negative spaces and contact. More density or randomness cannot repair the wrong silhouette. Preserve measured regular patterns where they exist; “organic” does not mean randomly perturb everything. For each important part, inspect three scales: - **Macro:** shape, dimensions, support, alignment, population envelope and occlusion. - **Meso:** constituent pieces, joints, openings, thickness, folds, branch hierarchy, seams, edge wear, deposits and attachment/contact. Choose mechanisms relevant to this scene. - **Micro:** correctly scaled grain, pores, scratches, pits, directional fissures and roughness/transmission variation, only where supported by the reference. Use geometry where a feature affects outline, shadow or parallax. Repeated structures need meaningful families, bounded variation and coherent placement rather than identical stand-ins or uniform noise. Match material response as well as average color; do not globally darken a surface to hide wrong sheen or double-light appearance sampled from the scan. Inspect one coherent lighting setup across all views. Use a clay pass to distinguish form and lighting from material errors. Preserve display/exposure conventions while comparing. Treat regional color statistics as diagnostics, not percentages of fidelity. ## Proof for this pass Render each relevant close-up at its real evaluation resolution, with a matched reference pose and field of view. Save labeled **reference / previous / current** crops using identical pixel rectangles, alongside uncropped context views. An upscaled small preview does not prove fine detail. Change sampling/resolution deliberately and record it so noise differences are not confused with improved geometry. Keep all principal forms native GN and all claimed procedural materials editable Shader Nodes. Allocate resources to visible structure, keep instances where possible, and serialize expensive renders. Keep the last working checkpoint; a successful build that silently skipped a failed part is not progress. For each target, report **observed mismatch → implemented change → what the new image proves → remaining difference**. Verify the change in a second useful view, preserve improvements, and integrate all current parts. End this pass with an honest ranked list of remaining errors, not a declaration of completion based on detail counts or a prettier image.skills/gaussian-splat-to-blender-nodes/templates/prompts/hybrid-projection.md3.8 KBbe55b838bf2cupstream ↗
# Prompt: project-independent hybrid appearance mapping Use after a reconstructed scene and registered reference captures exist. Fill the project inputs; retain decisions already established. This prompt authorizes only the work requested by the user and does not schedule reviews or contact other sessions. --- Use **gaussian-splat-to-blender-nodes** and read its hybrid-projection reference. - Project: `{{PROJECT_ROOT_AND_NAME}}` - Frozen reconstructed blend/source: `{{SOURCE_CHECKPOINT}}` - Reference captures, provenance and calibrated cameras: `{{CAPTURE_PACKAGE}}` - Selected reconstructed objects/materials: `{{TARGET_SCOPE_OR_INSPECT_AND_IDENTIFY}}` - Appearance mode: `{{CAPTURED_RADIANCE_OR_ESTIMATED_ALBEDO}}` - Intended viewing distance, output views and resource budget: `{{REVIEW_TARGET}}` Add captured appearance to the selected reconstructed surfaces. Preserve the original procedural source. Derive object names, material slots, coordinates, source masks, tolerances and camera poses from this project. Do not copy another scene's object list, hue filters, world-space rejection boxes, lighting gains or fitted camera path. The shipped projection JSON is a configuration template, not a calibrated preset. 1. Inspect current source/config/report hashes and capture dimensions. Confirm one world frame, vertical FOV and identical source/reconstruction cameras. Inspect hero and opposite/oblique images before fitting any texture. Identify source-only occluders. 2. Configure evaluated mesh targets and actual face material slots. Keep meaningful object boundaries and the source Shader Node materials as fallback. Set source-valid masks for wrong-object pixels; use scoped color/region rejection only with visual evidence. 3. Run a small serialized prepare → project → assemble pilot with the supplied scripts. Use fresh versioned outputs. Do not launch competing expensive renders. Inspect actual appearance, mix and coverage artifacts; successful scripts and high coverage are not evidence of visual fidelity. 4. Compare matched reference/procedural/hybrid full frames at normal viewing size. Rank only material current mismatches, up to three: wrong-object projection, broad ghosts/stretches, large material/color errors and materially missing coverage. Preserve improvements. Separate geometry and camera errors from projection defects. Use crops/masks only when they explain a visible problem. Accept rough scan edges and minor seams unless they create conspicuous wrong-object appearance; do not optimize residuals for their own sake. 5. Correct the highest-impact cause and verify the same complementary views. Unseen or rejected samples must fall back to the source material, never inherit a neighbor's captured texture. Check material boundaries using actual face slots, not normal direction. 6. Reopen the packed hybrid file and verify UV/material wiring, source-copy visibility and fallback behavior. Audit untouched live GN collections separately. Report the selected surfaces as baked evaluated meshes: later geometry/UV/frame changes require rebaking. Captured radiance includes shadows/highlights and is not clean albedo. Label the unlit mode and any emitted-light effect. Treat irradiance-divided albedo as an approximation; inspect lit views for baked lighting and double lighting. Do not claim universal relightability. When no major defect remains in this scope, package the accepted result as ready for review with exact artifact paths/hashes, source/coverage limitations and remaining visible mismatches. Reuse current evidence and avoid duplicate renders. If a release comparison is requested, include clearly labeled reference, clay/grey and hybrid views on one shared full 360° path; label unsupported coverage honestly. No claim of perfect replication from one view, a coverage percentage or a passing script.skills/gaussian-splat-to-blender-nodes/templates/prompts/part-contract.md3.7 KB22ea9e81c2cfupstream ↗
Contents are not inlined in the catalog. Open the upstream file.
skills/gaussian-splat-to-blender-nodes/templates/prompts/project-brief.md10 KB3c4fd8246455upstream ↗
Contents are not inlined in the catalog. Open the upstream file.
skills/gaussian-splat-to-blender-nodes/templates/prompts/review-and-correction.md4.6 KB1b1d5aa16447upstream ↗
Contents are not inlined in the catalog. Open the upstream file.
skills/gaussian-splat-to-blender-nodes/templates/starter.py5.4 KBf0655f58fdfbupstream ↗
Contents are not inlined in the catalog. Open the upstream file.
README.mdupstream ↗
Contents are not inlined in the catalog. Open the upstream file.
Plain-text copy, no login: /skill-bundles/gokayfem-gaussian-splat-to-blender-nodes-skills-gaussian-splat-to-blender-nodes.md