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

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_cama·f px too), which leaves the calibration with a single unknown.

Two implementation choices worth not rediscovering:

  • out["depth"] is inverse depth. The rasteriser returns invdepths, so metric depth is its reciprocal and a non-positive value means no geometry on that ray, not zero distance. D11's CoC formula wants 1/z anyway.
  • D10 renders bands, not rows. 1080 renders per view is not affordable. The seam between adjacent bands is d_target / n_bands px, and n_bands = clip(⌈d⌉, 16, 64) keeps that at or under one pixel. rolling_shutter_blur is 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.

Downloads last month

-

Downloads are not tracked for this model. How to track
Inference Providers NEW
This model isn't deployed by any Inference Provider. 🙋 Ask for provider support