FCP v6

2026-10-29 04:03 · worktree ~/code/fcp_devel_v6, branch v6 (decision 27; lobe parts in progress) · scripts in fcp_2910_v6/scripts, the shared helpers (objects, measures, renders) in fcp_2810_partition/scripts · the partition study that led here: fcp_2810_partition/report

v6 as committed. (1) Point clouds drop their short, small tracks (at most 3 layers, under 5 % of the largest slice): the slicers' blobs on noisy and sparse clouds; a mesh keeps them. (2) encode(atoms="auto") stops at twice the noise level read at the top height degrees of the long parts (the atoms are orthonormal: noise spreads evenly over them; median |score| / 0.6745); a prefix like any other. The format and the decoder are v5.31's; meshes encode to the same bytes. 287 tests pass (6 new).

v5.31 against v6

scripts/eval_v6.py: the ten meshes and per object four clouds of fcp_2710_tests/pointclouds; reference the clean mesh; ratios: geometric mean over the objects; green / red: 2 % better / worse (normals 5 %); "auto": the atoms="auto" descriptor against v5.31's 6144.

inputparts
v5.31 → v6
Chamfer %
v5.31 256
× v6
256
Chamfer %
v5.31 1024
× v6
1024
Chamfer %
v5.31 6144
× v6
6144
>30° ×
256
>30° ×
1024
>30° ×
6144
auto: bytes ×
(vs 6144)
auto: Chamfer ×
(vs 6144)
mesh134 → 1340.3581.0000.2321.0000.1991.0001.0001.0001.0000.751.003
cloud 10k135 → 1080.4380.9810.2580.9980.2121.0070.9910.9950.9990.651.007
sparse cloud 2048184 → 1430.8030.9780.5170.9990.4880.9970.9840.9890.9960.561.006
noisy mesh, 10k samples190 → 1460.6230.9870.5410.9950.5380.9971.0120.9970.9970.650.997
per-point noise 0.6 %, 10k279 → 2130.9980.9450.4530.9940.3801.0031.0020.9890.9970.701.006

The rate-distortion partition (prototype)

