Skip to content

Shots and RiSCAN projects

sylva.shots

Pulse-centric data for ray-based canopy metrics.

Shots dataclass

Laser pulses, each with zero or more echoes.

Point clouds lose the pulses that hit nothing and the order of echoes along each beam; gap fraction, clumping and ray-traced voxels need both. Shots keeps one row per pulse and the echoes in compressed sparse row (CSR) form: the echoes of shot i are echo_range[echo_start[i] : echo_start[i] + echo_count[i]]. A shot with echo_count == 0 is a genuine miss and still carries information about free space.

Build one with sylva.io.read_rxp_shots, sylva.riscan.ScanPosition.read_shots, from_ray_cloud, from_pointcloud or load.

Attributes:

Name Type Description
origin, direction

(n_shots, 3) beam origin and unit direction.

echo_start, echo_count

CSR offsets into the per-echo arrays.

echo_range ndarray

Range from the origin to each echo, ascending within a shot.

echo_attrs dict[str, ndarray]

Per-echo attributes (amplitude, reflectance, ...).

n_shots property

n_shots: int

Number of pulses, including misses.

n_echoes property

n_echoes: int

Number of echoes over all pulses.

shot_of_echo

shot_of_echo() -> np.ndarray

Index of the owning shot for each echo.

Returns:

Type Description
ndarray

Length n_echoes.

echo_rank

echo_rank() -> np.ndarray

Position of each echo within its shot.

Returns:

Type Description
ndarray

Length n_echoes; 0 is the first (nearest) return.

echo_xyz

echo_xyz() -> np.ndarray

Echo positions in the shots' frame.

Returns:

Type Description
ndarray

(n_echoes, 3), origin + direction * range.

to_pointcloud

to_pointcloud() -> PointCloud

The echoes as a point cloud; misses are dropped.

Returns:

Type Description
PointCloud

One point per echo with return_number (1 = first), number_of_returns, range (m) and every echo attribute.

transform

transform(matrix: ndarray) -> Shots

Move the pulses into another frame (e.g. scanner to project with the SOP).

Parameters:

Name Type Description Default
matrix ndarray

(4, 4) rigid transform. Origins are transformed as points, directions are rotated; ranges are unchanged, so the matrix must not scale.

required

Returns:

Type Description
Shots

subset

subset(mask: ndarray) -> Shots

Select pulses.

Parameters:

Name Type Description Default
mask ndarray

Boolean array of length n_shots.

required

Returns:

Type Description
Shots

The selected pulses with all their echoes (never part of a pulse).

zenith_azimuth

zenith_azimuth() -> tuple[np.ndarray, np.ndarray]

Beam angles of each pulse.

Returns:

Name Type Description
zenith ndarray

Degrees from +z (0 = straight up, 90 = horizontal).

azimuth ndarray

Degrees clockwise from +y, atan2(x, y) wrapped to [0, 360) (RIEGL convention).

concatenate classmethod

concatenate(parts: list[Shots]) -> Shots

Stack pulse sets, e.g. several scan positions.

Parameters:

Name Type Description Default
parts list[Shots]

Shots in a common frame.

required

Returns:

Type Description
Shots

All pulses in order; only echo attributes present in every part are kept.

Raises:

Type Description
ValueError

If parts is empty.

pulses_per_line

pulses_per_line(pattern: dict, quantile: float = 0.98, shot_stride: int = 1) -> int

Effective number of pulses fired along each zenith line.

Nominally pattern["phi_count"], but RIEGL scanners fire ~1 % more pulses than the nominal grid (a VZ-2000i at "600 kHz" steps at ~631 kHz), so the count observed on saturated lines is a better estimate. Call in the scanner frame.

Parameters:

Name Type Description Default
pattern dict

Scan pattern from sylva.riscan.ScanPosition.pattern.

required
quantile float

Quantile of the per-line shot counts taken as "fully sampled".

0.98
shot_stride int

The shot_stride the shots were read with; the nominal count is divided by it.

1

Returns:

Type Description
int

The larger of the nominal count and the observed quantile.

expected_per_zenith

