Code and helpers to fit a canonical Archimedean spiral to deformed scrolls.
spiral_service.py hosts one persistent interactive fit session over HTTP for
the VC3D Spiral workspace; fit_spiral.py is the underlying fitter.
VC3D fit sessions and command-line fits allocate one CUDA element and synchronize
before loading fit inputs. This creates the CUDA context before dataset reads
increase filesystem cache pressure, and reports startup failures independently
of atlas size. CPU-only FitContext.load_host_inputs() callers remain unchanged.
The GPU atlas progress messages appear when geometry is actually materialized.
On Linux NVIDIA GB10 systems, startup OOM diagnostics include host memory figures and NVIDIA's manual cache-flush workaround. The application never flushes system caches itself. Early initialization does not guarantee that the full fit will fit in shared CPU/GPU memory.
Validate with the existing environment, from the repository root:
AGENTS_AGENT_MODE=1 PYTHONPATH=spiral-fitting spiral-fitting/.venv/bin/python -m pytest -q \
spiral-fitting/tests/test_cuda_startup.py \
spiral-fitting/tests/test_spiral_headless.py \
spiral-fitting/tests/test_spiral_progress.py
AGENTS_AGENT_MODE=1 PYTHONPATH=spiral-fitting spiral-fitting/.venv/bin/python -c \
'from types import SimpleNamespace; from fit_spiral import FitContext; FitContext.check_cuda_ready(SimpleNamespace(progress=None))'See NVIDIA's DGX Spark memory guidance.
Fits run on CUDA whenever it is available. Without it they run on Apple's MPS
backend, and without that on the CPU; FIT_SPIRAL_DEVICE=cuda|mps|cpu (or cuda:1)
picks one explicitly (devices.py). A distributed (torchrun) fit needs CUDA or
the CPU: torch.distributed has no MPS backend. The Triton kernels are CUDA only, so MPS and the
CPU use the PyTorch paths, and the CUDA startup check is skipped.
MPS has no float64. Device tensors that are float64 on CUDA take their dtype
from devices.float_hi(), which is float32 on MPS;
FIT_SPIRAL_MAX_PRECISION_FLOAT=32 applies the same cap on every device, for
comparing a CPU run with an MPS one. get_ink_metrics.py --device picks the
nnU-Net device; without it the folds take CUDA, else MPS, else the CPU, whatever
the fitting variables say.
The service snapshots patch directories, fiber JSON files and point collections
when claiming a dataset editing workspace. input_snapshot.py tries Linux
FICLONE or macOS clonefile for independent copy-on-write files. Unsupported
filesystems and cross-filesystem copies fall back to a streamed copy; other I/O
errors propagate. Hard links are never used.
The fallback hashes bytes while copying, verifies the destination, and compares the final source fingerprint with the captured tree. Clones are hashed once and compared with the final source. This reduces full content reads from four to three for ordinary copies and two for clones. Snapshots retain the existing SHA-256 format, sorted tree membership, empty directories and symlink rejection. Changes during capture that leave the source different from the captured bytes are rejected. No model inputs, precision or numerical algorithms change.
Run the focused tests using the existing environment, from the repository root:
PYTHONPATH=spiral-fitting spiral-fitting/.venv/bin/python -m pytest -q \
spiral-fitting/tests/test_input_snapshot.py \
spiral-fitting/tests/test_service_editing.py \
spiral-fitting/tests/test_service_editing_http.pyBenchmark real patches (temporary copies are removed; sources stay unchanged):
PYTHONPATH=spiral-fitting spiral-fitting/.venv/bin/python \
spiral-fitting/tests/benchmark_input_snapshots.py \
/home/sean/Desktop/spiral_dataset/verified_patches --iterations 5 --profileUse --destination PATH to test another filesystem. The default is /tmp.
--count selects the first N sorted patch directories (default 32).
Measured on Linux arm64, CPython 3.14, without Python optimization flags, with
cProfile enabled for both versions: 32 real verified patches, 55,302,864 bytes,
five iterations, ordinary-copy fallback on the local ext-family filesystem.
These are cache-warm measurements, not end-to-end fit initialization times.
| Snapshot implementation | Mean | Min | Median | Max |
|---|---|---|---|---|
| Previous copy plus three hash passes | 166.8 ms | 160.8 ms | 162.2 ms | 181.0 ms |
| Streamed copy/hash plus verification | 152.3 ms | 148.9 ms | 153.2 ms | 154.0 ms |
Mean time decreased 8.7%. Before the change, fingerprint calls accounted for 0.649 s of 0.834 s total across all five iterations; file copying accounted for 0.177 s. Afterwards, hashing remains the largest cost (0.336 s in SHA-256 updates), but the separate source pre-read is eliminated. Reflink throughput and actual macOS cloning require validation on supporting filesystems; unit tests cover the platform dispatch and fallback/error paths.
Filesystem API references: Linux FICLONE and Apple file cloning support.
fit_spiral.py requires a spiral-scroll.json file in the dataset root.
This is not covered by scrollprize.org/tutorial_spiral. Required keys:
schema_version— must equal1.name,voxel_size_um— required, no validation beyond presence.z_direction_is_top_to_bottom,left_handed_coordinates— the fitted volume's two properties from the open-data catalog (metadata.json), copied- verbatim as
true/false. Together they fix the spiral's sense under the catalog convention that every scroll shows the same spiral seen from its top:"CW"when they are equal,"ACW"when they differ. The z direction also orients exported surfaces (below). spiral_outward_sense—"CW"or"ACW"(case-insensitive). Derived from the two catalog properties when both are present, and then only needed as a cross-check (a mismatch is an error). Without them it is required and is read off the CT data by a person in VC3D, or from an already-fitted spiral.
Exported surfaces (the per-winding meshes/, the combined preview and
flatten_spiral_checkpoint.py's source surface) read like the scroll: column 0
is the outermost wrap, U running outside to inside as orient-segment
normalises it, and row 0 is the top of the scroll when
z_direction_is_top_to_bottom is known (rows stay in z order when it is
not). Each meta.json and preview manifest records the z direction the
layout was made with under grid_orientation (a grid without it is an older
one in sampling order: innermost first, rows by z); winding_column_ranges
are in the written grid, outermost winding first. left_handed_coordinates does not change the grid, only the
direction of its cross-product normal, which renders correct with
flip-normals = not left_handed_coordinates.
Optional paths object for per-input overrides when a dataset's file names
don't match the catalog's conventional defaults (e.g. tracks_dbm).
Also optional, and easy to get wrong silently: normal_zarr_group
(default "4") and lasagna_scale (default 4) select which OME-Zarr
pyramid level the normal_x/normal_y lasagna stores are read at.
lasagna_scale must equal the actual downsample factor of whichever
group you pick for this specific scroll's lasagna store — read it from
the store's own .zattrs multiscales metadata, do not assume it from
another scroll's example or from a generic recommendation. A mismatched
value either silently reads the wrong-resolution normal maps (no error) or
throws RuntimeError: lasagna z-ROI [...] is empty if the mismatch is
large enough to push the requested z-range outside the (wrongly-scaled)
store bounds.
Example (PHerc0826, where group "2" is a 4x downsample for this scroll
specifically):
{
"schema_version": 1,
"name": "PHerc0826",
"voxel_size_um": 9.362,
"spiral_outward_sense": "CW",
"normal_zarr_group": "2",
"lasagna_scale": 4,
"paths": {
"tracks_dbm": "tracks/PHerc0826_20250821151701_surface_m7_L0_th0.2.dbm"
}
}fit_spiral.py reads normal_x, normal_y and gradient_magnitude only
through the resident-pool sidecars that pack_resident_pools.py writes
(lasagna_data.py: there is no other loading path). Before the first fit on a
dataset, pack the stores at the group named by normal_zarr_group in
spiral-scroll.json:
python pack_resident_pools.py <dataset>/lasagna_inputs \
--what normals,grad_mag --normal-group 2The packer looks for *_nx.ome.zarr, *_ny.ome.zarr and *_grad_mag.ome.zarr
in that folder and writes <store>.respool_g<group>_pair (normals) and
<store>.respool_g<group> (grad_mag) next to them. Without the sidecars the fit
stops at input loading with
lasagna normals: resident-pool sidecar '.../PHercXXXX_nx.ome.zarr.respool_g2_pair' not found; build it with pack_resident_pools.py ...
The packer only iterates over chunk files that exist, so a store that holds only
the z chunk rows of your fit window packs correctly; rows outside it read back
as no-data. Pass --ct <scroll zarr> --ct-group <group> to drop bricks outside
the CT mask.
runners/run_sweep.py prefixes each active fit's live PROGRESS and
every-200-step loss lines with its configuration name. Optimization progress
includes the average iteration rate for the current stage (it/s). Complete
combined stdout/stderr for every attempt remains available under
<output>/.sweep/logs/<config>.log.
flatten_spiral_checkpoint.py is a standalone, one-shot exporter. It
reconstructs the combined surface from a fitted checkpoint, launches a private
Lasagna service, flattens with flatten_fast_nofilter.json, writes the final
TIFXYZ directory, and tears the service down even if the job fails or is
interrupted:
python flatten_spiral_checkpoint.py \
/path/to/checkpoint_fitted.ckpt \
/path/to/output.tifxyzThe checkpoint format does not embed the fixed umbilicus curve. The script
looks for umbilicus.json in the checkpoint's ancestors, in
$SPIRAL_DATASET, and in the standard local s1 dataset location. For other
layouts, pass --umbilicus /path/to/umbilicus.json. Use --lasagna-dir if
the Lasagna repository is not in its standard sibling or ~/villa location.
An existing output path is never overwritten.
The text VC3D shows when hovering a Spiral tab control or a setting in the
Spiral Configuration window lives in descriptions.json, not in code:
configis keyed by configuration key. The service attaches each entry to the/configurationcatalog as the field'sdescription, re-reading the file per request, so an edit shows after VC3D reconnects. A new configuration key needs an entry here;tests/test_config.pyfails on a missing or stale key.panelis keyed by the control ids registered withdescribe(...)in VC3D'sSpiralPanel.cpp. VC3D reads this section from the copy beside thespiral_service.pyit would launch (the same lookup, includingSPIRAL_SERVICE_PATH) and reloads it as soon as the file changes. A panel id without an entry falls back to theconfigentry of the same name.
Plain text only; a newline starts a new line in the tooltip.
model_num_flow_stages sets how many stationary velocity fields the flow
diffeomorphism composes. The stages are the slabs of the flow lattices'
leading axis (flow_field.flows.{0,1} are [stages, 3, ...]), integrated in
order by one fused kernel launch per direction; the inverse runs the slabs
backwards in reverse order. One stage is the original single-field model.
These optional settings change how the flow lattices are optimized, without
adding loss terms or changing the model parameterization. The smoothing,
lazy-moment and shared-second-moment switches default to off; gradient
clipping defaults to disabled. The optimizer_flow_* settings apply at run
boundaries and are read every step.
The step order is: DDP gradient averaging, NaN/Inf detection and replacement with zero, optional clipping, optional smoothing, then the optimizer update. Sanitizing before smoothing prevents a single invalid entry from contaminating its neighborhood.
optimizer_flow_grad_smoothingGaussian-smooths each flow lattice's gradient.optimizer_flow_grad_smoothing_sigma_voxelssets the standard deviation in scroll-voxel units of the flow coordinate frame. Cartesian smoothing is isotropic. Cylindrical smoothing runs along z and periodically around each ring, independently for the local z/radial/tangential components.optimizer_flow_grad_smoothing_across_sigma_voxelsoptionally smooths across rings at matching angles; its default of 0 disables this pass. These are lattice directions approximating along/across-sheet directions, not measured sheet tangents or winding boundaries. Kernels are truncated at three sigma and renormalized at nonperiodic borders to preserve constant gradients.optimizer_flow_grad_smoothing_low_res_sigma_voxelsoverrides the coarse lattice's along-sheet width; 0 uses the same width as the fine lattice. With the default fine spacing of 16 voxels and sixfold coarse spacing, a width of 32 voxels is 2 fine cells but only about 0.33 coarse cells. Very small widths become identity kernels. The fitter reports effective widths at startup when smoothing is enabled. The across-ring width has no separate coarse override and is ignored for Cartesian fields.optimizer_flow_lazy_momentsupdates moments and applies the gradient step only to entries whose processed gradient is nonzero. Smoothing can make an entry active even without a direct sample there. Other entries retain their moments; configured AdamW weight decay still applies everywhere. This preserves gradient-scale history through unsampled steps and suppresses momentum-only movement there, but retains stale history too. It does not prevent large relative steps on a previously untouched smoothing tail.optimizer_flow_shared_second_momentuses one denominator across all cells and vector components of each lattice's leading slab, independently for each flow stage and for the coarse/fine lattices. Per-entry Adam scaling can make even a smooth gradient produce nearly sign-sized steps, particularly on first touch. A shared denominator preserves relative magnitudes and direction of the first moment before lazy masking and weight decay; it does not guarantee a smooth final displacement. The shared statistic is the mean of positive stored second moments, capped before averaging atoptimizer_flow_shared_second_moment_clip_quantile(default 0.99). A value of 1 disables the cap. This limits outliers' effect on the common scale at the cost of local adaptivity. Full per-entry moments remain stored.optimizer_flow_grad_clip_median_multipleclips individual gradient components to this multiple of the median nonzero absolute component value, separately for each lattice slab. Zero disables it. Clipping before blur limits how far an extreme gradient can affect neighbors and optimizer moments, but can also suppress legitimate corrections and change vector direction. The median and second-moment cap are estimated from fixed-stride samples; the final averages and clipped fractions use the whole slab. Sampling is deterministic for identical inputs but may miss sparse support.model_flow_field_low_res_lr_scalemultiplies the scheduled base learning rate for the coarse lattice independently of the fine lattice's existing ramp. It defaults to 1; 0 freezes coarse parameter values, including weight decay, although moments may still update. The fitter reads it every step, but the configuration catalog currently classifies it as a model-rebuild setting, like the othermodel_learning-rate controls.
flow_grad_smoothing.py provides Triton CUDA kernels and PyTorch reference/
fallback paths. lazy_moment_adamw.LazyMomentAdamW retains AdamW's state format
so its flags can be switched between runs without converting moment buffers.
For custom optimizer updates, diagnostics report the denominator, nonzero
update fraction, and per-component RMS over nonzero updates in voxel units.
These describe flow-parameter increments, excluding weight decay, not final
sheet displacement after integration. After both moment flags are disabled,
stored optimizer diagnostics can still describe the last custom step rather
than the current fused AdamW step. Clipping diagnostics report the bound
and fraction clipped. No full-scroll accuracy or throughput comparison is
established by the implementation tests.
Gaussian update smoothing has precedent in Vercauteren et al., Diffeomorphic Demons. Smooth velocity metrics are central to Beg et al., Computing Large Deformation Metric Mappings. These motivate the preconditioning here; this discrete blur followed by Adam, clipping and masking is not an implementation of classical Sobolev gradient descent or LDDMM, and inherits no guarantee of the same fitted solution or fold-free numerical integration. Direction-dependent smoothing has a related motivation in Pace et al.'s sliding-organ registration, although this fitter uses cylindrical coordinates rather than detected sliding interfaces.
Lazy moments follow the masked-update idea of PyTorch SparseAdam, using an explicit nonzero mask on dense gradients, a global per-parameter step counter, AdamW's epsilon placement, and optional decoupled weight decay. Adam-mini provides precedent for sharing adaptive scales within parameter blocks; our grouping and winsorization are custom choices, and retaining full moments does not provide its state-memory saving. Koloskova et al., Revisiting Gradient Clipping analyze clipping's stabilization and stochastic bias; their results do not validate this particular sampled-median rule or its combination with Adam.
VC3D connects to a Spiral service in one of three modes, all speaking the same authenticated HTTP protocol:
- Localhost — VC3D launches and owns the service on loopback. Nothing to set up beyond the Python environment; the dataset (plus optional output and cache roots) is chosen in the connection panel and VC3D launches the bound service with those values. Selecting a different dataset restarts the owned service — one service instance is bound to one dataset.
- Remote (SSH) — the supported internet flow. SSH access to the host is the only client-side prerequisite: VC3D opens and manages its own SSH tunnel, reads the service's auto-generated API key over SSH, and attaches to a persistent loopback service you start on the host. VC3D never starts the service on a remote host.
- Remote (LAN) — direct HTTP on a trusted network, authenticated with the service's auto-generated API key. No reverse proxies, VPNs, or manual tunnels are ever required.
In every mode the service — not the client — owns the base inputs: it is
launched with --dataset (inputs) and --output (all generated state),
resolves the dataset once at startup, and advertises the result through
/dataset. --output must resolve outside the dataset root; the optional
--cache (derived host caches) defaults to the documented user cache,
$XDG_CACHE_HOME/vc3d/spiral (~/.cache/vc3d/spiral). Clients can add
ephemeral inputs, commit them, and change run parameters, but cannot repoint
the session at different host paths.
The service host needs the Spiral environment (a CUDA-capable PyTorch plus the
dependencies in pyproject.toml, Python ≥ 3.14). With uv:
cd spiral-fitting
uv sync # creates .venv from pyproject.tomlThis also builds Spiral's native helpers as vc_spiral.spiral_sampling,
vc_spiral.track_crossings, vc_spiral.track_store, and
vc_spiral.surface_index. OpenMP is used when the toolchain provides it; the
same modules build with serial kernels when it does not.
or with conda/pip, install torch for your CUDA version and then
pip install -e . from spiral-fitting/.
On Linux x86-64, uv sync installs brook-cu12, CuPy and cuCIM for the GPU
backend of extract_surface_tracks.py instead of Kimimaro, its CPU backend;
on a machine without an NVIDIA GPU of compute capability 8.0 or newer, add
Kimimaro with uv sync --extra cpu (with pip: pip install -e '.[cpu]'), and
pass --extra cpu on later syncs too, since a plain uv sync removes it again.
Other platforms get Kimimaro by default.
Normals and gradient magnitude samples are served by fully
resident device brick pools. Each store's occupied bricks are packed once
into a flat sidecar next to the source zarr by pack_resident_pools.py:
python pack_resident_pools.py /path/to/lasagna_inputs \
--ct /path/to/<scroll>_ds2.zarr --ct-group 2 --verify 2000--ct zeroes every voxel whose CT voxel reads 0 (the mask region around the
scroll) so those bricks drop out of the pool and sample as no-data. The
fitter loads the sidecars restricted to the configured z-ROI in one
sequential read per channel (for the full s1 ROI: ~10 GiB normals); after
that every gather is pure device indexing with no I/O and no
eviction. When a required sidecar is missing, the fitter builds it before GPU
loading and reports chunk progress. In DDP runs only rank 0 builds it. Manual
prepacking with --ct remains useful because the CT mask can substantially
reduce the resident pool size.
Set FIT_SPIRAL_RESIDENT_BOUNDS_CHECK=1 to enable per-gather bounds
assertions when debugging new sampling code.
Start a persistent loopback service on the GPU host with its dataset. Give each independently operated service a stable session name, port, and GPU. Nothing is exposed on the network; VC3D tunnels to it over SSH:
tmux new -s spiral-alice 'python spiral_service.py --port 8765 \
--dataset /data/scrolls/s1 --output /data/spiral-output/s1 \
--gpus 0 --session-name alice'
tmux new -s spiral-bob 'python spiral_service.py --port 8766 \
--dataset /data/scrolls/s1 --output /data/spiral-output/s1 \
--gpus 1 --session-name bob'The service uses only physical CUDA device 0 by default. Select a different
device or enable distributed fitting across several GPUs with a comma-separated
host-side list:
python spiral_service.py --port 8765 \
--dataset /data/scrolls/s1 --output /data/spiral-output/s1 --gpus 0,1,2,3Multi-GPU sessions run one fitter rank per listed device and split the configured per-step sample counts across those ranks by default. The device list is fixed for the lifetime of the service; restart it to change the selection.
A named service writes autosaves, previews, artifacts, uploaded checkpoints,
Lasagna output, and ephemeral inputs beneath <output>/<session-name>/, held
under an exclusive lease: two live services cannot own the same
output/session-name pair. Launches without --session-name use <output>/
directly. Permanent dataset inputs and the shared user cache stay untouched —
nothing generated is ever written under the dataset root.
Every completed Spiral preview is flattened by the host's Lasagna service
before it becomes downloadable in VC3D. The published grid uses a fixed
20-voxel output step: each dimension is
ceil(((source_points - 1) * source_step) / 20) + 1. Winding membership,
loss-map overlays, and run differences are transferred through Lasagna's
output-to-source correspondence so they remain aligned when the output grid
dimensions differ from the Spiral grid. If flattening or artifact mapping
fails, the service reports the publication error and VC3D keeps displaying the
previous successfully published preview.
Every checkpoint and every raw preview export carry a content digest of the
model state that placed the surface (model_state_sha256: live parameters,
frozen constraint-bake epochs and run window). The service records each
published preview under that digest in
<output>/.spiral-published/preview-index.json. When the fitter reports that
its resident model equals a checkpoint - after a save, an in-session load or a
startup resume - the service re-shows the flattened surface it already
published for that digest instead of waiting for a new export and flatten, and
pins that surface against the fixed-count preview retention for as long as the
checkpoint file exists. A checkpoint saved without a published preview, or one
whose preview has already been pruned, gets nothing back; request a preview as
before.
On first start the service generates a strong API key at
~/.config/vc3d/spiral_api_key (mode 0600) and prints it to the console.
For an SSH profile you never copy it: VC3D reads that file over SSH.
In VC3D's Spiral workspace, add a Remote (SSH) profile with the
[user@]host destination (your ~/.ssh/config aliases, agents, and jump
hosts work unchanged) and the service port (8765 above), then Connect.
Non-interactive SSH authentication (keys or an agent) is required. If SSH does
not trust the host key yet, run ssh <destination> once in a terminal to
accept it — VC3D deliberately never auto-trusts host keys.
The fit survives viewer disconnects, laptop sleep, and network drops;
disconnecting or closing VC3D never terminates a service it did not launch.
The workspace reports the active loading, optimization, checkpoint, and
preview stage with elapsed time. Stages with a real work total also show a
counter and ETA; opaque native or CUDA operations deliberately use an
indeterminate bar instead of a guessed overall percentage. The same stage
updates are printed by standalone fit_spiral.py, with periodic elapsed-time
heartbeats when output is captured to a log.
While connected, the circular-arrow button beside the connection controls
restarts the remote service and reconnects automatically. The service replaces
its own process in place, so a containing tmux session remains alive and an
attached terminal is not disconnected.
python spiral_service.py --bind 0.0.0.0 --port 8765 \
--dataset /data/scrolls/s1 --output /data/spiral-output/s1Copy the API key printed at startup into the Remote (LAN) profile's API key
field (or export SPIRAL_API_KEY before starting VC3D). A non-loopback bind
always requires an API key (auto-generated when absent).
Plaintext-HTTP risk note: direct HTTP is not encrypted — on-path observers can read the API key and the transferred data, so use it only on networks the operator trusts. Over the internet, use an SSH profile instead. HTTPS endpoints behind an existing TLS proxy also work; VC3D uses normal system CA validation and never ignores certificate errors.
- Location:
~/.config/vc3d/spiral_api_key(respectsXDG_CONFIG_HOME), or pass--api-key-file PATH. - The key is created on first start (mode
0600) and reused on later starts. - To rotate it, delete the file and restart the service; reconnect clients with the new key. The key is never written to HTTP logs, responses, or the ready line — the console print at startup is the intended way to obtain it.
--nonceis only for processes launched and owned by VC3D.
--dataset must point at a dataset root containing at least umbilicus.json
and spiral-scroll.json; the service refuses to start when either is missing.
Verified patches are required when their default-on input toggle is active,
but a patch-free fit can initialize with that source disabled. The dataset
holds inputs only.
--output is required and must resolve outside the dataset root. Every piece
of generated state — run directories, autosaves, previews, published
artifacts, ephemeral inputs, upload staging, and uploaded checkpoints — lives
under it (under <output>/<session-name> for a named service). Make sure its
filesystem has room for checkpoints and previews.
--cache holds derived host caches (content-addressed, shareable between
datasets). It defaults to $XDG_CACHE_HOME/vc3d/spiral
(~/.cache/vc3d/spiral) and must also resolve outside the dataset root. The
headless fit_spiral.py CLI accepts the same --cache with the same default
(FIT_SPIRAL_CACHE_DIR still overrides it for the CLI).
If the dataset root is read-only the fit still works, but Commit current inputs is unavailable (committing writes inputs into the dataset).
Open the Spiral workspace and pick the profile in the Spiral Service
section. For the local profile, set the dataset root (and optionally output
and cache roots) there — VC3D launches its owned service bound to those
values. Connection must succeed (an authenticated /health handshake and an
API-version check) before session controls enable. The base-input rows always
populate read-only from the service's advertised dataset resolution; run
parameters (z range, iterations, advanced config) stay editable and persist
per profile. Generated previews, geometry, and
checkpoints transfer through the artifact API into a local cache — no shared
filesystem is needed. Optional: set the profile's Local dataset path if
this machine mounts the same dataset, so input surface overlays
(verified patches/shell) can be displayed locally. It is assumed to
correspond to the dataset root the service advertises, which is the prefix
service paths are translated from; without it those overlays are simply marked
unavailable.
spiral-scroll.json in the dataset root is the only source of the scroll's
name and voxel resolution and of the Lasagna store layout (zarr groups,
coordinate scale). None of them are panel settings: the panel reports them
read-only, and the service rejects a session request that carries
scroll_name, voxel_size_um, lasagna_group or lasagna_scale.
Optional supervision sources have rebuild-scoped boolean switches in Advanced
config. Set an input_use_* key to false to skip validation, loading,
sampling, and losses for that source without changing its tuned weights or
sample counts. Available switches cover verified patches, tracks,
fibers, each PCL role (absolute, relative, same_winding, and
drawn_control_points), normals, gradient magnitude, winding inference, and
the outer shell. For example:
{
"input_use_tracks": false,
"input_use_fibers": false,
"input_use_pcl_drawn_control_points": false
}Most role switches require a whole-fit rebuild; same-winding and relative PCL switches apply at the next Run. Disabled roles retain their accepted workspace content, and enabling a role restores that desired content. Disabling a prerequisite also disables its dependent supervision: winding inference needs the outer shell.
The API 33 client and service use one revisioned input workspace per dataset. One service holds the dataset editing lease and one client owns that workspace; other connections can observe. Disconnecting retains ownership, drafts and command receipts. Reconnect with the same client resumes them. A fit rebuild replays desired inputs without replacing the editing workspace. Reconnect and rebuild also discover newly added dataset inputs, preserving their collection IDs and any existing workspace edits. A restarted service replaces a clean client catalog; pending edits or commands stay tied to their original workspace.
Add/Apply changes captures the selected local revisions and applies the whole validated batch at a worker boundary, including while idle or after the final step. Commit current inputs first applies the selected revisions, then persists those exact revisions. Editing can continue during either action; a response for an older revision leaves newer edits dirty. Failed transfers or publication can be retried with the retained command and bytes.
In the flattened Spiral preview, tap Ctrl to toggle patch painting: left-drag
paints, right-drag erases, and Escape exits. Ctrl+wheel changes brush size
without toggling the mode; Shift+right-drag draws control-point lines. Starting
a stroke on a session-drawn selection extends that patch in its original color.
Drawn selections remain brush-editable after Apply and Commit, including across
preview updates when their original surface can be projected onto the new one.
Finalization keeps the largest edge-connected component of complete quads;
selections with no complete quad remain editable and report an error.
Drawn patches use the same editing workspace as other inputs. Add/Apply and Commit capture their current selections; an edit made while an older snapshot is being saved stays dirty. Erasing a previously staged patch completely stages a deletion on the next Add/Apply. The input list shows local brush errors and colors; Remove on a local brush edit discards that edit. Dataset patches use the existing managed patch editor.
Brush regression checks use the existing build and Python environment:
AGENTS_AGENT_MODE=1 cmake --build volume-cartographer/build --target VC3D test_spiral_brush_patch test_spiral_input_workflow test_spiral_input_draft test_spiral_point_placement_mode test_spiral_point_collection_edit -j 4
AGENTS_AGENT_MODE=1 QT_QPA_PLATFORM=offscreen SPIRAL_TEST_PYTHON="$PWD/spiral-fitting/.venv/bin/python" ctest --test-dir volume-cartographer/build -R '^spiral_(brush_patch|input_workflow|input_draft|point_placement_mode|point_collection_edit)$' --output-on-failureSet SPIRAL_PATCH_REAL_INPUT to a real tifxyz patch directory to include the
read-only selection check and draft-copy validation. SPIRAL_PATCH_EVIDENCE_DIR
optionally receives before/after selection-mask images. These checks cover CPU
geometry and the editing workflow; they do not run GPU fitting.
In the Spiral workspace, Q and E place same-winding and relative-winding
points. Relative annotations count 0, 1, 2, ... in placement order; F reverses
the chain and mirrors annotations. Existing collections remain editable after
Apply. Shift+E prepares local drafts. Managed patch and fiber editors save to
session working copies; successful saves update drafts and do not apply or
commit automatically. Linked-fiber saves use working copies for their peers.
The input list includes baseline dataset entries and additions, with a filter and selection checkboxes. Edit, Remove, Restore, Retry and Discard Local Changes operate on individual drafts. Applying Remove stops future supervision; Commit deletes the managed dataset entry. Restore remains available before committing a deletion. Baseline restores reference the retained service revision and do not require access to the service filesystem. Removal cannot reverse completed optimizer steps. Invalid selected drafts keep the batch unapplied and remain editable. Scoped external-source conflicts offer Use Current, Apply Local After Review, or Save Local as New; unrelated PCL collection changes are merged during publication.
Editing workspaces are disposable session storage. Discard and Exit drops
local drafts and releases the workspace directly. Commit exits only after
publication succeeds, then releases temporary copies. Release and service
shutdown stop file users and remove the entire editing-workspaces/<id>
directory, including baseline snapshots, revisions and uploads. Reconnect,
Stop, and Rebuild keep the workspace and its drafts. The next claim snapshots
only committed dataset inputs.
New workspaces carry versioned ownership metadata and a lifetime advisory lock.
Startup reclaims marked directories whose owner has exited, including after a
crash or forced termination. Live owners, symlinks, unrecognized markers and
legacy folders without markers are left untouched; legacy storage requires
manual cleanup. Dataset inputs, saved fit outputs, exports and shared caches
are preserved. Interrupted Commit recovery directories (.spiral-publication-*
beside dataset targets) are retained and their paths logged. Cleanup errors are
reported with the workspace path; when a reader or worker cannot stop, files
and ownership remain intact for a release retry or later startup reclamation.
Cleanup regression checks (no installation needed):
AGENTS_AGENT_MODE=1 PYTHONPATH=spiral-fitting spiral-fitting/.venv/bin/python -m pytest -q \
spiral-fitting/tests/test_workspace_cleanup.py \
spiral-fitting/tests/test_service_editing.py \
spiral-fitting/tests/test_service_editing_http.py \
spiral-fitting/tests/test_spiral_service_v2.pySet SPIRAL_REAL_PCL=/path/to/real/abs_winding.json for the real-scroll
close/reopen check. It edits a temporary dataset copy and verifies that both the
source bytes and committed copy remain unchanged; it uses a resident test double
and does not run GPU fitting.
Regression checks for this workflow (using existing environments/builds):
# From the repository root:
AGENTS_AGENT_MODE=1 spiral-fitting/.venv/bin/python -m pytest -q \
spiral-fitting/tests/test_service_editing.py spiral-fitting/tests/test_service_editing_http.py \
spiral-fitting/tests/test_revisioned_runtime.py spiral-fitting/tests/test_revisioned_geometry.py
AGENTS_AGENT_MODE=1 ninja -C volume-cartographer/build test_spiral_input_draft test_spiral_input_workflow -j 2
AGENTS_AGENT_MODE=1 QT_QPA_PLATFORM=offscreen SPIRAL_TEST_PYTHON="$PWD/spiral-fitting/.venv/bin/python" ctest --test-dir volume-cartographer/build -R '^spiral_input_(draft|workflow)$' --output-on-failureThe workflow tests start a loopback HTTP service and cover remote baseline restore, service restart, and edits made while a submission is running. The service suite includes transfer, revision, conflict, and publication recovery checks; runtime and geometry tests cover worker boundaries and supervision. For the retained CUDA check on temporary copies of a real dataset patch:
AGENTS_AGENT_MODE=1 SPIRAL_REVISION_LIVE_DATASET=/path/to/dataset \
SPIRAL_REVISION_PATCH=patch-directory-name \
spiral-fitting/.venv/bin/python -m pytest -q spiral-fitting/tests/test_revisioned_live_fit.pyInput uploads only transfer immutable bytes. The editing workspace owns acceptance, application, and persistence; there is no separate ephemeral-input ledger or automatic commit on editor save. Checkpoint uploads remain service-scoped.
Directional DT timing is an independent control on every interactive Run.
When Restrict DT losses to final is unchecked, the Run adds no DT gate.
When checked, the adjacent percentage is the eligible suffix of the originally
requested Run: the first eligible iteration is
run_start + floor(iterations * (1 - percentage / 100)). Thus 25% of a
10,000-iteration Run suppresses DT for 7,500 iterations and permits it for the
final 2,500; for small Runs the eligible step count is rounded up. Zero percent
suppresses DT for the whole Run and 100% adds no suppression. Stopping early
does not recalculate the original window. The schedule is transient Run state,
not advanced configuration or checkpoint state, and is cleared before the
Run's autosave.
Input-local validation failures reject the entire selected candidate without changing active supervision. Distributed ranks prepare the same candidate and must all agree before installation. Unexpected worker/device failures retain fail-stop behavior. Publication prepares all outputs before changing targets; a failure after publication starts retains the transaction and queues later mutations behind recovery. Recovery covers the running service, not crashes, and does not promise atomic visibility across multiple dataset files.
Checkpoints are one panel section, and loading one is one button. It lists
what the service advertises (checkpoints at the dataset root, and those under
the output directory such as the autosave) plus any client-local .ckpt
you browse for; a local file is uploaded to the service's
<output>/uploaded-checkpoints/ directory on the way (the panel shows
progress and the transfer restarts if interrupted). Checkpoints are identified
by SHA-256, so choosing content the service already retains reuses it without
transferring the file again, and the service validates new archives and keeps
the newest few unique uploads.
Before the first fit is initialized, Load initializes it directly from the selected checkpoint; it does not first construct a throwaway model. The configuration profile becomes Checkpoint and displays the resolved configuration carried by that checkpoint. Initialize Fit is the separate from-scratch action.
With an existing fit, Load replaces the resident model's weights, optimiser and RNG state in place. When the checkpoint does not match the live model the service refuses it and says what a rebuild would have to replace: rebuilding the model only keeps the loaded dataset inputs and everything already added to the fit, while a whole-fit rebuild re-reads the dataset and replays the workspace's desired revisions, including uncommitted additions. The panel reports the reasons and asks; a checkpoint no rebuild can accept — one written against another dataset, or whose stored configuration holds a value the schema cannot interpret — is reported and nothing is offered. A checkpoint-backed session takes its durable configuration from the checkpoint, so the local advanced-config profile does not override it.
A checkpoint's stored configuration is loaded tolerantly
(checkpoint_migrations.tolerate_config): keys the schema no longer has are
dropped and keys the checkpoint predates take their current defaults. Each
such edit is reported as a note or session warning. Only a stored value the
schema cannot validate (a retired enum member, an out-of-range number) refuses
the checkpoint. Tolerance covers configuration alone: a checkpoint whose
parameters do not fit the live model — a multi-stage flow saved in the
pre-slab extra_flow_fields.* layout, or an exponential-gap fit from before
late August 2026 — is still refused on tensor geometry.
The Iterations value on Run is a count added to the checkpoint's durable iteration. The progress bar is local to that run and therefore starts at zero; the session status line reports the global current and target iterations.
The section also holds Save on Service and Download…, and reports the checkpoint the resident fit was actually built from. That report is read-only: it is not a field, and a rebuild carries it forward by itself.
Stop the service with Ctrl-C or SIGTERM (tmux kill-session -t spiral);
it tears the fit session down at a safe boundary. Logs go to the service's
stdout/stderr on the host — for a tmux session, tmux attach -t spiral; for
an unowned service VC3D's Python-output dialog only reminds you of this. A
service started on an explicit port can be restarted immediately (the socket
uses SO_REUSEADDR). VC3D's remote restart control does not run
tmux kill-session; it gracefully closes the fit and re-executes the service
with the same interpreter, arguments, and process ID. Note that a large artifact
download during a running fit competes with the fitter for the Python
interpreter and can slow iterations somewhat.
# ~/.config/systemd/user/spiral-service.service
[Unit]
Description=VC3D Spiral fitting service
[Service]
WorkingDirectory=%h/villa/spiral-fitting
ExecStart=%h/villa/spiral-fitting/.venv/bin/python \
%h/villa/spiral-fitting/spiral_service.py \
--port 8765 --dataset /data/scrolls/s1 \
--output /data/spiral-output/s1 --gpus 0
Restart=on-failure
[Install]
WantedBy=default.targetsystemctl --user daemon-reload
systemctl --user enable --now spiral-service
journalctl --user -u spiral-service -f # logs (includes the API key print)Direct command-line use remains fully supported; the unit is a convenience.
extract_surface_tracks.py turns a surface-prediction volume into the tracks
DBM that the next sections pack, index and rasterize. The predictions are
binarized (> 0) and cut into thin slabs: horizontal ribbons 4 voxels deep
every 16 voxels of z, then vertical zx and zy slabs 4 voxels thick every 64
voxels across the occupied yx range. In every slab the connected components
are labelled, max-pooled by 4, filtered by size and skeletonized, and each
skeleton chain between branch or end points with at least 10 vertices becomes
a track.
python extract_surface_tracks.py \
--predictions /path/to/<surface-predictions>.zarr/0 \
--out /path/to/<dataset>/tracks/<name>.dbm \
--z-min 10900 --z-max 11300Every option defaults to the configuration at the top of the script, which
also holds the slab geometry, the size thresholds and path_mode. Its two
paths are placeholders: the script stops with an error until they are set or
passed as --predictions and --out. --predictions takes a local path or a
URL such as s3://..., opened with open_zarr from ../vesuvius/src; the GPU
backend reads a local zarr v2 array in the layout of the surface predictions
(uint8, blosc, / chunk keys) straight from its chunk files instead. The z
range is half-open ([z-min, z-max)).
Two backends write the same keys in the same format:
gpu: Brook (brook-cu12) and cuCIM on NVIDIA GPUs, one worker process per GPU. It needs Linux x86-64, GPUs of compute capability 8.0 or newer (Ampere or later) and an NVIDIA driver R545 or newer (R570 or newer where the driver JIT-compiles Brook's PTX). The CUDA 12 libraries come as pip wheels (brook-cu12bundles its runtime; CuPy and cuCIM use NVIDIA's CUDA wheels installed with them), so no system CUDA toolkit is needed.--gpus 0,1selects GPUs by theirnvidia-smiindex; the default is every visible GPU. Without--gpus, a setCUDA_VISIBLE_DEVICESis read the same way (nvidia-smiindices, PCI bus order); UUID and MIG entries are not accepted, so pass--gpusinstead. With--gpus,--backend autodoes not fall back to Kimimaro when the GPU backend cannot run.cpu: Kimimaro, one slab at a time.uv syncinstalls it on every platform except Linux x86-64, where it is the optionalcpuextra (uv sync --extra cpu) for machines without a compatible GPU.
--backend auto (the default) picks the GPU backend when its check passes
(platform, packages, and the NVIDIA driver version and the compute capability
of the selected GPUs, as reported by nvidia-smi) and path_mode is
'interjoint', and Kimimaro otherwise. The
script prints the backend it uses and why. --backend gpu and --backend cpu
stop with the reason when that backend cannot run. The GPU backend implements
only the 'interjoint' path mode.
Brook implements Kimimaro's skeletonization on the GPU. Its skeletons are close to Kimimaro's but not always identical, so the two backends' DBMs agree closely rather than byte for byte (see the measurements below). Kimimaro collects the skeletons of its 8 worker processes as they finish, so the order of the tracks within a key can also differ between two CPU runs.
Every key (h:{z}, vy:{y}, vx:{x}) holds a pickled list of (N, 3) int32
ZYX arrays in full-resolution voxels, and an empty slab holds an empty list.
Keys already in the DBM are skipped, so an interrupted run resumes where it
stopped, with either backend; delete the DBM to recompute it. When
write_native_packed_store is set (the default), the script then writes the
packed store described in the next section. That step imports tracks.py,
which needs PyTorch; --no-packed-store skips it, and convert_track_store.py
can write the store later.
The GPU backend decodes the z range once into a bit-packed block in POSIX
shared memory (/dev/shm) of about (z-max − z-min) × Y × X / 8 bytes, where Y
and X are the full extent of the volume, plus at most a quarter of that again
(at the default slab spacing) for the byte columns of the vx slabs. For 400
slices of a 7888 × 8096 volume the block takes 3.2 GB. The script stops with an
error when /dev/shm has too little free space.
Measured on Scroll 1 surface predictions with the default slab geometry and thresholds. Times run from the start of the extraction to the closed DBM, reading and decoding included, interpreter start-up and the packed store not:
| Input (Y × X) | z range | Hardware | Backend | Runs | Time |
|---|---|---|---|---|---|
| public predictions, 7888 × 8096 | 10900–11300 | Intel Core i9-14900KF | Kimimaro, 8 workers | 1 | 336.9 s |
| same | 10900–11300 | NVIDIA RTX 4090, driver 615 | GPU | 3 | 4.1, 4.2, 4.2 s |
| predictions, 8174 × 8174 | 10900–11300 | 4 × NVIDIA H100 80GB, driver 570 | GPU | 3 | 4.4, 4.5, 4.6 s |
| same | 10752–14848 | 4 × NVIDIA H100 80GB | GPU | 3 | 15.7, 15.9, 19.6 s |
| same | 10752–14848 | 1 × NVIDIA H100 80GB | GPU | 1 | 40.0 s |
The 19.6 s run was the first in a new environment (numba compiles and caches
its kernels once). On z 10752–14848 (446 keys) the GPU backend wrote 1,006,831
tracks with 41,530,237 points, and a Kimimaro run on the same range wrote
1,006,740 tracks with 41,543,412 points (+0.01 % tracks, −0.03 % points). The
GPU DBMs were byte-identical across runs and between 1 and 4 GPUs, and the tests
compare them with the per-slab loop run with brook.skeletonize. These are
single inputs; speed and agreement depend on the data and the GPUs.
Run the tests from the repository root:
PYTHONPATH=spiral-fitting spiral-fitting/.venv/bin/python -m pytest -q \
spiral-fitting/tests/test_extract_surface_tracks.pyThey compare fast_tracks.py with the networkx walk of the CPU backend on
fixed and random graphs, check the backend selection and the command line
without a GPU or Kimimaro, and run the CPU backend on a small synthetic volume
when Kimimaro is installed. With Brook, CuPy, cuCIM and a GPU of compute
capability 8.0 or newer, they also run the GPU backend on one GPU on two small
synthetic zarr stores, one read by the direct chunk reader and one through the
fallback reader, and compare the DBMs with the per-slab loop run with
brook.skeletonize.
Legacy track DBMs store a pickled list of NumPy arrays in every key. For large datasets this spends minutes decoding millions of Python objects each time a fit starts. Convert a DBM once to the adjacent packed format:
python convert_track_store.py \
/data/tracks/2um_ds2_ps256_surf_v2.dbmThis writes 2um_ds2_ps256_surf_v2.dbm.vctracks/ atomically. The directory
contains contiguous coordinates, ragged offsets, source IDs, family codes,
Z bounds, arclengths, and tortuosities. fit_spiral.py automatically prefers
a current adjacent packed store while retaining the DBM as the authoritative
source and compatibility fallback. A source-file fingerprint prevents a stale
store from being used after the DBM changes; rerun with --force to replace it.
The native vc_spiral.track_store loader memory-maps the packed files, applies the Z
ROI from per-track metadata, and emits one compact float32 ragged array without
constructing per-track Python objects. The crossing builder also stages
directly from a current packed store, bypassing DBM and pickle decoding.
Crossing-connected track sampling needs the exact shared voxels between the horizontal and vertical track families. Build that index once as a CSR sidecar instead of sorting every track point whenever a fit session loads:
python build_track_crossings.py \
/data/tracks/2um_ds2_ps256_surf_v2.dbm \
--z-min 4000 --z-max 17000 \
--temp-dir /fast/disk/tmpThe optional Z range is half-open ([z-min, z-max)) and retains only tracks
entirely contained in that range. Omit both options to index the whole DBM.
The standalone builder uses a hybrid memory/disk index: it streams DBM tracks
into temporary coordinate and packed-voxel files, keeps the coordinates
memory-mapped, then loads and radix-sorts the packed keys in RAM. The native
vc_spiral.track_crossings kernel uses all requested workers for sorting,
exact-voxel discovery, arclength calculation, and pair consolidation. The
extension is built with the other Spiral native modules by uv sync from this
directory. A slower Python fallback remains available.
The builder needs roughly 20 bytes of temporary disk space per selected point.
The native radix sort temporarily holds about 32 RAM bytes per point; after the
sort, those arrays are released before the 8-byte-per-point arclength vector and
compact 16-byte crossing events are consolidated. This avoids retaining either
the selected track database or Python dictionaries of crossing pairs in RAM.
Temporary files are removed after the sidecar is written. Without --temp-dir,
the temporary workspace is created beside the tracks DBM rather than under the
system temporary directory.
The script writes
/data/tracks/2um_ds2_ps256_surf_v2.dbm.crossings.npz atomically.
fit_spiral.py finds it automatically from the configured tracks path. The
sidecar includes a fingerprint of every DBM backing file; a stale or malformed
file is ignored and the fitter falls back to its in-memory exact crossing
scan. Re-run the builder after changing the DBM (--force replaces a current
cache). A range-limited sidecar can serve the same or a narrower fitting Z
range; building another range replaces it. Point-level track exclusion also
uses the fallback because clipping a
track changes its crossing-local indices.
tracks_to_ome_zarr.py rasterizes the ZYX polylines produced by
extract_surface_tracks.py into a compressed uint8 OME-Zarr. Value 0 is
background; values 1–255 are assigned with proximity-aware reuse and display
as categorical colors with VC3D's Glasbey colormap. Rasterization uses worker
processes, while independent Zarr chunks are compressed and written by a
thread pool using Zstandard level 3.
Use a paired OME-Zarr to copy the exact volume shape and physical geometry:
python tracks_to_ome_zarr.py \
/data/tracks/2um_ds2_ps256_surf_v2.dbm \
--out /data/tracks/2um_ds2_ps256_tracks.ome.zarr \
--like /data/volumes/2um.ome.zarr \
--like-group 0Alternatively pass --shape Z,Y,X. If neither --shape nor --like is
given, the script first scans the DBM and uses the maximum track coordinate
plus one. The explicit forms avoid that extra pass for large databases.
--resume continues an interrupted conversion. Multiple positional DBMs are
combined into one output, so separate scrolls should be converted in separate
commands.
Set dense_spacing_mode to winding_model and provide the compact exported
crossing directory at the conventional <dataset>/winding_inference path or
override paths.winding_inference in spiral-scroll.json. Two vocabularies
deliberately coexist: winding_model names the fitting mode and its tunables
(sample_count_winding_model_*, winding_model_relative_pair_delta,
winding_model_huber_delta, the dense_spacing_winding_model_* losses),
while winding_inference names the exported artifact and everything tied to
its on-disk identity (the input path, the winding_inference_crossings
artifact type, and the checkpoint fingerprint field). The store is
checksum-verified and copied to each fitting GPU at startup; rays whose
crossings cannot intersect the configured z-range are excluded from sampling,
and optimisation then does no inference-store filesystem I/O. The default
24,000 samples per step are split evenly between long relative-winding pairs
(sample_count_winding_model_relative_pairs, index separation drawn from
winding_model_relative_pair_delta) and adjacent-passage density pairs
(sample_count_winding_model_density_pairs). The independent Lasagna normal
and native minimum-spacing losses remain available alongside it.
The compact store is created by the Vesuvius winding-model
export_spiral_supervision.py tool; see its NATIVE_PHASE_CACHE.md for the
exact export command and format.
For a headless fit, pass the dataset root with --dataset and select inference
mode (plus any independently disabled losses) through
FIT_SPIRAL_CONFIG_OVERRIDES. The dataset's spiral-scroll.json and the
declarative input catalog determine which conventional inputs are resolved.
The optional fiber-direction loss consumes one packed artifact extracted from a remote Lasagna fiber prediction. Extraction downloads only chunks intersecting the requested z ROI and keeps the highest-presence voxel in each fixed prediction-space cell:
./.venv/bin/python fiber_direction_samples.py \
https://example/fibers.lasagna.json \
/path/to/dataset/fiber_directions.npz \
--z-roi 10000,11000 --output-scale 4 \
--presence-threshold 160 --cell-size 2The z ROI is half-open and expressed in the output/fitter coordinate system;
--output-scale 4 means one fitter /2 coordinate is four base /0 voxels. The
extractor always covers the fiber volume's complete XY extent.
Both input_use_fiber_directions and loss_weight_fiber_directions default
to off/zero; set the toggle true and the weight above zero to enable the
loss. The fitter then loads the conventional fiber_directions.npz artifact
and samples sample_count_fiber_direction_points observations per step.
Positions and directions constrain only local fitted-sheet orientation; they
do not attach a sample to a particular winding.
Each fiber strip is tagged at load time (spiral_helpers.classify_fiber_hv):
VC3D's manual H/V tag wins, then its automatic tag when the recorded certainty
reaches pcl_vertical_fiber_min_auto_certainty, then a geometric fallback that
calls a strip vertical when its z extent is at least
pcl_vertical_fiber_min_z_fraction of its path length. The fit log reports the
vertical / horizontal / untagged split.
Papyrus carries its horizontal fibers on the front face of the sheet (the face
toward the umbilicus and the lower-winding neighbour) and its vertical fibers
on the back face, so the two fiber classes sit a few voxels apart along the
sheet normal and at most one of them can lie on the face the fit targets. The
strip losses read every fiber as lying on the fitted winding, which leaves a
permanent residual on vertical strips and on every vertical-to-horizontal link
junction. With pcl_vertical_fiber_radial_offset_enabled set, vertical strips
(as classified by spiral_helpers.classify_fiber_hv, see above) carry a
per-point target offset of pcl_vertical_fiber_radial_offset_voxels (default
4, positive = outward) along the sheet normal of the fitted spiral (the
scan-space gradient of the fitted winding, not the straight line to the
umbilicus): the radius loss, the whole-strip DT target, the DT snap, and the
satisfaction metric all expect those points that far outside the winding, on
its back face, instead of on it. Horizontal strips and regular point
collections are never offset.
The offset is a physical scroll-space distance, not a spiral-space constant.
In spiral space the fitted sheet's normal is the radial direction, so the
offset acts on the shifted radius, but the scroll-to-spiral map is not an
isometry (the gap expander rescales the radial coordinate per gap and the flow
stretches locally), so each point's offset is multiplied by the transform's
local stretch along the sheet normal, |J^T n|
(sample_spiral.get_radial_normal_stretch; the same J^T covector transport
the dense-normals loss uses). J^T n is the scan-space gradient of the fitted
winding, so a positive offset displaces the expected fiber position along it:
the increasing-winding direction of the fitted spiral at that point, away from
the umbilicus, which coincides with the line from the umbilicus only where the
sheet happens to be perpendicular to it. The loss evaluates that stretch for its sampled
points each step under no_grad, the DT target cache and the satisfaction
metric for the points they read. After a constraint bake the resident geometry
lives in the frozen stack's output frame, so each vertical strip and fiber
catalog point also carries radial_offset_bake_scale, the product of every
frozen epoch's normal stretch at the pre-bake point
(fit_spiral.accumulate_radial_offset_bake_scale); ingested inputs pick it up
when they are pushed through the stack, re-materialised strips inherit it from
the catalog, and the final scroll-space export drops it because the composed
transform's Jacobian then carries the whole stretch. The offset travels with
the strip bundle (radial_offsets, resident-frame voxels after the bake
scale) next to the winding annotations, so linked components mixing both fiber
classes read as one winding. Unlike the other pcl_ settings, both keys are
Run-scoped: a Run that changes them refills every retained strip's offsets from
its stored vertical/horizontal tag and rebuilds the strip bundle and DT target
caches, so no fit rebuild is needed. VC3D's spiral panel exposes them as a
checkbox and a voxel distance next to the fibers path.
Every point of every point collection (regular PCLs and fibers) is attached to
the patch surface it lies on when the inputs load, and again for inputs added
to a running session. A point attaches when a patch surface is within
pcl_link_distance_tolerance scroll voxels (default 2.5). General collections
take the largest-area patch within tolerance, then the nearest;
between_patches__A__B collections take the nearest of their named pair.
Every linking setting applies at a Run boundary. The tolerance and window
settings re-link every resident collection, regular and fiber, from the
retained catalogs against the resident patches and re-derive all the
point-collection views (cross-patch groups and unattached strips); the fiber
side-rule settings below only affect fibers, so they re-link and
re-materialise the fibers alone.
pcl_link_window_points and pcl_link_window_min_points (both default 1)
gate candidates on their neighbours: a patch the point itself lies within
tolerance of is eligible only when at least pcl_link_window_min_points of the
centred window of pcl_link_window_points consecutive points (id order, the
point included) also lie within tolerance of it. Eligible candidates are ranked
as usual, largest area then nearest, so a fiber stays on the big patch its
neighbours share instead of hopping onto a patch only one point touches. Even
window counts round up to the next odd count, and at a collection's ends the
requirement is clipped to the window members available. Points already
attached to a patch count as window members when a session relinks.
pcl_fiber_link_side_filter keeps fibers off patches on the wrong side of the
sheet, which is how a fiber ends up on an adjacent winding when windings touch.
The front of a sheet faces inward, toward the umbilicus and the neighbouring
winding with the lower winding number; horizontal fibers lie on that front
face and vertical fibers on the back. A vertical fiber (classified as described above) therefore only attaches to a patch whose surface is
in front of it, a horizontal fiber only to one behind it, and untagged fibers
and regular collections are unrestricted. A hit is rejected when the projection
foot lies more than pcl_fiber_link_side_margin_voxels (default 0.5) on the
wrong side of the point along the inward direction; the margin absorbs points
lying on the traced surface itself.
No fitted transform exists when the inputs load, so the inward direction starts
as the line to the umbilicus at the point's z, which local deformation can turn
away from the true sheet normal. Once pcl_fiber_link_model_direction_step
steps (default 10000) have completed, whether run in the session or restored
from a checkpoint, every fiber is relinked once with the inward direction taken
from the fitted spiral's decreasing-winding direction (the negative scan-space
gradient of the fitted winding, fit_spiral.inward_winding_direction), and the
fiber training views are re-materialised. A checkpoint from before that step
loaded after the switch relinks back under the umbilicus direction at its first
step.