HTTP Endpoints¶
All endpoints listed below are served by backend-api/main.py on
http://127.0.0.1:<backend-port> — the port is chosen dynamically per app
instance (see Processes & IPC).
The tables are grouped by feature area. To find a handler, grep ^@app\. in
main.py.
Health & metadata¶
| Method | Path | Source | Purpose |
|---|---|---|---|
| GET | / |
main.py |
Root ping |
| GET | /health |
main.py |
Liveness probe. Also reports memory (physical / available / RSS / budget / admission_budget_bytes, from memory_budget.py; read by Settings to show what the budget resolved to) and admitted (operations currently counted against the memory budget) |
| GET | /version |
main.py |
Returns BACKEND_VERSION (used by the supervisor) |
| GET | /api/device-info |
main.py |
Reports whether synthetic-scan ray tracing runs on GPU or CPU. Shipped Windows/Linux builds always contain the CUDA path (the release CI fails otherwise) and macOS never does, so the path is decided by a runtime probe for a usable NVIDIA GPU (gpu_present/gpu_count/gpu_name/driver_version, via pyhelios.runtime.get_gpu_runtime_info — mainly nvidia-smi). effective_path is "gpu" when a GPU is present on a non-macOS host, else "cpu" (Helios falls back to CPU/OpenMP; cudart is statically linked so a GPU build still runs driverless). reason is a human-readable explanation. The renderer surfaces this as the GPU/CPU pill in the Synthetic Scan Options dialog |
Curve / surface fitting¶
| Method | Path | Source | Purpose |
|---|---|---|---|
| POST | /api/fit |
main.py |
Fit a built-in model to data |
| GET | /api/models |
main.py |
List available fitting models |
| POST | /api/fit/custom |
main.py |
Fit a user-supplied model expression |
| POST | /api/fit/prospect |
main.py |
PROSPECT leaf optical model |
| POST | /api/fit/crown |
main.py |
Fit crown shapes + derive per-tree metrics. Streams PHP1 progress ahead of a JSON tail (one entry per fitted crown: mesh arrays, a metrics block, and a shape-dependent params block giving the fit's defining parameters); cancelable via the run-id token |
LaTeX & export¶
| Method | Path | Source | Purpose |
|---|---|---|---|
| POST | /api/latex |
main.py |
Render expressions to LaTeX |
| GET | /api/latex |
main.py |
Retrieve a previously rendered expression |
| POST | /api/export |
main.py |
Export fit results |
Meshing & sampling¶
| Method | Path | Source | Purpose |
|---|---|---|---|
| POST | /api/triangulate |
main.py |
Triangulate a point cloud |
| POST | /api/triangulate/helios |
main.py |
Helios-style triangulation. Each scans[] entry carries its own acquisition geometry (origin, n_theta/n_phi, theta_min/max, phi_min/max); an optional grid (center/size + nx/ny/nz) comes from a voxel box. With no grid the backend auto-fits a single cell over all points and sets grid_warning on the response. Each scan is triangulated independently, so the response includes triangle_scan_ids — the source scan index per triangle — for coloring by scan |
| POST | /api/lidar/scan |
main.py |
True ray-traced synthetic LiDAR scan via the PyHelios lidar plugin. meshes[] carry world-space vertices/triangles (+ optional per-vertex colors); scanners[] carry each scanner's renderer id plus its ScanParameters (origin, n_theta/n_phi, theta_min_deg/max, phi_min_deg/max, return_mode (single/multi), max_returns (multi), return_selection (strongest/first/last, single), exit_diameter_m, beam_divergence_mrad). A legacy return_type (single/multi) is still accepted and mapped to return_mode. Optional extra_fields[] names custom primitive-data labels to sample onto hits (column-format driven). All meshes load into one Helios Context; scanners are added in order so the Helios scanID equals the request index, and each scan's stored ReturnMode/maxReturns/selection is set via the per-scan setters. syntheticScan ray-traces once (one global rays_per_pulse: every scan fires that many sub-rays across its beam cone, and rays_per_pulse=1 collapses the cone to one exact ray per pulse — the idealized scan) and hits are partitioned back per scanner via getHitScanID. Optional synthetic_scan_memory_budget_mb caps the transient ray-tracing scratch buffers (via LiDARCloud.setSyntheticScanMemoryBudget) so a large fan-out is chunked instead of traced in one OOM-prone batch; omitted/null/≤0 leaves Helios's automatic path-dependent default (4 GiB CPU / 8 GiB GPU) in place, and chunking is result-invariant. Returns results[] — one per scanner (scanner_id, points, colors, and scalars{}: intensity/distance/timestamp/target_index/target_count read via getHitData) — occlusion-aware, unlike random surface sampling |
| POST | /api/mesh/import |
main.py |
Parse a textured .obj (+ sibling .mtl + images) or a .ply polygon mesh (ASCII or binary, with per-vertex color) from a disk path into geometry, V-flipped per-vertex UVs, per-material triangle groups, and base64-encoded textures. Returns a PHB1 binary frame: geometry rides in the buffers (vertices, indices, and optional normals/colors/uv_coordinates) while materials/textures ride in the JSON meta. JSON would break here — a scanner-grade mesh (millions of triangles) serializes past V8's ~512 MB string cap, where the renderer's response.json() throws ERR_STRING_TOO_LONG regardless of timeout. Streamed behind PHP1 progress markers (so a PLY import that waits for Open3D to load says so); a missing file, an unsupported extension, or a face-less PLY header is still rejected with a real 404 / 400 before the stream starts |
| POST | /api/triangulate/check-spacing |
main.py |
Opt-in diagnostic cross-checking the auto-estimated Lmax against actual in-grid point spacing (the renderer offers it when the Otsu indicators aren't both High). Builds a KD-tree over up to tens of millions of points, so it streams keepalive whitespace to survive WebKit's ~60s stall timeout, then yields the JSON verdict. Reuses HeliosTriangulationRequest |
Scanning support & job control¶
| Method | Path | Source | Purpose |
|---|---|---|---|
| POST | /api/scan/export-xml |
main.py |
Export objects to a Helios XML + per-scan data bundle (or, with write_xml: false, one data file per object in data_format). Entries need only origin + a point source — the Export window lists every cloud, so a plain import with no scan geometry exports fine to the data formats (only the XML bundle and PTX need the sweep/grid, and the UI blocks those rows). Streams PHP1 progress markers ahead of its JSON tail, one per object (weighted by point count), and is cancelable via /api/cancel/{run_id} — a cancel unlinks the files it had already written so a partial batch never looks complete. Set dest_dir (absolute) and the backend writes every file itself, returning metadata only with data: null and written: true — the app always does this, and it's the only form that works for more than one scan: this response carries every selected scan at once, so base64 (~1.33x inflation) overruns V8's ~512 MB string cap in the renderer's response.json() and fails as "Unexpected end of JSON input" — the same trap as dest_path on /api/pointcloud/export, but reached sooner because the payload scales with the number of scans. Omitting dest_dir keeps the legacy base64-in-data response for callers with no filesystem destination |
| POST | /api/trajectory/parse |
main.py |
Parse a binary trajectory (SBET .sbet/.out) into the canonical PoseStream wire dict. Server-side because it needs pyproj for the geographic→UTM projection. Text trajectories (.csv/.txt/.tsv/.traj) are parsed in the renderer and never reach this endpoint |
| POST | /api/cancel/{id} |
main.py |
Cancel an in-flight streaming op (point-cloud import / synthetic scan / triangulation / LAD / DEM / crown fit / point-cloud + object export). Streaming endpoints emit their run_id as the first PHP1 marker; POSTing it here stops the work and frees the C++/numpy memory without waiting for the computation to finish. Idempotent — an unknown or already-finished id returns canceled: false rather than an error |
Skeleton extraction¶
| Method | Path | Source | Purpose |
|---|---|---|---|
| POST | /api/skeleton/extract |
main.py |
Extract a topological skeleton |
QSM (Quantitative Structure Model)¶
| Method | Path | Source | Purpose |
|---|---|---|---|
| POST | /api/qsm/build |
main.py |
Reconstruct a dormant tree as connected cylinders with radii + topology, segment continuous shoots, and classify them by shoot rank (trunk=0, scaffolds=1, …) |
| POST | /api/qsm/import |
main.py |
Read a QSM back from a per-cylinder CSV (qsm/csv_io.py), reconstructing the shoot table and recomputing metrics. Returns the same QSMBuildResponse shape as /api/qsm/build, so an imported QSM needs no separate renderer path |
| POST | /api/qsm/phyllotaxis |
main.py |
Auto-detect the phyllotactic angle from the QSM's branching geometry (child-shoot azimuths around each parent). Returns a canonical angle + pattern + leaves-per-node + confidence; pre-fills the Add Leaves modal |
| POST | /api/qsm/leaves |
main.py |
Place leaves on the QSM's terminal shoots and return a textured mesh |
| GET | /api/qsm/leaf-textures |
main.py |
List the curated built-in leaf textures available for QSM leaf placement |
| POST | /api/qsm/adjust-leaf-angles |
main.py |
Rotate placed leaves so each voxel cell's leaf-angle distribution matches a target measured from a leaf-on triangulation, via per-cell optimal assignment. Takes either a triangulation or precomputed cell_targets. Accepts JSON or a PHB1 binary frame (tin_vertices/tin_indices/tin_cell_ids, plus optional tin_scan_ids/tin_scan_origins, with the grid under grid_for_triangulation in the header meta). The renderer always sends the frame — this tool only accepts full-resolution Helios triangulations, which do not fit in a JSON body |
Takes inline points or a source descriptor (octree-backed clouds). The full
pipeline lives in the qsm/ package and is a thin call from the endpoint:
geodesic level-set skeleton → segment tree + GrowthLength continuation +
shoot rank (largest-GrowthLength axis continuation; trunk=0) → robust IRLS
cylinder fit + SurfCov/mad → monotone-taper radius correction (anchored to
a per-species twig_radius_mm, default 4.23 mm) → axis termination
(qsm/continuation.py) → horticultural metrics.
Axis termination lets a shoot END at a codominant fork, so a headed tree's trunk
stops at the heading cut instead of being traced up one scaffold. It runs AFTER
radius correction on purpose: the test is a sibling-radius comparison
(r2/r1 >= fork_symmetry, default 0.75, aRchi's published value) plus a subtree-size
floor, and only the corrected radii separate it — measured at a real redbud's true
fork, symmetry is 0.50 on the provisional radius, 0.39 on the raw fit and 0.90 after
correction. Set axis_termination: false to restore the never-terminating behavior,
or raise/lower fork_symmetry per tree architecture.
Returns cylinders[] (each with start/end/radius/parent_id/shoot_id/
rank/surf_cov/mad), shoots[] (continuous axes with rank + parent/child
links), and a metrics block (TCSA, trunk diameter, height, scaffold count, woody
volume split stem-vs-branch, plus per-rank length/diameter/crotch-angle). The
headline output is the per-shoot rank — topological branching order with axis
continuation (NOT Strahler). Validated against PyHelios ground-truth fixtures
(backend-api/tests/qsm/) on both determinate-trunk and central-leader
architectures.
Ground segmentation¶
| Method | Path | Source | Purpose |
|---|---|---|---|
| POST | /api/segment/ground |
main.py |
Classify points into ground (1) / plant (2) via the Cloth Simulation Filter. Takes inline points or a source descriptor (read at full resolution — no downsampling, so labels align 1:1). Returns per-point labels + counts. Used for flat (in-memory) clouds; session clouds use /api/cloud/session/{id}/segment_ground instead. A cloth too fine for the extent is refused with 400 (see the session variant) |
The classifier is the cloth-simulation-filter package (import CSF), a
SWIG C-extension bundled via collectAll in scripts/build-backend.mjs.
Noise filtering¶
| Method | Path | Source | Purpose |
|---|---|---|---|
| POST | /api/pointcloud/denoise |
main.py, denoise.py |
Classify points into clean (1) / noise (2). Takes inline points or a source descriptor (full resolution, labels align 1:1). Returns per-point labels, the flagged count/fraction, the resolved parameters, and any warnings. Used for flat (in-memory) clouds. Above PHYTOGRAPH_DENOISE_TILE_MIN_POINTS (4 M) the radius and voxel criteria run per buffered tile with parameters resolved once (stats.tiled); SOR stays global |
| POST | /api/cloud/session/{id}/denoise |
main.py, denoise.py |
Same classification on the session's in-RAM hit survivors, appending a noise_class column and rebuilding the octree so the flagged points render. Nothing is deleted — removal goes through /filter or /split |
Three criteria, all in backend-api/denoise.py as index-preserving keep-masks:
ror (radius outlier, the default), voxel_count (O(N), for very large
clouds) and sor (statistical). Any parameter left unset is derived from the
cloud's own p95 nearest-neighbor spacing and echoed back in params_used.
Both routes run the compute in the killable seg_worker subprocess
(tool: "denoise"), so a client disconnect actually stops it — a single
cKDTree.query is a monolithic C call that cannot poll a cancel flag.
Don't change the SOR default to 2.0
DEFAULT_SOR_STD_RATIO is 4.0, not the conventional open3d value. SOR's
threshold is set by the extremes still present in the cloud, so it becomes
more aggressive on each pass — measured at 73% of a test tree's twig points
destroyed on a second run. The reasoning and the numbers are in
denoise.py's module docstring, pinned by
tests/test_denoise.py::test_sor_second_pass_eats_fine_structure.
Tree segmentation¶
| Method | Path | Source | Purpose |
|---|---|---|---|
| POST | /api/segment/trees |
main.py |
Segment individual trees with TreeIso (cut-pursuit graph method, CPU-only) or, with method: "chm", a canopy-height-model watershed (chm_trees.py; chm_crown_scale (about the width of the smaller crowns; default 2.5 m), chm_min_height, optional chm_cell — no cost prompt, tiling or fusion_warning). Takes inline points or a source descriptor (full resolution; labels align 1:1) and optional seed_points (trunk seeds for human-in-the-loop — each seed yields one tree). Returns per-point labels (0 = unassigned, 1..N = trees), num_trees, ground_warning (true when no ground_class labels came with the points, i.e. Ground Segmentation was not run — a reminder, not a detection), and fusion_warning — set when an instance holds several trunks, i.e. trees were probably merged and num_trees is too low (see _treeiso_row_fusion_warning). Used for flat clouds; session clouds use /api/cloud/session/{id}/segment_trees instead |
TreeIso is vendored (MIT) under backend-api/vendor/treeiso/; its graph-cut
backend cut_pursuit_py is bundled via collectAll in
scripts/build-backend.mjs. No GPU or PyTorch required.
Wood / leaf segmentation¶
| Method | Path | Source | Purpose |
|---|---|---|---|
| POST | /api/segment/wood |
main.py |
Classify points into wood (1) / leaf (2) from local geometry, or with a trained model (method: "ml", optional model_id; see ML models). Aggregates multiple sources at full resolution (concatenated in order so labels slice back per source) and accepts optional per-point reflectance. Returns per-point labels aligned to input order. Session clouds use /api/cloud/session/{id}/segment_wood instead |
ML models¶
| Method | Path | Source | Purpose |
|---|---|---|---|
| GET | /api/ml/models |
main.py |
Installed point-classification models (bundled + user-imported), optionally ?task=wood_leaf. Each carries its class schema, input channels, origin and metrics |
| GET | /api/ml/device |
main.py |
Where ML inference runs (cuda / mps / cpu), as torch reports it. Probed once in a worker (torch never loads into the server process), then cached |
| POST | /api/ml/models/import |
main.py |
Validate a model package directory ({path}) and install it into the user model directory. Weights are loaded against the declared architecture first; a package reusing a built-in id is refused |
| DELETE | /api/ml/models/{id} |
main.py |
Remove a user-imported model (built-ins cannot be removed) |
DEM (digital elevation model)¶
| Method | Path | Source | Purpose |
|---|---|---|---|
| POST | /api/dem |
main.py |
Generate a DEM from a flat cloud (inline points / source). Returns a PHB1 binary frame (heightmap mesh + regular grid); cancelable |
| POST | /api/dem/export-raster |
main.py |
Write a DEM grid to ESRI ASCII (.asc) or GeoTIFF (.tif), returned base64. The renderer round-trips the grid it got from /api/dem, with voids encoded as nodata (JSON can't carry NaN) and the origin shifted back to true-world coordinates. GeoTIFF uses tifffile (pure-Python — no GDAL) |
Leaf area density¶
| Method | Path | Source | Purpose |
|---|---|---|---|
| POST | /api/lad/compute |
main.py |
Per-voxel leaf area density via PyHelios. Accepts either a JSON LADComputeRequest (fresh-triangulation path) or a PHB1 binary frame carrying the request fields plus the mesh as raw buffers — the binary path lets a 1M+ triangle mesh ride back compactly to be injected via setExternalTriangulation instead of re-triangulated. Streams PHP1 progress ahead of the JSON result. Screens occluded voxels by total probed beam path (occlusion_threshold_m, default 100x the voxel side length) and can fill them by LAD-kriging (fill_occluded); each cell reports path_length_total / under_sampled / lad_filled, and total_leaf_area counts measured voxels only. Requires misses (see the admonition below) |
| POST | /api/lad/snap-grid |
main.py |
Sample a DEM under each voxel column so the grid can be displaced to follow the ground. Returns the authoritative per-column offsets (column_offsets) the UI renders and feeds to the inversion, plus kept_columns / dropped_columns |
Plant models & sessions¶
| Method | Path | Source | Purpose |
|---|---|---|---|
| GET | /api/plant/models |
main.py |
List available plant models |
| POST | /api/plant/session/create |
main.py |
Start a new plant simulation session |
| POST | /api/plant/session/{session_id}/advance |
main.py |
Advance a session in time |
| GET | /api/plant/session/{session_id} |
main.py |
Get session status |
| DELETE | /api/plant/session/{session_id} |
main.py |
Destroy a session |
| GET | /api/plant/sessions |
main.py |
List active sessions |
| POST | /api/plant/morph/parse |
main.py |
Parse a morph expression |
| POST | /api/plant/morph |
main.py |
Apply a morph to a plant |
| POST | /api/plant/generate |
main.py |
Generate a plant from parameters |
| POST | /api/plant/canopy/generate |
main.py |
Generate a grid of plants as one merged mesh |
| POST | /api/plant/generate/stream |
main.py |
Generate a plant or canopy with SSE progress |
POST /api/plant/generate/stream¶
Generates a single plant or a canopy and streams progress as Server-Sent Events, so the UI can show a live progress bar (and cancel by aborting the request). This is the path the Generate Plant popup uses.
Request (PlantStreamRequest) carries a mode ("single" or "canopy")
plus the relevant fields from PlantGenerationRequest / PlantCanopyRequest.
The stream emits:
event: progress
data: {"progress": 0.0-1.0, "message": "Growing plants..."}
event: result
data: <PlantGenerationResponse-shaped JSON>
event: error
data: {"detail": "..."}
Progress maps the C++ growth phase (via pyhelios
PlantArchitecture.setProgressCallback) to 0–0.6, geometry extraction to
0.6–0.95, and serialization to the final 1.0. Single-plant builds create a
retained session (returned as session_id in the result) so the age slider
keeps working; canopies are stateless and include the canopy echo fields.
POST /api/plant/canopy/generate¶
Builds a regularly spaced grid of plants from one library species
(pyhelios buildPlantCanopyFromLibrary) and returns the whole canopy as a
single merged mesh — the same PlantGenerationResponse shape as
/api/plant/generate, so the renderer is identical.
Request (PlantCanopyRequest):
| Field | Type | Default | Meaning |
|---|---|---|---|
plant_type |
str | "bean" |
Library species (see /api/plant/models) |
age |
float | 30.0 |
Age of every plant, days (≥ 0) |
center_x/y/z |
float | 0.0 |
Canopy center, meters |
spacing_x/y |
float | 0.5 |
Spacing between plants, meters |
count_x/y |
int | 3 |
Plants in X / Y (must be > 0) |
germination_rate |
float | 1.0 |
Probability (0–1) each position is filled |
random_seed |
int? | null |
Optional seed for reproducibility |
leaf_inclination |
{beta_mu, beta_nu}? |
null |
Steer leaf inclination toward this Beta distribution (Helios convention, mean 90·ν/(μ+ν) deg), set on the library model before the build via setPlantModelLeafInclinationDistribution. Also accepted by /api/plant/generate, /api/plant/generate/stream and /api/plant/session/create |
The response echoes back plant_count (plants actually built after
germination), count_x, count_y, spacing_x, and spacing_y. Invalid
counts, age, or germination rate return success: false with an error
message (no pyhelios work is done). helios_xml holds the first plant's
structure as a representative sample.
Point cloud I/O¶
| Method | Path | Source | Purpose |
|---|---|---|---|
| POST | /api/pointcloud/import |
main.py |
Import a LAS/LAZ file (multipart upload) |
| POST | /api/pointcloud/preview |
main.py |
Cheaply inspect a file for the import wizard: reads only the header + first ~20 rows (ASCII) or header + a few points (PLY/PCD/LAS) and returns the detected delimiter, per-column auto-detected role, a type_hint (integer/float/categorical/empty) used to pre-tick the categorical box, sample rows, and remappable (true for ASCII, false for in-file-layout formats). Never 500s on a parse problem — returns a 200 with a warning so the wizard can still offer auto-detect |
| POST | /api/pointcloud/import_by_path |
main.py |
Parse a point cloud from a path on disk (dispatches .xyz/.txt/.csv/.pts/.asc to pandas, .ply/.pcd to open3d). Returns a packed binary stream so multi-GB scans aren't bottlenecked by JSON encoding. Accepts an optional column_plan (the import wizard's explicit per-column roles + custom scalar slug/label + rgb_is_255 scale) that overrides auto-detection; absent → identical to the previous behavior |
| POST | /api/pointcloud/export |
main.py |
Export a point cloud to LAS/LAZ — or, for octree-backed clouds (via a source descriptor), to any of LAS/LAZ/XYZ/TXT/CSV/PLY/OBJ (obj is still accepted here, but the Export window no longer offers it for point clouds — a .obj can only be re-imported as a mesh). The backend streams from the source file and applies any pending translation. Set dest_path (absolute) and the backend writes the file itself, returning metadata only with data: null — the app always does this, and it's the only form that works at scale: base64-in-JSON inflates the body ~1.8x, so a 25 M-point XYZ export lands near 1 GB and the renderer's response.json() throws ERR_STRING_TOO_LONG ("Unexpected end of JSON input"). Omitting dest_path keeps the legacy base64-in-data response for callers with no filesystem destination. A session exported to a LAS/LAZ file streams in 2 M-row blocks (_export_session_to_las) and never copies the survivor set |
Octree building, cropping, and filtering for imported clouds go through the cloud-session endpoints (next section) — the in-RAM array is the source of truth and the octree is derived from it. There is no longer a standalone "convert/crop/segment a file into an octree" endpoint; those were removed when the session model landed.
Mutable cloud sessions (the in-RAM source-of-truth model)¶
Every path-imported point cloud is loaded into a cloud session: the full
attribute set (positions + colors + intensity + scalar extra-dims) is held in
RAM on the backend as the authoritative copy. The source file is read exactly
once, at create (_source_to_las → _read_las_into_arrays); afterwards
every edit mutates the in-RAM arrays and rebuilds the derived Potree octree from
them (_session_to_las → PotreeConverter) — the file is never re-read. The
octree is a disposable render cache; the array is the source of truth.
Deletions are an exact per-point boolean mask (instant, no rebuild); undo is a
mask-snapshot stack. Compute endpoints (triangulate/skeleton/c2m/icp/export)
read the masked array directly via PointSource.session_id, so they honor
unbaked deletions with no rebuild. Filter and ground/tree segment run their
algorithms on the array and append columns; "split"/"extract" spin off child
sessions from the array. All of it is file-read-free after import.
| Method | Path | Source | Purpose |
|---|---|---|---|
| POST | /api/cloud/session/create |
main.py |
Load a source file fully into a new in-RAM session and build its first octree (hits-only). Also builds a SECOND projected-miss octree when the scan has placeable sky/miss points, returned as miss_octree_cache_id. Honors the wizard column_plan once (survives all edits). The ONLY point the file is read. The source path is validated before the response opens (a missing file is a clean 404); the import then runs OFF the event loop and streams PHP1 progress markers (Reading source file… → Loading points into memory… → Building octree…) ahead of a JSON tail carrying session_id + octree metadata. Cancelable via /api/cancel/{run_id}: a cancel unwinds the worker and SIGKILLs the PotreeConverter child, so the work really stops. Because the stream is already open by then, in-flight failures come back as error in the JSON tail rather than an HTTP error status. Nothing is left behind — the temp dir and octree staging dir unwind, and the session is registered only as the final step, so a canceled import strands neither RAM nor a half-built cache entry |
| POST | /api/cloud/session/{id}/delete_region |
main.py |
Set the per-point deleted mask for points in a region (box/polygon/squares_union). Instant — array mask only, no rebuild. Excludes is_miss points from the selection (a crop only ever deletes hits). Returns counts, plus backfilled_misses_stale: true when the crop invalidated a separately-backfilled miss buffer (kept and still usable — LAD restores deletions outside the voxel grid and clears the flag itself, warning only when points were deleted from inside it, which a re-backfill cannot repair). Clears octree_cache_id (the cached octree no longer describes the points) but carries it into rendered_octree_cache_id, which _evict_octree_cache pins — the renderer keeps streaming from that directory and masks the deleted points until a rebuild lands, so it must not be evicted underneath a visible cloud |
| POST | /api/cloud/session/{id}/reset_edits |
main.py |
Undo: restore the deleted mask to an earlier snapshot (edit_count deletes kept) |
| POST | /api/cloud/session/{id}/reset_label_edits |
main.py |
Undo labeling: reverse-apply the stored per-point deltas, newest first. Prefer undo_after_stroke_ids (the ids the renderer still holds, NEWEST FIRST) over edit_count for a global undo — the renderer's stroke list counts USER GESTURES while this history counts RECORDED CHANGES, and a stroke whose from-class gate matched nothing is never recorded, so the two legitimately diverge and a count silently rolls back the wrong edit. The session keeps up to the first id it recognizes (an empty list keeps nothing). stroke_id is the join key between the two lists. commit_labels drops the column's history when it arrives, so a commit is an undo boundary |
| POST | /api/cloud/session/{id}/bake |
main.py |
Permanently apply deletions — rebuild the octree from the surviving array points (_session_to_las → PotreeConverter), compact the arrays, clear the mask. Also reprojects + rebuilds the miss octree from the survivors, returned as miss_octree_cache_id. The one deliberately-slow step. No file read. The session is resolved before the response opens (a missing session is a clean 404); the rebuild then streams PHP1 progress markers ahead of the JSON tail and is cancelable via /api/cancel/{run_id} (SIGKILLs the PotreeConverter child). Every session mutation happens AFTER the build returns, and a cancel checkpoint sits between the two, so a cancel — including one landing as the converter finishes — leaves the pending deletions exactly as they were rather than half-applying them. Returns deleted_history_len: the undo depth that survived the bake, normally 0, non-zero only when a delete landed after the compaction. The renderer's pendingDeletes stack mirrors that history one-for-one (its entries index reset_edits), so it truncates to this rather than assuming the bake absorbed everything — which matters now that an applied crop's bake runs in the background while the user can still edit. ?compact=false is that background display refresh: it rebuilds the octree but keeps the deleted rows under the mask (LAD restores the deleted hits outside its grid from them) and ends undo at the mask. Only the default compacting bake (Permanently apply deletions) removes rows, and it counts the removed hits so LAD warns. Both return pending_deleted_count, the renderer's pendingDeletedCount against the octree just built (also returned by delete_region, reset_edits and a non-rebuilding filter) |
| POST | /api/cloud/session/{id}/filter |
main.py |
Delete the points a spatial+scalar filter excludes (array columns), rebuild from the survivors. Composes on the current survivors. Empty result → point_count: 0, no commit/rebuild |
| POST | /api/cloud/session/{id}/split |
main.py |
Keep the filter-passing points on this session; move the excluded points to a NEW leftover session. Both rebuilt from arrays. Powers crop/filter "Segment" |
| POST | /api/cloud/session/{id}/extract |
main.py |
Create a NEW child session from the filter-selected points, parent untouched. Always carries the parent's sky/miss points onto the child. Single-subset; the ground and per-tree splits both use the batch extract_by_column below |
| POST | /api/cloud/session/{id}/extract_by_column |
main.py |
Batch form of extract: fan a categorical column ({slug}, e.g. tree_instance) out into ONE child session per distinct value, parent untouched. exclude_values defaults to [0] (ground/miss/unassigned). rebuild_parent (default false) additionally rebuilds the PARENT's octree in the same concurrent pool as the children and returns it as parent — it pairs with defer_octree on the segmentation endpoints so the parent's rebuild, the single biggest cost of a classify-then-split run, overlaps the children's instead of running alone to completion first (measured 15.8 s → 11.7 s on a 1.2 M-point 2-way split). include_misses (default false) additionally duplicates the parent's sky/miss rows onto EVERY child, matching what single-shot extract always does — the ground/plant split sets it so each half keeps LAD's Beer's-law transmission denominator, while the per-tree split leaves it off so ~100 tree clouds don't each carry a full copy of the miss shell. Powers split into one cloud per tree and split into ground + plant clouds. All subsets are sliced under a single lock — one stable argsort of the column, then a per-child gather by absolute row index, so a K-way split costs O(N) in total rather than K full-survivor copies — and the per-child octree builds then run concurrently in a bounded pool (_EXTRACT_BUILD_POOL). The slug is validated before the response opens (a typo is a clean 400); the builds then stream PHP1 progress markers ahead of the JSON tail so the renderer can show a cancelable Building n of N clouds… pill. A cancel or failure drops the child sessions it had already registered, so a half-finished split doesn't strand a second copy of the cloud in RAM. The cancel is observed both at each build's start AND after each completion, and the run's cancel Event is forwarded into _session_rebuild so PotreeConverter is SIGKILLed — without the latter two, a split whose K fits the pool (the 2-way ground split always does) had every build past its only checkpoint before the user could click Cancel, and the children leaked |
| POST | /api/cloud/session/{id}/duplicate |
main.py |
Copy a session's surviving points into a NEW independent session (parent untouched) and build its octree. A pure array copy — no file read, so every wizard customization is preserved. Powers scan "Duplicate" |
| POST | /api/cloud/session/merge |
main.py |
Concatenate the surviving points of ≥2 sessions (body {session_ids}) into one NEW session and build its octree. Reconciles differing global shifts (re-expresses every input into a common world_shift) and unions scalar extra-dim columns (zero-filling inputs that lack a column). Builds a projected-miss octree when any input carried misses. Returns {merged: {session_id, point_count, world_shift, cache_id, has_misses, miss_octree_cache_id, …octree}}. Powers Stitch Clouds — the merge runs here, not in the renderer, because octree clouds hold their points in the session (the renderer's flat positions is empty) |
| POST | /api/cloud/session/{id}/segment_ground |
main.py |
Run CSF on the array, append a ground_class column, rebuild from arrays. defer_octree skips the rebuild and returns {octree_deferred: true} with no octree fields at all (returning the pre-column octree with a cache_id would be the stale-octree bug bake guards against) — for a caller that immediately POSTs extract_by_column with rebuild_parent. Estimates the run's wall time and memory first (_cost_advisory): past the guideline it answers 409 with a structured cost_warning, and the caller re-sends with acknowledge_cost: true; a cloth too fine for the extent (>25 M cloth nodes) is refused with 400 and the coarsest resolution that fits. Large clouds run per buffered tile (tiled.py); the response's tiled field carries the plan |
| POST | /api/cloud/session/{id}/denoise |
main.py |
Classify noise on the array's hit survivors, append a noise_class column, rebuild from arrays. Non-destructive |
| POST | /api/cloud/session/{id}/segment_trees |
main.py → tiled_trees.py |
Run TreeIso (or, with method: "chm", the canopy-height watershed in chm_trees.py, fed the ground_class ground points as its DTM; the response then carries a chm block with the resolved cell and ground source); ground_warning is true when the cloud has no ground_class column, i.e. Ground Segmentation was never run on the array, append a tree_instance column, rebuild from arrays. tiling (auto/on/off) and tile_buffer_m segment large plots in buffered tiles stitched by stem anchor (see Stem detection & tiling); the response's tiling block reports tiles run and trees possibly cut off by a buffer |
| POST | /api/cloud/session/{id}/detect_stems |
main.py → stem_seeds.py |
Automatic trunk seeds from the 1–2 m height-above-ground layer (needs height_above_ground). Plain def; chunked layer read. Returns {seeds: [{x, y, z, radius_m, n_inliers, arc_coverage}], layer_points} in the session's stored frame |
| POST | /api/cloud/session/{id}/segment_wood |
main.py |
Wood/leaf segmentation on the in-RAM survivors → append wood_class → rebuild the octree. No file read. The compute runs in a killable subprocess so Cancel can SIGKILL it; the column write + rebuild happen in the parent afterwards, so a cancel mid-compute leaves the session pristine. The renderer's "split into wood + leaf clouds" then fans wood_class out through extract_by_column (include_misses + rebuild_parent), not two serial extract calls. Supports the same defer_octree as segment_ground |
| POST | /api/cloud/session/{id}/dem |
main.py |
DEM from the session's in-RAM survivors (ground-aware). Returns a PHB1 frame (heightmap mesh + grid). With add_height_column, also appends a height_above_ground scalar and rebuilds the octree |
| POST | /api/cloud/session/{id}/tree_inventory |
main.py → tree_inventory.py |
Tree list + stem curve for every tree_instance > 0 (method: Tree inventory measurements). Plain def, admission-gated; indexes the eligible points (alive, not a miss) in chunks, sorts them by tree once, then gathers and measures ONE tree at a time from the (memory-mapped) session arrays. Ground from height_above_ground, else a grid of ground_class points, else the tree minimum. Then a stand pass (stand_metrics.py): plot boundary = hull of the ground points, canopy union, and per-tree Hegyi index / crown overlap / edge flag. Streams PHP1 progress then a JSON tail {trees, stem_curve, stand, ground_sources, warnings, params}; cancelable between trees via the run-id token; a bake mid-run is reported as an error, not silently mis-indexed |
| POST | /api/project/scene |
main.py |
Stage the renderer's scene document (opaque bytes, streamed to disk) for a project save; returns a token |
| GET | /api/project/scene/{token} |
main.py |
Download an opened project's scene document; one download, then the token expires |
| POST | /api/project/save |
main.py → project_file.py |
Write a .phyto project: the staged scene plus every listed cloud session (all columns, pending deletes, delete/label undo history, miss buffer) and the display octrees, streamed to <path>.partial then renamed over the target. Plain def, admission-gated, cancelable. See Project file |
| POST | /api/project/open |
main.py → project_file.py |
Read a .phyto project: install its octrees, restore each session under a NEW id (memory-mapped store for large clouds), stage the scene document. Returns {session_map, scene_token}. No pickle anywhere; a failure registers nothing |
| POST | /api/cloud/session/{id}/tree_qsm |
main.py |
One QSM per tree_instance through the same _SessionTreeReader and the same _qsm_pipeline as /api/qsm/build. Optional wood-only filter (wood_class), per-tree voxel thinning to max_points_per_tree. A failing tree is reported in its own result, never fails the batch. Plain def, admission-gated, cancelable between stages. Returns {results: [{tree_id, success, error, points_used, metrics, cylinders?, shoots?}], warnings} |
| POST | /api/cloud/session/{id}/transform |
main.py |
Bake an affine 4×4 (row-major, TRUE-world-frame) transform into the session geometry: rigid for ICP, rigid-plus-scale (possibly non-uniform) for the Transformation tool. The bottom row must be [0, 0, 0, 1] and the linear part must have a positive determinant (no mirroring), else 400. The session stores points with world_shift subtracted, so the matrix is conjugated by the shift — which means a caller holding a matrix built in the renderer's stored frame must conjugate it to world first (conjugateByShift in lib/affineDelta.ts). Points take the full matrix; stored normals take the inverse-transpose of its linear part (renormalized); a non-similarity flags normals stale (curvature changed); a uniform scale multiplies a distance column. A permanent, non-undoable geometry change — this is what commits a Transform. octree_mode chooses what happens to the derived octree: "rebuild" (default) returns a CURRENT octree — a pure translation takes the in-place rewrite (octree_transform.translate_octree_dir, ~1.8 s on 10 M points), a rotation reconverts (~83 s, since rotation re-buckets the octree's nodes). "pose" leaves the octree ALONE: the geometry moves (~0.5 s, and that is what every compute path reads via _read_points_from_source), the existing octree is returned unchanged, octree_posed: true is reported, and CloudSession.octree_pose is set. The renderer then draws that octree through a stored pose — correct for rendering, LOD, picking and GPU clipping alike, because potree composes the octree object's matrix. |
| POST | /api/cloud/session/{id}/rebuild_octree |
main.py |
Rebuild a session's octree(s) from its CURRENT arrays, clearing octree_pose. The repayment step for a deferred transform. Needed only by operations that ship a screen-space region (lasso/rect crop, erase brush, label brush): those freeze a camera looking at the posed octree and the backend replays it against session positions, so the frames must agree first. Compute and export never need it. |
| POST | /api/cloud/session/{id}/backfill-misses |
main.py |
Recover sky/miss points and persist them in a lightweight per-session buffer (CloudSession.backfilled_misses). Builds an ephemeral PyHelios cloud from the scan as measured — deleted hits are restored via _session_to_lad_arrays(restore_mask=...), because the gapfill synthesizes a miss into every raster cell with no return, so gap-filling over crop survivors alone would resurrect each cropped pulse as a transmitted beam (restored_deleted_hits reports how many were fed back) — runs gapfillMisses() (auto-selects the row/column or timestamp path; row_index/column_index are relabeled to the bare row/column keys the C++ dispatcher probes), and slices the synthesized misses via the bulk getters. Hit arrays are untouched. Rebuilds the projected-miss octree and returns its miss_octree_cache_id. Session-resolve + eligibility run up front (404, or 400 when a scan has no usable timestamp, no grid, and either moves or declares no raster). A static scan with neither column is placed by direction on its declared raster (n_theta/n_phi/ranges plus tilt_roll_deg/tilt_pitch_deg/azimuth_offset_deg, helios-core v1.3.89); a static scan's rounded timestamps are dropped, up front by _audit_pulse_columns or on Helios's own refusal via deleteHitData; the heavy build/gapfill/extract streams PHP1 progress markers ahead of the JSON tail (_do_backfill_misses + _bin_frame_streaming_response) so the renderer shows a per-stage progress bar. Short-circuits (plain JSON) when the scan already has misses; a Helios reconstruction failure (too-sparse grid) returns an error field in the JSON tail rather than a 500 |
| DELETE | /api/cloud/session/{id} |
main.py |
Free the session's in-RAM arrays (called when a cloud is removed from the scene) |
LAD requires misses — no silent gapfill
/api/lad/compute no longer recovers misses on the fly. A scan must already
carry sky/miss points — retained by the source format (E57 / structured PLY)
or recovered up front via backfill-misses, which persists them so
_session_to_lad_arrays appends them to the LAD cloud. If none are present
the endpoint returns a structured success: false error directing the user
to Backfill Misses (the Helios C++ calculateLeafArea fail-fast still
backstops). This applies to every LAD source path, including the
non-session file_path / inline-points paths, which have no backfill step
and therefore must ship recorded misses.
Registration & comparison¶
| Method | Path | Source | Purpose |
|---|---|---|---|
| POST | /api/c2m/distance |
main.py |
Cloud-to-mesh distance. A session source is streamed block by block (_iter_session_hit_positions) so the query never holds the whole cloud; only the float32 distance per point is kept |
| POST | /api/c2m/icp-register |
main.py |
Cloud-to-mesh ICP |
| POST | /api/c2c/icp-register |
main.py |
Cloud-to-cloud ICP |
| POST | /api/m2m/icp-register |
main.py |
Mesh-to-mesh ICP. Accepts JSON or a PHB1 binary frame (target_vertices/target_indices/source_vertices/source_indices as buffers, scalars in the header meta). The renderer always sends the frame: the mesh picker offers Helios triangulations, and four arrays of that size as JSON numbers exceed V8's max string length, so the request died in JSON.stringify before it was sent |
Reading points — the source descriptor
Octree-backed clouds keep no point positions in the renderer (the geometry
lives only in the on-disk Potree octree, streamed to the GPU). So the
downstream endpoints — /api/skeleton/extract, /api/triangulate,
/api/c2m/distance, /api/c2m/icp-register, /api/c2c/icp-register, and
/api/pointcloud/export — accept an optional source object in place
of the inline points array:
"source": {
"session_id": "3f2a…",
"max_points": 20000,
"translation": [tx, ty, tz],
"want_colors": true
}
session_id is required. A cloud's file is read exactly once, at import
(/api/cloud/session/create); from then on the session's in-RAM arrays are
the source of truth, and they carry every edit — deletions, translation,
filtering, segmentation labels — that the file on disk does not. Computing
from the file would therefore return a silently wrong answer, so
_read_points_from_source rejects a file-only source with a 400 for
every compute and export path. This is enforced at that single chokepoint,
not per-endpoint, so the rule holds for every caller.
source_path may still be sent alongside session_id, but it is
provenance only and is never re-read. The allow_file_source: true
escape hatch exists for files that are not live clouds (unit tests driving
the loaders directly); the app never sets it.
When source is set the backend reads (and optionally stride-downsamples)
the session's points, applies the pending translation (added to every
point), and runs the same computation. Flat (PLY/PCD) clouds keep sending
inline points unchanged. /api/triangulate returns points_used so the
UI can warn when the global triangulate max points cap downsampled a large
cloud.
Live API docs
FastAPI's interactive docs are exposed at /docs on the backend's port
while it's running, with request/response schemas auto-generated from
the Pydantic models in main.py. In a dev session the port is printed in
the [dev] startup lines.