expected_per_zenith(pattern: dict, zenith_edges: ndarray, pulses_per_line: int | None = None) -> np.ndarray

Number of pulses the scan pattern fired into each zenith ring.

pattern holds theta_start, theta_delta, theta_count (zenith lines, degrees) and phi_count (azimuth steps per line), as parsed from a RiSCAN project.rsp.

Parameters:

Name Type Description Default
pattern dict

Scan pattern from sylva.riscan.ScanPosition.pattern.

required
zenith_edges ndarray

Ring edges in degrees from up.

required
pulses_per_line int | None

Overrides phi_count (see pulses_per_line).

None

Returns:

Type Description
ndarray

Pulses fired per ring (length len(zenith_edges) - 1): zenith lines whose angle falls in the ring times pulses per line.

fill_missing

fill_missing(pattern: dict, pulses_per_line: int | None = None, seed: int = 0, shot_stride: int = 1) -> Shots

Add the pulses that returned nothing.

RiVLib's point stream only contains echoes, so a pulse that went to the sky is simply absent. Given the angular scan pattern this compares, per zenith line, the pulses fired against the shots observed and adds the difference as shots with echo_count == 0 spread uniformly in azimuth (the exact azimuths cannot be recovered because the mirror angles are not on the nominal grid). Ray-traced metrics then see the free space these pulses sampled.

pulses_per_line defaults to pulses_per_line, which corrects for the scanner firing slightly more pulses than the nominal grid but can overshoot by a few per cent. Where the scan looks at the ground, the median count on the downward lines is exact, since every pulse there returns (see sylva.canopy.fired_pulses_per_ring).

Call this on shots in scanner coordinates (before applying a SOP), as the pattern's zenith lines are defined in the scanner frame; all shots must share one origin. ScanPosition.read_shots(fill_missing=True) does this in the right order.

Parameters:

Name Type Description Default
pattern dict

Scan pattern from sylva.riscan.ScanPosition.pattern.

required
pulses_per_line int | None

Pulses fired per zenith line; estimated if None.

None
seed int

Seed for the random azimuths, so the output is reproducible.

0
shot_stride int

The shot_stride the shots were read with. The estimate of pulses per line is divided by it; without it, thinned shots get shot_stride times too many misses.

1

Returns:

Type Description
Shots

The input followed by the added misses (the input itself if none are missing).

save

save(path: str | Path, double: bool = False, row_group_size: int = 1 << 20, zstd_level: int = 3, origin_tolerance: float = 0.001) -> None

Write the sylva shots format: a Parquet file with one row per pulse.

Columns are scan (index into the scanner positions kept in the file metadata), zenith and azimuth (rad), range (a list of echo ranges, empty for a pulse without a return) and one list column per echo attribute, zstd compressed in row groups of row_group_size pulses that can be read or voxelised one at a time (sylva.voxels.ray_voxelize accepts the path). Any Parquet reader (polars, pyarrow, duckdb, R arrow) opens it.

Angles and ranges are float32 unless double: echo positions then come back to about 0.01 mm per 100 m of range. Origins within origin_tolerance (m) of each other become one scanner position (echoes stay put; ray clouds carry per-ray rounding noise on the origin); 0 keeps them exact. With more than 65 536 positions (mobile or airborne scanning) origins are stored per pulse at 0.1 mm, delta encoded.

Parameters:

Name Type Description Default
path str | Path

Output .parquet file; overwritten if it exists.

required
double bool

Store angles and ranges as float64.

False
row_group_size int

Pulses per row group; the unit of partial reads and of streaming in sylva.voxels.ray_voxelize. Smaller groups use less memory when streaming.

1 << 20
zstd_level int

zstd compression level (1 fast, 22 smallest).

3
origin_tolerance float

Distance (m) within which origins are merged into one position.

0.001

load classmethod

load(path: str | Path, groups: list[int] | None = None) -> Shots

Read a shots file written by save.

Parameters:

Name Type Description Default
path str | Path

.parquet shots file.

required
groups list[int] | None

Row-group indices to read (see file_info); all if None.

None

Returns:

Type Description
Shots

Raises:

