YAML Metadata Warning:empty or missing yaml metadata in repo card
Check out the documentation for more information.
recon_variants_v3/— every v3 combination, on six objects- What got built
- Running it
-r 1: the one place v3 departs from v2 and the pipeline- What is not in the config: the DP control row
- The three families that cannot be a filter
- Intrinsic miscalibration is two thirds baked into the pixels
- Severity is a magnitude and never a direction
- D13's occluder is placed, not just sized
- Compression: what changed and what did not
- The paper Table-II sweeps are wired, not built
- The compress stage downloads weights, and four jobs at once is enough to fail
- Two traps inherited from v2, still live
- Nothing is reused from
recon_variants_v2/ - The compromise that carries over
- What got built
recon_variants_v3/ — every v3 combination, on six objects
The 85 combinations of configs/3dgs_qa_config_v3.json, built for the six objects of
configs/3dgs_qa_config_v3_obj.json, plus one control row this tree adds. One
combination is one model directory and one orbit.mp4; 86 × 6 = 516.
recon_variants_v3/
spec.py the 85 + DP -> work: input code, phase, parent
degrade.py the image-space degradations, and the intrinsic warp
build_input.py the input directories that are filters of the C0 export
proxy_render.py the input directories that are RENDERS of the baseline
compress_params.json the MesonGS rungs, as parameters <- edit this to retune
make_registry.py compress_params.json -> registry.json for the runner
run_row.sh one (object, combination), end to end
run_compress.sh the M1 stage, through the pipeline's own runner
write_config.py runtime + realized-parameter metadata for a trained model
label_compressed.py the same for a compressed one
make_jobs.py job lists, one phase at a time
array.sbatch the Slurm wrapper, one array task per row
inputs.sbatch CPU-only pre-build of the filter inputs
submit.sh one phase, fanned out over every GPU pool
status.py counts, gaps, achieved ratios, severity ladders
contact_sheet.py tile one view across families or severities, to eyeball it
inspect/ the contact sheets that were checked
input/<object>/<input code>/ derived inputs (72 codes per object)
models/<object>/<config id>/ point_cloud/ config.json orbit.mp4
Objects: CO3Dv2/apple/43_2134_6384, CO3Dv2/backpack/187_20213_38445,
CO3Dv2/backpack/498_71584_139228, OmniObject3D/china_006/standard,
uCO3D/61065-44079-22459, uCO3D/64949-51328-55540.
The object list resolves two things the config file leaves loose: OmniObject3D / china_006 is exported one level deeper (china_006/standard), and
uCO3D / 64949-51328-555403 has no export — 64949-51328-55540 does, and is the
sequence v2 used.
What got built
516/516 models, zero failed jobs. Every one has a point_cloud.ply, a non-empty
orbit.mp4 at 1888×1080, and a config.json carrying all 23 fields of
design_scope.required_runtime_metadata. Gaussian counts run 1,656 (a 50-iteration
row) to 3,494,382, median 373,010.
Verified rather than assumed, on the finished build:
| check | result |
|---|---|
| severity ladders monotonic, all families × 6 objects | yes, every controlled parameter |
| D2 motion blur, target 6/12/24/48 px | 5.999 / 12.0 / 23.99 / 47.95, spread ≤0.25 px |
| D10 rolling shutter, target 4/12/24/48 px | 4.0 / 12.0 / 23.99 / 47.97, spread ≤0.32 px |
| D11 defocus, target P90 CoC 2/5/10/20 px | exact, spread ~0 |
| D13 occluder, target image area 0.05/0.12/0.25/0.45 | 0.0500 / 0.1201 / 0.2500 / 0.4500 |
| D9 == composition of the six V2 single-factor rows | yes, component-for-component |
| pose / intrinsic ladders share direction | yes — exactly ×3, ×6 and ×3, ×7 |
| D11 depth-dependence | object blurs harder than background at every rung |
| compression, six-object medians | Z7 +0.7%, Z8 +4.3% of label |
The one thing left for a human is the field catastrophic_identity_preserved /
collapse_flag / collapse_reason, deliberately null on every model: v3's catastrophic
policy asks whether a V4 row is still recognisably its own distortion or an
undifferentiated collapse, and no measurement can answer that. The fields exist so the
review pass has somewhere to write. object_fraction_occluded is worth reading first —
at D13 V4 it reaches 1.000 on 21 of 36 corrupted views, which is the spec's image-area
target doing what it says rather than a placement bug.
Running it
submit.sh is the entry point. It builds the pending job list for a phase and submits
one array per GPU pool over the whole span:
# once, or the compress phase dies 60 ways — see the LPIPS section below
mkdir -p ~/.cache/torch/hub/checkpoints && cd ~/.cache/torch/hub/checkpoints
curl -sSL -o vgg.pth https://raw.githubusercontent.com/richzhang/PerceptualSimilarity/master/lpips/weights/v0.1/vgg.pth
curl -sSL -o vgg16-397923af.pth https://download.pytorch.org/models/vgg16-397923af.pth
cd <repo>
recon_variants_v3/make_jobs.py --inputs --pending > recon_variants_v3/jobs_inputs.tsv
sbatch --array=1-54%24 recon_variants_v3/inputs.sbatch recon_variants_v3/jobs_inputs.tsv
./recon_variants_v3/submit.sh base # 6 rows
./recon_variants_v3/submit.sh train-short # 24 rows, T0/T2/T3/T4
./recon_variants_v3/submit.sh train-long # 342 rows, T1
# after base lands, because these need the baseline rendered / trained:
./recon_variants_v3/submit.sh proxy # 84 rows
./recon_variants_v3/submit.sh compress # 60 rows
./recon_variants_v3/status.py # counts and gaps
./recon_variants_v3/status.py --ratios # target vs achieved, per compressed model
./recon_variants_v3/status.py --severity # realized ladders, with a monotonicity check
Three things about that are load-bearing on this cluster, not stylistic:
Inputs are pre-built on CPU (inputs.sbatch, no --gres). Several rows share one
input directory and run_row.sh flock-guards the build, so building inside the GPU jobs
means the second row to reach a directory idles a GPU for the length of someone else's
build.
Every pool gets an array over the whole span, never a row slice. --gres takes one
GPU type, and the pool is what gates throughput here — a slice means the fast pool
finishes its rows and exits while the slow one still has hundreds queued. Overlap is
safe because run_row.sh takes the row's lock with flock -n and exits 0 without it,
so a duplicate skips in seconds. a6000 is capped at %4: that is a hard association
limit for this user, and hala is the only a6000 node that mounts dataset_QA.
Phases are split by measured wall time, because backfill only places a job in a gap
it fits inside — a 2h request pends where a 50-minute one starts. submit.sh carries
one --time per phase: 20 min for the undertraining rows (50–1000 iterations), 50 min
for T1, 1h15 for proxy (a D10 build at V4 renders 48 bands × 36 views before its 30k
training starts), 25 min for compress. Measured: T1 runs ~26 it/s on A100-80G, about
20 min of training plus ~4 min of camera loading and rendering; a T0 row is 21 seconds
end to end; a proxy input build is 44 s to 3.7 min; a compress row is ~1.5 min.
Every step skips itself when its output exists, so --pending after a crash re-queues
exactly the gaps and a resubmitted array resumes rather than restarts.
base is the clean C0/T1 baseline. It is submitted on its own not because train
depends on it but because proxy and nine of the ten compress rows do, and putting it
behind 366 other array elements would idle both.
-r 1: the one place v3 departs from v2 and the pipeline
run_row.sh passes -r 1 to train.py. Nothing else in this repo does, and it is
not a cleanup — it is required by the way v3 defines severity.
With no -r, 3DGS silently downscales any image wider than 1600
(utils/camera_utils.py: "Encountered quite large input images (>1.6K pixels width),
rescaling to 1.6K"). These exports are 1888–1925 wide, so the default trains at
~1600 × 915: a 915-pixel short side, not the 1080 the exports were built at.
v3 is the first version whose severities are denominated in pixels:
σ = α·min(H,W), a motion displacement in px, a rolling-shutter displacement in px,
a circle-of-confusion radius in px. All of those are computed at the export's 1080
short side. Training at 1600 wide would deliver ≈0.84× of every one of them — and by
a factor that differs per object, since 1888/1600 = 1.180 but 1925/1600 = 1.203. The
pixel targets would not hold across the six objects, let alone match their labels.
Cost: ~1.4× the pixels, so a T1 row runs nearer 25 min than 18, and an orbit render
nearer 21 s than 15. --time in array.sbatch is sized for that.
Consequence worth stating plainly: v3 models are not resolution-comparable to v2's 216 or the pipeline's 960, which were all trained at 1600 wide.
What is not in the config: the DP control row
DP is not a v3 factor level. It is the control the config's own construction asks
for and does not define.
D2, D10 and D11 are specified as renders of a clean 3DGS proxy, and the config is
explicit that only the corrupted views become renders — the other half stay original
photographs (use_original_rgb_for_uncorrupted_views: true). A model trained on that
therefore sees two image domains, and the gap between a photograph and a render
is not the distortion being measured. DP is the same construction with the
degradation removed: the same selected 50% of views, replaced by clean proxy
renders. Subtracting it from D2/D10/D11 separates "this is what motion blur did" from
"this is what rendering half the views did".
It rides in the same 2-character slot as D0, so its config_id is still 34 chars,
and it is marked control: true everywhere so it never counts as one of the 85.
One extra model per object; 6 in total.
The three families that cannot be a filter
v3 redefines motion blur and adds rolling shutter and defocus, and all three are
physical processes during image formation, not post-filters of a finished
photograph. proxy_render.py renders the clean baseline instead:
| family | what is rendered | severity is |
|---|---|---|
| D2 motion blur | N poses along T0·Exp(t·ξ), t ∈ [-0.5, 0.5], averaged in linear RGB |
median endpoint reprojection displacement, 6/12/24/48 px |
| D10 rolling shutter | one pose per row band, τ(y) = y/(H-1) - 1/2, no averaging |
median top-to-bottom displacement, 4/12/24/48 px |
| D11 defocus | one pose, RGB and depth, then CoC-binned layered compositing | P90 circle-of-confusion radius, 2/5/10/20 px |
Severity is calibrated, not set. The twist direction is drawn once per
(object, view) and shared across V1–V4; its amplitude is then bisected until the
measured displacement hits the target within 0.5 px. The displacement is measured on
the input directory's own SfM points — the visible geometry of the scene, evaluable
in numpy, where doing it on renders would cost 20 extra renders per view. Rotation
and translation are mixed so they contribute comparable pixel motion (a rotation of
a rad moves a point ≈ a·f px; a translation of a·r_cam ≈ a·f px too), which
leaves the calibration with a single unknown.
Two implementation choices worth not rediscovering:
out["depth"]is inverse depth. The rasteriser returnsinvdepths, so metric depth is its reciprocal and a non-positive value means no geometry on that ray, not zero distance. D11's CoC formula wants1/zanyway.- D10 renders bands, not rows. 1080 renders per view is not affordable. The seam
between adjacent bands is
d_target / n_bandspx, andn_bands = clip(⌈d⌉, 16, 64)keeps that at or under one pixel.rolling_shutter_bluris false in the config, so a row takes its pixels from exactly one pose rather than averaging over the band.
Intrinsic miscalibration is two thirds baked into the pixels
v3's J factor perturbs focal length, principal point and radial k1. Only the first
of those can be handed to the trainer. The reference 3DGS reads params[0]/params[1]
into FoVx/FoVy, discards cx/cy entirely, and hard-asserts on any camera model
carrying distortion:
assert False, "Colmap camera model not handled: only undistorted datasets ..."
So a perturbed cx written to cameras.bin would be a silent no-op, and a k1
cannot be written at all. Both are realised in the image instead, which is the same
error seen from the other side — the trainer's centred pinhole is simply wrong:
| sub-perturbation | how | reaches the trainer? |
|---|---|---|
focal ε_f |
(1+ε_f)·fx, (1+ε_f)·fy in cameras.bin |
yes, as FoVx/FoVy |
principal point Δx·W, Δy·H |
image shifted by (-Δx·W, -Δy·H) |
as pixels |
radial Δk1 |
radial resample about the optical centre | as pixels |
Borders are replicated. These are full-frame captures whose object occupies about a
quarter of the frame height, so the replicated band is background only — but it is a
real edit and images_rewritten records it. One bias is drawn per object, not per
camera: the config asks for a calibration error "constant across that object's
selected views", and these exports give every image its own camera_id, so a
per-camera draw would contradict that.
Severity is a magnitude and never a direction
revision_notes_v4 requires V1–V4 of one family to reuse the same sampled direction,
mask and trajectory and to differ only in how far they go. Every draw in degrade.py
is therefore a unit draw whose seed deliberately excludes the severity code, and the
severity scales it afterwards. Measured, on apple, same view:
| family | V1 | V2 | V4 |
|---|---|---|---|
| D4 white balance | 6922 K | 7345 K | 9879 K |
D6 vignetting s |
0.079 | — | 0.574 (identical optical centre) |
This is also why v3 rebuilds all three pose levels rather than reusing the exporter's
C0R0P1I0Z0: that directory exists under the same name but was drawn at 0.005 ×
camera distance where v3's P1 is 0.002, and it shares no direction with P2/P3.
spec.RAW_OK lists the four directories that can be read in place — C0–C4 at P0.
The same seeding rule makes view selection family-independent: Q1 is the first quarter of the same permutation whose first half is Q2, and the selection seed names neither the family nor the severity. So every D at the same Q/K corrupts the same views, which is what lets one DP control stand in for all three proxy families.
D13's occluder is placed, not just sized
Spec item 16 defines the occluder's target area as a fraction of the image (0.05 / 0.12 / 0.25 / 0.45). On these captures the object is small — roughly a quarter of the frame height — so at V3/V4 the requested area is simply larger than the object itself, and where the ellipse goes decides whether the row is an occlusion artifact at all. Both obvious placements fail, measured:
| placement | object covered, V1 → V4 |
|---|---|
| centred on the object's mask centroid | 0.61 → 1.00 — a view-count reduction, not an occluder |
| anchored to an image edge | 0.00 at every severity — occludes nothing |
offset by U(0.5,1)·(r_occluder + r_object) |
0.23 → 0.51 → 0.83 → 1.00 |
The third keeps the distortion identifiable across the ladder, which
revision_notes_v5 requires of V4, while still hitting the image-area target to
within 0.1% (the ellipse is clipped by the frame, so apply bisects its scale).
object_fraction_occluded is recorded per view: at V4, 21 of 36 corrupted views are
still fully covered, and that is the spec's area target rather than a placement bug.
Compression: what changed and what did not
M1 is labelled MesonGS++ in v3. There is no separate ++ repository — the
mechanism the config describes (importance pruning, octree geometry coding, attribute
transform, selective SH VQ, group-wise mixed-precision quantisation, entropy coding)
is what pipeline/mesongs already does, and those six stages are exactly what its
five knobs control. M2 (LightGaussian) and M3 (CompGS-VQ) are retained by v3 as
method definitions only; no active combination uses them.
That has one pleasant consequence: v3 saves no checkpoints. MesonGS reads the ply.
LightGaussian's final .pth and Compact3D's midpoint one were the only reasons v2
wrote them, at ~2.4 GB per run.
Rungs, and how much each one is trusted. rung_calibration_status in every compressed
model's config.json records which of the three kinds it is, because the three are not
read the same way:
| rung | v3 label | status | achieved |
|---|---|---|---|
| Z3, Z5, Z6 | 32x, 64x, 96x | v2's settings unchanged | measured_over_6_objects_in_v2 — 31.0x, 62.2x, 105.7x median |
| Z5X1, Z5X2 | 64x geo-/appearance-heavy | v2's settings unchanged | 119.4x, 85.3x median |
| Z7, Z8 | 128x, 256x | calibrated on apple, 2026-08-17 | calibrated_on_apple_only |
| Z7X1, Z7X2 | 128x geo-/appearance-heavy | follow Z7's prune by design | profile_pair_not_aimed_at_label |
How Z7 and Z8 were calibrated, and the two things that made it non-obvious
A single-object probe has to be corrected before it can retune a cross-object label. apple is a low-running sequence: over v2's own models on this exact sequence, Z3 landed at 26.8x against a 31.0x six-object median (0.865), Z5 at 52.5x against 62.2x (0.844), Z6 at 87.9x against 105.7x (0.832). So apple runs at 0.847 ± 0.017 of the median, and a raw apple number understates the rung by ~15%.
Ratio is not proportional to 1/N. A two-point fit per rung shows a large fixed cost
that pruning cannot touch — bytes = 12.11·N + 201 KB for Z7 (codebook 1024, 5 bits)
and 11.77·N + 109 KB for Z8 (512, 4 bits). Z7X1's 32.7 bytes-per-point at only 9054
points is that fixed cost dominating. The first retune used a naive 1/N model and
under-delivered for exactly this reason: it predicted 1.19× from Z8's prune step and got
1.089×.
The probe path on apple, and what the full build then measured:
| rung | percent | apple probe | six-object median | vs label |
|---|---|---|---|---|
| Z7 | 0.84 → 0.855 | 98.6x → 104.4x | 128.9x | +0.7% |
| Z8 | 0.90 → 0.916 → 0.927 | 181.3x → 197.4x → 212.7x | 266.9x | +4.3% |
| Z7X1 | 0.855 | 217.0x | 277.9x | +117%, by design |
| Z7X2 | 0.855 | 148.9x | 175.8x | +37%, by design |
Both balanced rungs came in tighter than the rungs v2 measured over six objects (Z3 −2.5%, Z5 +1.4%, Z6 +12.5%).
The results that check the method
v2's five unchanged rungs reproduce. Z3 31.2x vs v2's 31.0, Z5 64.9 vs 62.2, Z6
108.0 vs 105.7, Z5X1 122.8 vs 119.4, Z5X2 87.7 vs 85.3 — all within 4%, even though v3
trains at native resolution and v2 did not. So -r 1 did not perturb compression
behaviour, and the v2 anchors these rungs were calibrated against still hold.
Spread dominates, and it puts a floor on all of this. Object-to-object spread runs
1.7× to 8.0× per rung — Z6 covers 81x to 218x, Z7X1 covers 141x to 1128x — which dwarfs
every median-to-label gap being tuned. That is exactly why a rung is defined by its
parameter set and not by a ratio target. Do not retune a rung whose median is already
within a few percent: the label is a nominal, and achieved_compression_ratio on each
model is the number that means something.
An undertrained parent shifts the rate. The Z7X0T3 row compresses a 300-iteration
parent, and the same rate_args give 109.6x there against 128.9x on the T1 parent — a
−14% offset. A barely-trained parent has far fewer Gaussians, so the fixed codebook and
header cost is a much larger share of the compressed size. That row's gap against its
label is the parent, not a miscalibration.
To retune: fit bytes = a·N + c from two measured points, solve for the N that hits the
target, then convert N back to a prune fraction through that rung's measured octree
retention factor (retention/(1−percent): 0.910 for Z7, 0.846 for Z8), not through
1−percent — the octree merges points into shared voxels, so retention always runs
below the nominal prune.
The X profiles are a controlled pair, not two rates: X1 moves only the octree depth (position precision) and X2 only the SH codebook and attribute width, with the prune fraction held at the rate's own value. So both keep the same number of primitives and differ only in which attribute is coarsened — measured, Z7 and Z7X2 came out at 34121 and 34080 Gaussians, which is the property holding to 0.1%.
That is also why they are not retuned to their label. Z7X1 measured 217x and Z7X2 149x against a 128x label, and v2 measured the same shape one rung down (Z5X1 119.4x, Z5X2 85.3x against 64x): coarsening one attribute hard compresses more than spreading the same budget across both. Pulling them onto 128x would mean giving them different prune fractions, which is the one thing that would stop them being a pair. Z7X1 sheds primitives too (9054, retention 0.035) despite holding Z7's prune, because a depth-8 octree merges many splats into shared voxels — that is the geometry-heavy profile doing its job, not a rate change.
Those rungs are merged into a private registry.json rather than the pipeline's,
because the pipeline's mesongs Z labels mean 8x–16x for 960 existing models and must
keep meaning that. lib.sh honours a REGISTRY= override; unset — which is every
pipeline job — is unchanged.
The paper Table-II sweeps are wired, not built
Spec items 22–25 (LightGS / C3dGS / Compact-3DGS / CompGS, four colour × four
geometry levels each) live in compress_params.json under paper_table_ii, and
make_registry.py --table-ii emits them as rungs. No combination references them,
because none of the 85 does — v3's revision notes keep those methods as paper
references. Building them is 64 models per object-set and also needs training-side
work these 85 do not: LightGaussian reads the final .pth and Compact-3DGS the
midpoint one, and this tree saves neither.
The compress stage downloads weights, and four jobs at once is enough to fail
MesonGS's evaluate_test calls LPIPS, and this fork's lpipsPyTorch fetches its
weights from the network at runtime via torch.hub.load_state_dict_from_url. With
an empty torch cache, four simultaneous compress jobs were enough to get
urllib.error.HTTPError: HTTP Error 429: Too Many Requests
and all four died before writing anything. It fails early — evaluate_test runs at
iteration 0, where it also calibrates the quantisers — so the whole ~9 minutes is
wasted, and 60 concurrent jobs would fail 60 ways.
Pre-cache both files once and every job finds them locally:
mkdir -p ~/.cache/torch/hub/checkpoints && cd ~/.cache/torch/hub/checkpoints
curl -sSL -o vgg.pth \
https://raw.githubusercontent.com/richzhang/PerceptualSimilarity/master/lpips/weights/v0.1/vgg.pth
curl -sSL -o vgg16-397923af.pth https://download.pytorch.org/models/vgg16-397923af.pth
Two files, because LPIPS needs its own linear weights (7 KB) and the torchvision
VGG16 backbone (528 MB). ~/.cache is on the shared home filesystem, so caching once
on the login node covers every compute node.
Two traps inherited from v2, still live
Viewer port. Every 3DGS fork calls network_gui.init() and binds --port
(default 6009) with a bare listener.bind(), so the second run on a node dies with
OSError: [Errno 98] Address already in use before iteration 1. run_row.sh asks the
kernel for a free port, as pipeline/common/train_generator.sh does.
Output directory names. write_metrics.py parses a model directory's basename as
a 10- or 16-char combination code, and a v3 config_id is 34 (v2's was 32 — the J
factor is new). The compress stage therefore stages into a legacy-named directory and
moves the result into the v3-named one afterwards.
Nothing is reused from recon_variants_v2/
Six of v3's rows have a v2 twin with identical inputs and iterations — the C0–C6 baselines at T1. They are retrained here anyway, for the reason v2 gave for the same choice: this tree is a comparison between variants, and a row that came from a different run introduces a second difference into a comparison meant to isolate one. Everything else genuinely differs — the undertraining ladder moved from 300/1000/3000/7000 to 50/100/300/1000, all three pose sigmas changed, the J factor is new, and the corruption family was rewritten from three levels to sixteen.
The compromise that carries over
The exporter restricted each subset's I0 cloud to the tracks its own views observed.
The exported points3D.bin carries no track data (every observation list is empty)
and the SfM working directories were deleted on 2026-08-03, so that restriction cannot
be recomputed — a geometric re-projection test does not reproduce it either (on
apple it keeps 10846 points where C4 kept 6766). C5 and C6 therefore inherit C4's
cloud: the same 180° arc, and a superset of what the rule would have given them.
Recorded per directory as point_init_source.
A corrupted view is written back as JPEG quality 95, so it carries one more generation of JPEG than an untouched view — untouched views are hardlinked, not copied. D15 is the exception: it is written as the exact bytes its own quality produced, because recompressing the family whose factor is the quality would be meaningless.