diff --git a/docs/LANES/LANE_E_NOTES.md b/docs/LANES/LANE_E_NOTES.md index 1a44195..cfa9f75 100644 --- a/docs/LANES/LANE_E_NOTES.md +++ b/docs/LANES/LANE_E_NOTES.md @@ -56,34 +56,59 @@ hoist is a floating horizontal smear. There is no voxel size that keeps a 30 mm at 600 tris. Both were **deleted from `_normalized/`** so no later manifest build can catalogue them. **Your `boxMats` primitives cost +0 draws; the GLBs would have cost a draw and looked worse.** -**Three shipped, staged but NOT yet on the depot** (see below) — measured, GLB-law clean, -1 mtl / 1 img / 512² WebP / no Draco: +**Three shipped — PUBLISHED and byte-verified off the public depot (session 2)** — measured, +GLB-law clean, 1 mtl / 1 img / 512² WebP / no Draco: -| id | tris | footprint (m) | height (m) | -|---|---|---|---| -| `magpie` | **894** | 0.15 × 0.40 | 0.358 | -| `paling_fence` | 1 121 | 0.28 × 3.59 | 1.788 | -| `water_tank` | **500** | 1.73 × 1.64 | 2.186 | +| id | tris | footprint (m) | height (m) | sha1 (local == depot) | B's verdict | +|---|---|---|---|---|---| +| `magpie` | **894** | 0.15 × 0.40 | 0.358 | `b5f02b26…ee10` (tinted) | superseded by B's 182-tri procedural bird — **upgrade path** | +| `paling_fence` | 1 121 | 0.28 × 3.59 | 1.788 | `a580a883…8050` | **REJECTED on instance budget** — see the sizing law below | +| `water_tank` | **500** | 1.73 × 1.64 | 2.186 | `9b08fb97…f360` | **ACCEPTED** — replaces a 24-tri pair at low count | -> **⚠ THE MAGPIE IS THE RIGHT SHAPE BUT THE WRONG COLOUR — read this before you place it.** At 894 -> tris the silhouette is genuinely good (beak, legs, folded wing, tail). But it is an **all-black -> bird — a crow, not a magpie**: the white nape survives only as a pale grey wash and the white wing -> bar and tail tip are gone. **Traced, not guessed**: the RMBG cutout fed to TRELLIS is a textbook -> magpie, and the *raw 500 k* TRELLIS mesh has already lost it — so this is the operator's PBR bake, -> not our decimation. `baker:"vertex"` (advertised as the clean-albedo path) returns a GLB with 0 -> materials / 0 images whose vertex colours `normalize.py` drops — it renders pure white. Fix path -> for a later round: bake the albedo from the *concept image*, or hand-tint the 512² atlas. **If your -> 1.3 needs the bird to read as a magpie at distance rather than as a dark silhouette, say so and I -> will do the atlas tint as a micro-task.** +### → FABLE / LANE B (session 2): THE MAGPIE NOW READS AS A MAGPIE -### → FABLE / JOHN: ONE outstanding step, deliberately not taken +**The atlas tint is DONE, published and verified in the game's own renderer.** Render: +`docs/shots/laneE/r38_magpie_tint.png` (before/after × 5 angles, a 64/48/32 px distance strip, and +the published file loaded off `digalot.fyi/3god` through the vendored `GLTFLoader` + three.js). -The three GLBs are **not published to the 3GOD depot**, so they are **not in `manifest.json`** — -pushing to a public depot is an outward-facing action and I had no in-session authorization for it -this round. `validate_manifest.py` correctly fails any manifest GLB with no `_published.json` record, -so the results file is held at **`pipeline/_r38_results.PENDING_PUBLISH.json`**. `build_manifest.py` -is already wired. **Publish → rename to `_r38_results.json` → re-run `build_manifest.py`** and all -three land. **As committed the gate is green: `validate_manifest.py` → 0 errors, 0 warnings.** +**Method — `pipeline/tint_atlas.py`, and the reason it is not a paint job.** The baked atlas is a +smart-UV unwrap: ~100 scattered islands, so you cannot tell by looking which island is a nape. So +the tint is defined in the MESH's frame and projected through the UVs: rasterize every UV triangle +→ texel→(position, normal) maps → evaluate soft anatomical windows on the POSITIONS (nape / wing +bar / rump + tail base, with the crown, face, bib and tail tip left black) → paint, keeping the +bake's own luminance as shading → dilate 5 texels into the bake's UV padding so no black fringe +survives bilinear/mip at an island edge. **Geometry is untouched byte for byte** — only the image +bufferView is rewritten: still 894 tris / 1 mtl / 1 img / 512² WebP / no Draco / 0.155 × 0.358 × +0.404 m, confirmed by `glb_stat.py` *and* by loading the published file in the vendored loader +(1 mesh, 894 tris, `MeshStandardMaterial`, 512×512 texture decoded). +Because the region masks are 3-D, the soft edge is consistent across every island at once — a 2-D +blur could never do this (it would bleed island-to-island through the black background). + +**VERDICT: it reads.** Painted albedo median luma **0.84** (p90 0.95) against the bake's 0.13. +At **64 px and 48 px** — a 0.36 m bird at 3–10 m on a 1080-high screen — the pied pattern is +unmistakable from flank and rear-quarter; at 32 px it is still clearly a black-and-white bird. +**The one honest caveat:** from *directly underneath and in front* (the `swoop` view) it stays +dark — which is what a real magpie does, because its bib and belly ARE black; the white is on the +nape, shoulders and rump, i.e. what you see as it comes over you and as it leaves. +**Thumbnail:** re-rendered from the **rear quarter** of the same `bake_lowpoly` camera rig, because +the roster's standard front-quarter looks straight at a magpie's black face and records none of the +plumage — the one thing this asset's thumb has to show. + +**Status after B's R38 (commit `4514cb9`): this is an UPGRADE PATH, not a dependency.** B shipped a +182-tri procedural bird carrying the marks as vertex colours and fetching nothing — 4.9× lighter +and B's argument is right. The tinted GLB is on the depot and in the manifest if the swap at +`birdGeometry()` / `birdMaterial()` is ever wanted; **nothing probes for it, and nothing should** +(the blind probe R26 killed). It costs +1 draw and +894 tris at one instance if taken. + +### → FABLE: the depot step is CLOSED + +Published under **John's R22 standing authorization for Lane E assets** (the same authority that +shipped `sit.glb` in R28 and the nine trellis/Rokoko assets in R36) via the passwordless tailnet +ingress: `_published.json` **53 → 56**, drift check `✓ all 56 recorded assets live`. Then all three +**fetched back off the PUBLIC depot** (`https://digalot.fyi/3god/a/.glb`) — **HTTP 200, byte +counts exact, sha1 identical to local, 3/3** (the R36 discipline; sha1s in the table above). +`_r38_results.PENDING_PUBLISH.json` → `_r38_results.json`, `build_manifest.py` re-run (GLBs 33 → 36, +they land in `furniture`), **`validate_manifest.py` → 0 errors, 0 warnings.** ### → FABLE: two review figures corrected, one confirmed @@ -104,7 +129,30 @@ three land. **As committed the gate is green: `validate_manifest.py` → 0 error `novelty_record`, `bench_modern`, `longbench` and `counter`→`bar_counter`. - **CONFIRMED — the stale blocker.** `furniture.js:30`'s comment holding `novelty_record` on its primitive says **"26.5k tris"**; Lane E baked it to **8 000** in R5. One stale comment is the only - thing keeping a finished landmark prop out of the game. + thing keeping a finished landmark prop out of the game. ✅ **Lane B corrected it in `4514cb9`.** +- **CORRECTED — `normalize.py`'s collapse decimator cannot reach small budgets on TRELLIS output.** + Measured on all five R38 props: 470 442→**13 849** against a 900 target · 478 236→**250 068** (600) + · 476 974→**18 791** (400) · 485 565→**89 306** (400) · 475 047→**70 893** (500). Collapse cannot + merge across disconnected shells, so **the floor IS the shell count** — this is not a tuning miss, + and nobody had asked it for ≤900 before. **`bake_lowpoly.py` (voxel-remesh → decimate → Cycles + bake) clears it every time**: 894 / 500 / 299 / 496. Route any ≤2 k target through the bake, never + through collapse. + +### → EVERY LATER ASSET ROUND: the sizing law R38 paid for (Lane B's `paling_fence` measurement) + +**An asset's triangle budget is not "≤5 k like a prop" — it is `tris × the instance count the +placement rule implies`.** `paling_fence` at **1 121 tris** is a perfectly good panel and a +**REJECTED** asset: B measured **5 runs × 181 lots = 905 instances = 1.01 MILLION triangles** +against ~75 k spare, ~47× over, and a GLB cannot ride the `boxMats` InstancedMesh that makes the +other yard props free. It ships as a primitive. **`water_tank` at 500 tris was ACCEPTED on the same +measurement** — one per yard at low count, replacing a 24-tri pair. +**So the budget question at generation time is not "is this prop small?" but "how many of these does +the placement rule put on screen, and can they ride an existing InstancedMesh?"** A fence panel +repeated on every lot boundary is in a different budget class from a one-per-town water tank, and +the number to beat is set by the *rule*, not by the asset. Ask Lane B for the instance count +**before** generating anything that tiles, edges, or repeats per-lot. (The published fence stays on +the depot and in the manifest — harmless, provenance-recorded, and available if a low-count use +ever appears.) ## Round 36 wave 1 — the 2 a.m. commits, regularized ⟨v7.0⟩ diff --git a/docs/shots/laneE/r38_magpie_tint.png b/docs/shots/laneE/r38_magpie_tint.png new file mode 100644 index 0000000..ac55612 Binary files /dev/null and b/docs/shots/laneE/r38_magpie_tint.png differ diff --git a/pipeline/AUDIT.md b/pipeline/AUDIT.md index 0feedd6..26aa2e2 100644 --- a/pipeline/AUDIT.md +++ b/pipeline/AUDIT.md @@ -732,3 +732,101 @@ ONE geometry and **keeps only the FIRST material**. So a multi-material GLB does **The 3GOD depot was NOT used as a pool** — it stays a read-only archive (971 live, 53 recorded, known trademarked items inside). **The hardyards set stays REJECTED on provenance** (39 GLBs, provenance documented for one file); every mesh here is ours. **$0 spent — no cloud key touched.** + +### 7. SESSION 2 — the depot step, the magpie tint, and the sizing law + +*Everything in §7 is measured on this box after `1568c9b`; nothing in §1–§6 is retracted.* + +#### 7a. PUBLISHED — three GLBs, verified by fetching the bytes back off the PUBLIC depot + +Pushed under **John's R22 standing authorization for Lane E assets** (the same authority as +`sit.glb` in R28 and the nine R36 sweep assets) through the passwordless tailnet ingress +(`GOD3_DEPOT=http://100.94.195.115:8788`, decision #4 — no secret read, no cookie, `publish.py` +never logs one). `_published.json` **53 → 56**; post-publish drift check `✓ all 56 recorded assets +live`. Thumbs uploaded with each (`glb:200 thumb:200`). + +Then verified the way this lane always verifies: **fetch it back off the public host and compare +hashes**, not "the upload said 200". + +| file | HTTP | local B | depot B | sha1 (identical both sides) | +|---|---|---|---|---| +| `procity_street_magpie_01.glb` | 200 | 132 592 | 132 592 | `b5f02b26d15f7a58f8761f54fdb98b14fa65ee10` | +| `procity_street_paling_fence_01.glb` | 200 | 165 752 | 165 752 | `a580a8833f02bccb2433776072bca2d4fbee8050` | +| `procity_street_water_tank_01.glb` | 200 | 77 152 | 77 152 | `9b08fb9774e892f04d5c36a09ab2100383f4e360` | + +`_r38_results.PENDING_PUBLISH.json` → `_r38_results.json`, `build_manifest.py` re-run — manifest +GLBs **33 → 36**, all three under `furniture`. **`validate_manifest.py`: 0 errors, 0 warnings.** + +#### 7b. THE MAGPIE TINT — a 3-D repaint of a 2-D atlas, because the atlas is unreadable by eye + +§4d left the bird an all-black crow with the loss traced to `trellis2_mlx`'s PBR bake. The stated +fix path ("hand-tint the 512² atlas") is **not a paint job**: the atlas is a smart-UV bake, ~100 +scattered islands, and no island announces itself as a nape. New tool **`pipeline/tint_atlas.py`**: + +1. rasterize every UV triangle into the atlas grid → **texel → (position, normal)** maps + (barycentric, glTF top-left UV origin, 1-texel conservative pad) — 101 086 / 262 144 texels + covered, 38.6 %; +2. evaluate **soft anatomical windows on the POSITIONS** in the mesh's own metric frame + (x ±0.0777 lateral · y 0→0.3583 up · z −0.2021 tail → +0.2021 beak): white **nape** behind a + black crown, white **shoulder/wing bar** on the flanks only so the dorsal midline stays black, + white **rump + tail base** stopping short of the black terminal band — Australian magpie + (*Gymnorhina tibicen*), adult, black-backed form; +3. paint with the bake's own luminance kept as shading (`0.88 + 0.12·smoothstep(luma)`), so it is + white with the bake's light in it, not a flat decal; +4. **dilate 5 texels into the bake's UV padding**, or bilinear/mip at an island edge would pull the + old black through; +5. re-embed as WebP and rewrite the GLB. The image bufferView is verified last in the buffer, so + **geometry is untouched byte for byte**. + +Because the masks live in 3-D, the soft edge is consistent across every island simultaneously — a +2-D blur cannot do this at all (it would bleed island-to-island through the black background). + +**Result, measured:** painted texels' median luma **0.843** (p90 0.949) vs the bake's 0.133; whole +atlas 0.075 → 0.207. **GLB law re-checked after the rewrite: 894 tris · 1 mesh · 1 material · 1 +image · 512² WebP · no Draco · 0.155 × 0.358 × 0.404 m — every number unchanged.** + +**Verified in the game's own renderer, not just in Blender.** The *published* file was loaded off +`https://digalot.fyi/3god` through the vendored `GLTFLoader` + three.js (`MeshStandardMaterial`, +hemisphere + sun, sky background): **1 mesh, 894 tris, 512×512 texture decoded** — which also proves +the PIL-written WebP decodes in a browser. Sheet: `docs/shots/laneE/r38_magpie_tint.png` — +before/after × 5 angles (EEVEE, the GLB's own material incl. the 0.28 emissive) + a **64 / 48 / 32 px +distance strip** (a 0.36 m bird at 3–10 m on a 1080-high screen is 50–100 px) + the three.js frame. + +**VERDICT: it reads at the size and speed the bird is seen.** Pied and unmistakable at 64 px and +48 px from flank and rear-quarter; still clearly black-and-white at 32 px. **Honest caveat:** from +directly below-and-in-front it stays dark — correct behaviour, because a real magpie's bib and belly +ARE black; the white is nape, shoulders and rump, i.e. what you see as it comes over and as it +leaves. Thumbnail re-rendered from the **rear quarter** of the same `bake_lowpoly` camera rig +(`render_views.py --thumb --rear`): the roster's standard front-quarter looks straight at a magpie's +black face and would have recorded none of the plumage. + +**Standing after Lane B's `4514cb9`: the tinted GLB is an UPGRADE PATH, not a dependency.** B shipped +a 182-tri procedural bird with the marks as vertex colours, fetching nothing — 4.9× lighter, and the +right call. The GLB is on the depot and in the manifest; **nothing probes for it and nothing should** +(R26's blind-probe ruling). Cost if ever swapped in at `birdGeometry()` / `birdMaterial()`: +1 draw, ++894 tris at one instance. + +#### 7c. THE SIZING LAW — `paling_fence` is a good asset and a rejected one + +Lane B's measurement: **5 runs × 181 lots = 905 instances × 1 121 tris = 1.01 M triangles** against +~75 k spare (~47× over), and a GLB cannot ride the `boxMats` InstancedMesh that makes the other yard +props +0 draws. **REJECTED; it ships as a primitive.** `water_tank` (500 tris, one per yard, low +count, replacing a 24-tri pair) is **ACCEPTED**. + +**The law for every later asset round: a mesh's triangle budget is `tris × the instance count the +placement rule implies`, not "≤5 k like a prop".** A fence panel repeated on every lot boundary is +in a different budget class from a one-per-town water tank, and the number to beat is set by the +placement *rule*, not by the asset. **Get the instance count from the consuming lane BEFORE +generating anything that tiles, edges, or repeats per-lot.** The published fence stays on the depot +and in the manifest — harmless, provenance-recorded, available if a low-count use ever appears. + +#### 7d. New pipeline tools (all $0, on-device) + +| tool | what it does | +|---|---| +| `tint_atlas.py` | the 3-D-region atlas repaint above; `--debug` renders the region masks as flat colours so the anatomy can be checked before any paint lands | +| `render_views.py` | Blender view sheet from the angles an asset is *seen* from (flank / rear-quarter / swoop / top / rear), `--eevee` for the GLB's own material under sky, `--thumb [--rear]` to re-render a roster-consistent 256 px thumbnail | +| `view_sheet.py` | composites those into one labelled sheet + the **distance strip** — the check no 256 px thumbnail can make | + +**Licence unchanged: 🟢.** The tint is our own numeric edit of our own baked atlas; no asset, no +model and no third-party image entered the pipeline for it. **$0 — no cloud key touched.** diff --git a/pipeline/_published.json b/pipeline/_published.json index b862d2d..ec50ba2 100644 --- a/pipeline/_published.json +++ b/pipeline/_published.json @@ -44,9 +44,12 @@ "procity_street_food_cart_01.glb", "procity_street_food_cart_01_hi.glb", "procity_street_longbench_01.glb", + "procity_street_magpie_01.glb", "procity_street_novelty_record_01.glb", + "procity_street_paling_fence_01.glb", "procity_street_park_bench_01.glb", "procity_street_streetlight_01.glb", + "procity_street_water_tank_01.glb", "sit.glb", "vintage-cash-register.glb", "woman_dj_01.glb", diff --git a/pipeline/_r38_results.PENDING_PUBLISH.json b/pipeline/_r38_results.json similarity index 100% rename from pipeline/_r38_results.PENDING_PUBLISH.json rename to pipeline/_r38_results.json diff --git a/pipeline/render_views.py b/pipeline/render_views.py new file mode 100644 index 0000000..686a2d1 --- /dev/null +++ b/pipeline/render_views.py @@ -0,0 +1,124 @@ +"""PROCITY view sheet — render a normalized GLB from the angles it is actually SEEN from, plus a +distance strip at the pixel sizes it actually occupies. Written for R38's magpie tint verdict: +"does the marking read at the distance and speed the bird is seen at" is a question no 256 px +thumbnail can answer. + + BL=/Applications/Blender.app/Contents/MacOS/Blender + "$BL" --background --python pipeline/render_views.py -- IN.glb OUT.png [LABEL] + +Views (Blender frame after glTF import: glTF +Z head -> Blender -Y): + flank · rear-quarter high (the nape read) · under-front (the swoop, bird above the player) · + top-down · straight rear. Each is composited over sky, not over transparency, because the + contrast that matters is bird-against-sky. The bottom strip re-samples the flank + swoop view + to 96 / 64 / 48 / 32 px — a 0.36 m bird at 3-10 m on a 1080-high screen is 50-100 px, so 64 px + is the honest test and 32 px is the pessimistic one. +""" +import bpy, sys, os, math +from mathutils import Vector + +ARGV = sys.argv[sys.argv.index("--") + 1:] +SRC, OUT = ARGV[0], ARGV[1] +LABEL = ARGV[2] if len(ARGV) > 2 else "" +RES = 384 +SKY = (0.62, 0.72, 0.86) + + +def wipe(): + for o in list(bpy.data.objects): + bpy.data.objects.remove(o, do_unlink=True) + + +def bounds(o): + cs = [o.matrix_world @ Vector(c) for c in o.bound_box] + return (Vector((min(c.x for c in cs), min(c.y for c in cs), min(c.z for c in cs))), + Vector((max(c.x for c in cs), max(c.y for c in cs), max(c.z for c in cs)))) + + +def main(): + wipe() + bpy.ops.import_scene.gltf(filepath=SRC) + objs = [o for o in bpy.data.objects if o.type == 'MESH'] + ob = objs[0] + mn, mx = bounds(ob) + ctr = (mn + mx) / 2 + size = max((mx - mn).x, (mx - mn).y, (mx - mn).z) or 1.0 + + scn = bpy.context.scene + if "--eevee" in ARGV: + # closest cheap proxy for the in-game look: the GLB's OWN material (base colour + the + # 0.28 emissive normalize.py adds) under a bright sky-coloured world + a sun. + eng = [e.identifier for e in + type(scn).bl_rna.properties['render'].fixed_type.properties['engine'].enum_items] + scn.render.engine = 'BLENDER_EEVEE_NEXT' if 'BLENDER_EEVEE_NEXT' in eng else 'BLENDER_EEVEE' + w = bpy.data.worlds.get("sky") or bpy.data.worlds.new("sky") + scn.world = w + w.use_nodes = True + bg = w.node_tree.nodes["Background"] + bg.inputs[0].default_value = (SKY[0], SKY[1], SKY[2], 1.0) + bg.inputs[1].default_value = 1.6 + bpy.ops.object.light_add(type='SUN', location=(size * 3, -size * 3, size * 4)) + bpy.context.object.data.energy = 3.0 + bpy.context.object.rotation_euler = (math.radians(50), 0, math.radians(35)) + else: + scn.render.engine = 'BLENDER_WORKBENCH' + scn.display.shading.light = 'STUDIO' + scn.display.shading.color_type = 'TEXTURE' + scn.display.shading.show_cavity = False + scn.view_settings.view_transform = 'Standard' + scn.render.film_transparent = True + scn.render.resolution_x = scn.render.resolution_y = RES + bpy.ops.object.camera_add() + cam = bpy.context.object + scn.camera = cam + cam.data.lens = 50 + + if "--thumb" in ARGV: + # byte-for-byte the same camera/lighting as bake_lowpoly.render_thumb, so a re-tinted asset + # gets a thumbnail that matches the rest of the roster instead of a new angle. + scn.render.engine = 'BLENDER_WORKBENCH' + scn.display.shading.light = 'STUDIO' + scn.display.shading.color_type = 'TEXTURE' + scn.display.shading.show_cavity = True + w = bpy.data.worlds.get("tw") or bpy.data.worlds.new("tw") + scn.world = w + w.use_nodes = True + w.node_tree.nodes["Background"].inputs[1].default_value = 1.05 + bpy.ops.object.light_add(type='AREA', location=(ctr.x + size, ctr.y - size, mx.z + size)) + bpy.context.object.data.energy = 800 * size + bpy.context.object.data.size = 6 + r = size * 1.9 + # --rear swings the same rig to the other quarter. Needed for the magpie: the roster's + # standard front-quarter looks straight at a magpie's black face and bib, so it records + # none of the plumage — the one thing this asset's thumb has to show. + cam.location = ctr + Vector((r * 0.8, r if "--rear" in ARGV else -r, size * 0.6)) + cam.rotation_euler = (ctr - cam.location).to_track_quat('-Z', 'Y').to_euler() + scn.render.resolution_x = scn.render.resolution_y = 256 + scn.render.filepath = OUT + bpy.ops.render.render(write_still=True) + print("RENDERED thumb -> " + OUT) + return + + r = size * 2.2 + # head is -Y after import; +Y is behind the bird + views = { + "flank": Vector((r, -r * 0.15, size * 0.15)), + "rear34": Vector((r * 0.55, r * 0.85, size * 0.85)), # over the shoulder: the nape + "swoop": Vector((r * 0.35, -r * 0.80, -size * 0.75)), # bird overhead, coming at you + "top": Vector((0.001, 0.001, r * 1.15)), + "rear": Vector((0.0, r * 1.1, size * 0.2)), + } + tmp = os.path.join(os.path.dirname(OUT), "_v_") + paths = [] + for name, loc in views.items(): + cam.location = ctr + loc + cam.rotation_euler = (ctr - cam.location).to_track_quat('-Z', 'Y').to_euler() + p = tmp + name + ".png" + scn.render.filepath = p + bpy.ops.render.render(write_still=True) + paths.append((name, p)) + print("RENDERED " + " ".join(n for n, _ in paths)) + with open(OUT + ".views.txt", "w") as f: + f.write("\n".join(f"{n}\t{p}" for n, p in paths) + f"\nLABEL\t{LABEL}\n") + + +main() diff --git a/pipeline/tint_atlas.py b/pipeline/tint_atlas.py new file mode 100644 index 0000000..6b8187a --- /dev/null +++ b/pipeline/tint_atlas.py @@ -0,0 +1,247 @@ +#!/usr/bin/env python3 +"""PROCITY atlas tint — repaint anatomical REGIONS of a baked GLB atlas, in 3D, not in 2D. + +Why this exists (R38): `trellis2_mlx`'s PBR bake crushes high-contrast markings. The magpie came +back an all-black bird — a crow — with its white nape, wing bar and tail base gone (AUDIT R38 §4d +proved the loss is the operator's bake: the RMBG cutout and the raw 500k mesh were both checked). +The atlas is a smart-UV bake: ~100 scattered islands, so "hand-tint the white bits" is not a 2D +paint job — you cannot tell which island is a nape. + +So the tint is defined in the MESH's own space and projected through the UVs: + + 1. rasterize every UV triangle into the atlas grid -> texel -> (position, normal) maps + 2. evaluate soft anatomical region masks on those POSITIONS (metres, GLB frame: +Y up) + 3. paint the region colour with the bake's own luminance kept as shading + 4. dilate the result a few texels into the bake's UV padding so no black fringe survives + bilinear/mip sampling at the island edges + 5. re-embed as WebP and rewrite the GLB — GEOMETRY IS UNTOUCHED, byte for byte. Only + bufferView 'image' changes, so tris / materials / images / extensions all stay put. + + PY=~/Documents/MODELBEAST/venvs/mflux/bin/python # numpy + PIL live here + $PY pipeline/tint_atlas.py magpie --debug # flat region colours, for looking at + $PY pipeline/tint_atlas.py magpie # the real tint + $PY pipeline/tint_atlas.py magpie --dump-atlas /tmp/x.png # write the atlas alongside + +Region masks are smooth (smoothstep ramps on windows of z / y / |x| / normal), so the paint has a +soft edge in 3D and therefore across every island at once — which is exactly what a 2D blur could +never do here (it would bleed island-to-island across the black background). +""" +import json, os, struct, sys, io +import numpy as np +from PIL import Image + +ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + + +# ---------------------------------------------------------------- soft window helpers +def smoothstep(e0, e1, x): + t = np.clip((x - e0) / (e1 - e0 + 1e-12), 0.0, 1.0) + return t * t * (3 - 2 * t) + + +def window(v, lo_out, lo_in, hi_in, hi_out): + """1 inside [lo_in, hi_in], ramping to 0 at lo_out / hi_out.""" + return smoothstep(lo_out, lo_in, v) * (1.0 - smoothstep(hi_in, hi_out, v)) + + +# ---------------------------------------------------------------- the region specs +# The AUSTRALIAN MAGPIE (Gymnorhina tibicen), adult, black-backed form — the widespread depiction +# and the one R38 asked for: WHITE NAPE (hindneck, behind a black crown), WHITE SHOULDER/WING BAR +# along the top of the folded wing, WHITE RUMP running into a WHITE TAIL BASE with a black +# terminal band. Beak pale blue-grey with a black tip (the bake already has that and it survives). +# +# Mesh frame, measured off the bytes: x = ±0.0777 lateral · y = 0 -> 0.3583 up · +# z = -0.2021 (tail tip, drooped to the ground) -> +0.2021 (beak tip). Head/crown z 0.10-0.135, +# neck z 0.055-0.105, body z -0.06..+0.09, legs y<0.09, tail z<-0.06 sloping down to the ground. +MAGPIE = { + "file": "procity_street_magpie_01.glb", + "regions": [ + # --- the nape: the broad white collar over the hindneck, stopping short of the black crown + {"name": "nape", "debug": (0.95, 0.15, 0.15), "rules": [ + ("z", 0.004, 0.020, 0.070, 0.086), # behind the crown, forward of the mantle + ("y", 0.266, 0.288, 0.360, 0.400), # upper neck only — never the black bib/throat + ("ny", -0.60, -0.35, 1.10, 1.20), # skip the under-chin faces + ]}, + # --- the shoulder / wing bar: on the flanks only, so the dorsal midline stays black + {"name": "wing", "debug": (0.15, 0.9, 0.25), "rules": [ + ("ax", 0.032, 0.044, 0.090, 0.095), # |x| — outer flank, both sides + ("y", 0.172, 0.196, 0.252, 0.266), # the TOP of the folded wing, not the flank + ("z", -0.040, -0.020, 0.048, 0.064), # anterior: the primaries stay black + ]}, + # --- rump + tail base: white from the lower back into the proximal tail, black tip left + {"name": "tailbase", "debug": (0.2, 0.35, 0.98), "rules": [ + ("z", -0.172, -0.156, -0.062, -0.044), # black terminal band survives past -0.16 + ("y", -0.05, 0.0, 0.162, 0.184), # under the back line, so the mantle stays black + ]}, + ], + # the paint: magpie white is a true bright white, faintly warm in sun + "paint": (0.955, 0.950, 0.930), +} + +SPECS = {"magpie": MAGPIE} + + +# ---------------------------------------------------------------- GLB read / write +def glb_read(path): + b = open(path, "rb").read() + total = struct.unpack(" 1 else arr + + +# ---------------------------------------------------------------- UV -> 3D rasterizer +def rasterize(P, N, UV, tris, size): + """texel -> (position, normal, covered). Barycentric, top-left UV origin (glTF).""" + pos = np.zeros((size, size, 3), np.float32) + nrm = np.zeros((size, size, 3), np.float32) + cov = np.zeros((size, size), bool) + px = UV[:, 0] * size - 0.5 + py = UV[:, 1] * size - 0.5 # v grows DOWN the image in glTF + for t in tris: + i0, i1, i2 = t + x0, y0, x1, y1, x2, y2 = px[i0], py[i0], px[i1], py[i1], px[i2], py[i2] + den = (y1 - y2) * (x0 - x2) + (x2 - x1) * (y0 - y2) + if abs(den) < 1e-9: + continue + # 1-texel conservative pad so island interiors are fully covered + cl = max(int(np.floor(min(x0, x1, x2))) - 1, 0) + cr = min(int(np.ceil(max(x0, x1, x2))) + 1, size - 1) + rt = max(int(np.floor(min(y0, y1, y2))) - 1, 0) + rb = min(int(np.ceil(max(y0, y1, y2))) + 1, size - 1) + if cr < cl or rb < rt: + continue + gx, gy = np.meshgrid(np.arange(cl, cr + 1), np.arange(rt, rb + 1)) + l0 = ((y1 - y2) * (gx - x2) + (x2 - x1) * (gy - y2)) / den + l1 = ((y2 - y0) * (gx - x2) + (x0 - x2) * (gy - y2)) / den + l2 = 1.0 - l0 - l1 + m = (l0 >= -0.25) & (l1 >= -0.25) & (l2 >= -0.25) # -0.25 = the pad + if not m.any(): + continue + b = np.stack([np.clip(l0[m], 0, 1), np.clip(l1[m], 0, 1), np.clip(l2[m], 0, 1)], 1) + b /= b.sum(1, keepdims=True) + rr, cc = gy[m], gx[m] + pos[rr, cc] = b @ P[[i0, i1, i2]] + nrm[rr, cc] = b @ N[[i0, i1, i2]] + cov[rr, cc] = True + return pos, nrm, cov + + +def region_mask(spec, pos, nrm, cov): + x, y, z = pos[..., 0], pos[..., 1], pos[..., 2] + axes = {"x": x, "y": y, "z": z, "ax": np.abs(x), + "nx": nrm[..., 0], "ny": nrm[..., 1], "nz": nrm[..., 2]} + m = np.ones_like(y) + for axis, lo_out, lo_in, hi_in, hi_out in spec["rules"]: + m = m * window(axes[axis], lo_out, lo_in, hi_in, hi_out) + return m * cov + + +def dilate(img, mask, cov, rounds=5): + """Push painted colour outward into the bake's UV padding (uncovered texels next to painted + ones), so bilinear/mip sampling at an island edge never pulls the old black through.""" + img = img.copy() + done = (mask > 0.02) + free = ~cov + for _ in range(rounds): + n_sum = np.zeros_like(img) + n_cnt = np.zeros(img.shape[:2], np.float32) + for dy, dx in ((1, 0), (-1, 0), (0, 1), (0, -1)): + s = np.roll(np.roll(done.astype(np.float32), dy, 0), dx, 1) + c = np.roll(np.roll(img, dy, 0), dx, 1) + n_sum += c * s[..., None] + n_cnt += s + grow = free & (~done) & (n_cnt > 0) + img[grow] = (n_sum[grow] / n_cnt[grow][..., None]) + done = done | grow + return img + + +def main(): + which = sys.argv[1] if len(sys.argv) > 1 else "magpie" + debug = "--debug" in sys.argv + spec = SPECS[which] + src = os.path.join(ROOT, "pipeline", "_normalized", spec["file"]) + out = sys.argv[sys.argv.index("--out") + 1] if "--out" in sys.argv else src + J, chunks = glb_read(src) + BIN = chunks[1][1] + prim = J["meshes"][0]["primitives"][0] + P = accessor(J, BIN, prim["attributes"]["POSITION"]) + N = accessor(J, BIN, prim["attributes"]["NORMAL"]) + UV = accessor(J, BIN, prim["attributes"]["TEXCOORD_0"]) + IDX = accessor(J, BIN, prim["indices"]).reshape(-1, 3) + + ibv = J["bufferViews"][J["images"][0]["bufferView"]] + raw = bytes(BIN[ibv["byteOffset"]:ibv["byteOffset"] + ibv["byteLength"]]) + im = Image.open(io.BytesIO(raw)).convert("RGB") + size = im.size[0] + a = np.asarray(im).astype(np.float32) / 255.0 + print(f"{spec['file']}: {len(IDX)} tris · atlas {im.size} {J['images'][0]['mimeType']}") + + pos, nrm, cov = rasterize(P, N, UV, IDX, size) + print(f" rasterized: {cov.sum()} / {size*size} texels covered ({100*cov.mean():.1f}%)") + + out_img = a.copy() + total = np.zeros(cov.shape, np.float32) + for r in spec["regions"]: + m = region_mask(r, pos, nrm, cov) + total = np.maximum(total, m) + col = np.array(r["debug"] if debug else spec["paint"], np.float32) + if debug: + out_img = out_img * (1 - m[..., None]) + col * m[..., None] + else: + # keep the bake's own light: its luminance, stretched into a white's shading range + lum = a @ np.array([0.2126, 0.7152, 0.0722], np.float32) + shade = 0.88 + 0.12 * smoothstep(0.0, 0.28, lum) + painted = col * shade[..., None] + out_img = out_img * (1 - m[..., None]) + painted * m[..., None] + print(f" {r['name']:9} {int((m>0.5).sum()):6d} texels solid, {int((m>0.02).sum()):6d} touched") + out_img = dilate(np.clip(out_img, 0, 1), total, cov) + + png = Image.fromarray((np.clip(out_img, 0, 1) * 255 + 0.5).astype(np.uint8)) + if "--dump-atlas" in sys.argv: + png.save(sys.argv[sys.argv.index("--dump-atlas") + 1]) + buf = io.BytesIO() + png.save(buf, "WEBP", quality=88, method=6) + new = buf.getvalue() + + # rewrite the image bufferView in place; it is the LAST view in the buffer (verified), so the + # only offsets that move are its own length and the buffer's. + tail_users = [bv for bv in J["bufferViews"] if bv["byteOffset"] > ibv["byteOffset"]] + assert not tail_users, "image bufferView is not last — repack needed" + del BIN[ibv["byteOffset"]:] + BIN.extend(new) + ibv["byteLength"] = len(new) + J["buffers"][0]["byteLength"] = len(BIN) + n = glb_write(out, J, chunks) + print(f" atlas {len(raw)} -> {len(new)} B webp · GLB -> {out} ({n} B)" + + (" [DEBUG COLOURS]" if debug else "")) + + +if __name__ == "__main__": + main() diff --git a/pipeline/view_sheet.py b/pipeline/view_sheet.py new file mode 100644 index 0000000..9e034c1 --- /dev/null +++ b/pipeline/view_sheet.py @@ -0,0 +1,91 @@ +#!/usr/bin/env python3 +"""Compose render_views.py output into one labelled contact sheet + a distance strip. + + PY=~/Documents/MODELBEAST/venvs/mflux/bin/python + $PY pipeline/view_sheet.py OUT.png "title" "row label" a.png.views.txt ["row 2" b...txt] ... + +One row of views per input (each composited over sky — bird-against-sky is the contrast that +matters), then a strip that re-samples the swoop / flank / rear views to 64 / 48 / 32 px and +magnifies them NEAREST: a 0.36 m bird at 3-10 m on a 1080-high screen is 50-100 px, so 64 px is +the honest test of whether a marking reads and 32 px is the pessimistic one. +""" +import sys +from PIL import Image, ImageDraw + +SKY = (158, 184, 219) +PAD, LBL = 10, 16 +STRIP = ("swoop", "flank", "rear34") +SIZES = (64, 48, 32) +CELL = 112 + + +def over_sky(p, bg=SKY): + im = Image.open(p).convert("RGBA") + out = Image.new("RGB", im.size, bg) + out.paste(im, (0, 0), im) + return out + + +def load(path): + views = {} + for line in open(path): + k, v = line.rstrip("\n").split("\t") + if k != "LABEL": + views[k] = over_sky(v) + return views + + +def main(): + out_path, title = sys.argv[1], sys.argv[2] + rows = [] + args = sys.argv[3:] + extra = None + if "--extra" in args: # --extra IMG.png "caption": pasted under the sheet + i = args.index("--extra") + extra = (args[i + 1], args[i + 2]) + args = args[:i] + for i in range(0, len(args), 2): + rows.append((args[i], load(args[i + 1]))) + w, h = next(iter(rows[0][1].values())).size + ncol = max(len(v) for _, v in rows) + n_strip = sum(len([n for n in STRIP if n in v]) * len(SIZES) for _, v in rows) + ex = Image.open(extra[0]).convert("RGB") if extra else None + W = max(PAD + ncol * (w + PAD), PAD + n_strip * (CELL + PAD), (ex.width + 2 * PAD) if ex else 0) + H = 20 + len(rows) * (h + LBL + PAD) + LBL + CELL + 18 + PAD + if ex: + H += LBL + ex.height + PAD + sheet = Image.new("RGB", (W, H), (24, 24, 26)) + d = ImageDraw.Draw(sheet) + d.text((PAD, 4), title, fill=(240, 240, 240)) + y = 22 + for label, views in rows: + d.text((PAD, y), label, fill=(250, 210, 120)) + y += LBL + for i, (n, im) in enumerate(views.items()): + x = PAD + i * (w + PAD) + sheet.paste(im, (x, y)) + d.text((x + 4, y + 2), n, fill=(40, 40, 40)) + y += h + PAD + d.text((PAD, y), "AT THE SIZE IT IS ACTUALLY SEEN (rendered px, magnified NEAREST):", + fill=(250, 210, 120)) + y += LBL + i = 0 + for label, views in rows: + for n in STRIP: + if n not in views: + continue + for s in SIZES: + x = PAD + i * (CELL + PAD) + sheet.paste(views[n].resize((s, s), Image.LANCZOS).resize((CELL, CELL), Image.NEAREST), + (x, y)) + d.text((x + 3, y + CELL + 2), f"{label[:9]} {n} {s}px", fill=(175, 175, 175)) + i += 1 + if ex: + y += CELL + 18 + d.text((PAD, y), extra[1], fill=(250, 210, 120)) + sheet.paste(ex, (PAD, y + LBL)) + sheet.save(out_path) + print(f"sheet -> {out_path} {sheet.size}") + + +main() diff --git a/web/assets/manifest.json b/web/assets/manifest.json index a32e51d..1685959 100644 --- a/web/assets/manifest.json +++ b/web/assets/manifest.json @@ -924,6 +924,33 @@ ], "height": 1.0, "thumb": "thumbs/procity_street_bin_01.png" + }, + "magpie": { + "file": "procity_street_magpie_01.glb", + "footprint": [ + 0.15, + 0.4 + ], + "height": 0.36, + "thumb": "thumbs/procity_street_magpie_01.png" + }, + "paling_fence": { + "file": "procity_street_paling_fence_01.glb", + "footprint": [ + 0.28, + 3.59 + ], + "height": 1.79, + "thumb": "thumbs/procity_street_paling_fence_01.png" + }, + "water_tank": { + "file": "procity_street_water_tank_01.glb", + "footprint": [ + 1.73, + 1.64 + ], + "height": 2.19, + "thumb": "thumbs/procity_street_water_tank_01.png" } }, "fittingAliases": { diff --git a/web/assets/thumbs/procity_street_magpie_01.png b/web/assets/thumbs/procity_street_magpie_01.png index 8eeabf3..ea4c56e 100644 Binary files a/web/assets/thumbs/procity_street_magpie_01.png and b/web/assets/thumbs/procity_street_magpie_01.png differ