Type Description
OSError

If the file is missing or is not a Sylva shots file.

file_info staticmethod

file_info(path: str | Path) -> dict

Describe a shots file without reading its pulses.

Parameters:

Name Type Description Default
path str | Path

.parquet shots file.

required

Returns:

Type Description
dict

n_shots, n_echoes, n_groups, echo bounds, scans (scanner positions; empty when origins are stored per pulse) and echo_attrs (names).

from_pointcloud classmethod

from_pointcloud(cloud: PointCloud, origin=(0.0, 0.0, 0.0)) -> Shots

Pulses from a single-position point cloud.

Use when the only record of a scan is its points (no .rxp). Misses are not recovered; add them with fill_missing if the scan pattern is known.

Parameters:

Name Type Description Default
cloud PointCloud

Points of one scan position. Adjacent points with equal gps_time form one multi-echo shot; otherwise each point is a single-return pulse. Other attributes become echo attributes.

required
origin

Scanner position in the cloud's frame.

(0.0, 0.0, 0.0)

Returns:

Type Description
Shots

from_ray_cloud classmethod

from_ray_cloud(cloud: PointCloud) -> Shots

From a raycloudtools ray cloud. nx, ny, nz (PLY) or sx, sy, sz (LAS/LAZ) point from each end point back to the sensor; bound == 0 (else alpha == 0) marks unbounded rays, which become echo-less shots. Returns sharing a beam_id / gps_time are joined into one multi-echo shot when number_of_returns is present.

Parameters:

Name Type Description Default
cloud PointCloud

A ray cloud read with sylva.read.

required

Returns:

Type Description
Shots

Origins are the per-ray sensor positions.

Raises:

Type Description
ValueError

If the cloud has no sensor-vector attributes.

Warns:

Type Description
UserWarning

If every ray starts at (0, 0, 0): the file was written without sensor positions, and ray-traced products would be wrong.

sylva.riscan

RiSCAN PRO project parsing.