One rule instead of tolerances. With orthonormal atoms, keeping those above a level lam is the best descriptor of its size; its error plus lam² per atom kept is the sum over atoms of min(score², lam²). The partition is chosen by the same rule: each track cut into the pieces of least total cost (every piece's rings projected on its own height basis, plus the energy the basis cannot hold, plus lam² × 0.5 for a part's header), by dynamic programming over cut positions. lam is the level of the T0-th atom at the reference budget T0 (iterated with the partition), so the partition depends on the data and one constant, and prefixes stay exact. It replaces FIT_TOL, FIT_GAIN, STEP_SLACK, FIT_MIN and the clouds' spacing tolerance; the same rule on meshes and clouds. A level from the noise alone (2 σ or the universal threshold) is optimal for large budgets and cuts 2-3 times more parts, worse at 256 atoms. scripts/rd_split.py (swapped in for the package's cut rule); CPU NumPy, 0.2-1 s per object, not optimized. Tracks are not dropped by it (a ring's energy includes its position, which is not the geometric cost of leaving it out): v6's short-track filter stays.

Speed (1024 atoms, best of 3; projections once per track in float32, each level only re-sums): mug / elephant / dino / horse / dress / shoe encode on CUDA in 62 / 77 / 69 / 68 / 171 / 89 ms with the package's rule, 187 / 110 / 142 / 172 / 255 / 153 ms with rdT256 (CPU 117-586 → 199-670 ms); the same parts and values as the first version.

On the 251 scans (not used to develop it): v6's rule against rdT256

scripts/scans_rd.py: the bench's scans, loaded and repaired as api/bench/scans.py does, each scored against itself; ratio rdT256 / v6.

251 scans with both (of 251; 0 did not encode or load).

atomsChamfer % median
v6
rdT256ratio
(geo. mean)
scans
better
worst
ratio
>30° %
median v6
rdT256ratio
(geo. mean)
2560.3600.3080.86398 %1.0127.719.70.647
10240.2460.2360.95393 %1.0314.912.90.792
61440.2040.2071.00835 %1.166.46.71.022

Parts 4778 → 4939; encode (6144 atoms, CUDA) median 71 → 170 ms.

Noise and density (ten objects): v6's rule against rdT256

The cloud kinds of fcp_2710_tests/pointclouds at 10k points (per-point noise encoded with noise=); ratio rdT256 / v6.

cloud (10k)parts
v6 → rdT256
Chamfer ×
256
Chamfer ×
1024
Chamfer ×
6144
objects
better 256
objects
better 1024
objects
better 6144
>30° ×
256
>30° ×
1024
>30° ×
6144
per-point noise 0.12 %112 → 1620.8830.9440.996100 %100 %70 %0.6260.7880.859
per-point noise 0.6 %213 → 2710.7950.9251.003100 %100 %50 %0.8160.7580.893
per-point noise 1.2 %165 → 2090.9010.9871.000100 %50 %70 %0.8810.8580.959
noisy mesh samples 0.6 %146 → 1940.9430.9950.99990 %70 %50 %0.8530.8390.985
noisy mesh samples 1 %142 → 1940.9091.0020.99990 %50 %70 %0.7930.7930.918
density gradient151 → 1910.8850.9511.007100 %90 %70 %0.6620.7990.903

Is more atoms always better? (monotonicity)

scripts/monotonicity.py: each descriptor encoded once at 6144 atoms, decoded at its prefixes 16 ... 6144; a step "worse" when the error grows by more than 0.5 % from the previous count; ten objects, 9 steps each.

The medians fall with every step for both rules, on meshes, clean and noisy clouds; per object, rate-distortion has fewer or as many increases (meshes, Chamfer: none against one). The increases are at small budgets and on a few objects: the open box, the GSO bowl, the battery, all with cavities or thin walls. Two causes, neither the cut rule: (1) the angle error is one-sided (decoded surface to the nearest true normal), so a decode that draws less of the object can score better normals; the open box at 128 atoms has good normals (11°) and poor Chamfer (4.2 %), at 256 it is drawn more fully (Chamfer 2.1 %) with noisy walls (35°). (2) On the noisy clouds the slicer leaves thin walls as one ring around both faces (no hole part for the cavity over most of the height); its first harmonics are a filled ellipse, so the cavity fills as atoms arrive before later atoms carve it out (the bowl from 1.6 to 4.0 % at 32 → 64 atoms; no single part's atoms cause it). That is the point slicers' thin-wall problem (OPEN.md 9). Tested and rejected: parts drawn without both mean coordinates (dropping them is worse everywhere).
inputcut ruleChamfer: steps worseworstangle error: steps worseworst
meshescut rule of v6 (tracked-fit)1 of 90+8 %4 of 90+72 %
meshesrate-distortion (rdT256)0 of 90+0 %4 of 90+8 %
clean clouds, 10k pointscut rule of v6 (tracked-fit)2 of 90+10 %12 of 90+50 %
clean clouds, 10k pointsrate-distortion (rdT256)1 of 90+36 %13 of 90+17 %
clouds with 0.6 % per-point noise, 10kcut rule of v6 (tracked-fit)9 of 90+150 %18 of 90+234 %
clouds with 0.6 % per-point noise, 10krate-distortion (rdT256)9 of 90+80 %11 of 90+225 %
error against atoms, meshes error against atoms, clean clouds, 10k points error against atoms, clouds with 0.6 % per-point noise, 10k

v6 + rdT256 against v5.31

inputparts
v5.31 → v6
Chamfer %
v5.31 256
× v6
256
Chamfer %
v5.31 1024
× v6
1024
Chamfer %
v5.31 6144
× v6
6144
>30° ×
256
>30° ×
1024
>30° ×
6144
auto: bytes ×
(vs 6144)
auto: Chamfer ×
(vs 6144)
mesh134 → 1560.3580.9240.2320.9690.1991.0020.5860.8851.033
cloud 10k135 → 1610.4380.8540.2580.9410.2120.9990.5990.7860.880
sparse cloud 2048184 → 2140.8030.8430.5170.9740.4881.0000.7770.8330.897
noisy mesh, 10k samples190 → 1940.6230.9310.5410.9910.5380.9960.8630.8360.982
per-point noise 0.6 %, 10k279 → 2710.9980.7510.4530.9190.3801.0050.8170.7490.891

v6 + rdT1024 against v5.31

inputparts
v5.31 → v6
Chamfer %
v5.31 256
× v6
256
Chamfer %
v5.31 1024
× v6
1024
Chamfer %
v5.31 6144
× v6
6144
>30° ×
256
>30° ×
1024
>30° ×
6144
auto: bytes ×
(vs 6144)
auto: Chamfer ×
(vs 6144)
mesh134 → 2310.3580.9950.2320.9590.1990.9980.7590.9020.988
cloud 10k135 → 2230.4380.9090.2580.9290.2120.9940.6680.7870.855
sparse cloud 2048184 → 3040.8030.8930.5170.9560.4880.9880.8520.8280.877
noisy mesh, 10k samples190 → 2640.6230.9540.5410.9840.5380.9970.9130.7990.951
per-point noise 0.6 %, 10k279 → 3430.9980.8040.4530.9000.3800.9970.8210.6900.850

The short-track filter: settings

scripts/drop_short.py: ratios to the package without it. v6 takes 3 layers / 5 %: 4 / 10 % loses real detail at 6144, 2 / 5 % keeps more blobs.

clouddrop (layers, area)partsChamfer ×
256
Chamfer ×
1024
Chamfer ×
6144
cloud 10kdrop3-5%135 → 1070.9810.9981.007
cloud 10kdrop2-5%135 → 1180.9860.9971.002
cloud 10kdrop4-10%135 → 1030.9791.0011.013
sparse cloud 2048drop3-5%184 → 1420.9760.9990.998
sparse cloud 2048drop2-5%184 → 1530.9810.9990.998
sparse cloud 2048drop4-10%184 → 1270.9660.9961.000
noisy mesh, 10k samplesdrop3-5%190 → 1420.9870.9960.997
noisy mesh, 10k samplesdrop2-5%190 → 1550.9880.9970.999
noisy mesh, 10k samplesdrop4-10%190 → 1210.9780.9940.994
per-point noise 0.6 %, 10kdrop3-5%279 → 2060.9400.9931.007
per-point noise 0.6 %, 10kdrop2-5%279 → 2340.9660.9971.002
per-point noise 0.6 %, 10kdrop4-10%279 → 1910.9210.9911.011

The noise floor: where to stop

scripts/noise_floor.py: the descriptor cut where the atoms fall under c times the level, against the 6144 prefix. It does not denoise (the slicers' noise is smooth along the height and sits in the low degrees too); it is a stopping point: v6 takes c = 2.

inputstop atatoms (mean)Chamfer ×
(vs 6144)
>30° ×
(vs 6144)
mesh2 × the noise level37041.0031.025
mesh3 × the noise level31091.0091.034
mesh4 × the noise level27361.0151.098
cloud 10k2 × the noise level40341.0021.001
cloud 10k3 × the noise level34161.0061.012
cloud 10k4 × the noise level30261.0111.034
sparse cloud 20482 × the noise level38621.0081.021
sparse cloud 20483 × the noise level31951.0201.053
sparse cloud 20484 × the noise level27631.0311.087
noisy mesh, 10k samples2 × the noise level43541.0001.085
noisy mesh, 10k samples3 × the noise level37321.0011.117
noisy mesh, 10k samples4 × the noise level32761.0041.163
per-point noise 0.6 %, 10k2 × the noise level45681.0031.017
per-point noise 0.6 %, 10k3 × the noise level42151.0061.024
per-point noise 0.6 %, 10k4 × the noise level39811.0101.043

Lobe parts (branch v6-lobes, experimental)

What joins a long part on one side (a handle's root, a limb) is peeled into a part that overlaps the body, so the body's part goes on through it; the decoder takes the union (the distance at a layer to the region's boundary only: the same bits on NumPy, numba and CUDA). fcp/_lobes.py: a robust fit along the height locates lobes (each round keeps at least the better half of the layers); each ring is cut on its own outline, walked off the lobe's tip and down to the fillets; the body is closed across the gap by its own wall (the ring either side of the gap in polar form, a cubic in angle, matched to both ends); a peeled run is extended along the moving cut until nothing sticks out; a run goes to the track it touches, and is not peeled when it touches none (free lobe parts added 6 parts on the elephant and the horse for nothing). Results (ten meshes, on against off): the mug's body is one part through the lower handle root, and the mug is better at every budget (256 atoms: Chamfer 0.264 against 0.282, normals over 30° 7.7 % against 12.7 %; with its upper root a part of its own, 0.246 and 4.0 %); the dino slightly better; the dress, shoe and horse worse at 256 / 1024 atoms; overall Chamfer 1.019 / 1.014 / 0.998 times, normals 0.97 / 1.02 / 1.00 at 256 / 1024 / 6144. The boundary flags cost 100-500 ms per decode on the host (the CUDA decode is 30-40 ms without them). Not in v6. 287 tests pass on the branch. Why the dress, shoe and horse lose. Their lobes are where a leg or an arm joins the body (the dress at 41-49: a leg's track ends at 40 and its remnant is peeled and given back to it); the body is cut at those joints anyway, so the peel only adds an overlapping part. The decisive test (second commit on the branch): keep a run only when the cut rule then makes fewer parts of the body and of the track its lobe joins. No run on the ten meshes passes: mug 6 → 7, dress 11 → 13 and 10 → 10, horse 8 → 9 and 4 → 4, shoe 12 → 12, dino 3 → 3, 6 → 6, 7 → 7 (the other runs touch no track). Peeling a lobe never makes the body's parts longer; the mug's gain at small budgets came from where the cuts fall and one more part. Conclusion: lobe parts do not make parts bigger; left on the branch as a record. Speed (1024 atoms, best of 3): v6 encodes the mug / dino / shoe in 62 / 75 / 91 ms on CUDA (118 / 129 / 168 on the CPU) and decodes in 13 / 16 / 17 ms (45 / 56 / 58); with lobes, encode about 1 s and decode 252 / 71 / 174 ms (host NumPy: the robust fit, the cuts, the boundary flags on every decode).

scripts/lobes_check.py: the v6 package with lobes on and off.

mesh

objectparts
off → on
Chamfer %
off 256
on 256Chamfer %
off 1024
on 1024Chamfer %
off 6144
on 6144>30° %
off 256
on 256>30° %
off 1024
on 1024>30° %
off 6144
on 6144
battery_stepped4 → 40.2590.2590.2590.2590.2590.2593.03.03.03.03.03.0
elephant27 → 270.7850.7850.3720.3720.2330.23359.659.642.442.416.516.5
bowl_of5 → 50.2090.2090.1980.1980.1970.1974.84.80.30.30.40.4
bowl_gso6 → 60.2180.2180.1990.1990.1980.1987.57.51.11.11.01.0
mug8 → 90.2820.2640.2250.2240.2210.22112.77.71.01.00.60.6
dino25 → 250.4040.3990.1940.1950.1340.13437.435.618.018.47.37.0
open_box2 → 20.2570.2570.2570.2570.2570.2571.01.01.01.01.11.1
dress17 → 190.3520.4200.1850.2090.1550.15342.748.931.534.826.826.1
shoe22 → 220.4400.4620.2510.2530.1900.19033.435.526.126.57.68.0
horse18 → 190.3740.3890.1850.1870.1420.14227.829.011.011.22.92.7
lobes_mesh_dino_lobes_a6144 lobes_mesh_dino_off_a6144 lobes_mesh_dress_lobes_a6144 lobes_mesh_dress_off_a6144 lobes_mesh_elephant_lobes_a6144 lobes_mesh_elephant_off_a6144 lobes_mesh_horse_lobes_a6144 lobes_mesh_horse_off_a6144 lobes_mesh_mug_lobes_a6144 lobes_mesh_mug_off_a6144 lobes_mesh_shoe_lobes_a6144 lobes_mesh_shoe_off_a6144

Tried and left out

Offset patches over an angle range (an offset along a smooth base's normal over a height and an angle range, Chebyshev × the height basis): a protrusion's sides run along the normal, so the offset is a step in angle and a polynomial rings (the mug's handle root: 0.3-2.6 layer heights off, against 0.15-0.38 for the parts). A cut tolerance raised with the noise: no fewer parts and a worse Chamfer (the clouds' extra parts are tracks, not cuts). Spike cut placement: meshes 0.96 times the Chamfer at 256 atoms, not validated on clouds. The partition rules of the study before (higher degree, cut tests in shape, minimal partitions): fcp_2810_partition.