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 |
|
|
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 ( |
shot_of_echo ¶
shot_of_echo() -> np.ndarray
Index of the owning shot for each echo.
Returns:
| Type | Description |
|---|---|
ndarray
|
Length |
echo_rank ¶
echo_rank() -> np.ndarray
Position of each echo within its shot.
Returns:
| Type | Description |
|---|---|
ndarray
|
Length |
echo_xyz ¶
echo_xyz() -> np.ndarray
Echo positions in the shots' frame.
Returns:
| Type | Description |
|---|---|
ndarray
|
|
to_pointcloud ¶
to_pointcloud() -> PointCloud
The echoes as a point cloud; misses are dropped.
Returns:
| Type | Description |
|---|---|
PointCloud
|
One point per echo with |
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
|
|
required |
Returns:
| Type | Description |
|---|---|
Shots
|
|
subset ¶
subset(mask: ndarray) -> Shots
Select pulses.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
mask
|
ndarray
|
Boolean array of length |
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, |
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 |
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 |
required |
quantile
|
float
|
Quantile of the per-line shot counts taken as "fully sampled". |
0.98
|
shot_stride
|
int
|
The |
1
|
Returns:
| Type | Description |
|---|---|
int
|
The larger of the nominal count and the observed |
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 |
required |
zenith_edges
|
ndarray
|
Ring edges in degrees from up. |
required |
pulses_per_line
|
int | None
|
Overrides |
None
|
Returns:
| Type | Description |
|---|---|
ndarray
|
Pulses fired per ring (length |
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 |
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 |
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 |
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 |
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
|
|
required |
groups
|
list[int] | None
|
Row-group indices to read (see |
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
|
|
required |
Returns:
| Type | Description |
|---|---|
dict
|
|
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
|
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 |
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. |
scans |
list[Path]
|
Every |
instrument |
str | None
|
Scanner model from |
rxp
instance-attribute
¶
rxp: Path | None
First non-monitoring .rxp of the position (None if no scan).
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 ( |
None
|
Returns:
| Type | Description |
|---|---|
ndarray
|
|
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 |
{}
|
Returns:
| Type | Description |
|---|---|
PointCloud
|
|
Raises:
| Type | Description |
|---|---|
FileNotFoundError
|
If the position has no |
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
|
False
|
**options
|
Passed to |
{}
|
Returns:
| Type | Description |
|---|---|
Shots
|
|
Raises:
| Type | Description |
|---|---|
FileNotFoundError
|
If the position has no |
ValueError
|
If |
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 |
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. |
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
|
|
origins ¶
origins() -> np.ndarray
Scanner positions in project coordinates.
Returns:
| Type | Description |
|---|---|
ndarray
|
|
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]
|
|
required |
Returns:
| Type | Description |
|---|---|
ndarray
|
|
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 |
required |
Returns:
| Type | Description |
|---|---|
RiscanProject
|
|
Raises:
| Type | Description |
|---|---|
FileNotFoundError
|
If |
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
|
|
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 |
required |
xyz
|
ndarray
|
|
required |
attributes
|
dict[str, ndarray]
|
Per-point |
required |
Returns:
| Type | Description |
|---|---|
ndarray
|
|
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
|
|
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
|
|
required |
amplitude
|
ndarray
|
|
required |
mode
|
str
|
|
'current'
|
steps
|
tuple[float, float] | None
|
|
None
|
Returns:
| Type | Description |
|---|---|
ndarray
|
|