diff --git a/claude-notes/plans/2026-06-24-fma-torso-bodyparts3d-splat.md b/claude-notes/plans/2026-06-24-fma-torso-bodyparts3d-splat.md new file mode 100644 index 000000000..1ae80c020 --- /dev/null +++ b/claude-notes/plans/2026-06-24-fma-torso-bodyparts3d-splat.md @@ -0,0 +1,60 @@ +# FMA torso gaussian splat — real BodyParts3D geometry, two cockpit pages + +> 2026-06-24 · status: in progress +> The `/fma` heart slice (PR #51) renders the FMA partonomy with *synthesized* +> 8:8-HHTL layout because FMA itself has **zero geometry**. This adds a **torso** +> rendered from **real anatomical meshes** — BodyParts3D, which keys 3D meshes +> directly on FMA concept IDs — across two new cockpit pages. + +## The convergence + +- **FMA partonomy** (`part_of`) = mereotopological containment in BodyParts3D + (NAR 2009, Table 1: `A part_of B` ⟺ `A° ⊂ B°`, coveredBy). So the HHTL + `[container:identity]` cascade *is* the spatial nesting. +- **BodyParts3D** (DBCLS, CC-BY 4.0) realizes FMA concepts as OBJ meshes in one + shared whole-body frame. `concept id` column **is** the FMA id. +- Z-Anatomy / the Unity app are curated atlases on the same data; we use the raw + FMA-keyed OBJ (no Blender needed). + +## Data (measured) + +- Root `FMA7181 trunk` → **178 concepts, 577 OBJ meshes, ~694K verts**, all present. + Regions: thoracic segment (548 meshes), body wall (81), abdominal (24), perineum (4). + The heart (PR #51) nests inside (`trunk → … → content of middle mediastinum → heart`). +- Source (external, NOT committed): `dbarchive.biosciencedbc.jp/.../partof_BP3D_4.0_obj_99.zip` + + `partof_inclusion_relation_list.txt` / `partof_element_parts.txt` / `partof_parts_list_e.txt`. +- License: **CC-BY 4.0** — attribution: "BodyParts3D, © The Database Center for + Life Science licensed under CC Attribution 4.0 International". + +## Pipeline + +``` +BodyParts3D (FMA partof tree + FJ OBJ meshes) + → tools/bake_torso_splat.py (BFS FMA7181 → concepts → FJ meshes → vertices, + recenter/normalize, region-colour, downsample) + → cockpit/public/torso.splat (SPL1 binary: real positions + rgb + opacity) + → /torso-live three.js orbit (reads SPL1; the "live orbit") + → torso.ply (Inria) → ndarray splat3d_flex → frames → /torso (the "splat", CPU) +``` + +`splat3d` (ndarray, CPU-SIMD, no GPU) renders bake-side via its own 1.95 toolchain +(q2 stays clean of the ndarray dep). Both pages read ONE asset → identical geometry. + +## Checklist + +- [x] `tools/bake_torso_splat.py` — BodyParts3D → SPL1 gaussian asset + manifest + (231K gaussians, 577 meshes, 102 structures; muted pastel per-structure hues) +- [x] `cockpit/public/torso.splat` (3.7 MB) + `torso.manifest.json` (attribution/legend) +- [x] SPL1 TS decoder + `/torso-live` three.js orbit page + route +- [x] `/torso` splat3d CPU render: scratchpad `torso-render` driver reads SPL1 → + `Gaussian3D` → ndarray::hpc::splat3d turntable (no Inria .ply needed) → + 20 JPEG frames in `cockpit/public/torso-frames/` → `/torso` viewer page + route +- [x] attribution surfaced in-UI; tsc clean +- [ ] PR + +Notes: +- The CPU render runs under ndarray's own 1.95 toolchain (scratchpad project, + path-dep on ../ndarray) — q2's workspace stays free of the ndarray dep. + ~6.6 s/frame on the scalar path (no AVX target-cpu in the scratchpad project); + correctness verified by viewing the rendered frames. +- Colours: golden-angle hue per structure at S=0.34 V=0.78 (muted, per request). diff --git a/cockpit/public/torso-frames/torso_000.jpg b/cockpit/public/torso-frames/torso_000.jpg new file mode 100644 index 000000000..bf2e52348 Binary files /dev/null and b/cockpit/public/torso-frames/torso_000.jpg differ diff --git a/cockpit/public/torso-frames/torso_001.jpg b/cockpit/public/torso-frames/torso_001.jpg new file mode 100644 index 000000000..194cc4e17 Binary files /dev/null and b/cockpit/public/torso-frames/torso_001.jpg differ diff --git a/cockpit/public/torso-frames/torso_002.jpg b/cockpit/public/torso-frames/torso_002.jpg new file mode 100644 index 000000000..0f578e671 Binary files /dev/null and b/cockpit/public/torso-frames/torso_002.jpg differ diff --git a/cockpit/public/torso-frames/torso_003.jpg b/cockpit/public/torso-frames/torso_003.jpg new file mode 100644 index 000000000..58eec237a Binary files /dev/null and b/cockpit/public/torso-frames/torso_003.jpg differ diff --git a/cockpit/public/torso-frames/torso_004.jpg b/cockpit/public/torso-frames/torso_004.jpg new file mode 100644 index 000000000..15b52f3e0 Binary files /dev/null and b/cockpit/public/torso-frames/torso_004.jpg differ diff --git a/cockpit/public/torso-frames/torso_005.jpg b/cockpit/public/torso-frames/torso_005.jpg new file mode 100644 index 000000000..18c60aef5 Binary files /dev/null and b/cockpit/public/torso-frames/torso_005.jpg differ diff --git a/cockpit/public/torso-frames/torso_006.jpg b/cockpit/public/torso-frames/torso_006.jpg new file mode 100644 index 000000000..fff4e708f Binary files /dev/null and b/cockpit/public/torso-frames/torso_006.jpg differ diff --git a/cockpit/public/torso-frames/torso_007.jpg b/cockpit/public/torso-frames/torso_007.jpg new file mode 100644 index 000000000..bfaaa5c2a Binary files /dev/null and b/cockpit/public/torso-frames/torso_007.jpg differ diff --git a/cockpit/public/torso-frames/torso_008.jpg b/cockpit/public/torso-frames/torso_008.jpg new file mode 100644 index 000000000..f8220759e Binary files /dev/null and b/cockpit/public/torso-frames/torso_008.jpg differ diff --git a/cockpit/public/torso-frames/torso_009.jpg b/cockpit/public/torso-frames/torso_009.jpg new file mode 100644 index 000000000..7073d5bd1 Binary files /dev/null and b/cockpit/public/torso-frames/torso_009.jpg differ diff --git a/cockpit/public/torso-frames/torso_010.jpg b/cockpit/public/torso-frames/torso_010.jpg new file mode 100644 index 000000000..053244925 Binary files /dev/null and b/cockpit/public/torso-frames/torso_010.jpg differ diff --git a/cockpit/public/torso-frames/torso_011.jpg b/cockpit/public/torso-frames/torso_011.jpg new file mode 100644 index 000000000..e7a5b09da Binary files /dev/null and b/cockpit/public/torso-frames/torso_011.jpg differ diff --git a/cockpit/public/torso-frames/torso_012.jpg b/cockpit/public/torso-frames/torso_012.jpg new file mode 100644 index 000000000..b3bf3dabb Binary files /dev/null and b/cockpit/public/torso-frames/torso_012.jpg differ diff --git a/cockpit/public/torso-frames/torso_013.jpg b/cockpit/public/torso-frames/torso_013.jpg new file mode 100644 index 000000000..a590c190a Binary files /dev/null and b/cockpit/public/torso-frames/torso_013.jpg differ diff --git a/cockpit/public/torso-frames/torso_014.jpg b/cockpit/public/torso-frames/torso_014.jpg new file mode 100644 index 000000000..7b63ac6d9 Binary files /dev/null and b/cockpit/public/torso-frames/torso_014.jpg differ diff --git a/cockpit/public/torso-frames/torso_015.jpg b/cockpit/public/torso-frames/torso_015.jpg new file mode 100644 index 000000000..2b928111e Binary files /dev/null and b/cockpit/public/torso-frames/torso_015.jpg differ diff --git a/cockpit/public/torso-frames/torso_016.jpg b/cockpit/public/torso-frames/torso_016.jpg new file mode 100644 index 000000000..cc535e5bc Binary files /dev/null and b/cockpit/public/torso-frames/torso_016.jpg differ diff --git a/cockpit/public/torso-frames/torso_017.jpg b/cockpit/public/torso-frames/torso_017.jpg new file mode 100644 index 000000000..9f96a8d38 Binary files /dev/null and b/cockpit/public/torso-frames/torso_017.jpg differ diff --git a/cockpit/public/torso-frames/torso_018.jpg b/cockpit/public/torso-frames/torso_018.jpg new file mode 100644 index 000000000..b1a4c29d8 Binary files /dev/null and b/cockpit/public/torso-frames/torso_018.jpg differ diff --git a/cockpit/public/torso-frames/torso_019.jpg b/cockpit/public/torso-frames/torso_019.jpg new file mode 100644 index 000000000..1e462d46d Binary files /dev/null and b/cockpit/public/torso-frames/torso_019.jpg differ diff --git a/cockpit/public/torso.manifest.json b/cockpit/public/torso.manifest.json new file mode 100644 index 000000000..712cebbef --- /dev/null +++ b/cockpit/public/torso.manifest.json @@ -0,0 +1,42 @@ +{ + "source": "BodyParts3D 4.0 (DBCLS) part-of OBJ, decimated 99%", + "license": "CC-BY 4.0", + "attribution": "BodyParts3D, (c) The Database Center for Life Science licensed under CC Attribution 4.0 International", + "root_fma": "FMA7181", + "root_name": "trunk", + "concepts": 102, + "meshes": 577, + "vertices_total": 693959, + "gaussians": 231320, + "radius": 0.0045, + "bbox_min": [ + -0.5499420475739409, + -0.2690260109315338, + -0.9999999999999999 + ], + "bbox_max": [ + 0.5499420475739409, + 0.2690260109315338, + 1.0000000000000002 + ], + "regions": [ + { + "fma": "FMA259209", + "name": "thoracic segment of trunk" + }, + { + "fma": "FMA259211", + "name": "abdominal segment of trunk" + }, + { + "fma": "FMA10427", + "name": "body wall" + }, + { + "fma": "FMA9579", + "name": "perineum" + } + ], + "generated_by": "crates/osint-bake/tools/bake_torso_splat.py", + "format": "SPL1: hdr[magic4|count u32|radius f32|bbox_min 3f|bbox_max 3f]; body count*[pos 3f|rgb 3u8|opacity u8]; little-endian" +} \ No newline at end of file diff --git a/cockpit/public/torso.splat b/cockpit/public/torso.splat new file mode 100644 index 000000000..2c7136a06 Binary files /dev/null and b/cockpit/public/torso.splat differ diff --git a/cockpit/src/TorsoRender.tsx b/cockpit/src/TorsoRender.tsx new file mode 100644 index 000000000..ecf20c4a6 --- /dev/null +++ b/cockpit/src/TorsoRender.tsx @@ -0,0 +1,110 @@ +// FMA torso · splat3d CPU render (the "splat" page). +// +// A turntable of frames rendered bake-side by ndarray::hpc::splat3d — the +// CPU-SIMD EWA gaussian-splat renderer (no GPU) — over the same torso.splat +// asset the /torso-live page orbits live. The frames are baked from real +// BodyParts3D anatomy (FMA-keyed meshes); see crates/osint-bake/tools/ +// bake_torso_splat.py + the scratchpad torso-render driver. +// +// This is the "splat" companion to /torso-live's "live orbit": splat3d owns +// these pixels (the spine renders), three.js owns the live one. Same geometry. +// +// Geometry: BodyParts3D, (c) The Database Center for Life Science, CC-BY 4.0. +import { useEffect, useRef, useState } from 'react'; + +const FRAME_COUNT = 20; +const frameSrc = (i: number) => `/torso-frames/torso_${String(i).padStart(3, '0')}.jpg`; + +interface Manifest { + attribution: string; + gaussians: number; + meshes: number; + concepts: number; +} + +export function TorsoRender() { + const [frame, setFrame] = useState(0); + const [loaded, setLoaded] = useState(0); + const [playing, setPlaying] = useState(true); + const [manifest, setManifest] = useState(null); + const drag = useRef<{ x: number; f: number } | null>(null); + const playRef = useRef(true); + + useEffect(() => { playRef.current = playing; }, [playing]); + + // preload all frames + useEffect(() => { + let n = 0; + for (let i = 0; i < FRAME_COUNT; i++) { + const im = new Image(); + im.onload = () => { n += 1; setLoaded(n); }; + im.src = frameSrc(i); + } + fetch('/torso.manifest.json') + .then((r) => (r.ok ? r.json() : null)) + .then((m) => m && setManifest(m as Manifest)) + .catch(() => {}); + }, []); + + // auto-rotate + useEffect(() => { + const id = window.setInterval(() => { + if (playRef.current) setFrame((f) => (f + 1) % FRAME_COUNT); + }, 95); + return () => window.clearInterval(id); + }, []); + + const onDown = (e: React.PointerEvent) => { + setPlaying(false); + drag.current = { x: e.clientX, f: frame }; + (e.target as Element).setPointerCapture?.(e.pointerId); + }; + const onMove = (e: React.PointerEvent) => { + if (!drag.current) return; + const df = Math.round((e.clientX - drag.current.x) / 16); + setFrame((((drag.current.f - df) % FRAME_COUNT) + FRAME_COUNT) % FRAME_COUNT); + }; + const onUp = () => { drag.current = null; }; + + const ready = loaded >= FRAME_COUNT; + + return ( +
+
drag.current === null && setPlaying((p) => !p)} + style={{ position: 'absolute', inset: 0, display: 'flex', alignItems: 'center', justifyContent: 'center', cursor: 'ew-resize', touchAction: 'none' }} + > + {/* eslint-disable-next-line jsx-a11y/alt-text */} + +
+ +
+
FMA torso · splat3d CPU render
+
+ ndarray::hpc::splat3d (EWA, no GPU) + {manifest ? ` · ${manifest.gaussians.toLocaleString()} gaussians · ${manifest.concepts} structures` : ''} + {' · real BodyParts3D anatomy'} +
+
+ {ready ? `${playing ? 'auto-rotating' : 'paused'} · drag to scrub · click to ${playing ? 'pause' : 'play'}` : `loading frames ${loaded}/${FRAME_COUNT}…`} +
+
+ +
+ live orbit → +
+ +
+ {manifest?.attribution ?? 'BodyParts3D, (c) The Database Center for Life Science, licensed under CC Attribution 4.0 International'} +
+
+ ); +} diff --git a/cockpit/src/TorsoSplat.tsx b/cockpit/src/TorsoSplat.tsx new file mode 100644 index 000000000..45faf4ce0 --- /dev/null +++ b/cockpit/src/TorsoSplat.tsx @@ -0,0 +1,220 @@ +// FMA torso · live-orbit gaussian splat of REAL anatomy. +// +// Renders cockpit/public/torso.splat — a gaussian cloud baked from BodyParts3D +// (the FMA-keyed 3D mesh database; meshes live in one shared whole-body frame, +// so these are real anatomical coordinates, not a synthesized layout). The bake +// is crates/osint-bake/tools/bake_torso_splat.py over the FMA `part_of` subtree +// rooted at FMA7181 (trunk). Each of ~100 structures carries its own hue. +// +// This is the "live orbit" companion to the CPU splat3d render (/torso): same +// baked geometry, rendered live in WebGL with OrbitControls. Imperative three.js, +// modelled on OsintScene3D.tsx. +// +// Geometry/data: BodyParts3D, (c) The Database Center for Life Science, +// licensed CC-BY 4.0. Attribution is shown in-view (required by the licence). +import { useEffect, useRef, useState } from 'react'; +import * as THREE from 'three'; +import { OrbitControls } from 'three/addons/controls/OrbitControls.js'; + +const PAGE_BG = 0x0a0e17; + +interface Spl1 { + count: number; + radius: number; + bboxMin: [number, number, number]; + bboxMax: [number, number, number]; + positions: Float32Array; + colors: Uint8Array; +} + +// Decode the SPL1 wire (mirrors bake_torso_splat.py): little-endian +// header 36 B: magic "SPL1" | count u32 | radius f32 | bbox_min 3f | bbox_max 3f +// body count x 16 B: pos 3f (12) | rgb 3u8 (3) | opacity u8 (1) +function decodeSpl1(buf: ArrayBuffer): Spl1 { + const dv = new DataView(buf); + const magic = String.fromCharCode(dv.getUint8(0), dv.getUint8(1), dv.getUint8(2), dv.getUint8(3)); + if (magic !== 'SPL1') throw new Error(`bad magic "${magic}" (expected SPL1)`); + const count = dv.getUint32(4, true); + const radius = dv.getFloat32(8, true); + const bboxMin: [number, number, number] = [ + dv.getFloat32(12, true), dv.getFloat32(16, true), dv.getFloat32(20, true), + ]; + const bboxMax: [number, number, number] = [ + dv.getFloat32(24, true), dv.getFloat32(28, true), dv.getFloat32(32, true), + ]; + const off = 36; + const positions = new Float32Array(count * 3); + const colors = new Uint8Array(count * 3); + for (let i = 0; i < count; i++) { + const b = off + i * 16; + positions[i * 3] = dv.getFloat32(b, true); + positions[i * 3 + 1] = dv.getFloat32(b + 4, true); + positions[i * 3 + 2] = dv.getFloat32(b + 8, true); + colors[i * 3] = dv.getUint8(b + 12); + colors[i * 3 + 1] = dv.getUint8(b + 13); + colors[i * 3 + 2] = dv.getUint8(b + 14); + } + return { count, radius, bboxMin, bboxMax, positions, colors }; +} + +interface Manifest { + attribution: string; + root_name: string; + concepts: number; + meshes: number; + gaussians: number; +} + +// A soft round sprite so each gaussian reads as a splat, not a hard square. +function gaussianSprite(): THREE.CanvasTexture { + const s = 64; + const cv = document.createElement('canvas'); + cv.width = cv.height = s; + const ctx = cv.getContext('2d')!; + const g = ctx.createRadialGradient(s / 2, s / 2, 0, s / 2, s / 2, s / 2); + g.addColorStop(0, 'rgba(255,255,255,1)'); + g.addColorStop(0.5, 'rgba(255,255,255,0.85)'); + g.addColorStop(1, 'rgba(255,255,255,0)'); + ctx.fillStyle = g; + ctx.fillRect(0, 0, s, s); + const tex = new THREE.CanvasTexture(cv); + tex.needsUpdate = true; + return tex; +} + +function mount(container: HTMLDivElement, splat: Spl1): () => void { + let w = container.clientWidth || window.innerWidth; + let h = container.clientHeight || window.innerHeight; + + const scene = new THREE.Scene(); + scene.background = new THREE.Color(PAGE_BG); + const camera = new THREE.PerspectiveCamera(45, w / h, 0.01, 100); + camera.position.set(0, 0.05, 3.0); + const renderer = new THREE.WebGLRenderer({ antialias: true }); + renderer.setSize(w, h); + renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)); + container.appendChild(renderer.domElement); + + const geom = new THREE.BufferGeometry(); + geom.setAttribute('position', new THREE.BufferAttribute(splat.positions, 3)); + geom.setAttribute('color', new THREE.BufferAttribute(splat.colors, 3, true)); // u8 normalized + + const sprite = gaussianSprite(); + const mat = new THREE.PointsMaterial({ + size: Math.max(splat.radius * 3.4, 0.012), + sizeAttenuation: true, + vertexColors: true, + map: sprite, + alphaTest: 0.28, + transparent: true, + depthWrite: true, + }); + const points = new THREE.Points(geom, mat); + // BodyParts3D's long axis (superior-inferior) is model +Z; stand it upright so + // the torso's height maps to world +Y. + points.rotation.x = -Math.PI / 2; + scene.add(points); + + const controls = new OrbitControls(camera, renderer.domElement); + controls.enableDamping = true; + controls.dampingFactor = 0.08; + controls.autoRotate = true; + controls.autoRotateSpeed = 0.6; + controls.target.set(0, 0, 0); + controls.minDistance = 0.6; + controls.maxDistance = 12; + + let raf = 0; + const tick = () => { + raf = requestAnimationFrame(tick); + controls.update(); + renderer.render(scene, camera); + }; + tick(); + + const onResize = () => { + w = container.clientWidth || window.innerWidth; + h = container.clientHeight || window.innerHeight; + camera.aspect = w / h; + camera.updateProjectionMatrix(); + renderer.setSize(w, h); + }; + const ro = new ResizeObserver(onResize); + ro.observe(container); + + return () => { + cancelAnimationFrame(raf); + ro.disconnect(); + controls.dispose(); + geom.dispose(); + mat.dispose(); + sprite.dispose(); + renderer.dispose(); + if (renderer.domElement.parentNode === container) { + container.removeChild(renderer.domElement); + } + }; +} + +export function TorsoSplat() { + const ref = useRef(null); + const [splat, setSplat] = useState(null); + const [manifest, setManifest] = useState(null); + const [error, setError] = useState(null); + + useEffect(() => { + let cancelled = false; + fetch('/torso.splat') + .then((r) => { if (!r.ok) throw new Error(`HTTP ${r.status} fetching torso.splat`); return r.arrayBuffer(); }) + .then((buf) => { if (!cancelled) setSplat(decodeSpl1(buf)); }) + .catch((e) => { if (!cancelled) setError(String(e)); }); + fetch('/torso.manifest.json') + .then((r) => (r.ok ? r.json() : null)) + .then((m) => { if (!cancelled && m) setManifest(m as Manifest); }) + .catch(() => {}); + return () => { cancelled = true; }; + }, []); + + useEffect(() => { + const container = ref.current; + if (!container || !splat) return; + return mount(container, splat); + }, [splat]); + + return ( +
+
+ +
+
FMA torso · gaussian splat
+
+ {manifest + ? `${manifest.gaussians.toLocaleString()} gaussians · ${manifest.meshes} meshes · ${manifest.concepts} structures — real BodyParts3D geometry, drag to orbit` + : splat + ? `${splat.count.toLocaleString()} gaussians — drag to orbit` + : error + ? '' + : 'loading torso.splat…'} +
+
+ + {error && ( +
+ {error} +
+ run: python3 crates/osint-bake/tools/bake_torso_splat.py … → cockpit/public/torso.splat +
+
+ )} + + + +
+ {manifest?.attribution ?? 'BodyParts3D, (c) The Database Center for Life Science, licensed under CC Attribution 4.0 International'} +
+
+ ); +} diff --git a/cockpit/src/main.tsx b/cockpit/src/main.tsx index 790d2ad56..830e8c217 100644 --- a/cockpit/src/main.tsx +++ b/cockpit/src/main.tsx @@ -8,6 +8,8 @@ import { RenderPage, OrbitPage, FlightPage } from './RenderPage'; import { OsintScene3D } from './OsintScene3D'; import { OsintGraph } from './OsintGraph'; import { FmaGraph } from './FmaGraph'; +import { TorsoSplat } from './TorsoSplat'; +import { TorsoRender } from './TorsoRender'; import { ReasoningPage } from './ReasoningPage'; import { ErrorBoundary } from './components/ErrorBoundary'; import './styles/cockpit.css'; @@ -79,6 +81,10 @@ createRoot(document.getElementById('root')!).render( } /> {/* FMA anatomy slice — part-of basin × leaf-limited global type (dual membership) */} } /> + {/* FMA torso — real BodyParts3D anatomy as a gaussian splat, two ways: + /torso = splat3d CPU render (turntable), /torso-live = three.js orbit */} + } /> + } /> {/* The Palantir JSON-graph cockpit (221 aiwar nodes) stays reachable at /palantir and as the catch-all for its own sub-routes. */} } /> diff --git a/crates/osint-bake/tools/bake_torso_splat.py b/crates/osint-bake/tools/bake_torso_splat.py new file mode 100644 index 000000000..22b9f0dee --- /dev/null +++ b/crates/osint-bake/tools/bake_torso_splat.py @@ -0,0 +1,208 @@ +#!/usr/bin/env python3 +"""Bake a REAL-anatomy gaussian-splat asset for the cockpit /torso pages from +BodyParts3D (the FMA-keyed 3D mesh database). + +FMA itself has zero geometry (it is a symbolic ontology). BodyParts3D (DBCLS) +realises FMA concepts as OBJ meshes in one shared whole-body coordinate frame and +keys each concept on its FMA id — so a torso splat needs no synthesized +positions: we read the real vertices. The FMA `part_of` tree (which IS the +mereotopological containment of those meshes, per BodyParts3D NAR 2009 Table 1) +selects the torso subtree and colours it. + +Inputs (BodyParts3D 4.0, external — NOT committed; download once): + partof_inclusion_relation_list.txt FMA part-of tree (parent_id name child_id name) + partof_element_parts.txt FMA concept -> FJ element OBJ files + partof_parts_list_e.txt FMA concept -> English name + partof_BP3D_4.0_obj_99/FJ####.obj the meshes (decimated 99%, shared frame) + https://dbarchive.biosciencedbc.jp/data/bodyparts3d/LATEST/ + +Output: cockpit/public/torso.splat (SPL1 binary) + cockpit/public/torso.manifest.json + +LICENSE / ATTRIBUTION (required, CC-BY 4.0): + "BodyParts3D, (c) The Database Center for Life Science + licensed under CC Attribution 4.0 International" + +SPL1 binary layout (little-endian), the cockpit decoder mirrors this: + header 36 B: magic "SPL1"(4) | count u32 | radius f32 | bbox_min 3xf32 | bbox_max 3xf32 + body count x 16 B: pos 3xf32 (12) | r,g,b u8 (3) | opacity u8 (1) +positions are in the NORMALIZED frame (centroid at origin, max half-extent = 1.0). + +Usage: python3 bake_torso_splat.py [root_fma] [budget] +""" +import collections +import colorsys +import json +import os +import struct +import sys + +ROOT_DEFAULT = "FMA7181" # trunk (synonym: Torso) +BUDGET_DEFAULT = 250_000 # ~4 MB asset; downsample vertices to this many gaussians +ATTRIBUTION = ("BodyParts3D, (c) The Database Center for Life Science " + "licensed under CC Attribution 4.0 International") + + +def load_tree(bp_dir): + parent, children, name = {}, collections.defaultdict(list), {} + with open(os.path.join(bp_dir, "partof_inclusion_relation_list.txt"), encoding="utf-8") as f: + next(f) + for line in f: + p, pn, c, cn = line.rstrip("\n").split("\t") + parent[c] = p + children[p].append(c) + name[p], name[c] = pn, cn + elems = collections.defaultdict(list) + with open(os.path.join(bp_dir, "partof_element_parts.txt"), encoding="utf-8") as f: + next(f) + for line in f: + cid, _nm, fj = line.rstrip("\n").split("\t") + elems[cid].append(fj) + return parent, children, name, elems + + +def bfs(root, children): + depth, order, q = {root: 0}, [root], [root] + while q: + n = q.pop(0) + for c in children.get(n, []): + if c not in depth: + depth[c] = depth[n] + 1 + order.append(c) + q.append(c) + return order, depth + + +def region_of(cid, root, parent): + """The depth-1 ancestor under `root` (thoracic/abdominal segment, body wall, + perineum) — the gross torso region the concept belongs to.""" + cur, prev = cid, cid + while cur in parent and cur != root: + prev, cur = cur, parent[cur] + return prev if cur == root else cid + + +def read_obj_vertices(path): + out = [] + with open(path, "rb") as f: + for ln in f: + if ln[:2] == b"v ": + p = ln.split() + out.append((float(p[1]), float(p[2]), float(p[3]))) + return out + + +def concept_color(idx): + """Deterministic distinct hue per concept (golden-angle walk) so each + anatomical structure reads as its own colour — muted/pastel (low saturation) + so the splat is soft, not neon.""" + h = (idx * 0.6180339887498949) % 1.0 + r, g, b = colorsys.hsv_to_rgb(h, 0.34, 0.78) + return (int(r * 255), int(g * 255), int(b * 255)) + + +def main(bp_dir, obj_dir, out_path, root=ROOT_DEFAULT, budget=BUDGET_DEFAULT): + parent, children, name, elems = load_tree(bp_dir) + if root not in children and root not in name: + sys.exit(f"root {root} not in BodyParts3D part-of tree") + order, depth = bfs(root, children) + + # gather (x,y,z, r,g,b) per vertex; colour each mesh by its DEEPEST owning + # concept. BodyParts3D `element_parts` lists every descendant element under a + # compound concept, so we claim meshes deepest-first — each structure (leaf) + # owns its own meshes and gets its own hue; the root only mops up unowned ones. + cidx = {cid: i for i, cid in enumerate(order)} # stable BFS index -> colour + pts = [] # list of (x,y,z,r,g,b) + seen_mesh = set() + regions = {} # region cid -> name + used_concepts = 0 + for cid in sorted(order, key=lambda c: -depth[c]): + fjs = elems.get(cid, []) + if not fjs: + continue + col = concept_color(cidx[cid]) + reg = region_of(cid, root, parent) + got = False + for fj in fjs: + if fj in seen_mesh: + continue + seen_mesh.add(fj) + p = os.path.join(obj_dir, fj + ".obj") + if not os.path.exists(p): + continue + for (x, y, z) in read_obj_vertices(p): + pts.append((x, y, z, col[0], col[1], col[2])) + got = True + if got: + used_concepts += 1 + regions.setdefault(reg, name.get(reg, reg)) + + if not pts: + sys.exit("no vertices gathered — check obj_dir / root") + + # deterministic downsample to the budget (uniform global stride). + stride = max(1, round(len(pts) / budget)) + sampled = pts[::stride] + + # recenter to centroid, normalize so max half-extent = 1.0. + xs = [p[0] for p in sampled]; ys = [p[1] for p in sampled]; zs = [p[2] for p in sampled] + cx, cy, cz = (min(xs) + max(xs)) / 2, (min(ys) + max(ys)) / 2, (min(zs) + max(zs)) / 2 + half = max(max(xs) - min(xs), max(ys) - min(ys), max(zs) - min(zs)) / 2 or 1.0 + inv = 1.0 / half + + gx = [(p[0] - cx) * inv for p in sampled] + gy = [(p[1] - cy) * inv for p in sampled] + gz = [(p[2] - cz) * inv for p in sampled] + bmin = (min(gx), min(gy), min(gz)) + bmax = (max(gx), max(gy), max(gz)) + radius = 0.0045 + opacity = 220 + + n = len(sampled) + buf = bytearray() + buf += b"SPL1" + buf += struct.pack(" 4 else ROOT_DEFAULT, + int(a[5]) if len(a) > 5 else BUDGET_DEFAULT)