Skip to content

Python Reference

Auto-generated from docstrings in backend-api/main.py via mkdocstrings.

Note

Only public, documented functions appear below. To extend coverage, add Google-style docstrings to handlers and helper functions in backend-api/main.py.

SlowRequestLogger

Log any request that takes longer than PHYTOGRAPH_SLOW_REQUEST_SECONDS.

SessionPinScope

Give every HTTP request its own cloud-session pin set (see _SESSION_PINS).

Raw ASGI for the same reason as SlowRequestLogger: BaseHTTPMiddleware breaks client-disconnect propagation, which cancellation depends on. This passes receive/send through untouched. await self.app(...) returns only once the response -- including a streamed body -- is fully sent, so the pins live for the whole request; a def handler runs under anyio's to_thread.run_sync, which propagates contextvars, so it sees the same set.

ProspectFitRequest

Bases: BaseModel

Request model for PROSPECT-D spectral fitting

PointSource

Bases: BaseModel

Tell a downstream endpoint to read points from a live cloud SESSION (in-RAM, the source of truth) instead of an inline points array.

Compute and export require session_id. A cloud's file is read exactly once, at import; every edit after that (deletions, translation, filtering, segmentation labels) lives only in the session arrays. Reading the file again would compute on pre-edit data and return a silently wrong answer, so _read_points_from_source rejects a file-only source unless the caller sets allow_file_source — the deliberate opt-in for files that are not live clouds (unit tests driving the loaders, external files).

Octree-backed clouds keep no positions in the renderer (the geometry lives only in the on-disk Potree octree, streamed to the GPU), so skeleton / triangulate / c2m / icp / export resolve their points here. When session_id is set the in-RAM session array is the point of truth (deletions honored, no file re-read); source_path is then provenance only and may be empty (e.g. a synthetic-scan session that never had a source file). When there is no session, source_path is read from disk.

Resolved by _read_points_from_source (defined later, alongside the other point-cloud loaders it reuses).

TriangulationGrid

Bases: BaseModel

A voxel grid to PIN a (ball-pivot / Open3D) triangulation to, so the mesh can later be re-used as the external triangulation for the leaf-area (LAD) inversion. Same shape as HeliosGrid (defined later in the file), declared separately so TriangulationRequest doesn't forward-reference it.

When set on a TriangulationRequest the backend (a) crops points to the grid's world AABB before meshing — so only points inside the box are triangulated — and (b) bins each output triangle's centroid into the grid, returning a per-triangle cell id. The renderer needs both: the crop confines the mesh to the box, and the cell ids let the LAD reuse path drop any triangle whose centroid still falls outside the grid (belt-and-suspenders for "only in-grid triangles feed the inversion").

TriangulationRequest

Bases: BaseModel

Request model for point cloud triangulation

TriangulationResponse

Bases: BaseModel

Response model for triangulation results

CrownFitRequest

Bases: BaseModel

Fit a geometric shape to a tree crown and compute per-crown metrics.

Points come from a cloud session (source.session_id) so the per-point classification labels (tree_instance / wood_class / ground_class) can be read back from sess.extras — the same authoritative store the segmentation tools write. A file/inline source is accepted for a single unlabeled tree, but then no labels are available (whole cloud = one tree, all points = crown).

TreeInventoryRequest

Bases: BaseModel

Measure every segmented tree of a cloud session (see tree_inventory.py and docs/docs/concepts/tree-inventory.md for the method and its sources). Unset fields take the method's documented defaults.

TreeQSMRequest

Bases: BaseModel

One QSM per segmented tree of a session (docs/docs/concepts/ stand-metrics.md#batch-qsm). The QSM options mirror QSMBuildRequest.

GroundSegmentationRequest

Bases: BaseModel

Request model for ground/non-ground segmentation via CSF.

Provide either inline points (flat clouds) or a source descriptor (octree-backed clouds — the backend re-reads the file). Unlike skeleton / triangulate, segmentation must NOT downsample: the returned labels align 1:1 with the resolved point order so the renderer can attach them as a per-point scalar. Callers leave source.max_points as None.

GroundSegmentationResponse

Bases: BaseModel

Per-point ground/plant labels aligned to the resolved point order.

DenoiseRequest

Bases: BaseModel

Noise classification for a FLAT (in-RAM) cloud. Provide inline points (the renderer sends ps.hits.points — hits only, misses already excluded at the buildPointSource chokepoint) or a source descriptor.

Session/octree clouds use /api/cloud/session/{id}/denoise instead, which persists the result as a column. This route only classifies and returns.

Parameters default to None = derive from the cloud's own point spacing.

criterion_params()

See SessionDenoiseRequest.criterion_params.

DenoiseResponse

Bases: BaseModel

Per-point noise labels aligned 1:1 with the resolved point order.

DemRequest

Bases: BaseModel

Generate a DEM from a flat cloud's inline points or a source descriptor.

Ground-aware: pass ground_labels (1=ground, 2=plant, aligned 1:1 with the resolved points) to grid only ground points; else CSF is run when auto_segment_ground is set, else all points are used (lowest-return surface).

LADExportCell

Bases: BaseModel

One voxel on the export wire.

Mirrors LADCell but coordinates are WORLD-frame (the renderer has already added worldShift back) and the optional Pimont fields may be absent.

LADExportRequest

Bases: BaseModel

Export a computed LAD voxel grid.

cells are WORLD-frame. origin is the grid's lower-left-bottom corner, also world. Rasters additionally require an axis-aligned, regular lattice — see grid_rotation / terrain_follow, which the endpoint rejects for "tif".

WoodSegmentationRequest

Bases: BaseModel

Per-point wood/leaf segmentation from XYZ geometry.

Provide inline points (flat clouds) or a source descriptor (octree-backed clouds — the backend re-reads the file at full resolution). Like ground segmentation the result must NOT be downsampled, so labels align 1:1 with the resolved point order. The tuning fields map onto segment_wood(): wood_bias is the wood-vs-leaf sensitivity (lower → more wood), the k_* fields set the neighborhood-scale search, reg_iters the smoothing strength, and voxel_size (>0) enables downsample-classify-propagate for very large clouds.

WoodSegmentationResponse

Bases: BaseModel

Per-point wood/leaf labels aligned to the resolved point order.

OrganSegmentationRequest

Bases: BaseModel

Plant organs and leaflets from a trained model.

units is what the cloud's coordinates are in ("m", "cm", "mm"), or "auto" to read it from the cloud's size (more than 30 units across is millimeters). The model works in meters; nothing in an XYZ file says which unit it was written in. model_id None = the bundled default.

TreeSegmentationRequest

Bases: BaseModel

Request model for individual-tree segmentation via TreeIso.

Like ground segmentation, provide inline points or a source descriptor; the result must NOT be downsampled so per-point labels align 1:1. Optional seed_points ([[x,y,z], ...]) anchor trees for human-in-the-loop seeding — each seed yields exactly one tree id. The remaining fields are TreeIso parameters (defaults match Xi & Hopkinson 2022).

TreeSegmentationResponse

Bases: BaseModel

Per-point tree instance ids aligned to the resolved point order.

PoseSample

Bases: BaseModel

One 6-DOF platform pose sample: time + position + Hamilton (body->world) quaternion (qx, qy, qz, qw, scalar last).

FrameMeta

Bases: BaseModel

Documented frame/CRS so a timestamp join is never silently wrong. up_axis is the world up convention ('z' for Phytograph); body_convention/time_ref are recorded for downstream interoperability (FLU/FRD, GPS-week vs relative).

PoseStream

Bases: BaseModel

A dense timestamped 6-DOF platform trajectory plus its calibration — the canonical moving-platform representation (see backend-api/trajectory.py).

Quaternions are Hamilton body->world (qx,qy,qz,qw). lever_arm (body-frame scanner optical center, meters) and boresight_rpy (sensor misalignment roll/pitch/yaw, radians) calibrate the platform->scanner transform; the per-beam emission origin is pos(t) + R(quat(t))·lever_arm. The backend resolver joins this to each return's timestamp (SLERP attitude, linear position).

HeliosScanEntry

Bases: BaseModel

A single scan with point data (or file path) and scanner position.

Provide either file_path (preferred for large scans) or points. When file_path is given, the backend reads the file directly via pyhelios, avoiding large JSON transfers.

HeliosGrid

Bases: BaseModel

An explicit triangulation grid, derived from a voxel box in the UI.

Helios's XML loader requires a block; it bounds the triangulation region. center/size are in world coordinates (the voxel box's transform); nx/ny/nz are its per-axis cell subdivisions.

HeliosTriangulationRequest

Bases: BaseModel

Request model for Helios triangulation

HeliosFilterEstimate

Bases: BaseModel

Auto-estimated triangulation filter, derived from the candidate edge-length distribution (Otsu separability) + a merged-multi-scan-cloud guard.

HeliosTriangulationResponse

Bases: BaseModel

Response model for Helios triangulation

SpacingCheckResponse

Bases: BaseModel

Verdict from /api/triangulate/check-spacing — an opt-in cross-check of the auto-estimated Lmax against the actual point spacing.

The auto-Lmax estimator (Otsu over candidate triangle-edge lengths) silently fails on sparse shells: a thin layer of surface points generates mostly bridge triangles spanning the cell interior, so the candidate-edge distribution looks bimodal (eta/sep_ratio read "Medium"+) even though its lower mode is still bridges, not surface. The chosen Lmax then far exceeds the true point spacing and the reconstructed normals — hence G(theta) — are garbage. The candidate edges can't self-diagnose this; an INDEPENDENT measure of the surface scale can. We compute the median nearest-neighbor spacing of the points strictly inside the grid cell(s) and compare it to lmax. This is O(N log N) (a KD-tree build + query) and can take tens of seconds on a tens-of-millions-of-points cloud, which is why it's a user-triggered button rather than part of the triangulation, and why it's only offered when the Otsu indicators aren't both High.

DemRaster

Bases: BaseModel

A regular axis-aligned DEM elevation raster, as produced by /api/dem.

Used by terrain-following LAD to sample a ground height under each voxel column. grid_z is the elevation per cell, row-major with row 0 = min y (grid_z[j*nx + i]), matching the renderer's MeshEntry.demGrid. Void cells are NaN. origin is the lower-left corner [minx, miny]; cell is the cell size (m). Coordinates are in the SAME frame as the scan points / grid (the renderer round-trips world_shift so the raster aligns with the cloud).

GThetaValueSpec

Bases: BaseModel

How to obtain a single G(theta) value for a cell (or a whole z-level).

GThetaOverrideSpec

Bases: BaseModel

Direct G(theta) override. spatial chooses constant-vs-vertical-profile; the value spec(s) choose how each G(theta) is obtained (constant / de Wit / Beta).

  • spatial="constant": spec is required and applies to every voxel (one scalar).
  • spatial="profile": profile is required, one GThetaValueSpec per z-level, length must equal grid.nz (the method is the same across levels; the per-level parameters vary with height). z-level 0 is the lowest band.

LADWoodSplit

Bases: BaseModel

The leaf/wood classification a LAD run splits by. slug None turns the split off; otherwise the named column's wood_values count as wood and its leaf_values as leaf, every other value as unclassified. wood_class needs no values (it already uses 1 = wood, 2 = leaf).

LADComputeRequest

Bases: BaseModel

Request model for leaf area density computation.

Reuses HeliosScanEntry for scans (each carrying its scanner origin, angular geometry, and return_type). The grid is REQUIRED (its nx/ny/nz are the LAD voxel divisions) — there is no meaningful "auto single-cell" LAD.

LADCell

Bases: BaseModel

