Skip to content

Masking

A mask is a boolean array with one entry per point, True where the point is kept. sylva.geo.masks builds masks from four sources: polygons, rasters, expressions over the attributes, and the distance to another cloud. Masks combine with &, | and ~ and index a cloud directly, so a selection that draws on several sources is a single line:

from sylva.geo import masks

keep = masks.inside_polygons(cloud, plots) & masks.expression(cloud, "height > 2")
subset = cloud[keep]

Each mask function has a crop_* companion that returns the kept cloud in one call; invert=True keeps the other points instead.

Mask Crop Keeps points
inside_polygons(c, polygons) crop_polygons whose x, y fall inside any polygon
raster_mask(c, raster, min, max, values) crop_raster whose raster cell passes a range or value test
expression(c, "height > 2") crop_expression, c.where(...) satisfying a condition on their attributes
near(c, other, distance) crop_near, difference within (or, for difference, beyond) a distance of another cloud

A worked example

The synthetic plot below has four trees on a 20 m square, with a 4 m margin of extra points around it.

import numpy as np
from sylva import ground, synthetic
from sylva.geo import masks

cloud = synthetic.forest()
cloud = ground.normalize_height(cloud, ground.make_dtm(cloud))

# The plot boundary; a polygon can also be read from a file (below).
plot = masks.Polygon([(0, 0), (20, 0), (20, 20), (0, 20)])
cloud = masks.crop_polygons(cloud, plot)                  # 295,068 points

# Vegetation more than 2 m above the ground.
canopy = cloud.where("height > 2 & classification != 2")   # 252,473 points

# Points in the canopy gaps: cells of the CHM lower than 2 m.
chm = ground.make_chm(cloud, resolution=0.5)
in_gap = masks.raster_mask(cloud, chm, max=2)             # 9,031 points

# Low tree points that are under canopy cover.
low = masks.expression(cloud, "0.5 < height < 2 & tree_id > 0")
covered = cloud[low & ~in_gap]

# Change detection: remove tree 1, then find what was lost.
after = cloud.where("tree_id != 1")
lost = masks.difference(cloud, after, 0.05)
np.unique(lost.attrs["tree_id"])                           # array([1])

Polygons

read_polygons reads ESRI shapefiles (.shp, with the .dbf attribute table and the .prj CRS when present) and GeoJSON (.geojson, .json). Each record becomes a MultiPolygon with its properties; holes and multi-part features are kept as stored.

plots = masks.read_polygons("survey/plots.shp")
plots.crs                                 # the .prj text
plots[0].properties                       # {'PLOT_ID': 7, 'NAME': 'north'}
stand = plots[[f.properties["STAND"] == "B" for f in plots]]

inside = masks.inside_polygons(cloud, stand)
which = masks.polygon_index(cloud, plots)            # index of the plot, -1 outside

When path is a directory, layer names the file to read (read_polygons("survey", layer="plots")).

Polygons can also be given as coordinates: a (K, 2) array of vertices is one polygon, a list of arrays is one polygon each, and Polygon(exterior, holes) and MultiPolygon(parts) describe holes and multi-part shapes.

circle = np.column_stack([10 + 5 * np.cos(t), 10 + 5 * np.sin(t)])
ring = masks.Polygon(circle, holes=[circle_small])
masks.crop_polygons(cloud, [square, ring], invert=True)

Polygons are closed: a point on an edge or a vertex is inside, a point on the boundary of a hole is inside, and a point on an edge shared by two polygons is inside both. The test uses exact orientation predicates, so points that lie on an edge are classified consistently whatever the edge's direction. A grid over the polygons' bounding boxes and a band index over each ring's edges keep the cost per point low with many polygons or with polygons of many vertices, and points are processed in parallel.

No reprojection

Polygons are taken in the cloud's frame. read_polygons reports the file's CRS in Polygons.crs but never applies it, and the masks do not compare it with the cloud's CRS. Reproject the layer or the cloud first when they differ. A cloud in a scanner or project frame needs polygons in that frame too.

Rasters

raster_mask looks up the cell under each point and tests its value: min and max give an inclusive range and values a set of accepted values (for class rasters). With neither, every point over a valid cell is kept. Points outside the raster and points over NaN cells are never kept.

masks.raster_mask(cloud, chm, min=10)                 # under tall canopy
masks.crop_raster(cloud, landcover, values=[3, 4])    # two classes of a class raster
masks.crop_raster(cloud, dtm, invert=True)            # points outside the DTM or over its holes

Cells include their southern and western edges, as in Raster.cell_index.

Expressions

expression(cloud, expr), crop_expression and PointCloud.where accept a condition written in a small language that the Rust core parses and evaluates; the text is never run as Python.

Element Syntax
values x, y, z, any attribute name, numbers (2, -0.5, 1e3), true, false
arithmetic + - * /, unary -
comparison < <= > >= == !=, chained as in 1.3 <= z < 40
membership classification in (3, 4, 5), classification not in (7, 18)
logic & or and, | or or, ! or not, parentheses

& and | bind more loosely than comparisons, so height > 2 & classification != 2 needs no parentheses (unlike the same expression in NumPy). Values are compared as float64, and a comparison with NaN is false except !=. Boolean attributes are conditions on their own (withheld & z > 1). Errors name the problem and its position:

ValueError: unknown attribute 'hieght' at position 0 (did you mean 'height'?); the cloud has: x, y, z, classification, height, intensity
    hieght > 2
    ^

Distance to another cloud

near(cloud, other, distance) keeps the points within distance of any point of other, in 3-D or, with horizontal=True, in x and y only. difference(cloud, other, distance) returns the points of cloud farther than distance from other: between two epochs of the same scene, difference(after, before, d) is what appeared and difference(before, after, d) what was lost. Choose d above the registration error and the point spacing, or noise and density differences show up as change.

stems_zone = masks.crop_near(cloud, stems, 0.1, horizontal=True)
new = masks.difference(epoch2, epoch1, 0.05)

A k-d tree is built on other and queried for each point in parallel. The result does not depend on the number of threads, and clouds of tens of millions of points on both sides fit in the memory of a workstation.