Tutorial: Spiral Fitting
Last updated: September 16, 2026
Most of our segmentation tools work bottom-up. GrowPatch, lasagna, and manual segmentation in VC3D all produce patches β pieces of papyrus surface that you grow bigger and bigger until they hit a tricky region and stall. Other tools trace individual fibers. Either way you end up with a big pile of small pieces: segments, fibers, point annotations. What we really want is the whole scroll β one surface covering every winding of the original papyrus sheet, from the center to the outer shell. However, gluing the pieces together directly is hard, especially where there are gaps between them. 1
That is what the spiral fit does. It takes the whole pile of partial evidence β surface patches, traced lines, winding annotations, volumetric predictions β and fits a single, globally coherent surface for the entire scroll that agrees with as much of that evidence as possible. Where the evidence is dense, the fitted surface follows it closely; where there are gaps, the spiral bridges them smoothly instead of stopping or leaving a gap.

The core idea: we know the scroll was originally one long rectangular sheet, rolled up into a neat spiral. The eruption of Vesuvius deformed that spiral into the crushed shape we see in the CT scan. Instead of reconstructing the surface piece by piece, we search for the combination of ideal scroll shape and smooth deformation that best explains everything we observe. Once we have those, virtual unrolling comes almost for free: any point in the scan can be mapped back onto the original flat sheet.
The last section of this tutorial goes into how it works internally; first, the practical part β what goes in, what comes out, and how to run it.
What goes inβ
The spiral is flexible about its inputs: it consumes many kinds of evidence, in almost any combination, and each kind can be created manually or automatically.
- Surface patches β small pieces of scroll surface, stored as
tifxyzmeshes (the grid-of-3D-points format used by VC3D). These can come from GrowPatch, lasagna (direct growth, or growth around fibers), neural Copy In/Out, or any other segmentation method. Only human-checked (verified) patches are recommended; they are also used to calculate evaluation metrics. - Strips and lines of points that follow the surface of a single sheet β either point collections drawn in VC3D, or fibers traced in VC3D.
- Relative winding annotations β sets of points lying on different windings, annotated with how many windings apart they are (e.g. "these two points are exactly one wrap apart"). Represented as VC3D point collections with relative-winding annotations.
- Absolute winding annotations β points annotated with the absolute winding number they lie on (e.g. "this patch is on winding 20"). Also VC3D point collections.
- Coarser volumetric guidance derived from machine-learning predictions: predicted surface normals (from lasagna, stored as zarr volumes), predicted gradient magnitude (which captures the local radial density of windings), and skeletonised surface-prediction tracks (created with
extract_surface_tracks.py). - Scroll-level structure: the umbilicus (the scroll's central axis, as a function of z β required), and optionally a mesh of the scroll's outermost surface, which pins down where the spiral must end.


None of these individually needs to cover the scroll. Sparse, scattered evidence β a patch in one region, a fiber in another, a few relative-winding annotations in an ambiguous area β is combined by the fit into one consistent global solution, and annotations placed where the scroll is most damaged contribute the most.
What comes outβ
The output is one tifxyz mesh per winding of the scroll β a full set of surfaces that conform to the input constraints, covering the whole fitted region including places no patch ever reached. Two variants are written for each winding: wNNN, the pure fitted spiral surface, and wNNN_spliced, where the geometry of verified patches is spliced into the fitted surface wherever the fit and the patch agree β more locally accurate wherever trusted geometry exists.
Since these are ordinary tifxyz meshes, everything downstream works as usual: you can load them in VC3D, flatten them, and render surface volumes for ink detection. The repo also includes a tool (render_ink.py) that concatenates the windings into fixed-width chunks, flattens them, and renders ink predictions as a series of horizontal strips β more on that below.
Alongside the meshes, a fit writes a model checkpoint, satisfaction metrics β per-input-type statistics of how much of the evidence the final surface actually honors β and, optionally, overlay images showing the fitted windings drawn over scan slices.
How to run itβ
The code lives in the villa repository under spiral-fitting; the main entry point is fit_spiral.py. You'll need Python β₯ 3.14 and an NVIDIA GPU.
git clone https://github.com/ScrollPrize/villa.git
cd villa/spiral-fitting
uv sync
Everything the fit needs is declared in the project's own pyproject.toml, torch included β on Linux it comes from the CUDA 12.8 wheel index, so there is no separate torch install to get right. uv sync also builds vc_spiral, a small C++ extension the fit uses to link point annotations to patch surfaces, so the machine needs cmake and a C++ toolchain; you no longer need a volume-cartographer Python install.
On Windows, uv sync also installs triton-windows, a community build of Triton, because PyTorch publishes no triton wheel there and the fit's fused kernels need one. The first run compiles those kernels once.
Get the datasetβ
Ready-made inputs are published in the spiral-input dataset, which lives on the dl.ash2txt.org data server : Spiral Datasets (~90 GB):
rclone copy :http: ./spiral_datasets/phercparis4 \
--http-url https://dl.ash2txt.org/datasets/spiral_datasets/PHercParis4/ \
--transfers 32 -P
Note that re-running rclone resumes interrupted downloads. For PHerc Paris 4, the dataset contains verified and unverified patches, tracks, fibers, the outer shell, winding annotation JSONs, the umbilicus, and the volume inputs β see the dataset README for the exact layout.
Configureβ
Two separate things configure a run: where the data is β given on the command line, plus one file inside the dataset β and how to fit it, a flat set of named settings.
The datasetβ
fit_spiral.py takes a --dataset root and resolves the conventional layout underneath it: umbilicus.json, verified_patches/, fibers/, fiber_directions.npz, outer_shell/, tracks/, winding_inference/, the lasagna_inputs/*.ome.zarr volumes, and the point-collection documents abs_winding.json, relative_windings.json, same_windings.json and drawn_control_points.json. A download of the published dataset is already in that layout, so there are no paths to edit.
You also need to provide a spiral-scroll.json in the dataset root, recording the physical facts of the scroll:
{
"schema_version": 1,
"name": "PHercParis4",
"voxel_size_um": 9.6,
"spiral_outward_sense": "CW"
}
name is free-form and is what appears in the generated run-folder name. spiral_outward_sense ("CW" or "ACW") says which way the spiral turns as it winds outward. No automated method determines it: it is read off the CT data by a person in VC3D, or taken from an already-fitted spiral. The file can also carry a paths object naming individual inputs whose filenames don't match the conventional ones ("tracks_dbm" is the usual one), and normal_zarr_group / lasagna_scale, which choose the OME-Zarr pyramid level the lasagna normal stores are read at β these are easy to get wrong silently, so read the scale off the store's own .zattrs rather than copying another scroll's values. The spiral-fitting README documents the full schema.
The fit configurationβ
Everything else β the fitted z-range, which inputs participate, loss weights, resolutions, step counts β is a flat dictionary of named settings defined in config.py: one attribute of the Config class per knob, each with its default. You can pass overrides as JSON:
FIT_SPIRAL_CONFIG_OVERRIDES='{"z_begin": 10500, "z_end": 11500, "optimizer_num_training_steps": 10000}' \
python fit_spiral.py --dataset ./spiral_datasets/phercparis4
The settings you are most likely to touch:
-
z_begin,z_endβ the slice range (in full-resolution voxels) to fit; the defaults, 4,000 and 17,000, span the whole written region of Scroll 1. Consider starting with a small range: fitting all of it needs a lot of GPU memory and time, and a ~1,000-slice range is a good first run on a smaller GPU. Per-step sample counts are scaled automatically to the size of the z-range, so the other hyperparameters don't need retuning when you change it. -
input_use_*β whether an input that is present is actually used. Each optional input source has its own toggle, independent of whether its file is there:input_use_verified_patches,input_use_tracks,input_use_fibers,input_use_fiber_directions,input_use_normals,input_use_gradient_magnitude,input_use_winding_inference,input_use_outer_shell, plus one per annotation role βinput_use_pcl_absolute,input_use_pcl_relative,input_use_pcl_same_winding,input_use_pcl_drawn_control_points.Note thatinput_use_tracksdefaults tofalse, so a dataset that includes a tracks DBM will not use it unless you turn it on:FIT_SPIRAL_CONFIG_OVERRIDES='{"input_use_tracks": true, ...}'A few ready-made override files live in
configs/.
A few environment variables control the run itself rather than the fit:
| Variable | Effect |
|---|---|
FIT_SPIRAL_CONFIG_OVERRIDES | JSON dict of Config overrides, e.g. '{"optimizer_num_training_steps": 10000}' |
FIT_SPIRAL_OUT_DIR | Parent directory for the generated run folder (default ./out) |
FIT_SPIRAL_RUN_DIR | Use this exact directory as the run folder, instead of generating a name |
FIT_SPIRAL_RUN_TAG | Tag appended to the output folder and mesh names |
FIT_SPIRAL_RESUME_PATH / FIT_SPIRAL_RESUME_STEP | Resume from a checkpoint |
WANDB_MODE | Set to online to log losses and visualizations to Weights & Biases (default disabled) |
The cache of preprocessed inputs β which speeds up subsequent runs a lot β defaults to ~/.cache/vc3d/spiral, shared across datasets since its entries are content-addressed; --cache DIR (or FIT_SPIRAL_CACHE_DIR) moves it elsewhere.
Fitβ
python fit_spiral.py --dataset ./spiral_datasets/phercparis4
That's it β the script loads the inputs (caching the expensive preprocessing), then runs 30,000 optimization steps, printing the loss breakdown every 200 steps. Multi-GPU is supported via torchrun --nproc-per-node=N fit_spiral.py --dataset ..., which splits each step's work across GPUs.
To run the whole pipeline in one command β fit, then render ink, then score it β use runners/run_single.py instead. It takes the same --dataset, plus an --ink-volume, and accepts the same configuration overrides as a --config JSON file; runners/run_sweep.py runs a whole folder of such configs concurrently across GPUs.
When it finishes, you get a self-contained run folder:
out/2026-07-08_s1_slice-10500-11500_27399-patch_<run-name>/
βββ checkpoint_fitted.ckpt # fitted model (resumable)
βββ satisfied_fitted.json # which individual inputs the fit honors
βββ satisfaction_metrics_fitted.json # and the summary of those, per input type
βββ spiral_on_*_fitted.png # fitted windings overlaid on inputs
βββ meshes/mesh/
βββ w010/ # one tifxyz mesh per winding...
βββ w010_spliced/ # ...plus the patch-spliced variant
βββ w011/
βββ ...
The overlay PNGs are only written when output_save_png_visualizations is on; it defaults to off, since rendering them means reading scan slices back at the end of the fit.
Rendering inkβ
To get from per-winding meshes to readable images, use render_ink.py. It groups the _spliced winding meshes into winding-range chunks, concatenates each chunk into a single mesh (written to a concat/ folder β useful for loading the geometry behind each strip as one mesh), SLIM-flattens it, renders it through an ink-prediction volume with vc_render_tifxyz, and composites the result into one JPEG strip per chunk:
python render_ink.py /path/to/run/meshes/mesh --volume /path/to/ink_prediction.zarr
You'll need a VC3D build on your PATH for the rendering and flattening binaries (vc_render_tifxyz, flatboi, β¦), and an ink-prediction zarr for the scroll. The output ink/ folder fills with strips named by winding range (e.g. w010-027.jpg).
Ink metricsβ
The script get_ink_metrics.py computes some metrics based on the amount of letter-like ink signal detected in the ink renders. By default it uses the model scrollprize/ink-coverage-32um from HuggingFace; this is a 2D nnUNet operating on small patches, trained to do binary segmentation of clearly-identifiable ink. The script measures the total area of ink detected, as well as evaluating whether columns are coherent and have approximately the expected width, and lines are locally coherent (based on sliding windows) and have approximately the expected pitch.
The ink-coverage model was only trained on PHerc. Paris 4, so it may not give accurate results for other scrolls with significantly different writing styles.
How it worksβ
Up to this point we treated the fit as a black box; here is what is actually inside it. (There are more math details in the paper Virtually Unrolling the Herculaneum Papyri by Diffeomorphic Spiral Fitting, though for a slightly older version of the algorithm.)
An ideal scroll...β
Originally, a scroll was one nearly rectangular sheet of papyrus, rolled up (often around a central rod). In cross-section that is a spiral β specifically, we model it as a perfect archimedean spiral, extruded into the plane. Treating it as arbitrarily large, the ideal scroll has just one free parameter: the tightness of its windings, . A point on the ideal sheet is addressed by two curvilinear coordinates β the angle along the spiral and the height along the axis β and sits at radius
so each full turn moves the sheet outward by one sheet-to-sheet spacing . Plug in any and you get a 3D point on the ideal sheet.
...horribly deformedβ
The eruption turned that neat spiral into the crumpled shape in the scan. We model the damage as a diffeomorphic transformation: a smooth, differentiable, invertible map of 3D space. That choice buys us exactly the guarantees we need:
- It cannot tear the sheet, make it pass through itself, or squish it to a point β it preserves topology. If it starts as a spiral, after deformation it is still a spiral, just a messed-up one.
- It is invertible: a point on the ideal scroll maps to a point in the scan, and β just as importantly β any point in the scan maps back to a point on the ideal (i.e. flattened) scroll. That inverse map is the virtual unrolling.
The deformation is composed of three parts applied in sequence: a coarse global scale and shear, the integral of a stationary velocity field (the most important one), and a local scaling of the gap between windings, defined everywhere on the sheet (this lets windings locally squeeze together or spread apart without disturbing anything else). Each part is smooth and invertible, so the composition is too.
The middle term deserves a closer look. Imagine a little 3D arrow attached to every point in space β a velocity field . Every point of the ideal spiral flows along these arrows, like dust in a (smooth, steady) wind. Mathematically, the trajectory of a point is defined by the ODE
and the transformation is where the flow ends up after one unit of time: . Don't worry too much about the equation β the intuition is what matters: every point rides smoothly along the flow, so the whole spiral deforms smoothly into a new shape, and running the flow backwards gives the exact inverse. This is the same machinery used in diffeomorphic medical image registration; in the code, the ODE is integrated with a few RungeβKutta steps (flow_fields.py, transforms.py).

Fitting as an inverse problemβ
Fitting is then an inverse problem: find the winding tightness and the deformation parameters (the velocity field, plus the scaling terms) such that the deformed spiral explains what we see in the scan. We don't fit to the raw CT intensities directly. Instead, every input from What goes in becomes a differentiable loss term saying what the deformed spiral should look like:
- points from a same-sheet strip should all land on some winding surface (and the same one);
- two points annotated as windings apart should land exactly windings apart;
- a verified patch should coincide with a single winding across its whole extent;
- tracks, normals, and gradient-magnitude volumes nudge the surface orientation and winding density;
- the innermost winding should wrap the umbilicus, and the outermost should follow the outer shell;
- and regularization terms keep the sheet parameterization from distorting.
All parameters are optimized jointly, with plain Adam, minimizing the weighted sum of these losses (the weights are the loss_weight_* entries in default_config). In effect, we tell the machine "here are all the constraints humans and models have gathered β find the deformation that squishes the ideal spiral so that they are all met", and gradient descent does the rest.
At the end, we sample each winding of the fitted ideal spiral on a regular grid, push the samples through the fitted deformation into scan coordinates, and write each winding out as a tifxyz mesh β the outputs described above.
One caveat when reading the paper: it describes a fully automatic setup that fits only raw surface-prediction tracks and fields derived from them. The current code fits the much richer curated evidence described in this tutorial β verified patches, fibers, and winding annotations β which is what makes it accurate enough to target whole-scroll segmentation. The underlying model and optimization are still very similar.
Footnotesβ
-
The surface tracer is an earlier attempt at this problem: it stitches overlapping patches into large segments automatically. But it requires the patches to physically overlap or touch, and it becomes unreliable at whole-scroll scale. β©