File formats¶
Point clouds¶
| Format | Import | Export | Notes |
|---|---|---|---|
.las |
✅ | ✅ | LAS 1.2/1.4. Export fidelity depends on the path: a general cloud export writes LAS 1.4 point format 7 (6 when color is unchecked) with intensity, GPS time, every selected scalar as a float32 ExtraBytes dimension, and a class column in the classification byte (the one chosen in the export window; automatically the first of manual_class, las_classification, ground_class, wood_class), with the class names in a LAS 1.4 classification-lookup record and every labeled column's class set in a Phytograph record that a re-import restores, and the flag_withheld / flag_synthetic / flag_key_point label columns as the point flags, while the batch export (two or more objects checked, or any scan) writes LAS 1.4 with intensity, RGB, and a float32 ExtraBytes dimension for every scalar (including is_miss, timestamp, target_index, target_count). Use the batch path for full-fidelity round-trips — it takes plain clouds too, so checking a second object is enough to get it. |
.laz |
✅ | ✅ | Compressed LAS. Round-trips with .las. |
.e57 |
✅ | ✅ | Structured scan format. Carries intensity and RGB color, and recovers sky/miss points from the grid on import (see below). Export is per-object (one .e57 each) via the batch export's Data only mode, carrying x/y/z, intensity, and color. Export writes a structured file whenever the scan has a grid to write — either the instrument's own row/column indices or a declared Ntheta × Nphi sweep to bin against — and marks misses with the format's own cartesianInvalidState flag, so an exported scan re-imports as scan data rather than as a cloud of far-field points. Points are written in the scanner's local frame with its pose in the scan header, as scanners themselves do. |
.ply |
✅ | ✅ | Import preserves arbitrary scalar fields; export offers a field picker, so any scalar column — including normals — can be written alongside x/y/z and RGB. Structured/organized PLYs recover sky/miss points (see below). |
.ptx |
✅ | ✅ | Leica Cyclone's structured-scan ASCII format (also written by RiSCAN and FARO). A multi-block .ptx imports as one scan per block, each with its own pose. Carries intensity and RGB, and recovers sky/miss points from the grid (see below), so single-return LAD works. Not for multi-return data: one line per grid cell means each pulse is collapsed to a single echo, and the fixed schema has no room for timestamp / target_index / target_count, so the scan re-imports as genuinely single-return with no warning — which biases LAD high (see below). Export is per-object via the batch export's Data only mode; PTX always writes the full grid with no-return cells left empty, so misses round-trip whether or not the write misses box is ticked. |
.pcd |
✅ | ✅ | Point Cloud Data format (PCL), ASCII. Parsed via Open3D, which drops non-standard scalar fields — so export carries position and color only and shows no field picker. Color is packed into a single rgb field, as the format requires. Use .ply or .las to keep intensity and scalars. |
.riproject |
✅ | — | RIEGL raw scanner project — a directory of scan positions, not a file. Windows, Linux (x86_64) and macOS, and needs a user-supplied RiVLib (plus Docker on macOS only, where RiVLib has no native build): see Import a RIEGL project. Carries reflectance, amplitude, deviation and per-pulse return numbering; scans arrive unregistered, though each position can be leveled using the instrument's own inclinometer (tilt only — not aligned to north or to each other); sky/miss points are recovered from the scanner's per-shot record, so LAD works (on Windows and Linux that recovery needs a C++ compiler; the scan imports without one, but with no sky shell). |
.PROJ |
✅ | — | RIEGL on-instrument project from a newer scanner (e.g. VZ-2000i) — also a directory. Same requirements and same columns as .riproject, but it also carries the instrument's own registration, so scans can land already aligned, level and north-oriented. Registration is often partial; each position reports whether it was registered or only placed from a meter-level prior. The .rdbx files beside each .rxp are not used. |
.xyz / .txt |
✅ | ✅ | Whitespace-separated. First three columns = x, y, z. |
.csv |
✅ | ✅ | Comma-separated. First non-numeric row treated as header. |
.pts |
✅ | ✅ | Leica/Cyclone PTS. A leading line holding the point count, then x y z intensity r g b — intensity before color. The order is fixed (a reader decodes these columns positionally), so PTS export shows no field picker; export to .txt/.csv/.ply/.las if you need to choose fields or carry other scalars. |
.asc |
✅ | ✅ | ASCII point cloud, treated like .xyz. The delimiter is sniffed from the contents (RIEGL's ASCII export, for instance, is comma-separated with a header row), and a header line is read if present. Not to be confused with the DEM ASC grid raster export (see DEM rasters) — that .asc is an elevation grid, this one is a point list. |
.ascii |
✅ | — | RiSCAN PRO's ASCII scan export, treated like .xyz. The delimiter is sniffed from the contents and a header line is read if present, so the common headerless x y z gps_time reflectance layout auto-detects as x, y, z, timestamp, intensity — check the mapping in the import wizard and adjust it there if your export differs. Import only: export the cleaned cloud to .xyz/.txt/.csv/.las instead. |
.obj |
— | — | Not a point-cloud format in Phytograph — it is a mesh format (see Meshes). A vertex-only .obj cannot be imported as a cloud: .obj always loads as a mesh, so a cloud written to .obj would come back as a face-less mesh rather than a point cloud. Use .ply to carry points into Blender or MeshLab, or .xyz for a plain column file. |
ASCII format details¶
- First three columns: x, y, z (required) — unless up to two leading all-integer index columns precede the fractional coordinates, in which case they're read as the scan row/column index and xyz follow.
- Additional columns become scalar fields. If the file has a header row,
column names become field names. Otherwise fields are named
field_0,field_1, …. - A column of text (
leaf,stem, a plant id such asA-12) is imported as a label: each distinct value becomes a named class, numbered from 1 in natural sort order (p-2beforep-10), and the column colors, filters and splits by those names. An empty cell is class 0, Unclassified. One column may hold up to 255 distinct values — or 16,383 when its name ends in_instance, for a column that numbers objects — and a column with more is refused by name, since it is free text rather than a label; set it to Skip in the import wizard. Numbering is per file, so two files with different sets of names number them differently. A label is one token: in a whitespace-separated file a name cannot contain a space, and in any file it cannot contain#, which starts a comment.True/Falsecolumns are read as 1/0, not as labels. A text column cannot be the timestamp, the miss flag, or a scan-index or multi-return column. A column that mixes numbers with even one word is a text column, so its numbers become names and are renumbered from 1. - Separator is auto-detected from the file's contents, not its extension:
the first data row is sniffed for comma, then tab, then semicolon, falling
back to whitespace. So a comma-delimited
.ascor.txtreads correctly, and a whitespace-delimited.csvdoes too. - Coordinates may be written in scientific notation (
1.234e+05), andnan/infare accepted in scalar columns. - Lines starting with
#(or//) are treated as comments. A comment on the first line is also read as a column header when its tokens resolve to a validx/y/zlayout — so a legend like# x y z r255 g255 b255 row column is_missnames every field on import instead of being discarded. An axis name may carry its unit (x_mm,y (m)); the unit is not applied for you, so pick it in the wizard. A#remark that isn't a column list (e.g.# exported by FooScan) stays an ordinary comment.
When a point cloud is loaded by path (dragged into the viewer, or attached
via Helios XML bulk import), it is converted to a streaming octree in the
Python backend and rendered tile-by-tile, so files far larger than the
browser's ~512 MB string limit load without exhausting memory. This applies
to every supported point-cloud format: ASCII (.xyz/.txt/.csv/.pts/.asc/.ascii)
via pandas, .ply (parsed directly, scalar fields preserved — see below),
.pcd (streamed, every field preserved — see below), and .las/.laz
(passed straight through). If the source XML provides an <ASCII_format> tag
for an XYZ-family file, Phytograph forwards it to the parser; recognized
column tokens are x, y, z, r/g/b (0–1 range),
r255/g255/b255 (0–255 range, normalized to 0–1 on read),
intensity, reflectance, timestamp, target_index, target_count,
row/column (structured-scan grid indices), is_miss/miss/sky
(sky/miss flag), deviation. Token spellings are matched the same way header
column names are, so aliases like col, red, easting, or reflectivity
resolve to their role too. Any other token is carried through as a named
scalar field (color-mappable in the viewer) rather than discarded —
on large octree-streamed clouds they travel into the octree as extra
attributes. (That's the default: a column whose Import checkbox you
untick in the import wizard is dropped instead, whatever its token.) Field names come from the file's header row when present
(e.g. Reflectance[dB] → Reflectance [dB]); when the file has no
header, the <ASCII_format> token itself names the field — so a headerless
.xyz referenced by an XML whose legend reads
row col x y z r255 g255 b255 reflectance (as in
example-datasets/BPPtree_scaninds.xml) imports with every column labeled,
not a positional Column N fallback. The hint is ignored for PLY/PCD because
those formats encode their column layout in-file.
intensity and reflectance are read at whatever scale the source uses —
Helios reflectance in dB (negative), [0, 1] floats, [0, 255] bytes, or
Helios's raw signed beam·normal dot product — and normalized to the viewer's
gradient by their observed range, so the Intensity color mode works for any
of them. When a file carries both an intensity and a reflectance
column, the first becomes the dedicated intensity channel and the second is
kept as a named scalar field (color-mappable under Scalar Field), so
neither is dropped.
When no <ASCII_format> hint is given, Phytograph auto-detects the
layout: a header row's column names — whether plain or written as a
leading # comment — are matched to roles where recognized (so
XYZ[0][m]/XYZ[1][m]/XYZ[2][m] map to x/y/z and the rest become
scalar fields), otherwise it falls back to a positional guess. The
positional path first locates where the coordinates start: up to two leading
all-integer columns sitting before the first column with fractional
precision are read as the scan row/column index (so a row col x y z …
terrestrial-scanner export isn't mistaken for x y z …). An all-integer
cloud, with no fractional column to anchor on, keeps xyz at column 0. After
xyz, a 0–255 integer triple is taken as RGB and a lone trailing column as
intensity. That last guess is checked against the values too: a trailing column
holding a few repeating non-negative whole numbers is a class label, not a
return strength (segmentation datasets commonly ship x y z label), so it is
left as an unnamed scalar for you to name, with a prompt to switch it to
Label. The RGB guess is range-checked: those three columns are only
assigned to red/green/blue when their sampled values actually look like 8-bit
color (0–255 integers), so columns that hold timestamps, return counts, or a
reflectance that ranges above 255 (e.g. Helios multi-return
x y z timestamp intensity return#) are left as reassignable scalars rather
than silently mislabeled as color.
.pts files get two extra rules, because the canonical PTS layout breaks both
of the general ones above:
- A leading line holding just the point count is recognized as a header, not as data. It is all digits, so the usual "does this line contain a letter" header test doesn't catch it.
- Columns are read as
x y z intensity r g b— intensity before color. The general rule only recognizes an RGB triple sitting directly after xyz, so without this a 7-column PTS lost both its color and its intensity.
Both rules apply only to .pts, so no other ASCII layout changes meaning. A
.pts written the other way round (x y z r g b intensity) still resolves
through the general rule.
.ply clouds are parsed directly (not via open3d), so arbitrary per-vertex
scalar properties — intensity, reflectance, and any custom numeric field —
are preserved and carried into the octree as color-mappable scalar fields.
red/green/blue become color, the first of intensity/reflectance
becomes intensity, and every other numeric property is kept under its own
name.
Normals¶
Compute Normals writes five per-point
columns — nx, ny, nz, curvature and verticality — which behave like
any other scalar column: they ride along in .las/.laz extra dimensions, and
can be selected in the export field picker for .ply and the ASCII formats.
nx/ny/nz are the standard PLY property names for normals, so a PLY
exported from Phytograph carries its normals into CloudCompare, MeshLab and
anything else that reads them. The same names are recognized on import, in
Phytograph's own spelling and in the normal_x / scalar_nx spellings
CloudCompare writes, so a round trip keeps the normals recognizable as normals
instead of splitting them into three unrelated scalar columns.
.pcd clouds (ASCII and binary) are streamed in blocks, never read whole,
and keep every single-valued field. x y z become positions, a packed
rgb/rgba becomes color, intensity/reflectance becomes intensity, and
normals come in as nx/ny/nz. Everything else (curvature, labels, …)
becomes a scalar field under its own name. A field with COUNT above 1 (a
feature histogram, say) is skipped. Points with non-finite coordinates (the
empty pixels of an organized cloud) are dropped. A binary_compressed PCD
cannot be streamed, so it is read through open3d and carries position and
color only. .las/.laz clouds retain their native extra dimensions.
Point positions keep the source file's own precision for every format.
The intermediate LAS Phytograph builds for display is quantized to 1 mm, but
the cloud itself never is, so sub-millimeter detail in a .ply, .pcd,
.xyz or .ptx survives import. .e57 is the exception for now: its
positions are stored at 1 mm.
.e57 clouds carry intensity and RGB color when the file records them
(both are surfaced in the import wizard and become color-mappable in the viewer);
color on the recovered sky/miss points is set to black. Other E57 per-point
fields beyond position, intensity, and color are not yet preserved — including
vendor extension fields such as RIEGL's rlms:amplitude / rlms:reflectance,
which are skipped rather than treated as an error, so a stock RiSCAN PRO export
imports normally.
Large / projected coordinates¶
Scans in a projected coordinate reference (UTM, state plane) have coordinates hundreds of thousands to millions of meters from the meridian/equator. Two features keep these usable:
- Global shift (at import, optional, persistent) — the import wizard can subtract a per-axis offset so the stored cloud sits near the origin, and remembers the offset so exports recover the original world coordinates. CloudCompare-style; suggested automatically when coordinates are large. See the import workflow.
- Automatic render offset (always on, render-only) — independent of the shift, the viewer draws every scene near the origin to avoid 32-bit-float artifacts (a kinked/missing ground grid, flickering QSM/skeleton meshes) at large magnitudes. It changes only what's drawn — never stored data, exports, measurements, or backend operations — so keeping large coordinates stays a fully supported choice.
Measurement units¶
Phytograph stores and reports everything in meters. Scans are converted at import, so what a format can say about its own unit decides whether you are asked:
| Format | Declares a unit? | What happens |
|---|---|---|
| LAS / LAZ | Yes, when it carries a CRS | Read from the coordinate reference system — a State Plane zone in US survey feet is detected as such |
| E57 | Fixed by the spec | Always meters; the format has no other option |
RIEGL .rxp / .riproject |
Fixed by the format | Always meters (scanner-local) |
| PTX | No | Defaults to meters; change it in the wizard |
| PLY / PCD | No | Defaults to meters; change it in the wizard |
ASCII (.xyz, .csv, .txt, .pts, .asc, .ascii) |
No | Defaults to meters; change it in the wizard |
A LAS/LAZ without a CRS carries no unit either, and is treated like the formats that cannot declare one. Two further cases are also treated as undeclared, because a single scale factor cannot describe them honestly:
- A geographic (latitude/longitude) CRS. Degrees are not a length, and there is no constant that converts them — the meters-per-degree of longitude depends on latitude. Such a file needs reprojection, which Phytograph does not do; its coordinates are left exactly as they are.
- A CRS whose axes disagree, such as horizontal US survey feet with vertical meters (a standard US survey configuration). Scaling all three axes by one factor would leave the horizontal correct and divide every elevation by 3.28.
In both cases the wizard asks rather than guessing.
The source unit is recorded with the scan. See Source units.
Export is in meters, not the original unit
A global shift is undone on export, restoring the original coordinate magnitudes — but the unit conversion is not. A survey imported in feet exports as meters. The numbers will differ from the source file by the conversion factor.
Sky/miss points¶
The leaf-area-density inversion needs to know which laser pulses hit the sky and returned nothing (the "misses"). Helios represents each miss as a real point placed very far away (~20 km) from the scanner along the pulse direction.
Phytograph recovers misses on import:
- E57 — the structured-grid
cartesianInvalidState/sphericalInvalidStateflag marks cells with no return. Those become miss points along the cell's beam direction. The scanner pose (origin) travels with the scan. - Structured / organized PLY — vertices with non-finite (NaN/Inf)
coordinates, or a
is_miss/miss/skyproperty, are treated as misses.
On export, the two structured formats keep misses in the form each one defines, so a round-trip through Phytograph doesn't lose them:
- PTX always writes the full grid, with no-return cells left empty. The Include miss points box makes no difference to the file, so it is grayed out when PTX is selected.
- E57 writes misses as cells flagged
cartesianInvalidState. Here the box does matter: unticking it leaves the misses out entirely (a smaller, hits-only file), while ticking it keeps them, correctly flagged.
Every other format carries misses as an ordinary is_miss column, which only
some readers understand — so prefer PTX or E57 when the misses matter.
Multi-return data and PTX¶
Retaining misses is one axis; retaining echoes is another, and PTX scores
well on the first while failing the second. Because it writes exactly one line
per grid cell, a pulse with several echoes must be collapsed to one — and its
fixed schema has no column for timestamp, target_index or target_count,
which is what
multi-return LAD
needs. Structured E57 keeps every echo for a cell; PTX cannot.
This matters because using only first echoes biases LAD high — Kent and
Bailey (2024) found it the most
biased of every weighting method they tested. And the loss is quiet: a
multi-return scan exported to PTX and re-imported is a single-return scan, and
since the return type is detected from the columns, it will honestly report
single-return — correct, but easy to miss if you assumed the echoes had
survived the round-trip. So export multi-return scans to E57, not PTX,
and import RIEGL data as a
.riproject / .PROJ rather than via a
PTX export from RiSCAN PRO. For genuinely single-return instruments — which is
what PTX was designed around — none of this applies.
Recovered misses are tagged with an is_miss flag (0 = hit, 1 = miss) and kept
in the scan. Because their true coordinates are ~20 km away, they are excluded
from the viewer's octree (so they don't wreck camera framing) and hidden by
default. Toggle Show misses on a scan row to draw them in a distinct color,
relocated onto the scan's bounding sphere so they sit at a sensible distance.
If a scan has no miss points but does have a timestamp column, the LAD
inversion recovers misses automatically by gapfilling the scan grid; if it has
neither, the inversion warns that its result is likely to be inaccurate.
RIEGL
.rxpstores only returns, but the misses are recovered anyway. A shot that hits nothing is absent from the file rather than flagged, so Phytograph reads the scanner's own per-shot record to reconstruct each miss with its true beam direction — see Import a RIEGL project. Loose.rxpfiles are not importable on their own; import the containing.riproject/.PROJdirectory.
Meshes¶
| Format | Import | Export | Notes |
|---|---|---|---|
.obj |
✅ | ✅ | Vertices + faces + normals + UVs + materials. On import, a sibling .mtl with map_Kd textures (and the images it names, alongside the file) is loaded and applied; on export, that .mtl and its texture images are written back alongside the .obj. |
.ply |
✅ | ✅ | Vertices + faces + normals + per-vertex color. ASCII and binary on import (read via open3d). No textures. |
.stl |
✅ | ✅ | Triangles only — no topology metadata. ASCII and binary on import; exported as ASCII. Binary STL's non-standard per-facet color is read only for facets that set its "color valid" bit. |
Polygonal faces with more than three vertices are triangulated on
import. Textured .obj import reads UV coordinates (vt) and per-material
diffuse color (Kd) and texture (map_Kd).
Exporting a textured mesh to .obj writes a bundle, not a single file:
<name>.obj— the geometry, withvttexture coordinates and ausemtlgroup per material, referencing the material library viamtllib.<name>.mtl— the material library:Kddiffuse color per material, plusmap_Kdfor textured ones (andmap_dwhere the texture carries an alpha cutout, so leaf silhouettes survive).<name>_<material>.png/.jpg— one image per textured material. The extension follows the image's actual format, not its name.
Organs with no image texture — petioles, internodes, stems, flowers — carry
their color per vertex rather than in a material. OBJ has no portable per-vertex
color, so on export they are grouped by color into generated materials
(color_0, color_1, …) and written as Kd, which is the channel the importer
reads back. A plant typically needs only a handful (a 20-day bean uses three).
Keep the three together: an .obj moved away from its .mtl and images
re-imports as untextured geometry. Meshes without materials (a triangulated
point cloud, a DEM surface) export as a single .obj, with no .mtl.
Kd is a linear color
The MTL format does not say which color space Kd is in, and tools
disagree. Phytograph writes and reads it as a linear reflectance, with
no conversion — the same convention as Blender and Helios, so a generated
plant's organ colors are exactly the values in the Helios plant library and
look the same in Blender under equal lighting. Export a model and re-import
it and the colors are unchanged. Tools that read Kd as sRGB (three.js, for
one) will show these colors darker.
Earlier versions wrote and read Kd as sRGB. An OBJ exported by one of
them imports lighter in this version, and renders too bright in Blender.
A DEM is stored as a surface mesh, so it exports through the same OBJ / PLY / STL formats.
DEM rasters¶
A DEM can also be exported as a GIS raster elevation grid (from the GIS raster row in the mesh export panel):
| Format | Import | Export | Notes |
|---|---|---|---|
.asc (ESRI ASCII grid) |
❌ | ✅ | Cell-centered elevation grid with an ncols/nrows/xllcorner/yllcorner/cellsize/NODATA_value header. Distinct from the .asc point-cloud import format above — here it's a raster. |
.tif (GeoTIFF) |
❌ | ✅ | Georeferenced raster (pixel scale + tiepoint, and a CRS when known). Written without GDAL, readable in QGIS / ArcGIS. |
The raster is written in the cloud's own coordinates; voids (cells with no
nearby ground) are written as the NODATA_value.
Gridded LAD¶
A leaf area density result is a 3D voxel grid, and exports in four formats (all export-only):
| Format | Notes |
|---|---|
.tif (GeoTIFF) |
Multi-band: one band per vertical level, band 1 = lowest, float32. One file per selected variable. Carries pixel scale + tiepoint, a CRS when known, GDAL_NODATA = -9999, and per-band descriptions naming each band's height range (lad z=0.50-1.00m). The band-per-level shape matches canopyLazR::lad.array.to.raster.stack() and AMAPVox's toRaster(). |
.csv (voxel table) |
One row per voxel, every field. The lossless format, and the only one that carries a rotated or terrain-following grid. |
.vox (AMAPVox) |
VOXEL SPACE line, a #min_corner / #max_corner / #split / #res / #type header, a column-name line, then whitespace-delimited rows keyed by i j k. PadBVTotal is the plant area density (m²/m³); nbSampling / nbEchos are the beam and echo counts. Read by the R AMAPVox package and DART / pytools4dart. |
.txt (summary) |
A short plain-text report: voxel size and grid dimensions, voxel/occlusion counts, total leaf area, and LAI. The only voxel export carrying LAI. |
.csv (profile) |
Written by Profile & LAI, not by the Export row: one line per vertical level — height, thickness, mean LAD, its standard deviation across the level, leaf area, the level's share of LAI, and the measured / occluded / filled voxel counts — followed by commented lines giving the bulk LAI, the measured and interpolated leaf areas, and the grid footprint. Its per-level LAI contributions sum exactly to the bulk LAI. |
Coordinates are always written in true world coordinates, so a raster georeferences correctly even for a cloud imported with a global shift.
The voxel CSV's column order is a stable contract — new fields are only ever appended:
i,j,k,x,y,z,size_x,size_y,size_z,lad,leaf_area,gtheta,hit_count,beam_count,
relative_density_index,mean_path_length,lad_variance,lad_std,ci_valid,
leaf_area_ci_lower,leaf_area_ci_upper,solved
On a terrain-following grid the lattice indices i,j,k are approximate —
lifted columns rise above the nominal grid, so k saturates at the top level.
The x,y,z columns are always exact and are the authoritative position.
Occluded voxels are never written as zeros. A voxel no beam reached is
unsampled rather than empty, and treating it as 0 biases any mean LAD or LAI
computed downstream. Such voxels are written as NODATA (-9999) in rasters,
as a blank lad with solved=false in CSV, and are omitted from .vox. A voxel
genuinely solved as empty air keeps a real 0.0. The summary counts occluded
voxels separately from the leaf-area total, so its LAI never absorbs them.
Rasters require a regular, north-up lattice, so the GeoTIFF option is
unavailable for a rotated or terrain-following grid — use CSV or .vox,
which store each voxel's own position.
PLY: point cloud or mesh?¶
.ply is an ambiguous container — the same extension is used for point
clouds (vertices only) and polygon meshes (vertices and faces). On import,
Phytograph reads the PLY header and routes automatically: if it declares
element face with at least one face, the file imports as a mesh;
otherwise it imports as a point cloud. You can override this with the
File → Import menu's explicit Point cloud / Mesh choices. A PLY mesh imported
this way keeps its geometry, normals, and per-vertex color, but (unlike PLY
point clouds) does not carry arbitrary per-vertex scalar fields.
Crown metrics CSV¶
Fit a crown & metrics can export a CSV with one row per fitted crown. The columns are:
| Column | Meaning |
|---|---|
scan_name |
The source scan the crown was fit from. |
tree_instance_id |
The tree id (0 when the whole cloud was one tree). |
shape |
ellipsoid, prism, cone, or alpha. |
tree_height_m |
Crown top minus the ground baseline. |
crown_volume_m3 |
Fitted-shape volume. |
crown_center_x/y/z |
Crown centroid, world coordinates. |
crown_dim_x/y/z_m |
Fitted-shape width × depth × height. |
crown_surface_area_m2 |
Fitted-mesh surface area. |
num_points_used |
Points used after fuzzy trimming. |
strictness |
The fuzziness value used for the fit. |
param_a_m, param_b_m, param_c_m |
Semi-extent along x/y/z. Ellipsoid + prism only. |
param_base_radius_m, param_height_m |
Cone only. |
param_alpha_m, param_alpha_auto |
Alpha shape only: the radius used, and whether the fit chose it. |
mesh_vertices, mesh_triangles |
Size of the fitted mesh. |
mesh_file |
The crown's mesh file, relative to the CSV. Alpha shapes only; empty otherwise. |
The param_* columns are the fit parameters — what it takes to
rebuild the solid, rather than describe it. A column always means the
same thing: param_a/b/c_m are the semi-extent along x/y/z for both
box-like shapes (an ellipsoid's semi-axes, a prism's half-extents).
Columns that don't apply to a row's shape are left empty.
There is no separate parameter column for the shape's center: for the
ellipsoid, prism, and cone it is exactly crown_center_x/y/z.
An alpha shape has no analytic parameters — a concave hull's geometry
is the result — so those crowns are written as mesh files beside the
CSV (.obj, .ply, or .stl, your choice) and mesh_file names the
one belonging to each row. That export writes several files, so it asks
for a folder instead of a save location.
Tree list CSV¶
Run a tree inventory exports a tree list with one row per measured tree. Lengths are meters, areas m², volumes m³, and angles degrees (azimuths clockwise from +y). Coordinates are world coordinates. A value that could not be measured is left empty. How each value is measured, and the source it follows, is on Tree inventory measurements.
| Column | Meaning |
|---|---|
scan_name |
The cloud the inventory ran on. |
tree_id |
The tree_instance id. |
species, status, label |
Your entries from the Tree Table. status is one of live, dead, damaged, uncertain, or empty. |
stem_x, stem_y |
Stem base position: where the stem axis meets the terrain. |
ground_z |
Terrain elevation at the stem base. |
dbh_m |
Diameter at breast height. |
dbh_rms_m |
RMS orthogonal residual of the DBH circle fit. |
dbh_arc_coverage |
Fraction of the circumference (in 10° sectors) holding stem points, 0–1. |
dbh_max_gap_deg |
Largest empty angular gap around the DBH circle. |
dbh_n_inliers |
Points on the DBH circle. |
dbh_method |
ransac or hough. |
breast_height_ref_z |
The uphill ground elevation breast height was measured from. |
height_m |
Tree height above ground_z. |
lean_deg, lean_azimuth_deg |
Stem lean from vertical, and the direction it leans toward. The azimuth is empty below 0.5°. |
crown_base_height_m |
Crown base height above ground_z. |
crown_projected_area_m2 |
Convex-hull area of the crown's vertical projection. |
crown_diameter_mean_m |
Mean of crown_max_width_m and crown_perp_width_m. |
crown_diameter_equiv_m |
Diameter of the circle with the projected area. |
crown_max_width_m, crown_perp_width_m |
Widest crown width, and the width at right angles to it. |
crown_ellipse_eccentricity |
Shape of the projection, 0 (circle) to 1. |
crown_offset_m, crown_offset_azimuth_deg |
Horizontal offset of the crown projection's centroid from the stem base, and its direction. |
crown_volume_voxel_m3 |
Occupied-voxel crown volume at the chosen voxel size. |
basal_area_m2 |
π/4 · DBH². |
slenderness |
Height / DBH (both in meters). |
ground_source |
height_above_ground, ground_class, or tree_min_z. |
n_points |
Points labeled with this tree id. |
flags |
Quality flags, ;-separated: few_points, partial_arc, high_residual, no_stem, ground_from_tree_min. |
hegyi_index, n_competitors |
Hegyi's competition index, and the trees inside its search radius. |
crown_overlap_m2, crown_overlap_fraction |
Summed overlap of this crown's projection with the others', and the share of it covered by at least one. |
edge |
true when the competition search circle reaches past the plot boundary (the index is then an underestimate). |
qsm_woody_volume_m3 |
Woody volume of the tree's QSM, when Build QSMs has run. |
agb_kg, agb_method |
Above-ground biomass and the method used, when a biomass method is chosen. Both are empty for trees outside the stand filter and for trees the method could not estimate. |
New columns are only ever appended, so a parser that reads columns by name or by position keeps working.
Stem curve CSV¶
The stem curve export has one row per stem slice, sorted by tree and then by distance along the stem.
| Column | Meaning |
|---|---|
scan_name, tree_id |
As in the tree list. |
axial_m |
Distance along the stem axis from the stem base (0.5 m steps). |
height_m |
Height of the slice center above the stem base's ground. |
x, y, z |
Slice center, world coordinates. |
diameter_m |
Fitted stem diameter. |
rms_m, arc_coverage, n_points |
Fit quality, as for DBH. |
ok |
true when the fit had neither few_points nor high_residual and continued the stem smoothly (0.5–1.5× the previous ok radius). A partial arc alone does not make a slice not-ok. |
Stand summary CSV¶
The Stand tab's export has two blocks. First, metric,value,unit rows: the
scan name, plot area and its source (measured or entered), the minimum
DBH, whether dead trees were excluded, the tree count, stems_per_ha,
basal_area_per_ha, qmd, lorey_height, mean_height, max_height,
canopy_cover (0–1), and the biomass method, wood density or species
group, biomass (kg and Mg/ha), and the counts of trees it could not
estimate or that lie beyond the equation's range. Then, after a blank line,
class,lo,hi,count rows: dbh_cm classes followed by height_m classes.
QSM metrics CSV¶
One row per tree of a batch QSM, failed trees included:
| Column | Meaning |
|---|---|
scan_name, tree_id |
As in the tree list. |
success, error |
Whether the QSM was built, and why not. |
points_in_tree, points_used, voxel_m, wood_only |
The tree's points, those used (after the wood filter and voxel thinning), the voxel edge (empty when not thinned), and whether only wood points were used. |
n_cylinders, n_shoots |
Model size. |
total_woody_volume_m3, stem_volume_m3, branch_volume_m3 |
Woody volumes. |
trunk_diameter_mm, tcsa_m2 |
Trunk diameter and cross-sectional area at the base cylinder. |
tree_height_m, total_length_m |
Model height and summed cylinder length. |
n_scaffolds, n_shoots_total, max_rank |
Branching structure (see QSM). |
canopy_width_m, canopy_height_m |
Canopy extent of the model. |
Skeletons¶
| Format | Import | Export | Notes |
|---|---|---|---|
.json |
✅ | ✅ | Full graph: nodes, edges, branch orders. The only importable skeleton format. |
.obj |
❌ | ✅ | Line segments (and cylinders when diameters are present). Export only. |
.ply |
❌ | ✅ | Vertices plus an edge element (vertex1/vertex2). Export only. |
Skeleton JSON shape¶
{
"nodes": [
{"x": 0.0, "y": 0.0, "z": 0.0, "branchOrder": 1},
...
],
"edges": [
[0, 1],
...
],
"metadata": {
"totalLength": 12.84,
"nodeCount": 512,
"edgeCount": 511,
"maxBranchOrder": 6
}
}
Note the exact shape, since the importer validates it: nodes have no id
(their array index is the id) and use camelCase branchOrder; edges are
flat [from, to] index pairs, not objects. metadata is informational —
node diameters are not written to JSON (use .obj if you need them).
Use .json for downstream analysis in Python/R, and to round-trip a skeleton
back into Phytograph. Use .obj or .ply for visualization in Blender or
MeshLab.
QSMs¶
| Format | Import | Export | Notes |
|---|---|---|---|
.csv |
✅ | ✅ | One row per cylinder. The only importable QSM format, and the one that round-trips. |
.obj |
❌ | ✅ | Triangulated cylinder mesh + materials, for Blender / CloudCompare / MeshLab. Writes a bundle — see below. |
.ply |
❌ | ✅ | Same geometry as OBJ, with per-face branch_order and radius. |
QSM OBJ bundle¶
Exporting a QSM to .obj writes the geometry and its colors, matching what
the viewer is showing at the time of export:
<name>.obj— one continuous tube per shoot, each its ownogroup (so the tree arrives as separable objects), with vertex normals and ausemtlper group. Leaves, if the QSM has them, follow as a finalo leavesgroup.<name>.mtl— the material library the.objreferences.<name>_bark.png/.jpg— the bark image, in Texture color mode only.<name>_<material>.png/.jpg— one image per textured leaf material, when the QSM has leaves.
What lands in the .mtl depends on the QSM's color mode in the viewer:
| Color mode | Materials written |
|---|---|
| Rank (default) | One material per shoot rank (rank_0, rank_1, …), each carrying that rank's palette color. |
| Shoot | One material per shoot (shoot_0, shoot_1, …), each with that shoot's distinct hue. |
| Color | A single qsm_color material holding the color you picked. |
| Texture | A single bark material with map_Kd pointing at the exported bark image; UV coordinates (vt) are written, and the tile size you set is baked into them. |
Colors round-trip exactly: Kd is written as an sRGB display value, so
re-importing an exported QSM mesh reproduces the shades the viewer showed.
Leaves added with the Add Leaves tool are exported
with the tree, carrying their own textured materials. Leaf textures are
alpha cutouts, so their materials get map_d alongside map_Kd — that is
what makes a leaf come back leaf-shaped instead of as an opaque rectangle.
Keep the files together: an .obj moved away from its .mtl (and the images the
.mtl names) loads as untextured gray geometry. .ply carries branch_order and radius per face
instead of materials, so use it when you want to color by branching order in a
downstream tool.
QSM cylinder CSV¶
The CSV uses the SimpleForest column layout, which
rTwig (import_qsm) and aRchi
(read_QSM(model = "simpleforest")) both read. Columns:
| Column | Meaning |
|---|---|
ID |
Cylinder id. |
parentID |
Parent cylinder's ID; -1 at the trunk base. |
branchID |
The continuous shoot (botanical axis) this cylinder belongs to. |
branchOrder |
Shoot rank — trunk 0, scaffolds 1, and so on. |
segmentID |
Same as branchID; written for reader compatibility. |
parentSegmentID |
The parent shoot's id; -1 for the trunk. |
startX/Y/Z, endX/Y/Z |
Cylinder axis endpoints, meters. |
axisX/Y/Z |
Unit axis direction. Derived — recomputed on import. |
radius |
Cylinder radius, meters. |
length |
Axial length, meters. Derived — recomputed on import. |
surfaceCoverage |
Fit quality in [0, 1]; low means the points covered one side only. Blank when unknown. |
meanAbsDeviation |
Mean point-to-surface distance, meters. Blank when unknown. |
Two things worth knowing:
- Coordinates are absolute world-frame, in the same coordinate system as the source scan (which for a projected cloud means real UTM values). The file records no CRS, so keep track of it separately.
- Row order matters. Cylinders are written base-to-tip within each shoot, and that ordering is what tells Phytograph how to draw each branch as a continuous tube. If you edit the file, don't re-sort the rows.
Everything Phytograph needs is in the file, so a QSM CSV round-trips: export one, re-import it, and you get back the same model — same cylinders, same shoot topology, and the same numbers in the results panel. Whole-tree metrics (trunk diameter, height, woody volume) aren't stored as columns because they are recomputed from the cylinders on import.
The importer is tolerant of the wider SimpleForest/TreeQSM family: column names
match regardless of case or separators (parentID, parent_id, and Parent Id
are equivalent), comma/semicolon/tab delimiters all work, unknown columns are
ignored, and files with no segmentID/branchID have their shoots derived from
the parent chain. Only ID, parentID, branchOrder, the six start/end
coordinates, and radius are strictly required.
Scan position files¶
For the Helios Triangulation workflow and bulk scan import:
| Format | Use | Layout |
|---|---|---|
| Plain text | ScanName X Y Z per row |
Tab, space, or comma separated |
| Helios XML | Single file with many scan definitions | The format Helios scan simulator uses |
A Helios XML file describes scan parameters and references separate point
cloud files — it holds no coordinates itself. Load it any of three ways: the
Add Scan tool's Import from XML file action, File → Import →
Scan XML… (or Auto-detect…), or by dragging the .xml onto the viewer.
All three run the same import; the XML's relative <filename> references are
resolved next to the XML on disk.
Per <scan>, Phytograph reads <origin>, <size> (theta/phi point counts),
the <thetaMin>/<thetaMax>/<phiMin>/<phiMax> sweep bounds,
<exitDiameter>/<beamDivergence> (multi-return optics), <scanTilt> —
two numbers, roll pitch in degrees, giving the scanner's residual tilt away
from level (absent → level) — and <scanAzimuthOffset> (the initial scanner
heading, in degrees). <filename> and <ASCII_format> auto-attach the
referenced point data.
A <scannerModel> tag (a Phytograph extension carrying an instrument id such as
riegl_vz400i) restores the scanner model chosen in the Add/Edit Scan dialog.
Scans exported from Phytograph write it so a non-default instrument round-trips;
an absent or unrecognized value imports as the generic scanner. Helios ignores
the tag, so the bundle stays Helios-loadable.
A <scan> carrying <scanPattern>spinning_multibeam</scanPattern> imports as a
spinning-multibeam scan instead of a raster scan. Such a scan replaces the
zenith point count and zenith sweep with <beamElevationAngles> — a
space-separated list of per-channel elevation angles in degrees above the
horizon (required for multibeam) — and takes its azimuth step count from
<Nphi> (or the second component of <size>). The azimuth sweep
(<phiMin>/<phiMax>) still applies. Scans exported from Phytograph round-trip:
a multibeam scan saved as Helios XML re-imports as multibeam.
Livox rosette (Risley-prism) scans are not exported to this bundle. A rosette has no zenith × azimuth grid — its circular field of view is emergent from the rotating-prism optics — so it cannot be represented in the grid-based Helios XML/ASCII format, and Phytograph skips such scans when exporting. (Their point data can still be exported as ordinary point-cloud files.)
A Helios XML may also contain top-level <grid> blocks (siblings of <scan>),
which describe the voxel grid Helios uses for leaf-area-density. On import,
each <grid> becomes a voxel grid object named Grid 1, Grid 2, …:
<grid> tag |
Maps to | Notes |
|---|---|---|
<center> x y z |
grid position | world coordinates (required) |
<size> x y z |
grid size | full extent per axis; all > 0 (required) |
<Nx> <Ny> <Nz> |
subdivisions | integer cells per axis; default 1 |
<rotation> |
z-rotation | degrees about the z-axis; default 0 |
<columnOffsets> |
terrain-following snap | Nx*Ny floats, row-major; per-column vertical (z) shift |
<keptColumns> |
dropped-column mask | Nx*Ny of 0/1; 0 = column outside the DEM footprint |
The last two are a Phytograph extension that round-trips a grid snapped to the
ground (see Estimate Leaf Area Density).
<columnOffsets> lists the per-(x, y)-column vertical offset that bends the grid
to follow the terrain, in row-major [j*Nx + i] order (x fastest); <keptColumns>
(written only when some columns were dropped) marks which columns fell inside the
DEM. A grid imported with these tags comes back already snapped, ready to use —
even with no DEM in the scene. The offsets are ignored by Helios's own loader and
are dropped if their count doesn't match Nx*Ny, in which case the grid imports
flat.
An XML with only <grid> blocks (no <scan>) imports just the grids.
Files holding several scan positions¶
Two point-cloud formats can hold several scanner setups in one file: a
multi-block .ptx (one block per setup) and a multi-scan .e57.
Phytograph imports these as one scan per position — each with its own
pose, its own grid, and its own scan parameters. Every other format holds a
single position and imports as one scan.
The scans are named after the file without its extension — plot3.ptx
holding three blocks imports as plot3 — scan 1, plot3 — scan 2,
plot3 — scan 3. A single-position file is named the same way, so
plot3.laz imports as plot3. The scan's own source: line in the Scans
panel still records the file it came from, extension included.
Each position also gets its own swatch color from the scan palette, exactly as separately-imported files do — so the positions stay distinguishable in the Scans panel and under the default Per-scan color mode.
They are not merged into one cloud, because a merged cloud would have to pick one origin to stand in for all of them, and that origin is used for real work:
- Leaf area density inverts transmission from a single scanner origin; the wrong one silently corrupts the result.
- Sky/miss points are displayed on a shell centered on the scanner, which would sit around the wrong point.
- The per-scan
row_index/column_indexrasters of two setups would collide.
All positions from one file share a single world shift, so they stay correctly co-located relative to each other. If one position fails to import, it's reported on its own and the remaining positions still import.
Scan parameters recovered from the point-cloud file¶
Some point-cloud formats embed the scanner's geometry in the file header. When you import one of these on its own (not via a Helios XML), Phytograph reads that metadata and auto-populates the new scan's scan parameters — the same fields the XML carries — so you don't have to enter them by hand. Whatever the file doesn't record is left at its default (blank), exactly as before.
| Format | Origin | Orientation | Angular sweep (zenith/azimuth) | Sample resolution |
|---|---|---|---|---|
.e57 |
✅ pose translation | ✅ pose rotation (applied to points) | ✅ from sphericalBounds, when present |
✅ from the structured grid, when present |
.pcd |
✅ VIEWPOINT, when non-identity |
— | — | — |
.las / .laz |
— | — | — | — |
.ply |
— | — | — | — |
ASCII (.xyz, …) |
— | — | — | — |
- E57 is the richest source: each scan's pose (origin + rotation) is applied to its points, and the angular sweep and grid resolution are read when the file includes them. A multi-scan E57 imports as one scan per position, each reading its own parameters — see Files holding several scan positions. E57 elevation (measured from the horizontal plane) is converted to Phytograph's zenith angle automatically.
- PCD records only a sensor origin (
VIEWPOINT); it's used only when it differs from the identity default that most files leave in place. - LAS/LAZ, PLY, and ASCII carry no standard scanner-geometry fields, so an imported scan starts with default parameters — set them in the Add Scan tool if you need them for Helios triangulation or LAD.
Platform trajectory files¶
A moving-platform scan (drone / UAV / mobile mapping) reconstructs a separate emission origin for every return by joining each return's timestamp to a dense 6-DOF platform trajectory. Attach one while importing the point cloud — the import wizard has an Import trajectory file… button, and attaching a trajectory to one scan fills in the others by default — or later via the Add Scan tool's Import trajectory file… when editing the scan. Supported formats:
| Format | Parsed | Layout |
|---|---|---|
.csv / .txt / .tsv / .traj |
In the app | One pose per row: 8 columns t x y z qx qy qz qw (quaternion) or 7 columns t x y z roll pitch yaw (Euler). Comma, tab, or whitespace separated. A header row, if present, is read: columns are mapped by name (so any order works — see below); without a header, columns are read positionally in the order shown. |
.sbet / .out |
On the backend | Binary Applanix SBET: 17 little-endian float64 per record (136 bytes), no header — time, lat, lon, alt, …, roll, pitch, heading, … (angles in radians). |
- Time is the join key. Each pose carries a time
t; the importer requires it to share a clock with the point cloud's per-point time (see GPS time below). Times must be strictly increasing. - SBET conversions. Latitude/longitude (radians) are projected to UTM (zone
auto-picked from the mean longitude; the EPSG is recorded on the trajectory),
altitude becomes Z, and the NED roll/pitch/heading attitude is converted to
Phytograph's ENU/Z-up body→world quaternion. A
*-smrmsgaccuracy companion, if present, contributes a position-RMS quality note. High-rate SBETs are decimated to a few thousand poses (the join interpolates between them; the last record is always kept so the time span is preserved). - Pick
sbet_<mission>.out, not its neighbors. POSPac writes about fifteen different internal formats under the same.outextension, so the extension alone says nothing about what is in the file. The one most easily picked by mistake issmrmsg_<mission>.out— the accuracy companion, named like the SBET apart from the prefix and written to the same directory. Phytograph checks the file's contents (latitude, longitude and attitude must be physically possible radians) rather than its name or size, and rejects a non-SBET with a message naming the likely mix-up. The POSPac export receipt (<mission>.log, written beside the export) confirms which file is which, along with the datum, grid and angle units used. - Header-based column mapping. When the first line is a header, columns are matched
by name regardless of their order, with units in brackets ignored —
Time [s],Easting/Northing/Height(orx/y/z),Roll/Pitch/Yaw(orheading), andqx/qy/qz/qw. This is what lets the SYSSIFOSS / HELIOS++ export — whose header is the position-firstEasting Northing Height Time Roll Pitch Yaw(angles in degrees) — import directly, even though its column order isn't time-first. A header that doesn't resolve to a complete layout falls back to positional parsing. - Degree vs radian Euler. Euler angles are read as radians unless any |angle| exceeds 2π, in which case the file is treated as degrees (HELIOS++/SYSSIFOSS convention) — no toggle needed.
- On import the scan's static tilt/heading are zeroed: a moving scan's attitude comes entirely from the trajectory (plus any fixed boresight), and the origin is anchored to the first pose as a fallback.
GPS time in LAS/LAZ¶
When a LAS/LAZ carries a per-point gps_time (point formats 1, 3, 4, 5, and 6+), it is
read as the per-return timestamp and kept at full double precision (a 32-bit float
would collapse adjacent returns at GPS-epoch magnitude). The LAS header's GPS time
type (global encoding bit 0) is honored:
- Adjusted Standard GPS time — an absolute clock that can be joined to a survey trajectory directly.
- GPS Week Time — seconds-into-week with no absolute epoch; it cannot be aligned to an absolute trajectory clock, so a moving-platform join with mismatched clocks fails loudly (rather than silently clamping every return to one pose) and you're told to re-export on a common clock.
Synthetic scans keep the same guarantee: a generated scan's per-return timestamp is also stored at full double precision (read from the engine via the float64 columnar path), so a synthetic moving-platform scan's trajectory join and its exported timestamps are not quantized — matching imported clouds.
ASCII imports (XYZ/TXT/PTS/CSV) share the guarantee too. A column mapped to the
timestamp role is written to the intermediate LAS's standard gps_time field
(float64), never a float32 ExtraBytes dimension. This matters at real GPS
magnitudes: 32-bit floats step by ~0.03 s at GPS week-seconds and ~32 s at
Adjusted-Standard magnitude, which is enough to merge thousands of distinct
returns onto a single time — breaking the pulse grouping that Backfill Misses
and multi-return processing depend on. Because an ASCII file declares no clock
of its own, the encoding is inferred from magnitude: values beyond the 604,800 s
GPS week are treated as an absolute clock, anything within it as GPS Week Time.
LAS ExtraBytes per-beam origins¶
If a LAS/LAZ carries the per-pulse emission point as three ExtraBytes columns — any of
ox/oy/oz, XOrigin/YOrigin/ZOrigin, or BeamOriginX/Y/Z (matched
case-insensitively) — those are read as the ground-truth per-beam origins (float64).
Importing such a cloud auto-creates a moving-platform scan: a decimated platform
trajectory is reconstructed from the origins (ordered by gps_time) so the scan is
flagged moving with its path drawn. Moving-platform LAD then uses the exact per-pulse
origins directly and skips the trajectory join entirely (if a separate trajectory is
also attached, the explicit origins win and it is ignored, with a warning).
ASCII/CSV/XYZ clouds can carry the same per-pulse origins as three columns (the same
ox/oy/oz and alias spellings). Map them in the import wizard with the Beam
Origin X / Y / Z roles (or let the header auto-detect them); they auto-create the same
moving-platform scan and feed LAD identically. Origins are captured at full coordinate
precision — bypassing the 1 mm display quantization — so projected/UTM-scale origins
survive exactly.
Plant parameter presets¶
The Morph popup exports / imports JSON describing a complete parameter set:
{
"species": "Apple",
"age_days": 1825,
"parameters": {
"internode_length": {"distribution": "normal", "mean": 0.04, "stddev": 0.005},
"insertion_angle": {"distribution": "constant", "value": 45},
...
}
}
Treat these as configuration; check them into the same repository as your analysis code.