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 |
| 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); 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) from a disk path into geometry, V-flipped per-vertex UVs, per-material triangle groups, and base64-encoded textures — the same response shape the textured renderer consumes for plant models |
| 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 scans to a Helios XML + per-scan ASCII bundle, returned as base64 files |
| 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 (synthetic scan / triangulation / LAD / DEM / crown fit). 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 cancelled: 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/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 |
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) → horticultural metrics.
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 |
The classifier is the cloth-simulation-filter package (import CSF), a
SWIG C-extension bundled via collectAll in scripts/build-backend.mjs.
Tree segmentation¶
| Method | Path | Source | Purpose |
|---|---|---|---|
| POST | /api/segment/trees |
main.py |
Segment individual trees with TreeIso (cut-pursuit graph method, CPU-only). 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, and a ground_warning flag. 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. 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 |
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. 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:
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 |
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 behaviour |
| 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. The backend streams from the source file and applies any pending translation |
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 + colours + 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 honour
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. Honours the wizard column_plan once (survives all edits). Returns session_id + octree metadata. The ONLY point the file is read |
| 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, but flagged so the UI + LAD warn to re-backfill) |
| 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}/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 |
| 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. Powers ground "split into clouds" |
| 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). Powers split into one cloud per tree. 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 cancellable 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 |
| 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 |
| POST | /api/cloud/session/{id}/segment_trees |
main.py |
Run TreeIso on the array, append a tree_instance column, rebuild from arrays |
| 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 |
| 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}/transform |
main.py |
Bake a rigid 4×4 (row-major, world-frame) transform into the session geometry and rebuild the octree. The session stores points with world_shift subtracted, so the matrix is conjugated by the shift. A permanent, non-undoable geometry change — this is what commits a Translate |
| 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 surviving points, runs gapfillMisses() (auto-selects the row/column or timestamp path; row_index/column_index are relabelled to the bare row/column keys the C++ dispatcher probes), and slices the synthesised 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 / 400-when-no-timestamp-or-grid); 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 |
| 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 |
Reading points from disk — the source descriptor (M4)
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:
json
"source": {
"source_path": "/path/to/scan.xyz",
"ascii_format": "x y z r255 g255 b255 reflectance",
"max_points": 20000,
"translation": [tx, ty, tz],
"want_colors": true
}
When source is set the backend reads (and optionally stride-downsamples)
the points from the original file via _read_points_from_source, applies
the pending translation (added to every point), and runs the same
computation. There is no octree reader — the source file is always the
point of truth. 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.
/api/triangulate/helios already reads each scan from file_path; M4 also
passes the known ascii_format so column mapping isn't guessed.
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.