A single voxel result.

LADComputeResponse

Bases: BaseModel

Response model for leaf area density computation.

SnapGridRequest

Bases: BaseModel

Compute per-column ground offsets for a 'snap to ground' grid displacement.

The grid is sampled against the DEM exactly as terrain-following LAD does (one source of truth: _sample_dem_columns). The renderer applies the returned offsets to displace the grid in the viewport, then sends the SAME offsets back on the grid for the LAD inversion — so what is seen is what is inverted.

TrajectoryParseRequest

Bases: BaseModel

Parse a binary trajectory file into the canonical PoseStream wire shape.

path is a server-readable file path (the renderer sends the picked path, same as the cloud-import endpoints). format is auto-detected from the extension when omitted. smrmsg_path optionally points at the SBET accuracy companion for a QC warning. target_poses caps the decimated pose count.

ScanExportGrid

Bases: BaseModel

A voxel-box grid to write as a Helios block on scan XML export, so a bundle like sphere.xml round-trips its grid. center/size are world extents; nx/ny/nz the per-axis cell counts; rotation the azimuth about +z in degrees.

ScanExportEntry

Bases: BaseModel

One scan to export. Point source is EXACTLY one of session_id / points / file_path (resolved in that precedence, mirroring the LAD path). A live cloud sends session_id alone — never session_id plus file_path, because a missing session must fail loudly rather than export the pre-edit file. Scanner geometry is written into the XML; translation is applied on export.

Only origin and a point source are required: the Export window lists every cloud in the scene, so an entry may carry no scan geometry at all. The data formats need none of it — only the Helios XML bundle and PTX do, and the UI blocks those rows rather than sending them.

ScanExportRequest

Bases: BaseModel

Export one or more scans to a Helios XML + per-scan ASCII bundle.

LidarScanMaterial

Bases: BaseModel

A textured material group on a scan mesh.

texture_data is a base64-encoded image (PNG/JPG). When it carries an alpha channel, Helios uses that channel as a transparency mask during ray tracing — leaf textures are leaf-shaped cutouts on a transparent background, so rays only register hits where the leaf is opaque instead of on the full rectangular quad. triangle_indices are ordinals into the mesh's triangles array that use this material.

LidarScanMesh

Bases: BaseModel

A single mesh to load into the scannable scene (world-space coordinates).

RisleyPrismSpec

Bases: BaseModel

One rotating wedge prism of a Livox-style Risley-prism deflector.

Sent in datasheet units — wedge angle in DEGREES, rotor rate in Hz — and converted to the radians / rad-per-second that pyhelios' RisleyPrism expects at the addScanRisley call site below (mirroring the deg->rad conversion the static/moving scan branches do for the angular sweep).

LidarScanScanner

Bases: BaseModel

A single scanner position + acquisition geometry (mirrors ScanParameters).

LidarScanRequest

Bases: BaseModel

Request model for a synthetic LiDAR scan.

LidarScanResult

Bases: BaseModel

Per-scanner scan result.

LidarScanResponse

Bases: BaseModel

Response model for synthetic LiDAR scan results — one entry per scanner.

SkeletonRequest

Bases: BaseModel

Request model for tree skeleton extraction using BFS graph-based algorithm

SkeletonBlock

Bases: BaseModel

Information about a skeleton block (cluster of points at same BFS level)

SkeletonEdge

Bases: BaseModel

Edge connecting two skeleton nodes

SkeletonResponse

Bases: BaseModel

Response model for BFS-based skeleton extraction

LeafInclinationSpec

Bases: BaseModel

A Beta leaf-inclination distribution the generated plants are steered toward as they grow (helios-core v1.3.88+, setPlantModelLeafInclinationDistribution). Helios's parameterization, the same one the LAD G(theta) override uses: beta_nu pulls toward vertical, beta_mu toward horizontal, and the mean inclination is 90 * nu / (mu + nu) degrees. Absent = the model's own leaf angles.

PlantGenerationRequest

Bases: BaseModel

Request for generating a plant model

PlantCanopyRequest

Bases: BaseModel

Request for generating a canopy of regularly spaced plants

PlantStreamRequest

Bases: BaseModel

Request for streaming plant/canopy generation with progress (SSE).

mode selects single-plant vs. canopy; the relevant subset of fields is used for each. Single plants additionally create a retained session so the age slider keeps working after generation.

PlantMaterial

Bases: BaseModel

Material definition for plant rendering

PlantMaterialGroup

Bases: BaseModel

Group of triangles sharing the same material

PlantGenerationResponse

Bases: BaseModel

Response containing generated plant mesh data and Helios XML structure

PlantSession dataclass

Holds an active pyhelios session for incremental plant growth

PlantSessionCreateRequest

Bases: BaseModel

Request to create a new plant session

PlantSessionCreateResponse

Bases: BaseModel

Response after creating a plant session

PlantSessionAdvanceRequest

Bases: BaseModel

Request to advance time on a plant session

PlantSessionAdvanceResponse

Bases: BaseModel

Response after advancing plant time, includes updated geometry

PlantSessionStatusResponse

Bases: BaseModel

Status of a plant session

QSMBuildRequest

Bases: BaseModel

Request for a full QSM build. Points come inline (points) or from a file/octree cloud (source), mirroring /api/skeleton/extract.

