@bitmagic/cli 0.1.73-dev.0 → 0.1.73
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +31 -0
- package/assets/blender-level/README.md +4 -0
- package/assets/character-rigging/SKILL.md +5 -2
- package/assets/character-skin-authoring/SKILL.md +1 -1
- package/assets/optimizing-assets/SKILL.md +222 -0
- package/assets/optimizing-assets/references/recipes.md +171 -0
- package/assets/optimizing-assets/references/runtime.md +156 -0
- package/assets/optimizing-assets/references/tools.md +131 -0
- package/assets/optimizing-assets/references/verification.md +118 -0
- package/assets/optimizing-assets/scripts/ab-compare.mjs +250 -0
- package/assets/optimizing-assets/scripts/optimize-glb.mjs +953 -0
- package/dist/scaffold/asset-pipeline-skill-files.js +2 -0
- package/dist/scaffold/asset-pipeline-skill-files.js.map +1 -1
- package/dist/scaffold/metahuman-skill-files.js +1 -1
- package/dist/scaffold/optimizing-assets-skill-files.d.ts +3 -0
- package/dist/scaffold/optimizing-assets-skill-files.js +54 -0
- package/dist/scaffold/optimizing-assets-skill-files.js.map +1 -0
- package/dist/scaffold/project-files.d.ts +1 -1
- package/dist/scaffold/project-files.js +5 -2
- package/dist/scaffold/project-files.js.map +1 -1
- package/dist/scaffold/project.js +2 -0
- package/dist/scaffold/project.js.map +1 -1
- package/dist/scaffold/skill-files.js +2 -1
- package/dist/scaffold/skill-files.js.map +1 -1
- package/package.json +4 -4
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
# Installing the shared compression tools
|
|
2
|
+
|
|
3
|
+
`optimize-glb.mjs` runs on two tools, installed once per machine into `~/.bitmagic/tools/` and
|
|
4
|
+
shared by every project on it:
|
|
5
|
+
|
|
6
|
+
- **`gltf/`:** glTF-Transform 4.5.0 with meshoptimizer 1.2.0 (simplify and meshopt), sharp 0.35.5
|
|
7
|
+
(image resize and encode) and gltf-validator. About 62 MB.
|
|
8
|
+
- **`ktx/`:** Khronos KTX-Software 4.4.2 (`ktx create` and `ktx extract`), needed only for KTX2
|
|
9
|
+
textures. About 6 MB.
|
|
10
|
+
|
|
11
|
+
The versions are pinned because the script relies on their defaults (what `prune`, `quantize` and
|
|
12
|
+
`weld` keep), which can change between releases. The script prints a warning when the installed
|
|
13
|
+
versions differ from these. `ab-compare.mjs` needs none of this for PNG captures.
|
|
14
|
+
|
|
15
|
+
Nothing is installed system-wide, nothing needs sudo or admin rights, and nothing is added to the
|
|
16
|
+
game's `package.json`. To remove the tools, delete the folder. Set `BM_TOOLS` to use another folder.
|
|
17
|
+
|
|
18
|
+
**Installing is a download. Ask the creator first,** and name what will be fetched (the npm
|
|
19
|
+
packages above, and one KTX-Software release file from github.com/KhronosGroup) and where it goes.
|
|
20
|
+
If they decline, stop here. `--meshopt=off` still needs the `gltf/` tools, so there is no
|
|
21
|
+
tool-free path.
|
|
22
|
+
|
|
23
|
+
## Check what is already there
|
|
24
|
+
|
|
25
|
+
```sh
|
|
26
|
+
ls ~/.bitmagic/tools/gltf/node_modules/@gltf-transform/core && echo gltf ok
|
|
27
|
+
~/.bitmagic/tools/ktx/bin/ktx --version
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
On Windows PowerShell:
|
|
31
|
+
|
|
32
|
+
```powershell
|
|
33
|
+
Test-Path "$env:USERPROFILE\.bitmagic\tools\gltf\node_modules\@gltf-transform\core"
|
|
34
|
+
& "$env:USERPROFILE\.bitmagic\tools\ktx\bin\ktx.exe" --version
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
Without `ktx/`, every preset that writes or reads KTX2 fails with a clear message. That covers
|
|
38
|
+
`distant-prop`, `hero-prop` and `character`, and any source that already carries KTX2 textures.
|
|
39
|
+
The `level` and `code-driven` presets, and `webp`, `jpeg` or `png` slot specs, work without it.
|
|
40
|
+
|
|
41
|
+
## glTF-Transform (macOS, Linux, Windows)
|
|
42
|
+
|
|
43
|
+
Node 20 or later is enough; the game already has it. Install exactly these versions:
|
|
44
|
+
|
|
45
|
+
```sh
|
|
46
|
+
npm install --prefix ~/.bitmagic/tools/gltf --save-exact @gltf-transform/cli@4.5.0 @gltf-transform/core@4.5.0 @gltf-transform/extensions@4.5.0 @gltf-transform/functions@4.5.0 meshoptimizer@1.2.0 sharp@0.35.5 gltf-validator@2.0.0-dev.3.10
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
On Windows PowerShell, the same packages with `--prefix "$env:USERPROFILE\.bitmagic\tools\gltf"`.
|
|
50
|
+
To move an existing install onto these versions, run the same command again.
|
|
51
|
+
|
|
52
|
+
## KTX-Software on macOS: unpack the signed package
|
|
53
|
+
|
|
54
|
+
Do not use Homebrew. On a Mac whose Xcode licence has not been accepted it fails with "You have not
|
|
55
|
+
agreed to the Xcode license", and accepting it needs sudo. Unpack Khronos' notarized installer
|
|
56
|
+
instead. Use `arm64` on Apple silicon and `x86_64` on Intel.
|
|
57
|
+
|
|
58
|
+
```sh
|
|
59
|
+
V=4.4.2; A=arm64; T=$(mktemp -d)
|
|
60
|
+
curl -L -o "$T/ktx.pkg" "https://github.com/KhronosGroup/KTX-Software/releases/download/v$V/KTX-Software-$V-Darwin-$A.pkg"
|
|
61
|
+
pkgutil --check-signature "$T/ktx.pkg" # expect: Developer ID Installer: The Khronos Group, Inc.
|
|
62
|
+
pkgutil --expand-full "$T/ktx.pkg" "$T/x"
|
|
63
|
+
mkdir -p ~/.bitmagic/tools/ktx/bin ~/.bitmagic/tools/ktx/lib
|
|
64
|
+
cp "$T"/x/*-tools.pkg/Payload/usr/local/bin/* ~/.bitmagic/tools/ktx/bin/
|
|
65
|
+
cp -P "$T"/x/*-library.pkg/Payload/usr/local/lib/libktx* ~/.bitmagic/tools/ktx/lib/
|
|
66
|
+
rm -rf "$T"; ~/.bitmagic/tools/ktx/bin/ktx --version
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
The binaries look for `libktx` in `../lib`, so `bin/` and `lib/` must stay side by side.
|
|
70
|
+
|
|
71
|
+
## KTX-Software on Linux
|
|
72
|
+
|
|
73
|
+
```sh
|
|
74
|
+
V=4.4.2; A=x86_64; T=$(mktemp -d) # or A=arm64
|
|
75
|
+
curl -L -o "$T/ktx.tar.bz2" "https://github.com/KhronosGroup/KTX-Software/releases/download/v$V/KTX-Software-$V-Linux-$A.tar.bz2"
|
|
76
|
+
tar -xjf "$T/ktx.tar.bz2" -C "$T"
|
|
77
|
+
mkdir -p ~/.bitmagic/tools/ktx
|
|
78
|
+
cp -a "$(dirname "$(find "$T" -type f -name ktx -path '*/bin/*' | head -1)")" ~/.bitmagic/tools/ktx/bin
|
|
79
|
+
cp -a "$(dirname "$(find "$T" -name 'libktx.so*' | head -1)")" ~/.bitmagic/tools/ktx/lib
|
|
80
|
+
rm -rf "$T"; ~/.bitmagic/tools/ktx/bin/ktx --version
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
If `ktx` cannot find `libktx.so`, run `export LD_LIBRARY_PATH=~/.bitmagic/tools/ktx/lib` in the
|
|
84
|
+
same shell as the script.
|
|
85
|
+
|
|
86
|
+
## KTX-Software on Windows
|
|
87
|
+
|
|
88
|
+
Download `KTX-Software-4.4.2-Windows-x64.exe` (or `-arm64.exe`) from the v4.4.2 release. Run it and
|
|
89
|
+
choose `%USERPROFILE%\.bitmagic\tools\ktx` as the destination. If it asks for administrator rights,
|
|
90
|
+
cancel and extract it instead with 7-Zip:
|
|
91
|
+
`7z x KTX-Software-4.4.2-Windows-x64.exe -o%USERPROFILE%\.bitmagic\tools\ktx`. Either way,
|
|
92
|
+
`%USERPROFILE%\.bitmagic\tools\ktx\bin\ktx.exe --version` must print 4.4.x.
|
|
93
|
+
|
|
94
|
+
## The skill's shell lines on Windows PowerShell
|
|
95
|
+
|
|
96
|
+
SKILL.md's commands are POSIX shell. In PowerShell:
|
|
97
|
+
|
|
98
|
+
```powershell
|
|
99
|
+
$O = ".agents/skills/optimizing-assets/scripts/optimize-glb.mjs"; node $O --audit src/work/world.json
|
|
100
|
+
$L = "engine/engine/loaders/GltfLoaderSupport.ts"; Test-Path $L
|
|
101
|
+
'setMeshoptDecoder', 'expectGltfLoaderSupport' | ForEach-Object { "$_ $((Select-String -Path $L -Pattern $_).Count)" }
|
|
102
|
+
(Select-String -Path engine/engine/GLBVoxelizer.ts -Pattern packedFloats).Count
|
|
103
|
+
Get-ChildItem -Recurse src -Include *.ts,*.js | Select-String -Pattern 'setMeshoptDecoder|meshoptDecoder' -List
|
|
104
|
+
Get-ChildItem -Recurse src -Include *.ts,*.js | Select-String -Pattern 'gltfLoaderSupportsKtx2|KHR_texture_basisu' -List
|
|
105
|
+
Get-FileHash in.glb -Algorithm SHA256
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
## Write the shared README
|
|
109
|
+
|
|
110
|
+
After installing, write `~/.bitmagic/tools/README.md`, so the next agent on this machine knows what
|
|
111
|
+
the folder is. If one exists that recommends running the plain `gltf-transform` commands
|
|
112
|
+
(`meshopt`, `etc1s`, `resize`) on game assets, replace it with this one: their defaults quantize
|
|
113
|
+
skinned and named meshes and encode normal maps as ETC1S.
|
|
114
|
+
|
|
115
|
+
```markdown
|
|
116
|
+
# Asset compression tools (shared, local)
|
|
117
|
+
|
|
118
|
+
Installed by an agent following the Bitmagic `optimizing-assets` skill, with the creator's OK.
|
|
119
|
+
Used by every project on this machine. Nothing is installed system-wide; delete this folder to
|
|
120
|
+
remove them.
|
|
121
|
+
|
|
122
|
+
- `gltf/`: glTF-Transform 4.5.0 (npm, versions pinned) with meshoptimizer 1.2.0, sharp 0.35.5 and
|
|
123
|
+
gltf-validator.
|
|
124
|
+
- `ktx/`: KTX-Software 4.4.2 (Khronos), `bin/` and `lib/` side by side.
|
|
125
|
+
|
|
126
|
+
Use them through the skill's script, not the plain `gltf-transform` commands:
|
|
127
|
+
`node .agents/skills/optimizing-assets/scripts/optimize-glb.mjs --help` in a game project. It puts
|
|
128
|
+
`ktx/bin` on PATH itself.
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
The script's `--help` runs without the tools, so it is a safe first command on any machine.
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
# Verifying an optimized asset
|
|
2
|
+
|
|
3
|
+
An optimization is done when three separate verdicts agree: the numbers went down, the engine loads
|
|
4
|
+
the file, and a player cannot tell the difference at the distance they see it. Report all three
|
|
5
|
+
separately. A strong SSIM does not approve a look, and a pretty sheet does not prove a download got
|
|
6
|
+
smaller.
|
|
7
|
+
|
|
8
|
+
## Numbers
|
|
9
|
+
|
|
10
|
+
- **Per asset:** the script's report gives transfer bytes, triangles and estimated GPU memory for
|
|
11
|
+
textures and geometry. The GPU estimate assumes KTX2 at 1 byte per pixel (BC7 or ASTC) and other
|
|
12
|
+
images at 4 (RGBA8), plus mips. JPEG, PNG and WebP always upload uncompressed.
|
|
13
|
+
- **Whole game:** run `bitmagic dev`, open the browser's network panel with the cache disabled,
|
|
14
|
+
and load to the start screen, then into the first level or race. Record the total transferred
|
|
15
|
+
before and after, and name the few largest requests. Remember one-off costs: the first KTX2 file
|
|
16
|
+
pulls the Basis transcoder (~0.25 MB).
|
|
17
|
+
- **Phones:** if the game picks assets per device, measure the phone set with a phone viewport and
|
|
18
|
+
a mobile user agent. Squall Riders' phone download was 13.5 MiB against 21.2 on desktop.
|
|
19
|
+
- **Report MiB or MB, not both mixed.** SR reported MiB and TOR reported MB. 45.4 MB is 43.3 MiB.
|
|
20
|
+
Say what the "before" was: the original file, not an earlier optimized variant.
|
|
21
|
+
|
|
22
|
+
## Decode in the engine's own loader
|
|
23
|
+
|
|
24
|
+
- Play the scene that uses the asset. The console must show no `setMeshoptDecoder must be called`,
|
|
25
|
+
`setKTX2Loader must be called` or `createRenderPipeline` errors. The last one is WebGPU rejecting
|
|
26
|
+
a vertex format, which an interleaved or int16 layout causes. Load it cold as well (cache
|
|
27
|
+
disabled, then a reload with the cache on): a KTX2 file that arrives before WebGPU is ready is the
|
|
28
|
+
failure an older engine shows (SKILL.md step 5).
|
|
29
|
+
- The dev asset panel builds its loader with the vendored engine's own factory, so it is a decode
|
|
30
|
+
check only when that engine attaches the decoder. Under a game-side shim the panel fails on
|
|
31
|
+
meshopt files that the game loads fine. After a `glb` swap it previews `sourceGlbUrl`, which is
|
|
32
|
+
the original: open `/asset-preview?kind=glb&url=<the new URL, encoded>` on the dev view instead.
|
|
33
|
+
- Run `bitmagic verify` (its `--help` lists the platform and renderer choices). Check WebGPU, the
|
|
34
|
+
default, and the WebGL fallback when the game supports it: the interleaved-buffer crash is
|
|
35
|
+
WebGPU-only.
|
|
36
|
+
- Check what code reads, not only what renders: lamps and anchors at the right place, animations
|
|
37
|
+
playing, IK grips on the handles, liveries and masks recolouring, colliders unchanged. Collider
|
|
38
|
+
GLBs are separate rows or files: never optimize or redirect them with the visual mesh.
|
|
39
|
+
- If the game parses GLB bytes itself (the script names such files), exercise that path too:
|
|
40
|
+
TOR's collision meshes come from its own reader, not from GLTFLoader.
|
|
41
|
+
|
|
42
|
+
## Look: A/B in the real game, before the swap
|
|
43
|
+
|
|
44
|
+
1. **Show both from one build.** The candidate stays in its work directory, which `bitmagic dev`
|
|
45
|
+
serves at `/.bitmagic/optimize/<asset>/…`. Add the temporary preview module from
|
|
46
|
+
[runtime](runtime.md): the page loads the original normally and the candidate with
|
|
47
|
+
`?preview=1`. Nothing is uploaded, and world.json is untouched. Delete the module after the A/B.
|
|
48
|
+
- If `src/` already rewrites asset URLs (`grep -rlE "resolveURL|setURLModifier" src/`, a
|
|
49
|
+
per-device variant map), its rewrite runs before the preview's, so `?preview=1` shows the
|
|
50
|
+
game's variant, not the candidate. Capture both sides with that game's originals override
|
|
51
|
+
(Squall Riders: `&assets=orig`).
|
|
52
|
+
- Before the first capture, confirm that the console printed `[preview] swapped <url>` and that
|
|
53
|
+
the network panel fetched `/.bitmagic/optimize/<asset>/…`. Without both, the candidate never
|
|
54
|
+
loaded and the A/B compares the baseline with itself.
|
|
55
|
+
2. **Pin everything that is not the asset.** Use the same build, camera position and direction,
|
|
56
|
+
time of day and weather, the same viewport and devicePixelRatio, and `?quality=<rung>` so the
|
|
57
|
+
engine's auto-tuner cannot drop a tier mid-run on a busy machine. If the game has a debug hook
|
|
58
|
+
to freeze time or the simulation, use it. Particles and water move between two runs.
|
|
59
|
+
3. **Capture PNGs** with whatever drives the browser here (the agent's browser tool, or Playwright
|
|
60
|
+
when the project has it). For each view, also capture a **plate**: the same view with the asset
|
|
61
|
+
out of shot or hidden, when the game allows it. The plate gives the silhouette ratio and an SSIM
|
|
62
|
+
on the object's own pixels.
|
|
63
|
+
- **Characters: freeze the pose.** Capture both sides in the bind pose, or with the clip
|
|
64
|
+
stopped at one keyframe time, so SSIM scores textures and geometry, not where the animation
|
|
65
|
+
happened to be. Check the pose on its own: the world positions of a few bones or grip points
|
|
66
|
+
at two or three keyframe times must match. The default keeps every key (the croc rider's
|
|
67
|
+
bones matched to 0.1 mm); `--resample=on` moved its hands by up to 1.2 mm, which alone
|
|
68
|
+
dropped its face view at 1 m to 0.962 object SSIM.
|
|
69
|
+
4. **Measure the noise floor:** capture the original twice and compare the two. A difference smaller
|
|
70
|
+
than that is not caused by the asset. SR's floor was 0.967–0.970 SSIM on a chase camera and
|
|
71
|
+
0.986–0.989 on a still sky.
|
|
72
|
+
5. **Views:**
|
|
73
|
+
- the closest distance a player can reach, which is the one that fails;
|
|
74
|
+
- the usual gameplay distance;
|
|
75
|
+
- for props, three azimuths;
|
|
76
|
+
- for characters, the gameplay camera and the closest menu or select-screen view.
|
|
77
|
+
- If the game has golden and dark or storm lighting, capture both: baked AO and dropped ORM
|
|
78
|
+
maps show up in dark scenes.
|
|
79
|
+
6. **Viewports:** a desktop one (1920×1080 or 2560×1440) and a phone one (e.g. 1688×780
|
|
80
|
+
landscape or 390×844 portrait). Distant props also shrink on a phone, which hides more.
|
|
81
|
+
7. **Score each view** with the shipped script, on a crop around the asset:
|
|
82
|
+
|
|
83
|
+
```sh
|
|
84
|
+
A=.agents/skills/optimizing-assets/scripts/ab-compare.mjs
|
|
85
|
+
node $A orig.png orig-again.png --crop=640,300,700,480 # the noise floor
|
|
86
|
+
node $A orig.png new.png --crop=640,300,700,480 --plate=plate.png --sheet=ab-close.png
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
It prints luma SSIM on the crop (and on the object with `--plate`), the mean-luminance ratio,
|
|
90
|
+
the silhouette ratio (with `--plate`) and the share of changed pixels; `--json` for a table.
|
|
91
|
+
PNG needs nothing installed. Targets SR used, and the number each one reads:
|
|
92
|
+
- characters: `SSIM (luma, object)` 0.985 on desktop and 0.975 on phones, and `SSIM (luma,
|
|
93
|
+
crop)` 0.992 or more with no `--crop` (the whole frame);
|
|
94
|
+
- distant props: `luminance ratio` 0.96–1.10 in every lighting, and `silhouette ratio`
|
|
95
|
+
0.99–1.01 on the brightest capture, or on a mask capture (the asset unlit white on black,
|
|
96
|
+
`--threshold=128`). In a dark or storm capture, dark parts of the object barely clear the
|
|
97
|
+
plate, so the ratio counts shading: ab-compare prints `low contrast` there. SR's beacon read
|
|
98
|
+
0.89 and 1.01 in storm light with an outline that a mask capture put at 0.999;
|
|
99
|
+
- hero props: `SSIM (luma, object)` 0.984 or more at the closest approach.
|
|
100
|
+
8. **Look at the sheets** (original | candidate | difference ×4, at 100%) yourself for seams,
|
|
101
|
+
banding, blotches, facets or a changed silhouette. Then show them to the creator: the look is
|
|
102
|
+
their call, and "no visible difference at race distance" is the verdict to ask for.
|
|
103
|
+
9. **After the swap,** capture the same views again without the preview module and compare them
|
|
104
|
+
with the original captures. That proves the game now loads what you tested. Keep captures lean:
|
|
105
|
+
PNG crops, not full-resolution sets, and delete what you no longer need.
|
|
106
|
+
|
|
107
|
+
## When it fails
|
|
108
|
+
|
|
109
|
+
- For a character, first rule out the pose: score a frozen pose and compare bone positions (step 3
|
|
110
|
+
above). A pose shift fails the gate with every texture untouched.
|
|
111
|
+
- Change **one** variable and rebuild. Order of likelihood: a bigger texture edge on the slot that
|
|
112
|
+
shows the defect; a different format (UASTC instead of ETC1S for chroma blotches, JPEG 4:4:4
|
|
113
|
+
instead of lossy WebP for normal maps); a smaller simplification error; `--normals=exact`; the
|
|
114
|
+
next closer class.
|
|
115
|
+
- If the closer class passes, ship that. A smaller download that the creator notices is a worse
|
|
116
|
+
game, not an optimization.
|
|
117
|
+
- Record what failed and the numbers in the asset's work directory, so the next agent does not
|
|
118
|
+
repeat it.
|
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/*
|
|
3
|
+
* ab-compare.mjs: the optimizing-assets skill's A/B numbers. It compares two captures of the same
|
|
4
|
+
* view (the original asset and the candidate) and prints what verification.md gates on: luma SSIM,
|
|
5
|
+
* the mean-luminance ratio and, given a capture of the view without the asset, the silhouette
|
|
6
|
+
* ratio and SSIM on the object alone. It also writes a sheet to look at: original | candidate |
|
|
7
|
+
* difference x4, at 100%. It warns when the object barely stands out from the plate (a dark or
|
|
8
|
+
* storm capture), because the silhouette ratio then counts shading rather than the outline.
|
|
9
|
+
*
|
|
10
|
+
* node ab-compare.mjs <original.png> <candidate.png> [--crop=x,y,w,h] [--plate=<no-asset.png>]
|
|
11
|
+
* [--threshold=<1-255>] [--sheet=<out.png>] [--json]
|
|
12
|
+
*
|
|
13
|
+
* PNG needs nothing installed (Node's zlib), so it runs on any machine; other formats are read
|
|
14
|
+
* with sharp from the shared tools when they are there. It captures nothing: take the screenshots
|
|
15
|
+
* with the browser tooling you have, same camera, same viewport, quality tier pinned. Run it on two
|
|
16
|
+
* captures of the ORIGINAL first: that SSIM is the noise floor, and a candidate difference smaller
|
|
17
|
+
* than it is not caused by the asset.
|
|
18
|
+
*/
|
|
19
|
+
import fs from 'node:fs';
|
|
20
|
+
import os from 'node:os';
|
|
21
|
+
import path from 'node:path';
|
|
22
|
+
import zlib from 'node:zlib';
|
|
23
|
+
import { pathToFileURL } from 'node:url';
|
|
24
|
+
|
|
25
|
+
const HELP = `ab-compare.mjs: A/B numbers for two captures of the same view
|
|
26
|
+
|
|
27
|
+
node ab-compare.mjs <original.png> <candidate.png> [options]
|
|
28
|
+
|
|
29
|
+
--crop=x,y,w,h compare only this rectangle (the asset and a margin), in pixels
|
|
30
|
+
--plate=<png> the same view without the asset: adds the silhouette ratio and SSIM on the
|
|
31
|
+
object's pixels, and takes the luminance ratio over those pixels
|
|
32
|
+
--threshold=<n> how far (0-255, any channel) a pixel must differ from the plate to count as
|
|
33
|
+
the object (default 16)
|
|
34
|
+
--sheet=<png> write original | candidate | difference x4 of the crop, at 100%
|
|
35
|
+
--json print the numbers as JSON`;
|
|
36
|
+
|
|
37
|
+
const argv = process.argv.slice(2);
|
|
38
|
+
const flag = (name) => {
|
|
39
|
+
const hit = argv.find((a) => a === `--${name}` || a.startsWith(`--${name}=`));
|
|
40
|
+
return hit === undefined ? undefined : hit.includes('=') ? hit.slice(hit.indexOf('=') + 1) : true;
|
|
41
|
+
};
|
|
42
|
+
const die = (msg) => { console.error(`ab-compare: ${msg}`); process.exit(2); };
|
|
43
|
+
if (flag('help') || argv.length === 0) { console.log(HELP); process.exit(0); }
|
|
44
|
+
const [origFile, candFile] = argv.filter((a) => !a.startsWith('--'));
|
|
45
|
+
if (!origFile || !candFile) die('usage: ab-compare.mjs <original.png> <candidate.png> [--crop=x,y,w,h] [--plate=<png>] [--sheet=<png>] (--help)');
|
|
46
|
+
|
|
47
|
+
const orig = await readImage(origFile), cand = await readImage(candFile);
|
|
48
|
+
const plate = typeof flag('plate') === 'string' ? await readImage(flag('plate')) : null;
|
|
49
|
+
for (const [f, im] of [[candFile, cand], ...(plate ? [[flag('plate'), plate]] : [])]) {
|
|
50
|
+
if (im.w !== orig.w || im.h !== orig.h) die(`${f} is ${im.w}x${im.h} but ${origFile} is ${orig.w}x${orig.h}: capture both at the same viewport`);
|
|
51
|
+
}
|
|
52
|
+
const crop = typeof flag('crop') === 'string' ? flag('crop').split(',').map(Number) : [0, 0, orig.w, orig.h];
|
|
53
|
+
if (crop.length !== 4 || crop.some((v) => !Number.isInteger(v) || v < 0) || crop[2] < 1 || crop[3] < 1 || crop[0] + crop[2] > orig.w || crop[1] + crop[3] > orig.h) {
|
|
54
|
+
die(`--crop=${flag('crop')} must be x,y,w,h inside the ${orig.w}x${orig.h} capture`);
|
|
55
|
+
}
|
|
56
|
+
const threshold = flag('threshold') === undefined ? 16 : Number(flag('threshold'));
|
|
57
|
+
/**
|
|
58
|
+
* Object pixels that clear the threshold by little (twice it, at most 32 more) barely count. Above
|
|
59
|
+
* this share of them the silhouette ratio is flagged: SR's beacon at 40 m had 2-3% in golden light
|
|
60
|
+
* and 69-79% in storm light, where its silhouette ratio read 0.89 and 1.01 for an unchanged outline.
|
|
61
|
+
*/
|
|
62
|
+
const LOW_CONTRAST = 0.1;
|
|
63
|
+
const nearLimit = threshold + Math.min(threshold, 32);
|
|
64
|
+
if (!(threshold >= 1 && threshold <= 255)) die('--threshold must be 1-255');
|
|
65
|
+
|
|
66
|
+
const [cx, cy, cw, ch] = crop;
|
|
67
|
+
const luma = (im) => {
|
|
68
|
+
const y = new Float32Array(cw * ch);
|
|
69
|
+
for (let j = 0; j < ch; j++) for (let i = 0; i < cw; i++) {
|
|
70
|
+
const p = ((cy + j) * im.w + cx + i) * 4;
|
|
71
|
+
y[j * cw + i] = 0.2126 * im.px[p] + 0.7152 * im.px[p + 1] + 0.0722 * im.px[p + 2];
|
|
72
|
+
}
|
|
73
|
+
return y;
|
|
74
|
+
};
|
|
75
|
+
const maxDiff = (a, b, k) => { // largest channel difference at crop pixel k
|
|
76
|
+
const p = ((cy + Math.floor(k / cw)) * a.w + cx + (k % cw)) * 4;
|
|
77
|
+
return Math.max(Math.abs(a.px[p] - b.px[p]), Math.abs(a.px[p + 1] - b.px[p + 1]), Math.abs(a.px[p + 2] - b.px[p + 2]));
|
|
78
|
+
};
|
|
79
|
+
const n = cw * ch;
|
|
80
|
+
const [yo, yc] = [luma(orig), luma(cand)];
|
|
81
|
+
const map = ssimMap(yo, yc, cw, ch);
|
|
82
|
+
const border = cw >= 32 && ch >= 32 ? 5 : 0;
|
|
83
|
+
const inner = (k) => { const x = k % cw, y = Math.floor(k / cw); return x >= border && y >= border && x < cw - border && y < ch - border; };
|
|
84
|
+
const mean = (arr, pick) => { let s = 0, c = 0; for (let k = 0; k < n; k++) if (pick(k)) { s += arr[k]; c++; } return c ? s / c : NaN; };
|
|
85
|
+
|
|
86
|
+
const result = { original: origFile, candidate: candFile, crop: { x: cx, y: cy, w: cw, h: ch }, ssim: +mean(map, inner).toFixed(4) };
|
|
87
|
+
let object = () => true;
|
|
88
|
+
if (plate) {
|
|
89
|
+
const inO = new Uint8Array(n), inC = new Uint8Array(n);
|
|
90
|
+
for (let k = 0; k < n; k++) { inO[k] = maxDiff(orig, plate, k) > threshold ? 1 : 0; inC[k] = maxDiff(cand, plate, k) > threshold ? 1 : 0; }
|
|
91
|
+
const count = (m) => m.reduce((s, v) => s + v, 0);
|
|
92
|
+
if (!count(inO)) die(`no pixel of ${origFile} differs from the plate by more than ${threshold}: is the asset in the crop?`);
|
|
93
|
+
object = (k) => inO[k] || inC[k];
|
|
94
|
+
result.objectPixels = count(inO);
|
|
95
|
+
result.silhouetteRatio = +(count(inC) / count(inO)).toFixed(4);
|
|
96
|
+
result.objectSsim = +mean(map, (k) => object(k) && inner(k)).toFixed(4);
|
|
97
|
+
// In a dark capture, dark parts of the object clear the plate by little, so shading decides
|
|
98
|
+
// whether they count as object: the silhouette ratio then measures shading, not the outline.
|
|
99
|
+
let near = 0;
|
|
100
|
+
for (let k = 0; k < n; k++) if (inO[k] && maxDiff(orig, plate, k) <= nearLimit) near++;
|
|
101
|
+
result.lowContrast = +(near / result.objectPixels).toFixed(4);
|
|
102
|
+
if (result.lowContrast > LOW_CONTRAST) {
|
|
103
|
+
result.warning = `low contrast: ${(result.lowContrast * 100).toFixed(0)}% of the object's pixels differ from the plate by ${nearLimit} or less, `
|
|
104
|
+
+ 'so the silhouette ratio is unreliable here. Gate the silhouette on the brightest capture, or on a mask capture (unlit white on black)';
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
result.luminanceRatio = +(mean(yc, object) / mean(yo, object)).toFixed(4);
|
|
108
|
+
let changed = 0, worst = 0;
|
|
109
|
+
for (let k = 0; k < n; k++) { const d = maxDiff(orig, cand, k); if (d > 8) changed++; worst = Math.max(worst, d); }
|
|
110
|
+
result.changedPixels = +(changed / n).toFixed(4);
|
|
111
|
+
result.maxChannelDifference = worst;
|
|
112
|
+
if (typeof flag('sheet') === 'string') { writeSheet(flag('sheet')); result.sheet = flag('sheet'); }
|
|
113
|
+
|
|
114
|
+
if (flag('json')) console.log(JSON.stringify(result, null, 1));
|
|
115
|
+
else {
|
|
116
|
+
const line = (label, value, why = '') => console.log(` ${label.padEnd(22)} ${String(value).padEnd(10)} ${why}`);
|
|
117
|
+
console.log(`${origFile} vs ${candFile}, crop ${cx},${cy} ${cw}x${ch}`);
|
|
118
|
+
line('SSIM (luma, crop)', result.ssim);
|
|
119
|
+
if (plate) line('SSIM (luma, object)', result.objectSsim, `${result.objectPixels} object pixels`);
|
|
120
|
+
line('luminance ratio', result.luminanceRatio, plate ? 'candidate / original, object pixels' : 'candidate / original, whole crop');
|
|
121
|
+
if (plate) line('silhouette ratio', result.silhouetteRatio, 'candidate / original object pixels');
|
|
122
|
+
if (plate) line('low contrast', `${(result.lowContrast * 100).toFixed(1)}%`, `object pixels within ${nearLimit} of the plate`);
|
|
123
|
+
line('changed pixels', `${(result.changedPixels * 100).toFixed(1)}%`, `a channel off by more than 8; largest ${worst}`);
|
|
124
|
+
if (result.sheet) line('sheet', result.sheet, 'original | candidate | difference x4');
|
|
125
|
+
if (result.warning) console.log(` warning: ${result.warning}`);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// ---------------------------------------------------------------------------------------------- SSIM
|
|
129
|
+
/** Per-pixel SSIM of two luma planes: 11x11 Gaussian window, sigma 1.5, K1 0.01, K2 0.03 (Wang et al. 2004). */
|
|
130
|
+
function ssimMap(a, b, w, h) {
|
|
131
|
+
const g = Array.from({ length: 11 }, (_, i) => Math.exp(-((i - 5) ** 2) / (2 * 1.5 * 1.5)));
|
|
132
|
+
const gs = g.reduce((s, v) => s + v, 0);
|
|
133
|
+
const k = g.map((v) => v / gs);
|
|
134
|
+
const blur = (src) => {
|
|
135
|
+
const tmp = new Float32Array(w * h), dst = new Float32Array(w * h);
|
|
136
|
+
for (let y = 0; y < h; y++) for (let x = 0; x < w; x++) {
|
|
137
|
+
let s = 0; for (let i = -5; i <= 5; i++) s += k[i + 5] * src[y * w + Math.min(w - 1, Math.max(0, x + i))];
|
|
138
|
+
tmp[y * w + x] = s;
|
|
139
|
+
}
|
|
140
|
+
for (let y = 0; y < h; y++) for (let x = 0; x < w; x++) {
|
|
141
|
+
let s = 0; for (let i = -5; i <= 5; i++) s += k[i + 5] * tmp[Math.min(h - 1, Math.max(0, y + i)) * w + x];
|
|
142
|
+
dst[y * w + x] = s;
|
|
143
|
+
}
|
|
144
|
+
return dst;
|
|
145
|
+
};
|
|
146
|
+
const prod = (p, q) => p.map((v, i) => v * q[i]);
|
|
147
|
+
const [ma, mb] = [blur(a), blur(b)];
|
|
148
|
+
const [saa, sbb, sab] = [blur(prod(a, a)), blur(prod(b, b)), blur(prod(a, b))];
|
|
149
|
+
const C1 = (0.01 * 255) ** 2, C2 = (0.03 * 255) ** 2;
|
|
150
|
+
const out = new Float32Array(w * h);
|
|
151
|
+
for (let i = 0; i < w * h; i++) {
|
|
152
|
+
const va = saa[i] - ma[i] * ma[i], vb = sbb[i] - mb[i] * mb[i], cov = sab[i] - ma[i] * mb[i];
|
|
153
|
+
out[i] = ((2 * ma[i] * mb[i] + C1) * (2 * cov + C2)) / ((ma[i] * ma[i] + mb[i] * mb[i] + C1) * (va + vb + C2));
|
|
154
|
+
}
|
|
155
|
+
return out;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// ---------------------------------------------------------------------------------------------- images
|
|
159
|
+
async function readImage(file) {
|
|
160
|
+
if (!fs.existsSync(file)) die(`no ${file}`);
|
|
161
|
+
const bytes = fs.readFileSync(file);
|
|
162
|
+
if (bytes.length > 8 && bytes.readUInt32BE(0) === 0x89504e47) return readPng(bytes, file);
|
|
163
|
+
const sharpPath = path.join(process.env.BM_TOOLS ?? path.join(os.homedir(), '.bitmagic/tools'), 'gltf/node_modules/sharp/dist/index.mjs');
|
|
164
|
+
if (!fs.existsSync(sharpPath)) die(`${file} is not a PNG; capture PNG, or install the shared tools (references/tools.md) to read other formats`);
|
|
165
|
+
const sharp = (await import(pathToFileURL(sharpPath).href)).default;
|
|
166
|
+
const { data, info } = await sharp(bytes).ensureAlpha().raw().toBuffer({ resolveWithObject: true });
|
|
167
|
+
return { w: info.width, h: info.height, px: data };
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** A PNG decoded to RGBA8: every colour type and bit depth, not interlaced (browsers never write Adam7). */
|
|
171
|
+
function readPng(b, file) {
|
|
172
|
+
let o = 8, w = 0, h = 0, depth = 8, type = 6, plte = null, trns = null;
|
|
173
|
+
const idat = [];
|
|
174
|
+
while (o + 8 <= b.length) {
|
|
175
|
+
const len = b.readUInt32BE(o), kind = b.toString('latin1', o + 4, o + 8), data = b.subarray(o + 8, o + 8 + len);
|
|
176
|
+
o += 12 + len;
|
|
177
|
+
if (kind === 'IHDR') {
|
|
178
|
+
[w, h, depth, type] = [data.readUInt32BE(0), data.readUInt32BE(4), data[8], data[9]];
|
|
179
|
+
if (data[12]) die(`${file} is an interlaced PNG; save it without interlacing`);
|
|
180
|
+
} else if (kind === 'PLTE') plte = data;
|
|
181
|
+
else if (kind === 'tRNS') trns = data;
|
|
182
|
+
else if (kind === 'IDAT') idat.push(data);
|
|
183
|
+
else if (kind === 'IEND') break;
|
|
184
|
+
}
|
|
185
|
+
const channels = { 0: 1, 2: 3, 3: 1, 4: 2, 6: 4 }[type];
|
|
186
|
+
if (!channels || !w || !h) die(`${file}: unsupported or broken PNG`);
|
|
187
|
+
const stride = Math.ceil((w * channels * depth) / 8), bpp = Math.max(1, (channels * depth) >> 3);
|
|
188
|
+
const raw = zlib.inflateSync(Buffer.concat(idat));
|
|
189
|
+
const lines = Buffer.alloc(stride * h);
|
|
190
|
+
for (let y = 0; y < h; y++) {
|
|
191
|
+
const filter = raw[y * (stride + 1)], line = raw.subarray(y * (stride + 1) + 1, (y + 1) * (stride + 1));
|
|
192
|
+
const cur = lines.subarray(y * stride, (y + 1) * stride), prev = y ? lines.subarray((y - 1) * stride, y * stride) : null;
|
|
193
|
+
for (let x = 0; x < stride; x++) {
|
|
194
|
+
const a = x >= bpp ? cur[x - bpp] : 0, up = prev ? prev[x] : 0, c = prev && x >= bpp ? prev[x - bpp] : 0;
|
|
195
|
+
const pa = Math.abs(up - c), pb = Math.abs(a - c), pc = Math.abs(a + up - 2 * c);
|
|
196
|
+
const pred = [0, a, up, (a + up) >> 1, pa <= pb && pa <= pc ? a : pb <= pc ? up : c][filter];
|
|
197
|
+
if (pred === undefined) die(`${file}: bad PNG filter ${filter}`);
|
|
198
|
+
cur[x] = (line[x] + pred) & 255;
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
const sample = (y, i) => { // component i of a row, scaled to 8 bits
|
|
202
|
+
if (depth === 16) return lines[y * stride + i * 2];
|
|
203
|
+
if (depth === 8) return lines[y * stride + i];
|
|
204
|
+
const v = (lines[y * stride + ((i * depth) >> 3)] >> (8 - depth - ((i * depth) & 7))) & ((1 << depth) - 1);
|
|
205
|
+
return type === 3 ? v : Math.round((v * 255) / ((1 << depth) - 1));
|
|
206
|
+
};
|
|
207
|
+
const px = new Uint8Array(w * h * 4);
|
|
208
|
+
for (let y = 0; y < h; y++) for (let x = 0; x < w; x++) {
|
|
209
|
+
const p = (y * w + x) * 4, s = x * channels;
|
|
210
|
+
if (type === 3) {
|
|
211
|
+
const i = sample(y, s);
|
|
212
|
+
px.set([plte[i * 3], plte[i * 3 + 1], plte[i * 3 + 2], trns && i < trns.length ? trns[i] : 255], p);
|
|
213
|
+
} else if (type === 0 || type === 4) {
|
|
214
|
+
const g = sample(y, s);
|
|
215
|
+
px.set([g, g, g, type === 4 ? sample(y, s + 1) : 255], p);
|
|
216
|
+
} else {
|
|
217
|
+
px.set([sample(y, s), sample(y, s + 1), sample(y, s + 2), type === 6 ? sample(y, s + 3) : 255], p);
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
return { w, h, px };
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/** original | candidate | difference x4, over the crop, as an RGB PNG. */
|
|
224
|
+
function writeSheet(file) {
|
|
225
|
+
const gap = 4, W = cw * 3 + gap * 2, rgb = Buffer.alloc(W * ch * 3, 96);
|
|
226
|
+
for (let j = 0; j < ch; j++) for (let i = 0; i < cw; i++) {
|
|
227
|
+
const p = ((cy + j) * orig.w + cx + i) * 4, q = (j * W + i) * 3;
|
|
228
|
+
for (let c = 0; c < 3; c++) {
|
|
229
|
+
rgb[q + c] = orig.px[p + c];
|
|
230
|
+
rgb[q + (cw + gap) * 3 + c] = cand.px[p + c];
|
|
231
|
+
rgb[q + (cw + gap) * 6 + c] = Math.min(255, Math.abs(orig.px[p + c] - cand.px[p + c]) * 4);
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
const row = W * 3, raw = Buffer.alloc((row + 1) * ch);
|
|
235
|
+
for (let j = 0; j < ch; j++) { // filter 1 (Sub): each byte minus the one a pixel to its left
|
|
236
|
+
raw[j * (row + 1)] = 1;
|
|
237
|
+
for (let x = 0; x < row; x++) raw[j * (row + 1) + 1 + x] = (rgb[j * row + x] - (x >= 3 ? rgb[j * row + x - 3] : 0)) & 255;
|
|
238
|
+
}
|
|
239
|
+
const crcTable = Array.from({ length: 256 }, (_, c) => { for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1; return c >>> 0; });
|
|
240
|
+
const crc = (buf) => { let c = 0xffffffff; for (const v of buf) c = crcTable[(c ^ v) & 255] ^ (c >>> 8); return (c ^ 0xffffffff) >>> 0; };
|
|
241
|
+
const chunk = (kind, data) => {
|
|
242
|
+
const body = Buffer.concat([Buffer.from(kind, 'latin1'), data]), head = Buffer.alloc(4), tail = Buffer.alloc(4);
|
|
243
|
+
head.writeUInt32BE(data.length); tail.writeUInt32BE(crc(body));
|
|
244
|
+
return Buffer.concat([head, body, tail]);
|
|
245
|
+
};
|
|
246
|
+
const ihdr = Buffer.alloc(13);
|
|
247
|
+
ihdr.writeUInt32BE(W, 0); ihdr.writeUInt32BE(ch, 4); ihdr[8] = 8; ihdr[9] = 2;
|
|
248
|
+
fs.writeFileSync(file, Buffer.concat([Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]),
|
|
249
|
+
chunk('IHDR', ihdr), chunk('IDAT', zlib.deflateSync(raw, { level: 9 })), chunk('IEND', Buffer.alloc(0))]));
|
|
250
|
+
}
|