Skip to content

Commit ac81aae

Browse files
committed
Name artifacts for their stage; rename the manifest (#17)
1 parent 78f52c6 commit ac81aae

32 files changed

Lines changed: 3200 additions & 504 deletions

.gitlab-ci.yml

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,30 @@ test:python:
5252
- echo Running the test suite against the installed dependencies
5353
- python -m unittest discover -s tests -v
5454

55+
# The only job that runs the suite where the binaries are. Both jobs above run
56+
# against a /usr/local with no OpenMVG in it, so anything reaching the built-in
57+
# default prefix passes for the wrong reason -- three tests did, asserting
58+
# ToolNotFound for real tool names and failing inside the image while CI stayed
59+
# green. The repo checkout shadows the image's editable install, so this tests the
60+
# MR's code against the real toolchain.
61+
#
62+
# It is also the only place test_openmvs.TestSurfaceMatchesTheInstalledBinaries
63+
# runs: OpenMVS is fetched as a tarball with no source tree to transcribe from, so
64+
# the binaries' own --help is the authority that ADR 0005's "every flag reachable"
65+
# is checked against, and that check skips itself wherever they are missing.
66+
test:in-image:
67+
stage: test
68+
image: ghcr.io/educelab/pgs-recon:edge
69+
rules:
70+
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
71+
- if: $CI_COMMIT_BRANCH
72+
script:
73+
# Assert the layout this job exists for, so a moved prefix fails here rather
74+
# than quietly making the job meaningless again.
75+
- test -x /usr/local/bin/openMVG_main_SfM
76+
- test -x /usr/local/bin/OpenMVS/ReconstructMesh
77+
- python -m unittest discover -s tests -v
78+
5579
# Full C++ dependency build across the same base-image / CUDA matrix as the
5680
# GitHub Docker images. Expensive, so gated to MRs that actually touch the
5781
# dependencies, Dockerfile, or CI config. CUDA variants compile the .cu kernels

CLAUDE.md

Lines changed: 29 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -35,13 +35,20 @@ The only tests are `tests/`: `python3 -m unittest discover -s tests`. They cover
3535
the staged-run planner (`test_stages.py`, pure logic, no filesystem), the stage
3636
records `StageTracker` writes (`test_tracker.py`, a temp dir but no binaries),
3737
artifact naming (`test_layout.py`), binary resolution and the run/record
38-
chokepoint (`test_toolchain.py`), `run_command`'s exit statuses
38+
chokepoint (`test_toolchain.py`), the MVS and MVG wrappers' flag surfaces
39+
(`test_openmvs.py` and `test_openmvg.py`, whose `SURFACES` tables are what make
40+
ADR 0005's "every flag reachable" enforceable — the MVG one maps each keyword
41+
argument to its argv flag, because OpenMVG's spellings are per-binary and cannot
42+
be derived), `run_command`'s exit statuses
3943
(`test_utility.py`), and `pgs-recon` end to end against a prefix of fake binaries
4044
plus its `--dry-run` (`test_pipeline.py`, `test_reconstruct.py`, which skip
4145
themselves when `configargparse`/`sfm_utils`/`exiftool` are missing). All
4246
stdlib-only, so they run anywhere in seconds — no reconstruction math is
4347
exercised, only what the pipeline asks the binaries to do. CI
44-
(`.gitlab-ci.yml`) runs that suite, then verifies that
48+
(`.gitlab-ci.yml`) runs that suite three ways — bare Python, with the Python deps
49+
installed, and inside `ghcr.io/educelab/pgs-recon:edge` (`test:in-image`, the only
50+
one where the binaries exist, so tests reaching the default prefix cannot pass for
51+
the wrong reason) — then verifies that
4552
dependencies build and the package pip-installs on Ubuntu 22.04 / 24.04. The
4653
canonical GitHub Actions workflow (`.github/workflows/build_docker.yml`)
4754
builds/publishes Docker images.
@@ -107,16 +114,18 @@ them and no `metadata` argument:
107114
that may legitimately be absent. Both are recorded paths read back from the
108115
manifest, never recomputed names.
109116
- **Outputs** are named by `pgs_recon/layout.py`, pure functions over the output
110-
root (plus, until ADR 0006 lands, the input artifact a chained name derives
111-
from). A wrapper never invents a filename; `layout` is the only place a name is
112-
written, which is what makes renaming safe.
117+
root: an intermediate is `<stage>_<role>.<ext>` and nothing in its name records
118+
which other stages ran (ADR 0006). A wrapper never invents a filename; `layout`
119+
is the only place a name is written, which is what makes renaming safe.
113120
- **Where the binaries are and what records them** is process-wide:
114121
`toolchain.configure(prefix=..., recorder=...)` once in `main()`.
115122
`toolchain.run()` is the single chokepoint that appends to
116123
`metadata['commands'][timestamp]` (a compatibility surface: `recon_dir.py` greps
117124
it) and then executes, so no wrapper can forget to record what it ran. An
118-
`atexit` hook writes the manifest to `<output>/metadata.json`; the effective
119-
config goes to `<output>/*_recon_config.txt`.
125+
`atexit` hook writes the manifest to `<output>/pgs-recon.json`; the effective
126+
config goes to `<output>/*_recon_config.txt`. `stages.find_manifest()` is what
127+
reads it, falling back to a pre-1.8 `metadata.json` (ADR 0007) — the one
128+
filesystem check in `stages.py`.
120129

121130
When adding a stage: add a `layout` function for its output, add the wrapper as a
122131
pure argv builder ending in `run()`, and wire it in `run_pipeline` between
@@ -128,14 +137,24 @@ the wrappers stay a complete library surface over each binary's flags.
128137
### Module layout
129138

130139
- `pgs_recon/openmvg.py`, `pgs_recon/openmvs.py` — thin wrappers, one function per
131-
binary, all routing through `toolchain.run`.
140+
binary, all routing through `toolchain.run`. Each is its binary's *complete*
141+
registered flag surface, transcribed from the pinned source and held to that by
142+
the `SURFACES` tables above; when upstream moves, the wrapper and the table are
143+
edited together. In `openmvg.py` a flag's spelling depends on how OpenMVG
144+
registered it: `make_switch` flags are plain `bool` (no value exists to emit),
145+
`make_option` over a bool is tri-state (`None` omits, `False` sends `0`), and
146+
everything else is `None`-means-omit.
132147
- `pgs_recon/toolchain.py` — binary resolution, the run/record chokepoint, and
133148
what the binaries that resolve names against a directory need: `work_dir()`
134149
returns the directory a set of artifacts shares, refusing with
135150
`ArtifactsNotColocated` (a `ToolFailed`) when they disagree — OpenMVS really
136151
does require co-location. `relative_to_dir()` instead *translates*, for
137152
`openMVG_main_SfM`'s `-M`, whose join onto `-m` resolves `../`, so the matches
138-
file may live anywhere; only an absolute path is unusable there.
153+
file may live anywhere; only an absolute path is unusable there. `run()` is also
154+
the one place argv becomes strings, so `_argv_token` narrows every `int`
155+
subclass there rather than in each wrapper's flag builder — that is what makes a
156+
`bool` reach argv as `0`/`1` and an `IntEnum` as its value (`str()` of one is its
157+
member name before Python 3.11), including for flags a wrapper assembles by hand.
139158
- `pgs_recon/layout.py` — what every artifact of a run is called.
140159
- `pgs_recon/pgs_data.py` — import logic for EduceLab "PGS Scan" directories,
141160
including grid-scan neighbor lookup that generates an OpenMVG **view pairs file**
@@ -145,7 +164,7 @@ the wrappers stay a complete library surface over each binary's flags.
145164
and exits `128 + signum` for a signal death) and timestamp helper.
146165
- `pgs_recon/utils/` — shared helpers: `apps.py` (logging setup), `geometry.py`,
147166
`quality.py`, `charuco.py`, `wavefront.py`, `educelab.py` (ChArUco/board detection),
148-
`recon_dir.py` (locate a finished run's SfM/mesh from its `metadata.json`),
167+
`recon_dir.py` (locate a finished run's SfM/mesh from its manifest),
149168
`sfm_json.py` (OpenMVG SfM_Data JSON surgery: cereal polymorphic registration,
150169
extrinsic frame transforms), `images.py` (8-bit sRGB normalization).
151170
- `pgs_recon/apps/` — standalone CLI utilities (one `main()` each). Apps must NOT

CONTEXT.md

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -117,9 +117,12 @@ already independent of the shape keeps it, including every name a binary chooses
117117
for itself (`mvg/sfm_data.json`, `recon_dir/sfm_data.bin`) and the final
118118
deliverable `mvs/<name>.<ext>`, which is a user-facing contract. An artifact name
119119
is only ever *written* — a resumed job finds an existing artifact through the
120-
**manifest**, never by rebuilding its name. Not yet in effect: until
121-
[ADR 0006](./docs/adr/0006-stage-named-artifacts.md) lands, names chain off the
122-
input's stem and so *do* encode the shape (`scene_dense_refine.ply`).
120+
**manifest**, never by rebuilding its name
121+
([ADR 0006](./docs/adr/0006-stage-named-artifacts.md), which replaced the chained
122+
names an output directory used to carry: `scene_dense_refine.ply`). One stage
123+
cannot spell out two roles: `densify` hands `DensifyPointCloud` a single `-o`
124+
from which it names both files it writes, so the pair is `densify.mvs` /
125+
`densify.ply` — stem for the stage, suffix for the role.
123126
_Avoid_: path, key, stem, filename
124127

125128
**Binding**:
@@ -140,7 +143,7 @@ than an error. Never inferred from what is on disk.
140143
_Avoid_: stale, invalid, out-of-date, needs-rebuild
141144

142145
**Manifest**:
143-
The run's `metadata.json`, the single record of what a reconstruction has
146+
The run's `pgs-recon.json`, the single record of what a reconstruction has
144147
finished: each stage's status, the artifacts it produced, and the effective
145148
arguments it ran with. It is what makes a run resumable and what a later job
146149
consults instead of being told again.
@@ -197,7 +200,7 @@ Resulting layout (stem `IR940`, recon name `scroll`):
197200
IR940_scene.mvs
198201
IR940_input.ply # staged copy of the mesh being textured
199202
IR940.obj # final, beside the recon's scroll.obj
200-
IR940_retexture_metadata.json # sidecar; recon's metadata.json untouched
203+
IR940_retexture.json # sidecar; recon's manifest untouched
201204
<datetime>_IR940_retexture_config.txt
202205

203206
The only hard collision the convention removes is the **undistorted-images

README.md

Lines changed: 77 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -28,8 +28,50 @@ docker run -v .:/working ghcr.io/educelab/pgs-recon:latest \
2828
Upon successful completion of the pipeline, your reconstructed model can be
2929
found in `recon/mvs/my-object.obj`.
3030

31+
### What lands in the output directory
32+
Every intermediate is named `<stage>_<role>`, after the stage that produced it,
33+
so a half-finished directory can be read for what has happened so far. Optional
34+
stages are marked; the rest are always present:
35+
36+
```
37+
recon/
38+
pgs-recon.json # the manifest: what ran, and with what arguments
39+
my-object_recon_config.txt # the effective arguments, loadable with -c
40+
mvg/
41+
sfm_data.json # the imported scene
42+
matches_dir/ # per-image features, matches[_filtered].bin
43+
recon_dir/
44+
sfm_data.bin # the solve
45+
robust_sfm.bin # --mvg-robust
46+
autoscale_sfm.bin # --mvg-autoscale
47+
landmarks[_scaled].ply # --mvg-autoscale: the markers it scaled from
48+
colorize_sfm.ply # sparse cloud coloured from the images
49+
mvs/
50+
convert_scene.mvs # the interface scene every MVS stage reads
51+
undistorted_images/
52+
densify.mvs densify.ply # --mvs-densify: scene + the dense cloud
53+
reconstruct_mesh.ply
54+
refine_mesh.ply # --mvs-refine (on by default)
55+
my-object.obj # the deliverable, + .mtl and texture image
56+
```
57+
58+
**Locate an artifact through the manifest, not by rebuilding its name.** Every
59+
stage records the paths it consumed and produced, relative to the output
60+
directory, and those records are what a resumed job reads — which is what lets
61+
these names change without invalidating a directory that already exists:
62+
63+
```shell
64+
jq -r '.stages.texture.outputs.mesh' recon/pgs-recon.json # the textured mesh
65+
jq -r '.stages.convert.inputs.sfm' recon/pgs-recon.json # the solved SfM it came from
66+
```
67+
68+
Upgrading from 1.7, where the manifest was `metadata.json` and intermediates were
69+
named by chaining (`scene_dense_refine.ply`)? Those directories are still read,
70+
but a 1.7 manifest carries no per-stage record, so a run against one rebuilds it
71+
from the start. See [docs/migrating-to-1.8.md](docs/migrating-to-1.8.md).
72+
3173
### Staged and resumable runs
32-
The pipeline records what it has finished in `<output>/metadata.json`, so
74+
The pipeline records what it has finished in `<output>/pgs-recon.json`, so
3375
**re-running the same command in the same output directory resumes it** rather
3476
than starting over. After a crash or an out-of-memory kill during mesh
3577
refinement, this picks up at `refine`:
@@ -75,19 +117,21 @@ mesh/refine/texture to a high-memory node, chained with `afterok`. Notes:
75117
re-refines and re-textures but touches nothing before it.
76118
* Changing the shape is allowed at any point. Adding `--mvs-densify` to a
77119
finished reconstruction re-runs densify and the mesh stages, and dropping it
78-
again re-runs them against the non-dense filenames.
120+
again re-runs them against the sparse cloud. Filenames stay put either way:
121+
an artifact is named for the stage that wrote it, not for the stages upstream
122+
of it.
79123
* Stages before `--from` are never run implicitly: if one is incomplete or its
80124
inputs have moved, the run fails immediately, naming each, instead of quietly
81125
doing work the job was not sized for.
82126
* If the range stops before stages the run invalidates, those stages are named
83127
in a warning and rebuilt by the next run that covers them. The final textured
84128
mesh keeps its usual `mvs/<name>.obj` filename in the meantime, so check the
85129
warning rather than the filename.
86-
* What is on disk is never consulted — `<output>/metadata.json` is the record. If
87-
you delete an intermediate by hand, use `--rerun` to rebuild it.
130+
* What is on disk is never consulted — `<output>/pgs-recon.json` is the record.
131+
If you delete an intermediate by hand, use `--rerun` to rebuild it.
88132
* An argument aimed at a stage outside the range is ignored with a warning,
89-
because it would change filenames the rest of the pipeline has already
90-
committed to. Per-invocation settings are exempt and can differ freely between
133+
because it would change what the stages in range consume, and this run is not
134+
sized to rebuild them. Per-invocation settings are exempt and can differ freely between
91135
jobs: `--path` and `--cam-db` apply silently, and `--threads`, `--log-level`,
92136
`--config` and `--output` are not recorded at all, so they never leak into a
93137
later job.
@@ -96,6 +140,30 @@ mesh/refine/texture to a high-memory node, chained with `afterok`. Notes:
96140
* `--no-mvs` is deprecated: use `--to colorize` for an SfM-only run. The old flag
97141
still works (it sets `--to colorize` and warns) but will be removed.
98142

143+
### When mesh refinement takes too long
144+
`refine` is the pipeline's slowest and hungriest stage, and it can run out of two
145+
different resources. Out of **memory** is the familiar one, and resuming the same
146+
command picks up where the kill happened.
147+
148+
Out of **wall clock** looks different: no progress in the log, one core pinned at
149+
100%, and memory flat. That is mesh *preparation* rather than the optimization —
150+
before refining anything, `RefineMesh` subdivides the input mesh and remeshes the
151+
result with single-threaded CGAL, which is silent at the default verbosity and on
152+
a mesh of a few hundred thousand vertices can run for tens of minutes or more.
153+
Adding cores or memory does not help. Turning it off does:
154+
155+
```shell
156+
# Skip the remesh; refine everything else as before
157+
pgs-recon -o recon/ --from refine --refine-ensure-edge-size 0
158+
159+
# Or subdivide less aggressively, so preparation has less to remesh
160+
pgs-recon -o recon/ --from refine --refine-max-face-area 64
161+
```
162+
163+
Both change the refined mesh, so they are options rather than defaults. If refine
164+
is not worth its cost on a given dataset, `--no-mvs-refine` drops it from the
165+
pipeline shape and textures the reconstructed mesh directly.
166+
99167
### Docker images
100168
We provide multi-architecture (x86, arm64) Docker images in the
101169
[GitHub Container Registry](https://github.com/educelab/pgs-recon/pkgs/container/pgs-recon).
@@ -154,7 +222,7 @@ transform. The input mesh must already be in the SfM coordinate frame.
154222
```shell
155223
docker run -v .:/working ghcr.io/educelab/pgs-recon \
156224
pgs-sfm-orient \
157-
-i /working/recon/sfm/sfm_data.bin \
225+
-i /working/recon/mvg/recon_dir/sfm_data.bin \
158226
--input-mesh /working/recon/mvs/my-object.obj \
159227
-o /working/recon/mvs/my-object-centered.obj \
160228
--save-transform /working/recon/orient.npy \
@@ -283,8 +351,8 @@ so a typo in any of them is unambiguous. The OpenMVG camera sensor database is
283351
expected at `<prefix>/lib/openMVG/sensor_width_camera_database.txt`.
284352

285353
Unlike most arguments, `--path` is deliberately *not* inherited from a previous
286-
run's `metadata.json` when a staged run resumes (see `--from`/`--to` above), so
287-
each job of a split reconstruction picks up the prefix of the node it lands on.
354+
run's manifest when a staged run resumes (see `--from`/`--to` above), so each job
355+
of a split reconstruction picks up the prefix of the node it lands on.
288356

289357
### Advanced Installation
290358
#### Installation Location

apptainer/submit_recon_pipeline.sh

Lines changed: 16 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414
# after that work is already done. Here only job 3 asks for the big node.
1515
#
1616
# Each job runs `pgs-recon` against the same --output directory and picks up
17-
# where the last stopped; state lives in that directory's metadata.json. Only
17+
# where the last stopped; state lives in that directory's pgs-recon.json. Only
1818
# job 1 needs -i/--name/-c: every argument it ran with is recorded and reloaded
1919
# by the later jobs.
2020
#
@@ -106,14 +106,15 @@ sbatch_common=(
106106

107107
# --- Job 1: stage in, convert to JPG, run every OpenMVG stage ----------------
108108
# --to convert stops after openMVG2openMVS, so this job produces the interface
109-
# scene.mvs that densify consumes and nothing more.
109+
# convert_scene.mvs that densify consumes and nothing more.
110110
#
111-
# --mvs-densify belongs HERE, on the run that establishes the pipeline shape:
112-
# densify renames the whole mesh chain (scene_mesh.ply -> scene_dense_mesh.ply),
113-
# so adding it on a later job would invalidate stages that job is not sized to
114-
# rebuild. pgs-recon would notice and warn rather than silently skip them, but
115-
# the chain would still need a re-run to catch up. (--mvs-refine is on by
116-
# default, so refine is already in the shape.)
111+
# --mvs-densify belongs HERE, on the run that establishes the pipeline shape.
112+
# Artifact names no longer move when it is added (ADR 0006), but reconstruct's
113+
# input still does -- from convert_scene.mvs to densify.mvs -- so adding it on a
114+
# later job would invalidate stages that job is not sized to rebuild. pgs-recon
115+
# would notice and warn rather than silently skip them, but the mesh stages
116+
# would still need a re-run to catch up. (--mvs-refine is on by default, so
117+
# refine is already in the shape.)
117118
job1=$(sbatch --parsable "${sbatch_common[@]}" \
118119
--job-name="pgs-mvg-${job_name}" \
119120
--output="pgs-recon_mvg_%j_out.txt" \
@@ -177,6 +178,12 @@ printf ' job 2 %-22s %s %s\n' "densify" "${part_gpu}" "${job2}"
177178
# recorded complete and skipped. To retry refine less aggressively instead, add
178179
# an argument it owns and the stages downstream of it re-run too:
179180
# pgs-recon -o <dir> --from refine --refine-resolution-level 2
181+
#
182+
# Memory is not the only way this job runs out. At the pinned OpenMVS, refine's
183+
# mesh *preparation* remeshes with single-threaded CGAL before any optimization
184+
# starts, and on a large mesh that can burn hours on one core with flat RSS -- so
185+
# a timeout here means --time or the knobs below, not --mem:
186+
# pgs-recon -o <dir> --from refine --refine-ensure-edge-size 0
180187
job3=$(sbatch --parsable "${sbatch_common[@]}" \
181188
--job-name="pgs-mesh-${job_name}" \
182189
--output="pgs-recon_mesh_%j_out.txt" \
@@ -233,7 +240,7 @@ time tar -cvzf "${processed_job_dir}/intermediate.tar.gz" \\
233240
"${processed_job_dir}/jpg" \\
234241
"${processed_job_dir}/mvg" \\
235242
"${processed_job_dir}/mvs" \\
236-
"${processed_job_dir}/metadata.json" \\
243+
"${processed_job_dir}/pgs-recon.json" \\
237244
${processed_job_dir}/*.txt \\
238245
--remove-files
239246

0 commit comments

Comments
 (0)