For an AGGREGATE build (several pre-registered multi-view scans of ONE tree fused into a single QSM), pass sources: each is read and the points concatenated in world space (each source's own translation applied). This is the only way to fuse octree-backed clouds, whose display positions are empty client-side. sources takes precedence over points/source.

QSMCylinder

Bases: BaseModel

One fitted cylinder of the woody structure.

QSMShoot

Bases: BaseModel

A continuous botanical axis (a maximal chain of continuation cylinders).

QSMImportRequest

Bases: BaseModel

Request to import a QSM from a per-cylinder CSV on disk.

QSMPhyllotaxisRequest

Bases: BaseModel

Round-tripped QSM topology for phyllotaxis auto-detection. Only the cylinders + shoots are needed (radii/metrics are irrelevant here).

QSMLeavesRequest

Bases: BaseModel

Add leaves to an existing QSM (cylinders + shoots round-tripped from the renderer). Exactly one texture source is used, in precedence order obj_path > texture_path > builtin_texture_name.

QSMLeavesResponse

Bases: BaseModel

Textured leaf mesh. Field names mirror PlantGenerationResponse so the frontend's plantResponseToMeshData() consumes it unchanged.

QSMBarkTextureRequest

Bases: BaseModel

Fetch one bark image: a curated builtin name OR an uploaded file path.

QSMGrid

Bases: BaseModel

The voxel grid the triangulation was built in (full extents + subdivisions).

QSMTriangulationInput

Bases: BaseModel

A leaf-on Helios triangulation overlapping the QSM, from the renderer.

QSMCellTarget

Bases: BaseModel

A precomputed per-cell leaf-angle target (escape hatch / test injection).

QSMAdjustLeafAnglesRequest

Bases: QSMLeavesRequest

Re-place the QSM's leaves (same Phase-1 params) then adjust their angles to a measured per-cell distribution. Exactly one of triangulation / cell_targets must be present (with grid when using cell_targets).

PlantMorphParseRequest

Bases: BaseModel

Request to parse plant XML into editable parameters

PlantMorphParseResponse

Bases: BaseModel

Parsed plant structure for the morph UI

PlantMorphRequest

Bases: BaseModel

Request to morph/regrow a plant from modified XML

PlantMorphResponse

Bases: BaseModel

Response from POST morph

MeshImportRequest

Bases: BaseModel

Request to import a mesh from a file on disk.

PointCloudExportRequest

Bases: BaseModel

Request for exporting a point cloud.

Flat clouds send inline points (+ optional colors) and format in {"las","laz"}. Octree-backed clouds send source instead — and may request any of {"las","laz","xyz","txt","csv","ply"} since the renderer has no positions to format text from. The backend reads the source file and applies pending translation in all cases.

Output goes one of two ways:

  • dest_path set — the backend writes the file itself and returns only metadata. This is the path the app uses, and the only one that works at scale: a base64-in-JSON body is ~1.8x the file size, and a 25 M-point XYZ export lands near 1 GB, well past V8's ~512 MB string cap. The renderer then fails in response.json() with "Unexpected end of JSON input" no matter how long it waits. Writing server-side avoids the base64 inflation, the giant JSON body, and holding another full copy in renderer memory.
  • dest_path omitted — legacy base64-in-data response, kept for callers with no filesystem destination (tests, a browser context). Only safe for small clouds, for the reason above.

PointCloudExportResponse

Bases: BaseModel

Response containing the exported file data

PointCloudImportResponse

Bases: BaseModel

Response containing imported point cloud data

ColumnPlanEntry

Bases: BaseModel

One column's import mapping, produced by the import wizard.

role is a Helios-style token (x/y/z/r255/g255/b255/r/g/b/intensity/ reflectance/skip) or the literal 'extra' for a carried scalar field. For an 'extra' column, slug/label give the on-disk LAS extra-dim name and the picker label (rename), and categorical marks it for discrete coloring in the renderer. index is the 0-based source column position.

ColumnPlan

Bases: BaseModel

Explicit column layout for an XYZ-family file, from the import wizard.

When attached to an import/convert request it fully overrides header/format auto-detection. rgb_is_255 records whether the r/g/b columns are 0-255 integers (True) or already 0-1 floats (False) so the LAS writer scales them correctly. Applies only to ASCII formats; PLY/PCD/LAS define their own layout and ignore it.

ImportPointCloudByPathRequest

Bases: BaseModel

Path-based point-cloud import.

ascii_format is a Helios string (e.g. 'x y z r255 g255 b255 reflectance') and applies only to XYZ-family extensions; PLY/PCD ignore it because their column layout is in-file. If omitted for an XYZ-family file, columns are sniffed from the first non-blank, non-comment row. column_plan, when present, fully overrides both — it's the explicit layout chosen in the import wizard.

PointCloudPreviewRequest

Bases: BaseModel

Inspect a point-cloud file cheaply for the import wizard.

Reads only the header + first max_rows data rows (ASCII) or the header + a few points (LAS/PLY/PCD) — never materializes the whole file. The optional ascii_format hint biases role detection the same way the import path does.

PreviewColumn

Bases: BaseModel

One source column as the wizard should present it.

detected_role is the auto-detected Helios role (or 'extra'/'skip'); suggested_slug/suggested_label mirror what import would name a carried scalar (so the wizard's defaults match the eventual on-disk attribute). type_hint is a sniffed value shape (integer/float/categorical/empty) used to pre-tick the categorical checkbox. remappable is True only for ASCII formats — PLY/PCD/LAS define their own layout, so roles can't be reassigned.

ScanCanceled

Bases: Exception

Raised inside a streaming worker when its run has been canceled.

ClientDisconnected

Bases: Exception

Raised by _run_killable when the HTTP client goes away (panel closed, Cancel button, fetch AbortController timeout) before the worker subprocess finishes — after the worker has been SIGKILLed.

CropOctreeRegion

Bases: BaseModel

Box, polygon, or sphere-union spatial region for the cloud-session edit endpoints (delete_region / filter / split / extract). See _canonical_region for validation rules — the handlers delegate to that helper.

ScalarFilter

Bases: BaseModel

Keep only points whose imported scalar attribute slug matches.

Two modes
  • Continuous (default): keep points in the inclusive range [min, max].
  • Categorical: when values is set, keep points whose value rounds to one of the listed class ids (an OR within the field — min/max ignored). Used by the filter UI's class-checkbox path for integer-valued labels like ground_class / tree_instance, where a value such as 2 means a discrete class, not a position on a continuum, and a multi-select keep need not be contiguous.

slug is the on-disk extra-dimension name (matches a key in the octree's attributeRanges / the extra_dims slugs produced by _xyz_column_plan).

CloudSession dataclass

An imported point cloud held in RAM as the COMPLETE source of truth.

The full attribute set — positions, colors, intensity, and every scalar extra-dimension — lives in these arrays. The source FILE is read exactly once (at create); after that every operation (delete/crop/erase, filter, ground/tree segment, bake, downstream compute) reads or mutates these arrays and never touches the file again. The Potree octree is a derived cache rebuilt from the arrays on bake.

LasReadResult dataclass

Everything _read_las_into_arrays materializes from a normalized LAS.

A dataclass (not a tuple) because the set has grown past readable positional unpacking and carries several Optionals with subtle precision contracts. positions (N,3) float64 — full-resolution coordinates. colors (N,3) uint16 | None — kept in LAS scale for byte round-trip. intensity (N,) uint16 | None. extras {slug: (N,) float32} — scalar extra-dim columns. extra_dims_meta ordered [{slug, label}] for the octree sidecar. timestamps (N,) float64 | None — gps_time, kept OUT of float32 extras (the LAD trajectory-join key; see CloudSession.timestamps). gps_time_encoding 'adjusted_standard' | 'gps_week' | None. beam_origins (N,3) float64 | None — per-pulse emission points read from ExtraBytes (ox/oy/oz aliases); when present LAD uses them directly and skips the trajectory join.

CloudSessionCreateRequest

Bases: BaseModel

Create a mutable cloud session from a source file and build its first (derived) octree. column_plan is the import wizard's explicit layout and is honored ONCE here, then carried for the life of the session so edits never re-auto-detect columns (the import-wizard option-loss fix).

DeleteRegionRequest

Bases: BaseModel

Mark points inside region as deleted on a cloud session. Instant: sets the in-RAM mask; does NOT rebuild the octree. The renderer mirrors the deletion on the GPU via its clip-volume stack, so the viewport updates immediately.

DepthLimit

Bases: BaseModel

Front-surface limit for a label lasso or rectangle: a coarse grid of view-depth THRESHOLDS over the outline, at the stroke's frozen camera. A point passes when its view depth is within its cell's threshold.

Built by the renderer (src/renderer/lib/frontSurface.ts) from the points it drew, so it is the renderer that decides what "the surface you can see" means; this side only compares, which is what keeps preview and replay equal. thresholds is cols*rows little-endian float32, row-major, base64; +inf means no surface is known in that cell (everything there passes), and so does a point outside the grid — the limit only ever REMOVES points.

LabelStroke

Bases: BaseModel

One replayable label edit: "inside region, points whose CURRENT class is in from_classes become to_class".

from_classes is TerraScan's From-class filter and is what makes fast, sloppy selection safe — "only repaint leaf→wood" turns overspray onto the ground into a no-op. None means "any visible", i.e. no class gate at all. That is deliberately NOT the same as listing every palette value: a point holding a class the palette doesn't define (imported data, or a class the user removed) must still be repaintable, so the None sentinel is load-bearing.

exclude_classes is what the stroke must NEVER change, whatever the From gate says: the classes the user has HIDDEN (so "paint over any visible class" means exactly that) and the ones they have LOCKED. Sent as an exclusion list rather than folded into from_classes, because "any visible" must still repaint classes the palette does not define.

stroke_id is generated by the renderer and is the join key between its stroke list and this session's label history, so undo can truncate both to the same point.

LabelRegionRequest

Bases: BaseModel

Repaint points on a cloud session. Instant: mutates the in-RAM label column and does NOT rebuild the octree.

strokes are applied IN ORDER and atomically (one lock acquisition), so a brush drag flushes its whole batch in a single call rather than one request per stamp. Order matters: labeling is not commutative (paint-everything-leaf then paint-a-subregion-wood is not the reverse), so the list is never sorted or deduped.

BackfillMissesRequest

Bases: BaseModel

Explicitly recover a session's sky/miss points and persist them (see POST .../backfill-misses).

Mirrors the LAD-relevant subset of HeliosScanEntry so the backfill cloud is built on the exact same array + addScan path as /api/lad/compute. origin is the scanner position (per-beam directions are reconstructed from it); the optional angular raster (n_theta/n_phi/theta_/phi_) sets the scan grid the gapfiller reconstructs misses over, falling back to a count-based estimate when omitted. trajectory marks a moving-platform scan (per-pulse origins joined by timestamp), which forces the timestamp gapfill path.

origin_known states whether origin is a REAL scanner position rather than a placeholder. It exists because the two cases are indistinguishable in the numbers — a scan legitimately authored at the world origin sends the same [0,0,0] a caller substituting a default would — and the difference decides whether the result means anything. Every per-hit ray direction is cart2sphere(xyz - origin) and the misses fan out from that same apex, so a made-up origin yields confidently wrong geometry (measured: 76 deg mean beam error, max 176 deg, for a scanner 22 m off). Defaults True so existing callers that DO send a real origin are unaffected; the renderer sends False when the scan carries no position.

ResetCloudEditsRequest

Bases: BaseModel

Undo. edit_count = how many committed deletes to KEEP; the mask is restored to that snapshot in the history and later ones are discarded. Omit to clear all deletions (edit_count = 0).

ResetLabelEditsRequest

Bases: BaseModel

Undo. edit_count = how many committed label edits to KEEP; the column is rolled back to that point and later entries are discarded. Omit to clear ALL labeling on the slug (edit_count = 0).

SegmentPickRequest

Bases: BaseModel

Pick the piece under seed (session coordinates, e.g. the surface point under the cursor). size None = automatic from the point spacing; the reply says what was used, so the panel can show and adjust it.

SessionSplitRequest

Bases: BaseModel

Split a session into the points a filter KEEPS (stay on this session) and the points it EXCLUDES (a NEW leftover session). Operates entirely on the in-RAM arrays — no source file read. Same predicate shape as the filter.

SessionExtractRequest

Bases: BaseModel

Extract the points a spatial+scalar filter SELECTS into a NEW child session, leaving the parent UNCHANGED. Operates on the in-RAM arrays — no source file read. Used by 'split into clouds' workflows that keep the classified parent and spin off per-class child clouds.

SessionExtractByColumnRequest

Bases: BaseModel

Split a session into ONE child session per distinct integer value of a categorical column (e.g. tree_instance → one cloud per tree), leaving the parent UNCHANGED. Operates on the in-RAM arrays — no source file read. This is the batch form of extract: instead of the renderer looping N HTTP calls (one full octree build each, serial), the server slices all subsets under a single lock and builds their octrees CONCURRENTLY.

SessionMergeRequest

Bases: BaseModel

Concatenate the SURVIVING points of two or more sessions into one NEW session (stitch). Operates entirely on the in-RAM arrays — no source file read. The inputs are left untouched (the renderer removes them from the scene, carrying each session id for deferred-free so undo can restore them).

SessionGroundSegmentRequest

Bases: BaseModel

Run CSF ground segmentation on the session's in-RAM points and append a ground_class scalar column (1=ground, 2=plant). No source file read.

SessionComputeNormalsRequest

Bases: BaseModel

Estimate per-point normals on the session's in-RAM points and append the nx/ny/nz/curvature/verticality columns.

SessionDenoiseRequest

Bases: BaseModel

Classify noise on the session's in-RAM points and append a noise_class scalar column (1=clean, 2=noise). No source file read, nothing deleted — the user commits with the Filter tool's existing Remove / Segment buttons, which is what makes an aggressive result recoverable.

Every parameter defaults to None = "derive it from this cloud's own point spacing"; an explicit value always wins. See denoise.resolve_params.

criterion_params()

Only the keys method actually consumes, and only when set. Sending an unrelated method's parameter would make resolve_params report a params_used the panel then displays as though it had been applied.

SessionDemRequest

Bases: BaseModel

Generate a DEM from a session's in-RAM survivors. Ground-aware: a prior ground_class column restricts gridding to ground points; else CSF is run when auto_segment_ground is set, else all points are used. Optionally write a height_above_ground (CHM) scalar back onto the cloud and rebuild.

SessionWoodSegmentRequest

Bases: WoodSegmentationRequest

Run wood/leaf segmentation on the session's in-RAM points and append a wood_class column (1=wood, 2=leaf). Inherits the segment_wood tuning fields; points/source are ignored (the session's arrays are the source of truth).

DetectStemsRequest

Bases: BaseModel

Automatic trunk seeds (stem_seeds.py; docs/docs/concepts/stem-detection- and-tiling.md). Unset fields take the documented defaults.

SessionOrganSegmentRequest

Bases: BaseModel

Organ segmentation on the session's in-RAM points; appends plant_organ and leaflet_id columns and rebuilds the octree.

SessionTreeSegmentRequest

Bases: TreeSegmentationRequest

Run TreeIso on the session's in-RAM points and append a tree_instance column. Inherits the TreeIso tuning fields; points/source are ignored.

ScalarFieldComputeRequest

Bases: BaseModel

Derive a new scalar column from an expression over existing ones.

ScalarFieldAggregateStatsRequest

Bases: BaseModel

Pooled statistics for ONE field across several sessions.

ScalarFieldManageRequest

Bases: BaseModel

Rename, delete or duplicate one scalar field.

SessionTransformRequest

Bases: BaseModel

Apply an AFFINE 4x4 transform to a session's geometry in place, then rebuild (or pose) the octree. Rigid for cloud-to-cloud ICP, which MOVES an octree-backed source cloud onto the target (ICP computes the matrix on world-frame points and this bakes it into the session so the streamed octree follows); rigid-plus-scale for the Transformation tool, whose scale may be non-uniform. matrix is 16 floats, ROW-MAJOR world-frame (the same layout ICPRegistrationResponse.transformation_matrix returns). The bottom row must be [0, 0, 0, 1] and the linear part must have a positive determinant (no mirroring, no collapse).

SessionFilterRequest

Bases: BaseModel

Apply a spatial + scalar filter to the session by DELETING the points the filter excludes (sets the deleted mask). Operates entirely on the in-RAM arrays — no source file read. region keeps points inside it (invert to flip); scalar_filters keep points whose attribute is in range/class. A point survives iff it passes the region AND every scalar filter.

SessionResampleRequest

Bases: BaseModel

Thin a session cloud by DELETING the points a resample drops (sets the deleted mask, instant — the caller bakes to rebuild the octree).

mode: - "random": keep a uniformly random fraction of the surviving hits (seeded, so the same request keeps the same points); - "voxel": keep one point per occupied cube of edge voxel_size (meters, stored-frame grid) — the point nearest the cube's center, so every kept point is a REAL measured point with all its attributes, not an average. Evens out density (dense near the scanner, sparse far away) where a random thinning keeps the imbalance.

Sky/miss points are never dropped (LAD's transmission denominator depends on them; see _do_session_filter). dry_run reports the counts without changing anything, for the panel's result estimate.

C2MDistanceRequest

Bases: BaseModel

Request for computing cloud-to-mesh distance statistics.

C2MDistanceResponse

Bases: BaseModel

Response with cloud-to-mesh distance statistics.

ICPRegistrationRequest

Bases: BaseModel

Request for ICP registration to align mesh to point cloud.

ICPRegistrationResponse

Bases: BaseModel

Response with ICP registration transformation.

CloudToCloudICPRequest

Bases: BaseModel

Request for ICP registration to align one point cloud to another.

GlobalRegisterRequest

Bases: BaseModel

Request for coarse (global) cloud-to-cloud registration.

Mirrors CloudToCloudICPRequest's dual input shape: either side may be a flat inline array or an octree/session-backed PointSource.

MultiScanRegisterRequest

Bases: BaseModel

Register a SET of scans together, using the scan graph to validate itself.

Pairwise registration cannot tell a correct pose from a wrong-but-well- fitting one on a repetitive planting -- a row-shifted alignment lands plant on plant and scores BETTER by every residual measure. Registering three or more overlapping scans makes the ambiguity resolvable: composing the poses around a closed loop must return you to where you started, and a row-shifted pose does not cancel around that cycle.

Measured across three real orchards: loops whose poses are all correct close to 0.013-0.106 m, loops containing a wrong pose to 4.2-6.3 m.

MeshToMeshICPRequest

Bases: BaseModel

Request for ICP registration to align one mesh to another.

unicode_to_ascii(s)

Convert unicode subscripts to ASCII numbers for phytorch compatibility.

health_check()

Health check endpoint for backend status.

Also reports the memory budget and what is currently admitted against it, so a support log or the renderer's diagnostics can say why a large-cloud operation is queued or slow without attaching a profiler.

get_version()

Version endpoint for Tauri app to check backend compatibility

device_info()

Report whether synthetic-scan ray tracing runs on GPU or CPU.

The packaged Windows/Linux builds always compile the CUDA ray-tracing path (the release CI fails the build otherwise), and cudart is linked statically so a GPU build still runs on a machine with no driver — Helios's cudaGetDeviceCount() returns 0 and it falls back to CPU/OpenMP. macOS builds are always CPU-only (no CUDA on Apple hardware). So the effective path is decided entirely by a runtime probe for a usable NVIDIA GPU (pyhelios.runtime.get_gpu_runtime_info, primarily via nvidia-smi): GPU when one is present on a non-macOS build, CPU otherwise.

ml_device(http_request) async

Which device ML inference will use: {device: cuda|mps|cpu, device_name, vram_gb, torch, reason}. Answered by torch itself, in a worker, then cached (the hardware cannot change under a running backend). Distinct from /api/device-info, which reports Helios ray tracing: an old NVIDIA card can serve one and not the other.

ml_models(task=None)

Installed models (bundled + user-imported), optionally for one task.

ml_models_import(request, http_request) async

Validate a model package and install it into the user model directory. The weights are loaded against the declared architecture before the copy, so a package that would only fail at first use is refused here.

ml_models_delete(model_id)

Remove a user-imported model. Built-in models cannot be removed.

riegl_image_build(request, http_request)

Build the RIEGL reader image from the bundled Dockerfile.

This is the one setup step that cannot be done for the user ahead of time: the image is always built locally and never pulled, because publishing it would mean redistributing RiVLib. The build itself only installs numpy and laspy onto a python:3.11-slim-bullseye base, so it carries no licensed bytes and is safe to re-run.

Streams PHP1 progress and honors /api/cancel/{run_id} like every other long operation. Declared def (not async def) per the event-loop rule — the work is a blocking subprocess, so FastAPI runs it in the threadpool.

riegl_project_inspect(request)

List a raw RIEGL project's scan positions without extracting points.

Drives the import selection UI: each entry carries the scan pattern (from the position's .pat file), the instrument identity and the GNSS fix, plus a centroid-anchored ENU offset so the caller can lay the positions out.

Cheap relative to extraction — the reader only pulls a bounded prefix of each point stream, which it must do at all because RiVLib emits the housekeeping records (where GNSS lives) as a side effect of reading points.

riegl_project_extract(request, http_request)

Extract selected scan positions to LAS, one file per position.

Each LAS is a normal Phytograph point cloud: the renderer imports it through the existing create_cloud_session path with no RIEGL-specific handling. That is deliberate — it keeps the container boundary dumb and means the directory-shaped .riproject never has to be understood downstream.

WHICH FRAME THE POINTS ARRIVE IN depends on the request and the layout:

frame="local" — the SCANNER'S OWN FRAME, unrotated. Raw projects carry no registration (that is what RiSCAN PRO produces), so every position sits at its own origin; origin_prior is the GNSS-derived ENU offset for seeding ICP, not a registration. frame="sensor" — additionally LEVELED by the position's own inclinometer, which is survey-grade (agrees with RiSCAN's SOPs to <=0.05 deg on two independent projects). The cloud comes out plumb — so ground/DEM/CSF assumptions hold — but NOT rotated to north and NOT aligned to the other positions: the onboard compass is 10-14 deg wrong on measured data and its own accuracy figure does not predict that, so no heading is applied and ICP still owns the remaining rotation. Positions with no usable inclinometer record import unleveled rather than failing. frame="registered" — each position's SOP is applied, so a .PROJ's scans land pre-aligned in the project frame. Per-position registration says how well: "registered" is the surveyed pose, "prior" is only the inclinometer/GNSS estimate and still wants ICP, "none" is unplaced.

The response's registered flag reports what actually happened rather than being hardcoded, so the caller can tell the user whether to run ICP.

Streams PHP1 progress and honors /api/cancel/{run_id}.

riegl_status(rivlib_path=None)

Report whether RIEGL .riproject import is available on this machine.

rivlib_path is the renderer's persisted setting, passed per request because the backend sidecar is spawned once at launch and would otherwise need a restart to notice the user changing it.

get_model(category, model_type)

Get the appropriate phytorch model based on category and type

fit_model(file=File(...), model_category=Form(...), model_type=Form(...), method=Form('auto'), max_iterations=Form(1000)) async

Fit a phytorch model to uploaded data.

list_models()

List available models and their required data fields

sanitize_equation(equation)

Clean up equation for Python evaluation

create_model_function(equation, param_names, input_symbols, constants, symbol_to_name)

Create a callable function from an equation string

fit_custom_model(request)

Fit a custom or built-in model to data.

get_model_builtin(model_id)

Get a built-in phytorch model instance

convert_symbol_to_latex(symbol)

Convert a symbol name to LaTeX, handling Greek letters and subscripts.

python_to_latex(equation, output_symbol='y', equation_type='explicit')

Convert a Python/numpy equation string to LaTeX using pytexit. Preserves the original term order.

convert_to_latex(request)

Convert a Python equation to LaTeX format.

convert_to_latex_get(equation, output_symbol='y', equation_type='explicit')

Convert a Python equation to LaTeX format (GET version for easy testing).

export_fit_results(request)

Export fit results to an Excel file with Parameters, Data, and Metadata sheets.

build_prosail_forward()

Build the PROSPECT-D forward model using prosail library

resample_spectrum(meas_wl, meas_R, model_wl)

Resample measured spectrum to model wavelengths

fit_prospect_d(wavelengths, reflectance, vis_weight=6.0, loss='soft_l1', f_scale=0.08, n_starts=6, stage1=True, do_calib=True, green_center=550.0, rededge_center=705.0, window_sigma=20.0, feature_boost=2.0, vis_min=380.0, vis_max=750.0, violet_center=410.0, violet_boost=2.0, rng_seed=42)

Fit PROSPECT-D model to spectral reflectance data.

Returns dict with
  • success: bool
  • parameters: dict of fitted parameter values
  • rmse: float
  • r_squared: float
  • fitted_spectrum: list of fitted reflectance values
  • wavelengths: list of wavelengths for fitted spectrum

fit_prospect_model(request)

Fit PROSPECT-D radiative transfer model to spectral reflectance data.

Input
  • wavelengths: array of wavelength values (nm)
  • reflectance: array of reflectance values (0-1 range)
  • Various fitting options (vis_weight, n_starts, etc.)
Output
  • Fitted biophysical parameters (N, Cab, Car, Cbrown, Cw, Cm, Ant)
  • Optional calibration parameters (a_scale, b_offset)
  • Fitted spectrum and fit statistics

triangulate_point_cloud(request, http_request)

Triangulate a point cloud (Open3D). Returns a PHB1 binary frame.

fit_crown_endpoint(request, http_request)

Fit crown shapes + metrics. Streams progress then a JSON tail (one entry per fitted crown), cancelable via the run-id token.

session_tree_inventory(session_id, request, http_request)

Tree list + stem curves + stand geometry for every segmented tree. Streams per-tree progress then a JSON tail; cancelable through the run-id token (checked between trees and between indexing chunks), admission-gated inside.

session_tree_qsm(session_id, request, http_request)

One QSM per segmented tree. Streams per-tree, per-stage progress then a JSON tail; cancelable between stages; admission-gated inside.

denoise_points(request, http_request) async

Classify a point cloud into clean (1) and noise (2) points. Returns per-point labels aligned to input order; persisting the result onto an octree-backed cloud is done by /api/cloud/session/{session_id}/denoise.

Runs in a KILLABLE subprocess so the panel's Cancel can stop it mid-run.

segment_ground_points(request, http_request) async

Classify a point cloud into ground (1) and plant (2) points using the Cloth Simulation Filter. Returns per-point labels aligned to input order; persisting the result onto an octree-backed cloud is done by /api/cloud/session/{session_id}/segment_ground.

The CSF compute runs in a KILLABLE subprocess (see _run_killable) so the panel's Cancel button can SIGKILL it mid-run; on client disconnect a canceled response is returned at once.

generate_dem(request, http_request)

Generate a DEM from a flat cloud (inline points / source). Returns a PHB1 binary frame (heightmap mesh + regular grid).

export_dem_raster(request)

Write a DEM grid to ESRI ASCII (.asc) or GeoTIFF (.tif); returns base64.

export_lad(request)

Write a LAD voxel grid as GeoTIFF / CSV / AMAPVox .vox / summary .txt.

Declared def, not async def: this is blocking CPU work over a potentially million-voxel grid, so it belongs in anyio's threadpool rather than owning the event loop (see the module docstring on blocking endpoints).

Returns {success, format, files: [{name, data_base64|null, path|null}]}. With dest_dir the backend writes each file itself and reports its path; without it the bytes ride back as base64, which is capped at _LAD_MAX_INLINE_CELLS.

segment_wood_points(request, http_request) async

Classify a point cloud into wood (1) and leaf (2) points from geometry. Returns per-point labels aligned to input order; persisting the result onto an octree-backed cloud is done by /api/cloud/session/{session_id}/segment_wood.

segment_organs_points(request, http_request) async

Label every point soil / stem / leaf and number the leaflets. Returns per-point arrays aligned to input order; persisting onto an octree-backed cloud is /api/cloud/session/{session_id}/segment_organs. Callers send HIT points only (a sky/miss point ~1 km out would be read as a giant plant).

segment_trees(points, params=None, seeds=None)

Assign each point a tree id (0 = unassigned, 1..N) via TreeIso.

With seeds, every TreeIso segment is reassigned to the id of its nearest seed (each seed -> exactly one tree); otherwise TreeIso's own 1..N labels are returned, aligned 1:1 to the input order.

segment_trees_points(request, http_request) async

Segment a multi-tree cloud into per-point tree instance ids via TreeIso.

Mirrors /api/segment/ground: inline points or a source descriptor in, per-point integer labels out (0 = unassigned, 1..N = trees), full resolution so labels align 1:1. Persisting onto an octree-backed cloud is done by /api/cloud/session/{session_id}/segment_trees.

The TreeIso pipeline is CPU-bound and runs for tens of seconds on a large tile, so it executes in a KILLABLE subprocess (_run_killable): other /api/* requests stay responsive, and if the client disconnects (panel closed, Cancel, fetch timeout) the worker is SIGKILLed and this returns at once instead of holding the request open.

Labels-only by design: this interactive path returns predicted instance ids and nothing else. If the source carries ground-truth fields (e.g. a benchmark PLY's instance/semantic), they are NOT echoed back here — only /apply carries source scalars through into the octree, and the eval harness (scripts/eval_tree_segmentation.py) reads GT straight from the file. There is no GT consumer on this endpoint.

helios_triangulate(request, http_request)

Triangulate point cloud data using PyHelios spherical Delaunay triangulation.

Returns a PHB1 binary frame (see _bin_frame_bytes) so multi-million-triangle meshes transfer compactly and parse as zero-copy typed arrays. Keepalive chunks during the (long) computation keep WebKit's stall timeout at bay.

triangulate_check_spacing(request) async

Cross-check the auto-estimated Lmax against the actual in-grid point spacing.

An OPT-IN diagnostic (the renderer offers it as a button when the Otsu indicators aren't both High). Potentially expensive — 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 so the renderer sends the exact scans + grid + lmax it triangulated with.

lad_compute(http_request) async

Compute per-voxel leaf area density via PyHelios.

Accepts either a JSON LADComputeRequest body (the default / fresh-triangulation path) or — when reusing a previously-run Helios triangulation — a PHB1 binary frame carrying the request fields in its header plus the mesh as raw buffers (see _decode_lad_request_frame). The binary path lets a 1M+ triangle mesh ride back to the backend compactly so it can be injected via setExternalTriangulation instead of being re-triangulated from scratch.

Either way the response streams PHP1 progress markers (see _bin_frame_streaming_response) ahead of the JSON result so the renderer shows a real per-stage progress bar and the keepalive survives WebKit's ~60s stall timeout. The renderer drains the markers and parses the JSON tail.

lad_snap_grid(request)

Sample a DEM under each voxel column so the grid can be displaced to follow the ground. Returns the authoritative per-column offsets the UI both renders and feeds to the LAD inversion (HeliosGrid.column_offsets).

trajectory_parse(request)

Parse a binary trajectory (currently SBET .sbet/.out) into the canonical PoseStream wire dict the renderer's poseStreamFromWire consumes. Text trajectories (.csv/.txt/.tsv/.traj) are parsed client-side and never hit this endpoint.

Binary parsing belongs server-side: it needs pyproj (Python-only) for the geographic->UTM projection, and the renderer only does IPC text reads.

scan_export_xml(request, http_request)

Export objects to a Helios XML bundle or per-object data files.

Streams PHP1 progress markers ahead of its JSON tail (same transport as /api/pointcloud/export). A batch export is one blocking round-trip covering every checked object, so without this the UI could only show an indeterminate spinner for what is routinely tens of seconds of formatting — and had no way to stop it. The markers also make the run CANCELABLE via /api/cancel/{run_id}; a cancel unwinds through ScanCanceled and takes the already-written files back out with it.

The JSON tail is the same ScanExportResponse shape as before; only the transport changed.

lidar_scan(request, http_request)

Ray-traced synthetic LiDAR scan. Returns a PHB1 binary frame (points + scalars per scanner can be millions of values).

cancel_run(run_id)

Cancel an in-flight streaming op (synthetic scan / triangulation / LAD).

The streaming endpoints emit their run_id as the first PHP1 marker; the renderer POSTs it here to stop the work and free the C++/numpy memory without waiting on the (possibly huge) computation to finish. Idempotent: an unknown or already-finished run_id returns found=False rather than an error.

segment_ground(points, cloth_resolution=0.05, rigidness=3, class_threshold=0.02, iterations=500, slope_smooth=False, time_step=0.65, auto_class_threshold=False, meta=None, tile=None)

Classify each point as ground (1) or plant (2) via the Cloth Simulation Filter (Zhang et al. 2016).

tile = None decides from the point count (_ground_tile_min_points): a large cloud runs per buffered XY tile through tiled.run_tiled, which bounds the CSF working set (its two copies of the points plus the cloth) by one tile instead of the whole cloud - the whole-cloud form needs ~60 B per point inside the worker on top of the parent's copy, which is what put a 100 M-point run past a 16 GB laptop. The collar is 30 cloth resolutions (2-30 m), the scale over which the cloth's answer at a point can depend on its neighbors; tests/test_tiled.py pins the seam agreement against the untiled result. With auto_class_threshold the threshold is measured ONCE on a stride sample and applied to every tile, so tiles cannot disagree about where the ground is.

CSF drapes an inverted cloth over the point cloud and labels points the cloth settles onto as ground. Defaults here are tuned for close-range plant scans on roughly-flat ground (cm-scale cloth resolution, high rigidness), NOT the airborne-LiDAR defaults the upstream docs assume.

Returns an int array of length len(points), aligned to input order, with values GROUND_CLASS_GROUND / GROUND_CLASS_PLANT.

With auto_class_threshold, class_threshold is ignored and derived from the settled cloth instead (see _estimate_class_threshold). This costs no extra simulation: CSF's class_threshold only feeds its final point-to-cloth comparison, never the cloth physics — verified by running the same cloud at 0.5 and 1.0 and getting bit-identical cloth_nodes.txt. So the cloth is settled once, the threshold is read off it, and the points are classified against the same cloth CSF would have used.

Pass a dict as meta to receive the threshold actually applied plus the estimator's diagnostics.

Raises ImportError if the CSF extension is unavailable — the caller turns that into a clean error response rather than a 500.

segment_wood(points, k_min=10, k_max=100, k_step=10, wood_bias=0.6, reg_k=20, reg_iters=3, min_speckle=0, branch_grow_sph=0.02, voxel_size=0.0, max_points=None, reflectance=None, reflectance_weight_max=0.4, method='geometric', backbone_support=0.0, warnings=None, model_id=None)

Classify each point as wood (1) or leaf (2) from geometry (+ optional reflectance assist), via one of two methods.

method="ml" is different in kind: a trained PointNeXt model (backend-api/ml/, chosen by model_id, default the bundled one) runs through _segment_wood_ml. None of the tuning kwargs below apply to it.

method="geometric" (the original) is purely point-wise. method="connectivity" additionally roots a geodesic skeleton at the trunk base and recovers the woody backbone — the set of points on continuous paths back to the base — which the point-wise method can't see, so it recovers thin branches/twigs and prunes geometrically-compact-but-disconnected leaf clumps. It REQUIRES ground removal (the geodesic roots at the lowest points); if the base looks like residual ground it appends a warning to warnings (a caller-supplied list) but proceeds, and it falls back to geometry when the skeleton degenerates. See _segment_wood_connectivity. backbone_support (0 = auto) tunes how much subtree support a node needs to count as backbone (higher → only major scaffold).

Pipeline (classical / non-ML, runs on CPU in seconds-to-minutes): 1. Per-point local-PCA features at an eigen-entropy-optimal scale (verticality, sphericity — Demantke 2011 / Weinmann 2015). 2. A wood saliency score = verticality + (1 − sphericity): wood is vertical (trunk/branches) and locally COMPACT (low sphericity), while foliage scatters the neighborhood in 3D (high sphericity) and hangs at varied non-vertical angles. OPTIONAL reflectance assist: when a per-point reflectance scalar is supplied (and reflectance_weight_max > 0), a 1-D GMM on the reflectance contributes a wood-probability term to this score, weighted by how separable wood/leaf are in it — so it helps high-contrast species (oak/beech: wood reads higher reflectance) and is inert (weight ≈ 0) on low-contrast ones (almond/redbud) or when reflectance is None. See _wood_blend_reflectance. A 2-component 1-D Gaussian Mixture splits the score; the higher-mean component is wood and wood_bias is its posterior threshold (0.5 = argmax; higher → stricter / less wood; lower → more wood). 3. Branch recovery (branch_grow_sph > 0): the verticality-weighted seed is high-precision but misses HORIZONTAL scaffold branches (low verticality → low score) even though they're woody (compact/low sphericity). Region-grow the wood label from the seed into CONNECTED low-sphericity points (sphericity < branch_grow_sph) to recover them (_wood_grow_branches). This is what makes thick crown branches read as wood rather than leaf. 4. Optional speckle pruning (min_speckle > 1): flip tiny isolated wood components back to leaf. 5. LeWoS-style graph regularization (iterated k-NN majority vote).

Linearity is deliberately NOT used: branches are linear (cylinders) and so are needles / narrow leaves, so linearity cannot separate the two. This was validated across two benchmark families — real TLS trees (Weiser et al. heiDATA: oak/beech/maple/pine/spruce, where neighborhood sphericity carries the signal, mean OA ≈ 0.85) and synthetic almond scans (narrow flat leaves, where verticality carries it, mean OA ≈ 0.80). The additive blend handles both; the one weak case is densely-scattered-leaf forms (e.g. the synthetic central-leader archetype) where only linearity/planarity separate — a trade accepted because production input is real TLS.

Very large clouds are handled automatically: above max_points (default _WOOD_SEGMENT_MAX_POINTS ≈ 1.5M, env-overridable) the geometry step runs on a voxel-downsampled subset and labels propagate back to full resolution by nearest neighbor — the per-point k-NN feature extraction is O(N·k_max) in memory and a multi-million-point cloud at full res can OOM the machine. Set voxel_size > 0 to choose the downsample resolution explicitly instead. Either way the returned labels are full-length.

Returns an int32 array length len(points), aligned to input order, with values WOOD_CLASS_WOOD / WOOD_CLASS_LEAF.

remove_statistical_outliers(points, nb_neighbors=20, std_ratio=2.0)

Remove statistical outliers using k-nearest neighbors. Points with mean distance > std_ratio * global std are removed.

build_neighbor_graph(points, search_radius, max_neighbors=20)

Build an undirected graph connecting neighboring points using a KD-tree.

Each point is linked to its up-to-max_neighbors nearest neighbors that lie within search_radius. Implemented as a single batched k-NN query (cKDTree, parallel workers) plus a radius mask — NOT a per-point query_ball_point Python loop, which on a multi-million-point TLS cloud materializes a Python list per point and runs for minutes. The batched query mirrors the wood-segmentation neighbor pass (_wood_local_pca_features).

Because cKDTree returns neighbors sorted by increasing distance, taking the first max_neighbors after dropping self and applying the radius mask is exactly the "keep the closest within radius" semantics of the old code — a behavior-preserving rewrite, not a quality change.

Parameters:

Name Type Description Default
points ndarray

Nx3 array of point coordinates

required
search_radius float

Maximum distance to consider points as neighbors

required
max_neighbors int

Maximum number of neighbors per point

20

Returns:

Type Description
dict

dict with 'neighbors' (per-point int32 ndarray of neighbor indices, so

dict

for j in neighbors[i] still yields ints) and 'kdtree'

select_root_set(points, threshold=0.02)

Select root points near the lowest point in z-direction.

Parameters:

Name Type Description Default
points ndarray

Nx3 array of point coordinates

required
threshold float

Height threshold τ (meters) above lowest point

0.02

Returns:

Type Description
list

List of indices of root points

bfs_label_points(neighbors, root_indices, n_points)

Label all points with their BFS distance from root set.

Parameters:

Name Type Description Default
neighbors list

List of neighbor indices for each point

required
root_indices list

Indices of root points (labeled as 1)

required
n_points int

Total number of points

required

Returns:

Type Description
ndarray

Array of labels (distance from root, -1 for unreachable points)

quantize_labels(labels, num_levels=60, use_nonlinear=True)

Quantize BFS labels into discrete intervals.

Parameters:

Name Type Description Default
labels ndarray

Raw BFS labels

required
num_levels int

Number of quantization levels (Q-S in paper)

60
use_nonlinear bool

If True, use sqrt scaling for better branch detail

True

Returns:

Type Description
ndarray

Quantized labels (0 to num_levels)

cluster_blocks(quantized_labels, neighbors)

Cluster connected points with the same quantized label into blocks. Uses DFS to find connected components within each quantization level.

Parameters:

Name Type Description Default
quantized_labels ndarray

Quantized BFS labels

required
neighbors list

List of neighbor indices for each point

required

Returns:

Type Description
tuple

(block_assignments, block_info) where:

tuple
  • block_assignments: Array mapping each point to its block ID (-1 if not assigned)
tuple
  • block_info: List of dicts with block metadata

find_block_connectivity(block_assignments, block_info, neighbors)

Find which blocks are connected based on edge connectivity in the graph.

Returns:

Type Description
dict

Dict mapping block_id -> set of connected block_ids

filter_blocks(block_info, block_assignments, connectivity, threshold_filter=30, use_proportion_filter=True, proportion_threshold=0.1)

Filter out small blocks (noise, leaves, small twigs).

Filters: 1. Threshold filter: Remove blocks with fewer than threshold_filter points 2. Proportion filter: Remove blocks whose size is too small relative to parent

Returns:

Type Description
tuple

(filtered_block_info, filtered_assignments, filtered_connectivity)

compute_skeleton_nodes(points, block_info)

Compute skeleton nodes as centroids of each block.

Returns:

Type Description
dict

Dict mapping block_id -> centroid [x, y, z]

build_skeleton_tree(block_info, connectivity, skeleton_nodes)

Build skeleton edges based on block connectivity. Uses a tree structure rooted at the lowest-level block.

Returns:

Type Description
tuple

(edges, edge_lengths) where edges are pairs of block IDs

laplace_smooth_skeleton(skeleton_nodes, edges, iterations=2)

Smooth skeleton using Laplace smoothing. Each non-bifurcation node is moved to average of itself and neighbors.

NEW_A = (A + B + C) / 3 where B is parent, C is child

count_branch_points(edges)

Count nodes with more than 2 connections (branch points).

calculate_skeleton_length_from_edges(skeleton_nodes, edges)

Calculate total skeleton length by summing all edge lengths.

Parameters:

Name Type Description Default
skeleton_nodes dict

dict mapping block_id to [x, y, z] coordinates

required
edges list

list of (parent_id, child_id) tuples

required

Returns:

Type Description
float

Total length of all edges

calculate_branch_orders(skeleton_nodes, edges, block_info)

Calculate Strahler branch order for each node in the skeleton.

Branch order (Strahler number) classification: - Order 1: terminal branches (tips/leaves) - When two branches of same order n meet, parent gets order n+1 - When branches of different orders meet, parent gets the higher order

Uses iterative post-order traversal to avoid Python recursion limits.

Parameters:

Name Type Description Default
skeleton_nodes dict

dict mapping block_id to [x, y, z] coordinates

required
edges list

list of [parent_id, child_id] lists

required
block_info list

list of block dictionaries with 'id' and 'level' keys

required

Returns:

Type Description
dict

dict mapping block_id to branch order (int)

order_skeleton_points(skeleton_nodes, edges, block_info)

Order skeleton points from root to tips using BFS from lowest-level node. Returns list of [x, y, z] coordinates in order.

order_skeleton_points_with_mapping(skeleton_nodes, edges, block_info)

Convert skeleton nodes dict to ordered list with index mapping. Returns tuple of (list of [x, y, z] coordinates, dict mapping block_id to array index).

Important: We must include ALL nodes and create a complete mapping so that edge indices can be correctly converted to array positions.

remove_outlier_skeleton_points(slices, threshold_factor=2.5)

Remove skeleton points that deviate significantly from the local trajectory. Uses distance from a smoothed trajectory as criterion.

smooth_skeleton_robust(skeleton_points, smoothing_factor=0.0)

Smooth skeleton using spline interpolation with curvature constraints.

smooth_diameters(diameters, window_size=5)

Smooth diameter profile using median filter. Returns a list of floats with NO None values (interpolates missing values). Returns None if all diameters are None.

calculate_skeleton_length(skeleton_points)

Calculate total length of skeleton by summing segment lengths.

compute_skeleton(points, params)

BFS graph-based tree skeleton extraction (Li et al. 2017).

Pure compute: takes the already-read (and max-points-guarded) points plus a plain dict of the SkeletonRequest tuning fields, and returns a plain dict of the SkeletonResponse fields (so it round-trips through JSON unchanged). The /api/skeleton/extract endpoint and the killable subprocess worker (seg_worker.py) both call this — the worker because it runs in its own process so Cancel can SIGKILL it. Heavy/hang-prone (outlier removal, KD-tree, BFS, clustering) and CPU-bound; the early-exit guards return {"success": False, ...} dicts rather than raising.

Algorithm stages: 1. Pre-processing: Statistical outlier removal 2. Build KD-tree neighbor graph 3. Select root points near base (lowest z-coordinate) 4. BFS label all points with distance from root 5. Nonlinear quantization using sqrt scaling 6. Cluster connected points with same quantized label into blocks 7. Filter blocks (threshold filter + proportion filter) 8. Compute skeleton nodes from block centroids 9. Build skeleton edges based on block connectivity 10. Apply Laplace smoothing

extract_stem_skeleton(request, http_request) async

Extract a tree skeleton via the BFS graph algorithm (Li et al. 2017).

Reads (and downsamples) the points, applies the max-points guard, then runs the heavy compute_skeleton pipeline in a KILLABLE subprocess so the panel's Cancel button can SIGKILL it mid-run (see _run_killable). On client disconnect (Cancel / fetch timeout) the worker is killed and a canceled response is returned at once.

build_qsm(request, http_request)

Build a true QSM, streaming per-stage progress as PHP1 markers ahead of the JSON result (mirrors triangulation / backfill). The renderer's fetchJsonWithProgress drains the markers and parses the trailing JSON, which is the same QSMBuildResponse shape _do_qsm_build returns.

Cancelable. Without the token trio the reporter's should_cancel() is hard-wired False, so every _cancel_checkpoint below is a no-op, no run_id ever reaches the client and /api/cancel/{run_id} cannot address this run -- i.e. the panel's Cancel button did nothing while the heaviest endpoint in the product ran for minutes. /api/skeleton/extract, which runs the same extraction, has had all of this; this one was simply never wired.

import_qsm_csv(request)

Read a QSM back from a cylinder CSV (the inverse of the renderer's export).

Returns the same QSMBuildResponse shape /api/qsm/build does, so the renderer needs no separate data path for an imported QSM. Metrics aren't in the file -- compute_metrics is a pure function of the model, so they're recomputed here and come back identical to the pre-export values.

detect_qsm_phyllotaxis(request)

Auto-detect the phyllotactic angle from the QSM's branching geometry.

Branches follow the phyllotaxis of leaves (modulo unbroken buds), so the azimuths of child shoots around each parent reveal the angle. Returns a canonical angle + pattern + leaves-per-node + a confidence; used to pre-fill the Add Leaves modal.

add_qsm_leaves(request)

Place leaves on the terminal shoots of a QSM and return a textured mesh.

get_qsm_leaf_textures()

List the curated built-in leaf textures available for QSM leaf placement.

get_qsm_bark_textures()

List the curated built-in bark textures available for QSM texturing.

get_qsm_bark_texture(request)

Return one bark image as base64, from the builtin library or an upload.

A plain def (not async def): this reads a file off disk, and a blocking handler belongs in the threadpool rather than on the event loop.

adjust_qsm_leaf_angles_route(http_request) async

Adjust a QSM's leaf angles, accepting JSON or a PHB1 binary frame.

async def only to read the body; every blocking step runs in the threadpool, so this does not hold the event loop (see the module note on def handlers).

adjust_qsm_leaf_angles(request)

Adjust a QSM's leaf angles to match a measured per-cell distribution.

get_available_plant_models()

Get list of available plant models from pyhelios library

create_plant_session(request)

Create a new plant session for incremental growth simulation. The session keeps the pyhelios context alive for efficient time stepping.

advance_plant_session(session_id, request)

Advance time for a plant session and return updated geometry.

get_plant_session_status(session_id)

Get the current status of a plant session.

delete_plant_session(session_id)

Delete a plant session and free resources.

list_plant_sessions()

List all active plant sessions.

parse_plant_morph_parameters(request)

Parse a plant structure XML string into editable parameters.

morph_plant(request)

Rebuild a plant from modified structure XML.

generate_plant_model(request)

Generate a procedural plant model using pyhelios PlantArchitecture.

Uses direct primitive extraction from pyhelios Context API to get valid geometry. Textured organs carry Helios's own per-vertex UV coordinates (V-flipped for three.js), so leaf textures sample the correct cell of the leaf atlas.

generate_plant_canopy(request)

Generate a canopy of regularly spaced plants using pyhelios PlantArchitecture.

Builds a grid of count_x x count_y plants of the same species, spaced by spacing_x / spacing_y meters and centered on the canopy center. All plants are added to a single context and returned as one merged mesh, matching the single-plant /api/plant/generate response shape so the renderer is identical.

generate_plant_stream(request, http_request) async

Generate a single plant or a canopy with Server-Sent Events progress.

Emits

event: run_id data: {"run_id": "..."} (first, so the client can cancel) event: progress data: {"progress": 0.0-1.0, "message": "..."} event: result data: event: canceled data: {} (build aborted; memory freed) event: error data: {"detail": "..."}

Progress maps the C++ growth phase to 0–0.6 (via setProgressCallback), geometry extraction to 0.6–0.95, and JSON serialization to the final 1.0. Single-plant builds create a retained session (echoed as session_id) so the age slider keeps working; canopies are stateless.

Cancellation: the run_id ride the first event; a client POSTing /api/cancel/{run_id} (or disconnecting) flips a shared ctypes flag that the C++ canopy/advanceTime loops poll, so a long build stops between plants/timesteps and its Context/PlantArchitecture are torn down promptly.

import_textured_mesh(request)

Parse an OBJ (+ MTL + texture images) or PLY from disk into textured geometry.

Returns a PHB1 binary frame, not JSON. A scanner-grade mesh is easily millions of triangles, and as JSON that body exceeds V8's ~512 MB string-length cap — response.json() then throws ERR_STRING_TOO_LONG in the renderer no matter how long it waits. The binary frame is ~6x smaller and decodes as zero-copy typed arrays. Materials/textures ride in the frame's JSON meta, which stays small (base64 images only).

Streamed with progress markers so a PLY import that has to wait for open3d to finish loading says so, instead of sitting on "Reading mesh" for up to ~14 s. The bad-path / bad-extension checks run BEFORE the stream starts so they still answer with a real 404 / 400 status.

export_point_cloud_las(request, http_request)

Export a point cloud, streaming PHP1 progress ahead of a JSON tail.

Formatting a multi-million-point text export is ~97% of its wall time and was previously one opaque blocking call, so the UI could only show an indeterminate spinner. _do_point_cloud_export reports per-chunk progress through the same streaming wrapper the triangulate/DEM/crown-fit endpoints use, which also makes the export CANCELABLE via /api/cancel/{run_id} — a 25 M-point export is a ~30 s operation the user needs a way out of.

The JSON tail is the same PointCloudExportResponse shape as before; only the transport changed (progress markers now precede it).

import_point_cloud_las(file=File(...)) async

Import a LAS or LAZ file (multipart upload) and stream back a packed binary (PHX1) point-cloud response — the SAME format as import_by_path, decoded straight into Float32Arrays by the renderer.

This is the no-disk-path fallback (a File blob with no real path can't use import_by_path). It previously returned points.tolist() as a JSON body, which on a large LAZ trips V8's ~512 MB max-string ceiling and triples peak memory; the binary stream avoids both. laspy + lazrs handle LAZ; reading is shared with the path-based loader via _load_las_arrays.

reap_seg_workers()

Kill any still-running segmentation workers. Called at backend shutdown so a wedged worker never outlives the server. Workers run in their OWN process groups (posix_spawn setpgroup=0), so they are NOT auto-reaped when the parent dies — this is what cleans them up on a graceful exit.

import_pointcloud_by_path(request)

Parse a point-cloud file from disk and stream back a packed binary representation. Dispatches by extension:

  • .xyz / .txt / .csv / .pts / .asc → pandas + optional Helios ascii_format hint.
  • .ply / .pcd → open3d (handles ASCII and binary).

preview_pointcloud(request)

Cheaply inspect a point-cloud file for the import wizard.

Reads only enough of the file to show the wizard what was auto-detected and a handful of sample rows. Never 500s on a parse problem — returns a 200 with a warning and best-effort columns so the wizard can still offer "import with auto-detect".

create_cloud_session(request, http_request)

Load a source cloud FULLY into an in-RAM session (the complete source of truth — positions + colors + intensity + every scalar extra-dim), build its first octree, and return {session_id, ...octree metadata}. This is the ONLY point the source FILE is read; every later edit/bake/op works on the arrays.

The source is normalized to a LAS once via _source_to_las (handling XYZ/PLY/PCD/LAS/LAZ + the wizard column_plan uniformly), that LAS is read into the session arrays AND fed to PotreeConverter for the first octree.

Streams PHP1 progress markers ahead of the JSON result and is CANCELABLE via /api/cancel/{run_id} — importing a multi-GB scan is a minute-scale operation that can wedge on a bad file, and the user needs a way out that actually stops the work (the PotreeConverter child is killed, not just detached).

create_multi_cloud_session(request, http_request)

Import a source as one session PER SCAN POSITION.

The plural sibling of /api/cloud/session/create. Multi-scan formats (E57 with several scans, PTX with several blocks) fan out here; every other format returns a single-element list, so the renderer has one code path.

Same streaming + cancellation contract as the singular endpoint.

backfill_cloud_misses(session_id, request, http_request)

Explicitly recover sky/miss points for a session and persist them.

LAD needs miss points (beams that returned nothing) for the Beer's-law transmission denominator. Some formats retain them (E57 / structured PLY); others don't but carry the data to RECONSTRUCT them — a per-hit timestamp and/or scan-grid row/column indices. This endpoint builds an ephemeral PyHelios cloud from the session's surviving points, runs gapfillMisses() (which auto-selects the row/column or timestamp path in C++), extracts the synthesized misses, and stores them in a lightweight per-session buffer (sess.backfilled_misses) — leaving the hit arrays untouched.

Session resolve + array assembly + eligibility run up front (so a bad request is a clean 404/400); the heavy build/gapfill/extract streams PHP1 progress markers ahead of the JSON tail (see _bin_frame_streaming_response) so the renderer shows a per-stage progress bar. The JSON tail is {backfilled, miss_count, has_misses, scan_origin, already_had_misses, restored_deleted_hits} on success, or carries error when reconstruction failed. restored_deleted_hits counts deleted hits fed back to the gapfill so it reconstructs the raster the scanner measured (see below).

delete_cloud_region(session_id, request)

Set the per-point deleted mask for points inside region. No rebuild.

label_cloud_region(session_id, request)

Repaint points inside each stroke's region. No octree rebuild.

Deliberately does NOT null octree_cache_id, which is where this diverges from delete_region — and the divergence is load-bearing, so do not "fix" the inconsistency:

A deletion is expressible on the GPU (the renderer hides the points with a clip volume), so a stale octree still renders correctly. A LABEL change is not: the octree bakes attribute values into octree.bin at PotreeConverter time. Nulling the cache id per stroke would either remount the cloud (a full re-stream per brush stamp) or leave the renderer pointing at a cache the backend considers stale. The octree is not WRONG after a stroke, it is BEHIND, and the renderer's client-side label overlay closes the gap until an explicit commit_labels rebuild.

Strokes are applied IN ORDER under one lock acquisition, so a brush drag is one request and one critical section rather than one per stamp.

reset_cloud_edits(session_id, request)

Restore the deleted mask to an earlier snapshot (undo).

get_cloud_label_summary(session_id, slug=MANUAL_CLASS_SLUG)

Current per-class counts for a label column. Read-only.

Exists so the renderer can populate the class list the moment the tool opens. Without it the panel showed every class as 0 on a fresh cloud — which reads as "nothing here" when in fact every point is Unclassified.

The renderer cannot compute this itself: the counts are over EDITABLE points (excluding deleted rows and sky/miss points), so a client-side data.pointCount would disagree with every subsequent update and the total would visibly jump after the first stroke.

A session with no label column yet reports every point as unclassified rather than an empty map, which is the true state.

get_unlabeled_clusters(session_id, slug=MANUAL_CLASS_SLUG)

Where a label column's still-unlabeled points gather, for the label tool's finder to step the camera through (N / Shift+N). Read-only.

Unlabeled means an EDITABLE point (alive, not a sky/miss point) whose class is 0 (Unclassified), or every editable point when the column does not exist yet. Coordinates are in the session frame, the one strokes are drawn in.

segment_pick(session_id, request)

The label tool's click-to-pick (F6): segment the cloud's editable points (cached), find the segment at seed, optionally grow it across adjacent segments with a similar normal, and return it as a voxel_set region for a label stroke. Read-only: nothing is labeled until that stroke is sent.

get_label_extent(session_id, slug, value)

How many editable points carry class value in column slug, and their bounds (session coordinates) — what the label tool frames an instance by.

reset_cloud_label_edits(session_id, request)

Roll the label column back to an earlier point in its history (undo).

Same shape as reset_edits so the renderer's undo path reads alike, but the mechanism differs: reset_edits restores a stored bool-mask SNAPSHOT, which for a label column would be (N,) float32 — 200 MB per stroke on a 50 M-point cloud. Here we REVERSE-APPLY the stored deltas instead, newest first. That is exact even when several strokes repainted the same point, because each delta holds the value as it was when that delta ran.

Redo needs no endpoint: the renderer holds the stroke list and simply re-issues label_region with the truncated tail.

commit_cloud_labels(session_id, request, http_request)

Rebuild the derived octree so the label column is baked into octree.bin. The slow step (a PotreeConverter run).

For the LABELS this is a display operation: every backend op already reads the in-RAM session arrays, so split, filter, extract, extract_by_column and LAS/LAZ export ALL see fresh labels with no commit at all. Do not insert a defensive commit before those paths.

It is also an UNDO BOUNDARY for this column: label_history[slug] is dropped when the request arrives, not when the build finishes. The renderer clears its stroke list at commit, so its next undo after a new stroke says "keep nothing"; with the older deltas still here that reverse-applied every committed stroke and the labels were lost. Dropping them here also frees the history's memory. Unlike bake it does NOT compact arrays or clear deletions, and other columns' histories are untouched.

Streams PHP1 progress markers ahead of the JSON tail and is cancelable via /api/cancel/{run_id}, like bake: the renderer runs it in its background refresh queue, whose Cancel used to stop the bake that follows it but not this converter run.

bake_cloud_session(session_id, http_request, compact=True)

Permanently apply deletions by rebuilding the octree FROM THE IN-RAM ARRAYS — the survivors (positions[~deleted] + colors + intensity + every scalar extra-dim) are written to a LAS via _session_to_las and fed to PotreeConverter. The source file is NOT read. Then the in-RAM arrays are compacted to the survivors and the mask cleared. Returns the new octree metadata. The deliberately-slow step (the PotreeConverter run).

No deletions → returns the current octree without rebuilding.

?compact=false (the renderer's background display refresh) rebuilds the octree but keeps the deleted rows under the mask, because LAD restores the deleted hits outside its grid from them. See _do_bake_cloud_session.

Streams PHP1 progress markers ahead of the JSON tail and is cancelable via /api/cancel/{run_id} (the cancel SIGKILLs the PotreeConverter child). Before this it was a silent 300 s-timeout POST with no busy state in the renderer at all: the Erase panel's button stayed live throughout, so an impatient user could fire several concurrent full rebuilds of the same session. A cancel leaves the session pristine — see _do_bake_cloud_session.

session_split(session_id, request)

Keep the filter-passing points on this session; move the excluded points to a NEW leftover session. Rebuilds both octrees from arrays. Returns {kept: {...octree}, leftover: {session_id, ...octree}} (leftover null if empty). No file read.

session_extract(session_id, request)

Create a NEW child session from the filter-selected points (parent untouched). Returns {session_id, ...octree} or null if the selection is empty. No source file read.

session_extract_by_column(session_id, request, http_request)

Fan a categorical column out into one child session per distinct value (parent untouched). Returns {session_id, children: [{value, ...octree}]} ordered by value. Empty selections are skipped. No source file read. Streams PHP1 progress markers ahead of the JSON result (cancelable pill) — a per-tree split of a large plot is a minute-scale operation and the caller has no other signal that it's running.

All subsets are sliced up front under a single _cloud_session_lock acquisition (one argsort + a per-child index gather); the expensive per-child octree builds then run CONCURRENTLY in a bounded thread pool. Distinct values hash to distinct octree cache keys, so _octree_build_lock never serializes them and the global session lock is already released before PotreeConverter runs (see _session_rebuild).

session_duplicate(session_id)

Copy a session's SURVIVING points into a NEW independent session (parent untouched) and build its octree. This is the keep-everything degenerate case of extract: a pure array copy via _session_subset — NO source file read, so every wizard customization (column plan, custom labels, categorical slugs, dropped/renamed extras) is preserved on the copy. The new session is fully independent: later edits to either side don't affect the other. Returns {session_id, duplicate: {session_id, point_count, ...octree}}.

project_scene_upload(http_request) async

Stage the renderer's scene document (an opaque blob) for a save. async def only to read the body, which is streamed to disk in chunks off the event loop.

project_scene_download(token)

The scene blob of an opened project; one download, then it is gone.

project_save(request, http_request)

Write the scene (staged blob) and every listed cloud session into one .phyto file. Streams progress; cancelable; writes to <path>.partial and renames over the target only when complete.

project_open(request, http_request)

Restore a .phyto file: install its octrees, create NEW sessions from its saved ones, and stage its scene blob (fetch it with GET /api/project/scene/{token}). Streams progress; cancelable; a failure leaves no half-registered session behind.

session_merge(request)

Concatenate the surviving points of >=2 sessions into one new session and build its octree (+ a projected-miss octree when any input carried misses). Reconciles differing global shifts and unions scalar extra-dim columns. No source file read. Returns {merged: {session_id, point_count, ...octree}}.

session_segment_ground(session_id, request, http_request) async

CSF on the in-RAM survivors → append ground_class → rebuild octree from the arrays. No source file read.

The CSF compute runs in a KILLABLE subprocess (see _run_killable) so the panel's Cancel button can SIGKILL it. The column write + octree rebuild run in the parent AFTER the compute returns, so a cancel during the long compute leaves the session pristine. The snapshot and the write run off the event loop: each is a full-length gather, and taking _cloud_session_lock on the loop thread parks every other request behind whichever worker holds it.

session_normals_status(session_id)

Whether this session carries normals, and whether they predate an edit.

The panel polls this to decide between "Compute" and "Recompute (normals may be out of date)". Cheap: two dict lookups under the lock, no array work.

session_compute_normals(session_id, request, http_request) async

Estimate normals on the in-RAM survivors → append five columns → rebuild the octree from the arrays. No source file read.

The compute runs in a KILLABLE subprocess (see _run_killable), which is also the only context where the tile pool may open (tiled.worker_count). The column write + octree rebuild run in the parent AFTER the compute returns, so a cancel during the long compute leaves the session pristine. The snapshot and the write run off the event loop (see the ground handler).

session_denoise(session_id, request, http_request) async

Noise classification on the in-RAM survivors → append noise_class → rebuild the octree from the arrays. No source file read, no deletion.

The compute runs in a KILLABLE subprocess (see _run_killable) so the panel's Cancel actually stops it: a single cKDTree.query is a monolithic C call that cannot poll a cancel flag. The column write + octree rebuild happen in the parent AFTER the compute returns, so canceling mid-compute leaves the session pristine.

session_generate_dem(session_id, request, http_request)

DEM from a session's in-RAM survivors (ground-aware). Returns a PHB1 frame (heightmap mesh + grid). When add_height_column, also appends a height_above_ground scalar and rebuilds the octree (cache_id in meta).

session_segment_wood(session_id, request, http_request) async

Wood/leaf segmentation on the in-RAM survivors → append wood_class → rebuild octree from the arrays. No source file read.

The compute runs in a KILLABLE subprocess (see _run_killable) so the panel's Cancel button can SIGKILL it. The column write + octree rebuild run in the parent AFTER the compute returns, so a cancel during the long compute leaves the session pristine. If the cloud's points are edited while it runs, the write is refused (409, _raise_if_session_edited_locked) rather than scattered onto the wrong points. The snapshot and the write run off the event loop: each is a full-length gather.

session_detect_stems(session_id, request)

Find trunks in the session's breast-height layer and return one seed per trunk, in the session's STORED frame (the frame seed_points of segment_trees is compared in). Needs the height_above_ground column: a trunk has to be found at a fixed height above the terrain.

Plain def: a chunked pass over the columns (only the thin layer is ever copied out), then clustering and circle fits on that layer.

session_segment_organs(session_id, request, http_request) async

Plant organs on the in-RAM survivors → append plant_organ and leaflet_id → rebuild the octree from the arrays. No source file read.

Computed on HIT survivors only: a miss (~1 km out) would inflate the cloud's size, which is what units="auto" reads, and drag the sliding crops across empty space. Misses get 0 in both columns. The worker is killable, and the columns are written only after it returns, so a cancel leaves the session as it was. The snapshot and the write run off the event loop (each is a full-length gather).

session_segment_trees(session_id, request, http_request) async

TreeIso on the in-RAM survivors → append tree_instance → rebuild octree from the arrays. No source file read.

The TreeIso pipeline runs in a KILLABLE subprocess (see _run_killable) so the panel's Cancel button can SIGKILL it mid-run; the column write + octree rebuild run in the parent AFTER the compute returns, so a cancel during the long compute leaves the session pristine.

If the cloud carries a ground_class column (from a prior ground segmentation that was labeled but not removed), the ground points are excluded from TreeIso and assigned tree id 0 (unassigned) — TreeIso only sees the plant points, so ground never gets clustered into a "tree".

Every full-length gather here runs off the event loop (see the ground handler): the snapshot under the lock, the plant/ground subsets, and the label write.

session_scalar_fields(session_id)

List the scalar fields an expression may reference or the panel may edit.

Cheap: metadata only, no column is touched.

session_scalar_field_stats(session_id, slug, bins=None)

Summary statistics + histogram for one field.

Measured over the points that are ALIVE and are REAL RETURNS (_session_editable_mask_locked) — the same mask _session_robust_color_stats_locked feeds the colorbar. Two reasons, and both bite:

  • A sky/miss point is a ray that hit nothing, projected ~1 km out along the beam. A histogram of z over raw rows is set entirely by those, and a mean is meaningless. This is the read-only face of the miss-exclusion rule that hangs the reconstruction tools.
  • A deleted point is not on screen. If the stats disagreed with the legend beside them, the panel would be reporting on a cloud the user cannot see.

scalar_fields_aggregate_stats(request)

Summary statistics + histogram for one field, pooled across sessions.

ONE distribution over the concatenation, not N side-by-side summaries: the question the panel answers is "what does this field look like over the plot", and N histograms answer a different one.

Deliberately NOT nested under /api/cloud/session/{id}/… — there is no single session here, and nesting it under one would misdescribe what it measures. It sits beside POST /api/cloud/session/merge, the other multi-session route.

Each session is masked by its OWN _session_editable_mask_locked BEFORE the concatenation. That ordering is the whole correctness of the endpoint: pool the raw columns instead and every scan's sky/miss points (~1 km out along the beam) land in the pool, which sets the percentiles and the histogram domain by itself.

session_scalar_field_compute(session_id, request)

Evaluate an expression into a new per-point column, then rebuild.

The column is FULL-LENGTH — every row, deleted ones included. See _session_set_full_column_locked for why arithmetic must not use the survivor-aligned chokepoint.

session_scalar_field_manage(session_id, request)

Rename / delete / duplicate a scalar field, then bring the octree along.

A rename or delete RELABELS the current octree (octree_relabeled: true, milliseconds at any size) rather than reconverting it; a duplicate adds a column and so rebuilds, or defers the rebuild when asked. A rename or delete falls back to that same rebuild/defer only when the octree cannot be relabeled — it is stale or posed (see the comment at the call).

Only a non-reserved extras column may be touched. The reserved set is not bureaucratic: deleting is_miss would silently break LAD's transmission term and the miss filter every reconstruction tool relies on, and renaming a class column would orphan the categorical scheme the renderer paints it with.

Store-backed (memmapped) sessions need nothing special here. _session_write_back_to_store already resolves a renamed slug by array identity and drops the column of a slug that has gone, so the on-disk store follows from the in-RAM dicts on the next spill.

session_transform(session_id, request)

Bake an affine 4x4 transform into the session's in-RAM geometry and rebuild the octree. The matrix acts in WORLD coordinates; the session stores points with world_shift subtracted, so we conjugate by the shift: stored_new = L·(stored + shift) + t − shift. A permanent (non-undoable) geometry change, like a filter commit.

Positions and every other POINT (beam origins, backfilled misses, the miss projection origin) take the full matrix. Stored normals are DIRECTIONS and take the inverse-transpose of L, renormalized. A non-similarity (non-uniform scale) changes each point's neighborhood shape, so curvature is flagged stale; a uniform scale c multiplies any scanner distance column by c.

session_rebuild_octree(session_id)

Rebuild a session's derived octree(s) from its CURRENT in-RAM arrays.

The repayment step for a deferred transform: it brings the display cache back into the same frame as the geometry and clears octree_pose, so the renderer can drop its stored pose.

Needed because a few operations cannot tolerate the octree being behind. The screen-space regions (lasso crop, erase, label brush) freeze a camera looking at the POSED octree and the backend replays it against session positions, so the two frames must agree first. Compute and export never need this — they read positions directly.

Idempotent and safe to call when nothing is stale: it simply rebuilds. No source file is read, and no deletions are applied (that is bake's job).

session_filter(session_id, request, http_request)

Delete the points a spatial+scalar filter excludes, on the in-RAM arrays.

Streams PHP1 progress markers ahead of the JSON result (cancelable pill). Applying a filter rebuilds the octree via PotreeConverter — a minute-scale job on a large plot — and before this streamed the button looked dead: the user had no signal it was running, clicked again, and each click queued ANOTHER full filter+reconversion. Cancel targets /api/cancel/{run_id}, which fires the event _run_potree_converter polls, so the converter child is hard-killed rather than left running behind a detached fetch.

session_resample(session_id, request)

Thin a session cloud (see SessionResampleRequest). Mask-only and fast; the caller rebuilds the octree with /bake. Returns the surviving hit count before and after, plus the usual remaining/pending counts on a commit.

delete_cloud_session(session_id)

Free a cloud session's in-RAM arrays.

compute_c2m_distance(request, http_request)

Compute Cloud-to-Mesh (C2M) distance statistics.

Uses Open3D's RaycastingScene for efficient point-to-mesh distance computation. Returns comprehensive statistics about how well the mesh fits the point cloud. Streams PHP1 progress markers (see _bin_frame_streaming_response) ahead of the JSON result so the renderer shows a cancelable progress pill.

run_icp_until_convergence(source_pcd, target_pcd, max_corr_dist, init_transform, max_iterations=100, rmse_threshold=1e-06, progress=None)

Run ICP iteratively until RMSE plateaus (convergence). Returns the final transformation and metrics.

progress, when supplied, is a _ProgressReporter: after each 20-iteration batch we poll it for cancellation (raising ScanCanceled between batches — a running C++ batch can't be interrupted mid-call) and report a monotonic 0.15→0.95 fraction so the renderer's pill advances per batch. A no-op when progress is None (e.g. direct unit-test callers).

icp_register_mesh_to_cloud(request, http_request)

Perform ICP (Iterative Closest Point) registration to align a mesh to a point cloud.

The point cloud is the TARGET (stays fixed), the mesh is the SOURCE (will be transformed). Pre-aligns by moving source center to target center, then runs ICP until convergence. Streams PHP1 progress markers ahead of the JSON result (cancelable pill).

icp_register_cloud_to_cloud(request, http_request)

Perform ICP (Iterative Closest Point) registration to align one point cloud to another.

The target cloud stays fixed, the source cloud will be transformed. Pre-aligns by moving source center to target center, then runs ICP until convergence. Streams PHP1 progress markers ahead of the JSON result (cancelable pill).

run_global_registration(target_pts, source_pts, voxel, estimator='ransac_fpfh')

Estimate a coarse rigid transform aligning source_pts onto target_pts.

Returns (transform 4x4, fitness, inlier_rmse). No ICP here — this is only the global search; refinement is the caller's job.

RANSAC gets a CorrespondenceCheckerBasedOnEdgeLength alongside the usual distance checker. That edge-length test is the specific guard against the repetitive-planting failure: it requires that the DISTANCES between paired points agree between the two clouds, so a candidate set that maps each tree onto its neighbor (preserving positions but not the pairing) is rejected.

multi_scan_register(request, http_request)

Register several scans together, validated by loop closure.

Declared def, NOT async def: the work is blocking CPU (a coarse search plus ICP for every pair, minutes on a real set), so FastAPI must run it in the worker threadpool. An async def handler owns the single event loop for its whole duration and starves every other request behind it -- see tests/test_event_loop_not_blocked.py.

Prefer this over repeated /api/c2c/global-register calls whenever three or more overlapping scans are available: the extra scans are what make a wrong-but-well-fitting alignment detectable at all.

global_register_cloud_to_cloud(request, http_request)

Coarse (global) registration of one point cloud onto another.

Declared def rather than async def so the blocking coarse search and ICP run in the worker threadpool instead of holding the event loop.

Unlike /api/c2c/icp-register this does NOT need the clouds to start close together: it reduces both to sparse per-plant anchors, matches those, and (by default) refines the winner with the same point-to-plane ICP. Streams PHP1 progress markers ahead of the JSON result (cancelable pill).

icp_register_mesh_to_mesh(http_request) async

Perform ICP (Iterative Closest Point) registration to align one mesh to another.

The target mesh stays fixed, the source mesh will be transformed. Pre-aligns by moving source center to target center, then runs ICP until convergence. Streams PHP1 progress markers ahead of the JSON result (cancelable pill).

Accepts JSON or a PHB1 binary frame; async def only to read the body, with the decode pushed to the threadpool so the loop is never held.