A .RiSCAN directory holds project.rsp (XML with the POP and every scan position's SOP), SCANS/ScanPosNNN/SINGLESCANS/*.rxp and often a DAT/ScanPosNNN.DAT copy of each SOP matrix. Older exports use all_sop.csv / project.pop instead, as do the scanner's own .PROJ projects (ScanPosNNN.SCNPOS/scans/*.rxp), which also record each position's attitude (roll, pitch and yaw from the inclination sensors and compass), a GNSS fix, and the reflective targets the scanner found; all layouts are handled.

The POP of geo-referenced projects is usually a geocentric (ECEF) transform with translations of thousands of km, so it is returned but only applied when asked for.

ScanPosition dataclass

One scan position of a RiSCAN project.

Attributes:

Name Type Description
name str

Position name, e.g. ScanPos001.

scans list[Path]

Every .rxp of the position, monitoring and residual files excluded.

instrument str | None

Scanner model from project.rsp (None for legacy projects).

rxp instance-attribute

rxp: Path | None

First non-monitoring .rxp of the position (None if no scan).

sop instance-attribute

sop: ndarray | None

4x4 scanner -> project transform.

pattern class-attribute instance-attribute

pattern: dict | None = None

Angular scan pattern of the first scan: theta_start, theta_delta, theta_count (zenith, degrees from up) and phi_start, phi_delta, phi_count (azimuth). Lets missing (no-return) pulses be reconstructed.

tiepoints class-attribute instance-attribute

tiepoints: Path | None = None

RIEGL .tpl list of the reflective targets the scanner found, if any.

gnss class-attribute instance-attribute

gnss: tuple[float, float, float] | None = None

(latitude, longitude, altitude) the scanner recorded. Metres off at best under canopy: good for deciding which scans could overlap, not for registering them.

attitude class-attribute instance-attribute

attitude: ndarray | None = None

(3, 3) rotation from the scanner's frame to a level, north-referenced one (the .pose roll, pitch and yaw, or pose_estimation.sop). A scanner on a tilt mount has its z-axis sideways; this levels it. Roll and pitch are good to about 0.1 degree, yaw to about a degree.

levelling property

levelling: ndarray | None

(4, 4) rotation that levels this scan (from attitude), or None.

origin property

origin: ndarray | None

Scanner position in project coordinates (the SOP translation), or None.

reflectors

reflectors() -> list

The targets the scanner found here (tiepoints), in its own frame.

Returns:

Type Description
list of sylva.coreg.Reflector

transform

transform(pop: ndarray | None = None) -> np.ndarray

Scanner-to-project (or scanner-to-global) transform.

Parameters:

Name Type Description Default
pop ndarray | None

Project-to-global matrix (RiscanProject.pop). If given, the result is pop @ sop.

None

Returns:

Type Description
ndarray

(4, 4) matrix; the identity if the position has no SOP.

read

read(pop: ndarray | None = None, **options) -> PointCloud

Read the scan as points in project (or global) coordinates.

Parameters:

Name Type Description Default
pop ndarray | None

Also apply this project-to-global matrix.

None
**options

Passed to sylva.io.read_rxp (shot_stride, min_range, echoes, ...).

{}

Returns:

Type Description
PointCloud

Raises:

Type Description
FileNotFoundError

If the position has no .rxp.

read_shots

read_shots(pop: ndarray | None = None, fill_missing: bool = False, **options) -> Shots

Read the scan as pulses in project (or global) coordinates.

Parameters:

Name Type Description Default
pop ndarray | None

Also apply this project-to-global matrix.

None
fill_missing bool

Reconstruct the no-return pulses from the scan pattern (see sylva.Shots.fill_missing) before applying the SOP. Needed for gap fraction and ray tracing.

False
**options

Passed to sylva.io.read_rxp_shots.

{}

Returns:

Type Description
Shots

Raises:

Type Description
FileNotFoundError

If the position has no .rxp.

ValueError

If fill_missing is set but project.rsp has no scan pattern.

RiscanProject dataclass

A parsed RiSCAN PRO project; build with read_riscan_project.

Index by position number or name: project[0], project["ScanPos003"]. len(project) is the number of positions.

Attributes:

Name Type Description
path Path

The .RiSCAN directory.

positions list[ScanPosition]

Every scan position, in project order.

pop ndarray | None

Project-to-global matrix, or None. Often geocentric (ECEF), so it is never applied unless passed explicitly.

name str

Project name.

names property

names: list[str]

Position names, in project order.

with_scans

with_scans(require_sop: bool = True) -> list[ScanPosition]

Positions that have a scan.

Parameters:

Name Type Description Default
require_sop bool

Only positions that also have a SOP, so can be read into project coordinates. False for registration, which does not need one.

True

Returns:

Type Description
list of ScanPosition

gnss_positions

gnss_positions() -> np.ndarray

Scanner GNSS fixes in local metres (see gnss_to_local).

Returns:

Type Description
ndarray

(n, 3), one row per position, NaN where there was no fix.

origins

origins() -> np.ndarray

Scanner positions in project coordinates.

Returns:

Type Description
ndarray

(n, 3) SOP translations, one row per position that has a SOP (positions without one are skipped, so rows may not line up with positions).

gnss_to_local

gnss_to_local(coordinates: list[tuple[float, float, float] | None]) -> np.ndarray

GNSS fixes as local metres about the survey's own centre.

An equirectangular projection, accurate to well under a metre over a plot, far finer than the fixes themselves.

Parameters:

Name Type Description Default
coordinates list[tuple[float, float, float] | None]

(latitude, longitude, altitude) per position, or None.

required

Returns:

Type Description
ndarray

(n, 3) east, north, altitude; NaN where there was no fix.

read_riscan_project

read_riscan_project(path: str | Path) -> RiscanProject

Parse a RiSCAN PRO project directory.

Reads no scan data, only the project structure and matrices.

Parameters:

Name Type Description Default
path str | Path

The .RiSCAN directory. With project.rsp, SOPs and scan patterns come from it (falling back to DAT/<pos>.DAT). Without it, the legacy layout is read: all_sop.csv (roll/pitch/yaw in degrees and x, y, z), project.pop and SCANS/ScanPos* or *.SCNPOS folders. For a scanner .PROJ (*.SCNPOS) each position also gets the scanner's attitude (ScanPosition.levelling), its GNSS fix and its .tpl target list.

required

Returns:

Type Description
RiscanProject

Raises:

Type Description
FileNotFoundError

If path is not a directory.

Examples:

>>> project = sylva.read_riscan_project("plot.RiSCAN")
>>> shots = [p.read_shots(fill_missing=True, shot_stride=4) for p in project.with_scans()]

read_export_settings

read_export_settings(path: str | Path) -> dict[str, tuple[float, float]]

Read a RiSCAN PRO export filter settings file.

One line per attribute, name, minimum, maximum (; also separates, # starts a comment), for example::

deviation, 0, 12
range, 2, 100
reflectance, -20, 5

Names are lower-cased and a leading riegl. is dropped.

Parameters:

Name Type Description Default
path str | Path

The settings file.

required

Returns:

Type Description
dict

{attribute: (minimum, maximum)} for range, deviation, reflectance and amplitude.

Raises:

Type Description
ValueError

On a malformed line, an unknown attribute (so a typo cannot pass silently) or a minimum above its maximum.

export_settings_mask

export_settings_mask(settings: dict[str, tuple[float, float]], xyz: ndarray, attributes: dict[str, ndarray]) -> np.ndarray

Keep-mask of points within every interval of settings, as RiSCAN's export filter.

Parameters:

Name Type Description Default
settings dict[str, tuple[float, float]]

From read_export_settings.

required
xyz ndarray

(n, 3) points in the scanner's frame (range is measured from its origin).

required
attributes dict[str, ndarray]

Per-point deviation, reflectance, amplitude as needed. RIEGL's "not measured" deviation (65535) counts as -1.

required

Returns:

Type Description
ndarray

(n,) bool; intervals are closed.

Raises:

Type Description
KeyError

If an attribute the settings bound is missing (silently skipping it would keep more than asked).

angular_steps

angular_steps(xyz: ndarray, sample: int = 2000000) -> tuple[float, float]

The scan pattern's angular increments (degrees), estimated from the points.

The polar step is the median step between consecutive records; the azimuth step is the spacing of the azimuths within one polar row (one shot per line), the median over a dozen rows. Falls back to 0.03 degrees, the usual VZ-series setting, for a scan too small to tell.

Parameters:

Name Type Description Default
xyz ndarray

(n, 3) points in the scanner's frame, in recording order.

required
sample int

Records used.

2000000

Returns:

Type Description
theta_step, phi_step : float

riscan_like_mask

riscan_like_mask(xyz: ndarray, amplitude: ndarray, mode: str = 'current', *, min_range: float = 0.5, window_steps: float = 1.5, window_range: float = 1.0, min_neighbours: int = 6, weak_db: float = 12.0, steps: tuple[float, float] | None = None) -> np.ndarray

What RiSCAN PRO's RXP import keeps, approximately, as a keep-mask.

Measured against RiSCAN's own databases on TERN VZ-2000i scans:

"current" The import a current RiSCAN performs (rimta5 with window analysis). It drops 0.02-0.3 % of the echoes RiVLib delivers, nearly all window echoes within half a metre of the scanner, so this drops range < min_range. Agreement 99.7 %. "legacy" The older conversion, which discards echoes the MTA resolution cannot support by their neighbours, 19-30 % of a scan. Reproduced as: drop an echo weaker than weak_db with fewer than min_neighbours other echoes within window_steps scan increments and window_range m of range, plus the near-range drop. Agreement 85-86 %; the rest of RiSCAN's decision depends on pulse timing the point stream lacks. "none" Keep everything.

Parameters:

Name Type Description Default
xyz ndarray

(n, 3) points in the scanner's frame, in recording order.

required
amplitude ndarray

(n,) amplitude (dB).

required
mode str

"none", "current" or "legacy".

'current'
steps tuple[float, float] | None

(theta_step, phi_step) of the scan pattern (degrees); estimated by angular_steps if None.

None

Returns:

Type Description
ndarray

(n,) bool.