Python API¶
import pyarchinit_photogrammetry as ppg
Document and chunks¶
| Call | Description |
|---|---|
ppg.Document(path=None), Document.open(path), doc.save(path=None) |
project file .ppg + folder .ppg.files/ |
doc.add_chunk(label), doc.chunks, doc.chunk |
chunks; doc.chunk is the active one (assign to change it) |
ppg.app.document |
the project shared by scripts, CLI (ppg run) and GUI |
Chunk workflow¶
One row per method of Chunk, in the order of the workflow; the parameters that matter most. The
longer explanations follow the table.
| Method | What it does |
|---|---|
add_photos(paths, recursive=False) |
files or folders; sensors grouped by body, lens and resolution; the EXIF GPS becomes the camera reference |
detect_markers(target_type="aruco_4x4_50") |
markers target <code> with their projections |
match_photos(quality, keypoint_limit, tiepoint_limit, generic=None, reference=None, apply_masks="keypoints", guided_matching=False, exclude_stationary=False, reset=True) |
keypoints and matches, with the choices of Metashape's Align Photos (Pairs, below) |
align_cameras(mapper="auto", use_gps=None, adaptive_fitting=False, reset=True, tiepoint_limit=None, ba_global_ratio=None, ba_global_refinements=None) |
the photos placed, by the global, the incremental or the hierarchical mapper (Mappers, below), held to the GPS of the photos; the transform is reset and the georeference made again when references exist |
optimize_cameras(fit_principal_point=False, use_markers=True, marker_weight=4) |
bundle adjustment of poses, calibration and tie points, on the control points and scale bars when there are any (Camera optimisation, below); returns the reprojection error (px) |
import_reference(csv, crs, columns="nxyz") |
references of markers and cameras from a text file: n label, x y z, a accuracy, - skip |
export_reference(csv) |
the references to a text file |
add_marker(...), add_marker_projection(...) |
markers by hand (how they are placed: Scale bars) |
add_scalebar(marker_a, marker_b, distance) |
a scale bar between two markers (Scale bars) |
triangulate_markers() |
the markers placed from their projections (two photos or more), or on the surface from one |
update_transform(use_cameras=None) |
the similarity onto the references (weighted, 7 parameters); returns the control and check RMSE and the error of every marker and bar |
build_depth_maps(quality, filter_mode, backend) |
depth maps; backends auto, cpu, patchmatch, colmap, openmvs |
build_point_cloud() |
the dense cloud from the depth maps |
build_model(source, surface, face_count, voxel_size, vertex_colors) |
the mesh from point_cloud, tie_points or depth_maps (Mesh from depth maps, below) |
build_texture(texture_size, page_size=8192) |
the texture of the model, in pages above page_size (Texture pages, below) |
build_dem(resolution, source) |
the DEM, a GeoTIFF in the chunk CRS |
build_orthomosaic(resolution, surface, blending, colour_correction, ghosting, fill_holes) |
the orthomosaic on the DEM or on the model (Seamless orthomosaic and colour correction) |
calibrate_colours() |
exposure and vignetting of the photos (Seamless orthomosaic and colour correction) |
export_point_cloud(path), export_model(path), export_dem(path), export_orthomosaic(path) |
the products in world coordinates (formats and options: Exports for the web, Confidence, cleaning and cropping) |
export_cameras(fmt="csv") |
the cameras, csv or colmap |
generate_report(path) |
the HTML report of the chunk |
Every processing method accepts progress=callback(fraction, message).
Pairs¶
The choices are those of Metashape's Align Photos. quality is the accuracy: ultra, high,
medium, low or lowest, the photos at full size or reduced to 1/2, 1/4, 1/8, 1/16 of their
side. generic is the generic preselection: the pairs that overlap are found first on the photos
reduced to 1024 pixels and the 20 strongest pairs of each photo are matched in full (None: from 40
photos up; below, every pair is cheap enough). reference is the reference preselection:
source (every photo with its neighbours nearest by the reference positions, the GPS of the
drone), estimated (by the positions a previous alignment estimated; photos not aligned yet with
every other), sequential (the photos before and after it in the order of the capture times: a
video, a walk around an object). With both preselections on, the pairs of both are matched; with
neither, every pair. The older pair_selection word (auto, exhaustive, generic,
sequential, reference) stands in when generic and reference are not given.
apply_masks applies the masks of the photos to the keypoints (no point is detected in the masked
parts), to the tiepoints (a point masked in one photo is dropped everywhere after the alignment:
masking a few photos is enough) or to none. guided_matching searches the matches again with the
geometry of each pair (more tie points, slower); exclude_stationary drops the matches that do not
move between two photos (the still background of a turntable). reset=False keeps the matches and
the alignment there are and only matches the photos added since; align_cameras(reset=False) then
registers them into the block, one photo after the other (when nothing can be kept, everything is
done again). meta["sfm"] records what ran: pair_selection (exhaustive, generic,
generic+source, ...), pairs, apply_masks, masked_tie_points, added.
The GPU¶
gpu (default: the PPG_GPU environment variable, else auto) puts the matching on the GPU as
well: auto takes the best GPU of the computer, all every GPU it has (each with its own stream
of pairs), off leaves everything to the CPU, an adapter index or part of its name picks one
(ppg doctor lists them). The kernels are written once in
WGSL and run through wgpu - Metal on macOS, Vulkan on Linux and Windows (ARM too), DirectX 12 on
Windows - in the native core (pyarchinit-photogrammetry-core 0.3, ppg_core.gpu_match): one
pass over a pair of photos gives the best and second-best dot products in both directions, so
COLMAP's ratio test, distance test and cross check come out of it exactly (the descriptors are
COLMAP's uint8 ones, the dot products exact integers; an Apple M4 Pro does a pair of 40,000
descriptors in 0.2 s). The pairs form one queue: the GPU takes them one by one from the front, the
CPU (COLMAP's matcher) in chunks from the back, each as fast as it goes, so the split follows the
speed of the two; the matches of the GPU go into the database and through COLMAP's geometric
verification, and the alignment sees no difference. The guided matching and the rigs of 360-degree
photos stay on the CPU. meta["sfm"]["gpu"], gpu_pairs and cpu_pairs say what did what.
The key points too: with the native core 0.4 COLMAP's SIFT (VLFeat's) is a set of WGSL kernels
as well (ppg_core.gpu_sift) - the Gaussian pyramid, its differences, the extrema with their
refinement, the orientations and the descriptors of a photo, an octave per submission. The photos
are read and reduced on the CPU, in threads, exactly as COLMAP reads them (its Rec. 709 grey, the
raster of the file, OpenImageIO's triangle filter in single precision, the longer side at the size
of the quality), so the GPU finds the points COLMAP's extractor finds, with the same descriptors
(99 % of them on the synthetic trench). An Apple M4 Pro does a 2016-pixel photo in 0.19 s where
one core of COLMAP's extractor takes 3.2 s; against all its cores the gain is smaller (57 photos of
Tell Sultan: 11 s against 19 s), and larger on a discrete GPU. The masks apply as before; the faces
of 360-degree photos stay with COLMAP's extractor, and so does a photo the GPU cannot do.
meta["sfm"]["features"] says gpu or cpu.
The fusion of the depth maps too (build_point_cloud(gpu=...), ppg_core.gpu_fuse): one thread
per pixel of every depth map instead of COLMAP's traversal, the maps covisible with it (up to 64,
kept on the GPU in a cache) checked with COLMAP's own tests - the pixel of the other map
back-projected within 2 px, the depth within 1 %, the normals within 20 degrees - the consistent
pixels averaged into the point, which the earliest map that holds it emits (no map repeats a
point another one made), kept with min_num_pixels pixels of the filter mode. No pixel is
absorbed by a neighbouring point, so the cloud is as dense as the depth maps (on the synthetic
trench 2.7 times COLMAP's, with the 90th percentile of the height errors 2.4 mm against 3.0),
and the confidence of every point - the maps that agree on its depth within 1 % - comes out of
the same pass, so depth_map_support is not run again (57 photos of Tell Sultan, maps of 1008
pixels: 1.8 s against 4.5 s of COLMAP's fusion and the confidence; the larger the survey, the
larger the gain, as COLMAP's traversal slows down with the number of maps). meta["dense"]["fusion"]
says gpu or colmap; a GPU that fails leaves the work to COLMAP's fusion.
The PatchMatch depth maps take the same choice (build_depth_maps(gpu=...)): the kernel of the
native core runs on the GPU, one thread per pixel, with the same cost (the normalised cross
correlation of the window through the plane of the pixel in up to six photos, the best three
counting), the same propagation and the same start, the whole run of a photo in one submission.
The photos form a queue served by a worker per GPU and one for the CPU kernel (which uses every
core), so GPUs and CPU share them by their speed; a photo a GPU cannot do goes to the CPU kernel.
meta["dense"]["gpu"] names the GPUs used.
Multispectral cameras¶
engine.multispectral: a multispectral camera (MicaSense RedEdge / Altum, DJI Mavic 3
Multispectral, Parrot Sequoia, DJI P4 Multispectral) writes one file per band of every capture, side
by side; the bands are found by their suffixes (_1.._6, _MS_G ..., _GRE ...) or by the
BandName of their XMP, the captures of a P4 Multispectral (six files of their own numbers) by
their CaptureUUID, a numbering that starts again in a second folder by the folder's name
(detect_bands(paths, recursive=False); looks_multispectral(paths) says from the first files
whether a folder holds captures at all, what the window asks on). add_captures(chunk, paths,
reference=None, recursive=False) makes the reference band (the red one by default) the photo of
each capture - an 8-bit stretched copy with the tags of the original, aligned and reconstructed as
any photo - and keeps the other bands in camera.meta["bands"]; called again with another folder
of the flight it adds to the captures there (names that repeat take their folder, the reference
band stays, files already in the chunk are left out). The radiometry (radiance_image, reflectance_image) follows the tags:
MicaSense's model (black level, gain and exposure, the RadiometricCalibration coefficients, the
vignetting polynomial), else digital numbers normalised by exposure and gain; reflectance comes from
a calibration panel in the flight (calibrate_panel(chunk, camera, region, reflectance): the
known reflectance per band over the panel's radiance) or from the irradiance of the sun sensor in
the tags. The bands of a capture are brought onto the reference band with a homography found on
the images (register_bands: the shift by phase correlation on the gradient magnitudes, an affine
at a reduced size, the homography at full size), in a chain by wavelength: each band is matched
against the registered bands nearest to it in wavelength (a leaf is bright and textured in the NIR,
dark and smooth in the red: NIR on red seldom converges, NIR on red-edge always does; on a P4
Multispectral over scrub the correlations go from 0.1-0.3 to 0.9). DJI's radiometric model (the
vignetting polynomial, the black level of the EXIF, gain, exposure, the gain adjustment, the
irradiance of the sun sensor) is followed as in their guide. build_reflectance(chunk, bands=None,
resolution=None, balance=True) runs the orthophoto engine on the reference band's geometry with
the registered bands as float values: <data>/multispectral/reflectance.tif, a float32 band per
spectral band, NaN where nothing. balance first brings the photos to the same reflectance where
they overlap (balance_gains: surface points projected into every capture and read in its bands,
the log values split into a value of the point and a gain of the photo, least squares by
alternation with the gains of mean zero, outliers beyond three MADs dropped): the strips between
flight lines flown in opposite directions go away, the mean level stays; the gains and their
spread per band are kept in meta["multispectral"]["balance"]. index_raster(chunk, "NDVI") (also NDRE, GNDVI, SAVI, NDWI, OSAVI, CIRE,
Thermal, or any formula over the band names) makes the index on the same grid; export_zoning(chunk,
"NDVI", "zone.gpkg", classes=3, method="quantiles" | "equal" | "kmeans") cuts it into classes
(small patches given to their surroundings) and writes the polygons with class, range, mean and
area into a GeoPackage - the prescription map of agriculture; in archaeology the crop marks over
buried walls and ditches. ppg multispectral PROJECT add|panel|reflectance|index|zoning|bands.
Satellite imagery¶
engine.satellite: a satellite scene comes with its rational polynomial coefficients (RPC),
two rational cubics giving the column and row of any ground point (longitude, latitude,
ellipsoidal height). read_rpc(scene, rpc=None) takes them from the GeoTIFF, from a _RPC.TXT
/ .RPB / .rpc sidecar or from the RPC_*.XML of a DIMAP product (Pleiades, SPOT: their
1-based pixel numbering is brought to 0-based); project and ground are the model and its
inverse (centre of the first pixel at (0, 0)). scene_info gives size, bands, ground sampling
distance and footprint in the CRS of the chunk. ortho_scene(chunk, scene, name=None, rpc=None,
resolution=None, height_offset=0, rgb=None) orthorectifies the scene on the DEM of the chunk
(orthorectify: every cell lifted to the DEM height, projected into the scene, the scene
resampled there, tile by tile in threads): the full result, all the bands in the type of the
scene, in <data>/satellite/<name>_ortho.tif, and an 8-bit RGBA rendition kept as a version of
the orthomosaic, so the drone orthomosaic and the satellite image of the same ground are compared
cell by cell. RPC heights are ellipsoidal: with a DEM in orthometric heights give the geoid
undulation as height_offset. dsm_from_pair(chunk, scene_a, scene_b, name=None, resolution=None,
height_range=None, use_dem=True) builds a surface model from a stereo pair (stereo_dsm): tile
by tile both scenes are resampled on the ground plane at a reference height (the median of the
DEM there, else the RPC's) along the direction of their baseline; a point above or below the
plane is displaced along the line of sight of each scene, so the disparity found by semi-global
matching (OpenCV SGBM) gives its height and its true position is recovered by taking the
displacement back; the result, a float32 GeoTIFF with the holes filled, is kept as a version of the
DEM and in <data>/satellite/. On the synthetic trench seen by two parallel-projection
"satellites" 25 degrees off nadir (base to height 0.93) the DSM is within 0.6 mm (median) of the
DEM. ppg satellite PROJECT info|ortho|dsm SCENE [SCENE_B] [--rpc] [--resolution] [--height-offset]
[--height-range zmin,zmax] [--rgb 3,2,1].
Other computers¶
The work that splits by photo goes to the other computers of the network through the project
folder (engine.nodes): on each of them, with the project folder mounted, ppg node
<project>.ppg.files takes units of work from nodes/todo (a few photos each, taken by an atomic
rename, given back when a node dies) and leaves the results in nodes/done. Key points: the node
runs COLMAP's extractor into a database of its own and sends keypoints and descriptors back, the
main program writes them into its database, so the alignment sees no difference. PatchMatch depth
maps: the node opens the project read-only on the shared folder and computes its units in the
workspace there, with its GPUs and CPU. The main program is a node too (it takes what nobody
took), so with no node present nothing changes; PPG_NODES=0 keeps everything here. A node maps
the main program's paths to its own (the project folder by itself, --map SRC=DST for the
photos). Over SSH, ppg remote user@host:/folder project.ppg [--photos ...] copies the project
folder (and the photos) there with rsync, starts the node and copies units and results back and
forth while a step runs here. meta["sfm"]["nodes"] and meta["dense"]["nodes"] name the
computers that took part. The alignment itself stays on one machine.
Mappers¶
mapper is incremental (one photo after the other, COLMAP), global (all the cameras at once,
GLOMAP: rotations, positions, then the adjustment on tiepoint_limit / 4 tracks per photo,
retriangulated; on the 57 photos of the Tell Sultan tutorial 58 s against 293 s, the same cameras
within 2 cm) or auto: global from 40 photos, incremental for fewer, for 360-degree rigs and
calibrations held fixed, when the global one places less than 90 % of the photos, and when its
cameras do not fit the GPS of the photos. meta["sfm"]["mapper"] says which ran. Photos are aligned
with one focal length (square pixels): fitting fx and fy separately can drift into a degenerate
solution. The result stays in the sensor model (OPENCV with fx = fy, no tangential distortion); a
sensor calibrated with fx != fy or tangential distortion is aligned as it is, and optimize_cameras()
refines every parameter.
use_gps (None: when the photos have them) gives the incremental mapper the reference positions
of the photos, with their accuracy, as pose priors: the block is held to them while it grows, as
Metashape's alignment holds it to the camera references, and a long chain or a ring of photos no
longer drifts (a wrong GPS fix is damped by a robust loss); the reconstruction then lives on the
metric axes of the references less a round offset, meta["sfm"]["gps_priors"]. adaptive_fitting
is Metashape's adaptive camera model fitting, done our way: the parameters only a sound block can
bear - the principal point, the tangential distortion, two focal lengths - are refined on a copy of
the reconstruction and kept only when the mean reprojection error drops by 1 % at least and every
camera stays within physical bounds (meta["sfm"]["adaptive_fitting"]).
tiepoint_limit (default: the limit given to match_photos) is Metashape's tie point limit the
mapper's way: the incremental and hierarchical mappers read a copy of the database that keeps, for
every photo, its most matched key points only (meta["sfm"]["tie_point_limit"] counts what was
kept; 0 keeps everything). ba_global_ratio and ba_global_refinements space out the global
adjustments of the incremental mapper, which by default adjusts the whole block every time it
grows by 10 % with up to 5 refinements each (meta["sfm"]["ba_schedule"]): on a block of a thousand
photos that is most of the alignment time. mapper="hierarchical" is COLMAP's hierarchical
pipeline: clusters of photos aligned on the cores, then merged.
Camera optimisation¶
With 3 or more control points (markers with an enabled reference, not check points) a free
adjustment is followed by one where the markers are fixed points at their reference coordinates,
observed in the photos (each measurement weighs marker_weight tie point observations): the block
bends to fit them, which a similarity alone cannot do. The fixed points are reached by continuation:
moved in steps from where the block sees them to their references, with an adjustment at each step,
so that no step starts far from its solution (a single adjustment with the points fixed metres away
diverges, the calibration running away; on the 997 photos of Tell Tuqan the targets start 1.8 m off
and are reached in 20 steps). On big blocks (more than 2.5 million observations) the steps run on a
copy with the strongest tracks, every photo keeping its share; then the poses go back to the whole
block, every tie point is triangulated again, the points that no longer fit are dropped and one
adjustment of everything closes. Control scale bars (2 or more, or with control points) enter the
same way: their markers are fixed at engine.sfm.bar_anchors (see Scale bars).
meta["sfm"]["optimize"] reports control_points, scale_bars, steps and the control/check point
and bar RMSE before and after (ppg optimize).
engine="ppg" uses our own adjustment instead (below), with gnss="plain" or "bias" and
rolling_shutter=True.
Our own bundle adjustment¶
engine.bundle.problem_from_chunk(chunk, rec, use_markers, gnss, rolling_shutter),
bundle.adjust(problem) and bundle.write_back(...): sparse trust-region least squares (scipy; the
Jacobian by finite differences grouped by the structure: a residual depends on one photo, one point,
one camera), projection with the COLMAP camera models. Control points pulled to their reference by
their accuracy (not fixed), control scale bars as distances between their markers, GNSS positions of
the photos with a bias per flight (bundle.flights: per sensor, a new flight after 20 minutes
without photos), rolling shutter (a linear and an angular velocity per photo, the time of an
observation from its row; a weak prior towards a still camera, as large as 2 pixels of shift at the
depth of the photo: on photos with a global shutter the velocities stay close to zero). Report:
gnss_bias (per flight, in the CRS), rolling_shutter_m. Rigs (faces of 360-degree photos) are
refused: COLMAP's adjustment keeps them.
Texture pages¶
A texture larger than page_size is split into pages as in Metashape (model_texture.jpg,
model_texture_1.jpg...; TextureFile comments and the texnumber of every face in model.ply,
as MeshLab reads them): the generic mapping puts groups of nearby faces on each page, the orthophoto
mapping a tile of the plan (the steep faces on pages of their own). engine.texturing.texture_pages(chunk)
lists the pages; exports write a material per page (OBJ), the pages and texnumber (PLY), a mesh
per page (GLB); the 3D view shows them side by side in one reduced image (display_texture);
versions, undo, texture patches and the Metashape import (pages and texnumber of mesh.ply) carry
every page.
Mesh from depth maps¶
chunk.build_model(source="depth_maps", surface="arbitrary", face_count="medium", voxel_size=None,
vertex_colors=True) builds the mesh straight from the depth maps of build_depth_maps(), without the
dense cloud (like source data: depth maps). The depth maps are fused into a truncated signed distance
field (TSDF in an Open3D VoxelBlockGrid: only the blocks near the surface are stored) and the surface
is extracted with marching cubes, with vertex colours averaged from the photos. A voxel counts once as
many depth maps see it as the dense-cloud fusion wants for the filtering mode (3 for moderate, 2 for
mild/none); pieces smaller than 1 % of the largest one are dropped. The mesh is decimated to
face_count (high/medium/low = 1/5, 1/15, 1/45 of the points the depth maps give at their own
resolution, or a number) and saved as model.ply like the Poisson mesh, so texture, DEM, orthomosaic
and sections work as before; surface="height_field" re-triangulates the fused surface in 2.5D.
voxel_size is in chunk units (metres once georeferenced): by default twice the size of a depth-map
pixel on the surface, raised (with a warning) when the surface would need more than a quarter of the
memory. The fusion runs in a child process (a crash does not close the program; progress and
cancellation work as usual) and needs depth maps of the cpu, patchmatch or colmap backend (OpenMVS keeps its
own format). engine.tsdf.fuse_depth_maps(chunk, voxel_size=, min_views=, colors=, memory_gb=) returns
the fused surface (FusedMesh, internal frame) without saving it. On the synthetic trench of the tests
(depth maps at quality high) it takes about 4 s and is closer to the true surface than the Poisson
mesh of the dense cloud: median distance 0.9 mm, 90th percentile 2.2 mm (Poisson 1.0 and 2.8 mm).
CLI: ppg mesh project.ppg --source depth_maps [--voxel-size 0.005].
Align and merge chunks¶
Chunks are aligned separately (each in its own arbitrary frame), brought into one frame, then merged, as with Workflow > Align Chunks and Merge Chunks.
| Call | Description |
|---|---|
doc.align_chunks(reference, chunks=None, method="markers", fix_scale=False, initial="auto", max_points=200_000, progress=None) |
moves chunks (Chunk objects, keys or labels; default: the other enabled chunks) into the world frame and CRS of reference; returns a report |
doc.merge_chunks(chunks, label=None, merge_markers=True, merge_tie_points=True, merge_point_clouds=True, merge_models=False, progress=None) |
new chunk (default label Merged chunk) in the frame of the first chunk; the inputs are kept |
ppg align-chunks project.ppg [CHUNKS...] -r REF --method markers\|points\|cameras [--fix-scale] [--initial ...] [--json] |
command line (default reference: the active chunk) |
ppg merge-chunks project.ppg [CHUNKS...] [--label L] [--no-markers] [--no-tie-points] [--no-point-clouds] [--models] [--activate] |
command line (default: all enabled chunks) |
Methods of align_chunks:
markers: markers with the same label in both chunks (at least 3, with a position), least squares (Umeyama).cameras: photos present in both chunks (the same file, or the same label when it is unique in each chunk). Least squares on the camera centres; with 1-2 common photos, or photos along a line, the rotation comes from the camera orientations.points: registration of the dense clouds (tie points for a chunk without one). Initial position from the common markers, else the georeference (both chunks in a projected CRS, or referenced in the same local grid), else the common photos, else a global registration (FPFH descriptors from Open3D, matched with a seeded RANSAC: needs the[mesh]extra);initial="markers"|"reference"|"cameras"|"global"forces one. A scaled point-to-plane ICP refines it; the result is accepted when most overlapping points fit and, with colours, the brightness of matched points agrees (elseValueError).fix_scale=True: rigid alignment that keeps the scale of each chunk; only for chunks already in metres (CRS, references or scale bars), elseValueError.
Only chunk.transform and chunk.crs change; photos, tie points, dense cloud and mesh stay
in the chunk's internal frame. Estimates use local frames (world coordinates minus a rounded
offset), so national grid/UTM coordinates lose no precision. An unlevelled local reference is
levelled first (as build_dem would do later). If one chunk cannot be aligned, nothing changes.
Marker references of a chunk in another projected CRS are converted to the new one. The
DEM and orthomosaic of an aligned chunk are in its old frame: build them again
(stale_products in the report).
The report: {"reference": {"key", "label"}, "method", "fix_scale", "crs", "levelled_reference",
"chunks": [...]}, one entry per chunk with key, label, method, correspondences, rmse
(reference world units, metres once georeferenced or scaled), scale (scale applied),
transform (the new chunk.transform), delta (old world -> reference world), stale_products;
residuals (per marker or photo: error, dx, dy, dz; angle_deg for photos) and
max_error for markers/cameras; initial, source/target (point_cloud or tie_points),
fitness, overlap, inlier_ratio, color_corr, attempts and check (residuals of the
common markers; when the ICP started from them they also anchor it - point-to-point terms
weighing together as much as the cloud - so a flat or noisy cloud cannot slide away from them,
anchored) for points. chunk.meta["chunk_alignment"] keeps a summary.
merge_chunks needs chunks in one frame (aligned, or georeferenced in projected CRSs; another
CRS is converted through a fitted similarity, with a warning above 5 mm). The merged chunk has
the CRS, transform and internal frame of the first chunk, and contains:
- sensors and cameras with new keys; a photo present in several chunks is kept once (the first aligned copy). Identical calibrations stay separate sensors.
- markers merged by label (projections combined, positions triangulated again), or all
kept with
merge_markers=False; scale bars remapped. - tie points, dense clouds (colours, normals, classes) and, with
merge_models=True, meshes (vertex colours) concatenated in the common frame. - a COLMAP model of the merged cameras (
engine.sfm.write_reconstruction_from_chunk, with the merged tie points as 3D points), so depth maps, dense cloud, mesh, DEM and orthomosaic can be built again on the merged chunk.
DEMs, orthomosaics and textures are not merged. merged.meta["merge"] lists the source
chunks, the photos found in more than one chunk and warnings.
Products from chosen point classes¶
The classes of the dense cloud (rules, lasso, trained classifier or stratigraphic units)
choose what goes into the products. Noise (NOISE_CLASSES = 7, 18) never makes a DEM or a mesh.
| Call | Description |
|---|---|
chunk.point_classes() |
points per class of the dense cloud, reading only its class column (PointCloud.read_class_counts(path)) |
chunk.build_dem(..., classes=[2]) |
DEM from these classes only: [2] gives a terrain model (DTM) without vegetation, people or tools; meta["dem"]["classes"] |
chunk.build_orthomosaic(..., hide_classes=[3, 71]) |
transparent where the visible surface is of these classes; meta["orthomosaic"]["hidden_classes"] |
chunk.build_model(source="point_cloud", ..., classes=[2, 64]) |
mesh from these classes (the surface closes over what is left out); only with the dense cloud as source; meta["model"]["classes"] |
sections.section(chunk, p1, p2, source=, classes=) |
a section through the dense cloud or the model, limited to these classes |
sections.sections_along(..., classes=) |
cross sections perpendicular to a baseline, with the same class filter |
sections.plan_slice(chunk, z, classes=) |
a horizontal slice of the model at height z, with the same class filter |
chunk.export_point_cloud(path, classes=[64]) |
only these classes (LAS keeps the class codes) |
PointCloud.class_mask(classes=None, exclude=()) |
boolean mask of the points to use |
classify.classify_by_units(chunk, units, base_code=100, keep_manual=True) |
one class per stratigraphic unit (unit_class_name: US 12 (area 1)), points on the edge included; returns a row per unit |
classify.units_table_csv(rows, path) |
writes the rows of classify_by_units to a CSV (semicolons) |
chunk.build_dem(..., classes=[2]) interpolates the gaps under what is left out.
chunk.build_orthomosaic(..., hide_classes=[3, 71]) takes as visible surface the highest dense
point, gridded at the point spacing and widened by one cell;
engine.ortho.hidden_cells(chunk, origin, shape, resolution, classes) gives those cells.
sections.section(...), sections_along and plan_slice with classes= take, from the dense
cloud, only the points of these classes; from the model, only the stretches of the cut whose middle
is nearest to a point of these classes and whose ends lie on such points (not the parts the mesh
interpolates over what is left out).
classify.classify_by_units(...) takes free codes from 100 and gives the same code when run
again (the old extent goes back to 1); it returns per unit name, code, sito, area_s, us,
points, area_m2 (plan, of the polygons), z_min, z_mean, z_max.
Command line: ppg dem scavo.ppg --classes 2, ppg ortho scavo.ppg --hide-classes 3,71,
ppg mesh scavo.ppg --classes 2,64, ppg section scavo.ppg x1 y1 x2 y2 --classes 2,
ppg export scavo.ppg --cloud muri.las --classes 64,
ppg classify-units scavo.ppg [--pyarchinit-site SITO] [--csv us.csv] [--no-keep-manual]
(the units drawn in the project, or the US polygons of a PyArchInit site).
Versions of the products¶
core.versions keeps earlier results of the dense cloud, the model, the DEM and the
orthomosaic to compare them. The active version is the product every tool uses (the usual
files of the chunk); kept versions are in versions/<product>/<id>/ inside the chunk folder,
listed in chunk.meta["versions"] with name, date and the parameters they were built with.
| Call | Description |
|---|---|
versions.keep_active(chunk, product, move=False) |
puts the active version among the kept ones; returns the entry |
versions.restore(chunk, product, entry) |
undoes a keep_active(move=True) (a build that failed) |
versions.set_active_name(chunk, product, name) |
names the active version (by default DEM del 16/10 11:20 (classi 2)) |
versions.active_info(chunk, product) |
name and date of the active version |
versions.versions(chunk, product) |
the kept versions |
versions.version_file(chunk, product, id) |
the main file of a kept version |
versions.activate(chunk, product, id) |
makes a kept version active; the active one is kept in its place |
versions.rename(chunk, product, id_or_None, name) |
renames a kept version (None: the active one) |
versions.delete(chunk, product, id) |
deletes a kept version and its files |
versions.dem_difference(chunk, id, out=None) |
active DEM minus a kept one, on the grid of the active one (GeoTIFF float32) |
versions.keep_active(...) copies the active version, or moves it with move when its files
are inside the chunk folder: a file of another program is always copied.
versions.dem_difference(...): surface minus terrain = height of walls and vegetation, after
minus before = excavated depth.
chunk.build_depth_maps(..., skip_unreadable=True) leaves out photos that cannot be read
(chunk.unreadable_photos(): missing, empty placeholders of files kept online only, not
images); without it they raise engine.visibility.UnreadablePhotos (also importable from
engine.dense). build_texture and build_orthomosaic take the same option;
chunk.relink_photos(folder, only_unreadable=False) points the photos to the files with the same
names in another folder (COLMAP then reads them from their common folder, or from a folder of
links when they are split, engine.sfm.photo_folder). The COLMAP model written
from imported poses refers only to points with two observations; engine.sfm.repair_reconstruction(chunk)
fixes models written by earlier versions (done before the depth maps and the camera optimisation).
Confidence, cleaning and cropping¶
| Call | Description |
|---|---|
chunk.compute_confidence("point_cloud" or "model", tol=0.01) |
depth maps agreeing on each point or vertex, stored as the confidence property of the PLY; returns the statistics |
engine.confidence.depth_map_support(chunk, points, tol, abs_tol=None) |
the same count for any points of the internal frame |
chunk.clean_point_cloud(min_confidence=, remove_classes=, statistical=(k, std), sparse=(radius, n), groups=(distance, n), color=((r, g, b), tol)) |
removes what engine.cleaning.point_filters selects (distances 0 = automatic from the point spacing); returns the counts per filter |
chunk.clean_model(min_confidence=, min_component=, max_edge=, repair=True, fill_holes=, smooth=, target_faces=) |
model cleaning (engine.cleaning.face_filters for the counts) |
chunk.clean_tie_points(max_reprojection_error=, min_image_count=, max_reconstruction_uncertainty=, max_projection_accuracy=, optimize=False) |
gradual selection (engine.tiepoints.tie_point_metrics, select_tie_points, remove_tie_points) |
engine.cleaning.delete_points(chunk, mask, keep_selected=False) |
Metashape's delete / crop selection on the dense cloud |
engine.cleaning.delete_faces(...) |
the same on the faces of the model |
chunk.export_dem(path, region=) |
the DEM cut by a polygon in the chunk CRS |
chunk.export_orthomosaic(path, region=) |
the orthomosaic cut by a polygon |
chunk.export_model(path, region=, zmin=, zmax=) |
the model cut by a polygon and between two heights |
chunk.export_point_cloud(path, region=, zmin=, zmax=) |
the dense cloud cut by a polygon and between two heights |
chunk.crop_dem(region) |
the same cut, written in the project |
chunk.crop_orthomosaic(region) |
the same for the orthomosaic |
chunk.crop_model(region, zmin, zmax) |
the same for the model |
chunk.crop_point_cloud(region=) |
the same for the dense cloud |
chunk.build_orthomosaic(..., workers=None, max_angle=70.0) |
the orthomosaic tile by tile, the tiles in parallel |
chunk.compute_confidence(...) counts the depth maps that agree within tol of the distance
(half an edge for a vertex); the count is also an extra bytes dimension of LAS exports.
chunk.export_dem(path, region=) and the other exports take the region as an (N, 2) ring, a
box, WKT, GeoJSON or a shapely geometry (engine.crop.as_polygon).
chunk.crop_dem(region) and the other crops never overwrite a product that is a file of another
program (chunk.own_product_path).
chunk.build_orthomosaic(..., workers=None, max_angle=70.0) builds the orthomosaic tile by tile
(the surface read one window at a time, every tile written at once, overviews at the end): any
size, the tiles in parallel; every cell takes the best of the photos that frame it (the four best
are tried in turn against occlusions), so the choice never depends on the tile around it; cells
that every photo sees from further than max_angle degrees from the vertical stay transparent.
Seamless orthomosaic and colour correction¶
| Call | Description |
|---|---|
chunk.build_orthomosaic(..., blending="mosaic", blend_sigma=8.0) |
how the photos are blended: mosaic, disabled (one photo per cell) or average |
chunk.calibrate_colours(vignetting=True, samples=20000) |
exposure gain of every photo (per channel) and vignetting of every lens, stored in chunk.meta["colour"] |
engine.colour.estimate(chunk) |
the same measurement as a module function |
build_orthomosaic(colour_correction=None) |
None: the calibration of the chunk if there is one; True: measured first when missing; False: none |
build_texture(colour_correction=None) |
the same option for the texture |
build_orthomosaic(ghosting=False) |
with True, people and vehicles seen by a few photos are left out |
build_orthomosaic(fill_holes=False) |
holes of at most 100 cells that no photo sees filled from their edges (cv2.inpaint, the cells of the same group only); larger holes stay transparent |
chunk.build_orthomosaic(..., blending="mosaic", blend_sigma=8.0): mosaic takes the details
of every cell from its best photo, the low frequencies blended across the seams over a few
blend_sigma cells (Gaussian), so different exposures leave no edge and nothing is blurred;
disabled: one photo per cell; average: every photo weighted by how well it sees the cell. A
patch never changes the pixels around it: outside the patches the weights come from the photos the
cells would take without any patch, inside a patch from its own cells (engine.ortho._blend).
chunk.calibrate_colours(...) fits the vignetting as v(r) = b1 r^2 + b2 r^4 + b3 r^6 on the
radius over the half diagonal, from surface points seen by several photos, least squares on all
the observations at once (scipy.sparse.linalg.lsqr, outliers left out between rounds, mean log
gain zero); chunk.meta["colour"] keeps the result with the colour scatter before/after.
build_orthomosaic(colour_correction=None) and build_texture apply the correction
(engine.colour.Correction.correct(cam, image)) as the photos are read.
build_orthomosaic(ghosting=False): where three or more of the candidate photos see a cell, the
best one that agrees with their median colour (within 30 levels) is taken.
Image quality and video¶
| Call | Description |
|---|---|
engine.quality.estimate_quality(chunk, cams=None) |
sharpness of every photo relative to the sharpest of the chunk (0-1, camera.meta["quality"]) |
engine.quality.low_quality(chunk, threshold=0.5) |
the blurred photos |
engine.quality.sharpness(image) |
the measure alone |
engine.video.add_video(chunk, video, folder=None, srt=None, overlap=0.7, every=None, max_frames=None) |
frames of a video added as photos, a new one as the view moves or one every every seconds |
engine.video.extract_frames(video, folder, ...) |
the frames alone, written as files |
engine.video.read_srt(path) |
the positions of a DJI .SRT track |
engine.video.position_at(track, t) |
the position of the track at a time, interpolated |
engine.quality.estimate_quality(...) measures, per cell of a 6 x 6 grid with texture, the
finest detail over the detail a few pixels wide, along x and y, the lower of the two (motion blur
in one direction counts), 80th percentile of the cells; independent of how much texture the scene
has.
engine.video.add_video(...) adds a new frame when the view has moved by 1 - overlap of its
width (median shift of ORB matches, a frame with too few matches only counts when sharp), the
sharpest in the next 0.4 s; or one every every seconds. The DJI .SRT beside the video
([latitude] [longitude] [abs_alt], GPS(lon,lat,alt) or GPS (lon, lat, alt)) gives every frame
its position (EPSG:4326, interpolated in time).
Fisheye lenses and 360-degree photos¶
| Call | Description |
|---|---|
chunk.add_photos(...) |
a fisheye lens gets an OPENCV_FISHEYE sensor (Kannala-Brandt: fx, fy, cx, cy, k1-k4) |
chunk.set_sensor_model(sensor_key, model) |
another camera model for a sensor (OPENCV, OPENCV_FISHEYE, FULL_OPENCV, PINHOLE, any COLMAP model): focal length and principal point kept, distortion reset; align again after it |
engine.panorama.add_panoramas(chunk, paths, size=None, fov=100, faces=None) |
equirectangular photos become the faces of a cube: pinhole photos with one fixed PINHOLE sensor |
| alignment | the faces of every panorama are one frame of a COLMAP rig (engine.panorama.rig_configs) |
engine.panorama.split_panorama(path, folder=None, size=None, fov=100, faces=None) |
the faces of one panorama alone, written as files |
engine.panorama.face_maps(...) |
the cv2.remap maps of a face |
engine.panorama.face_rotation(yaw, pitch) |
the rotation from a face camera to the panorama frame |
chunk.add_photos(...) recognises a lens whose EXIF name says fisheye (LensModel, or the
camera model), or a 35 mm equivalent focal length up to 15 mm; the sensor is aligned with one focal
length (RADIAL_FISHEYE) unless a calibration says otherwise. Projections far from the axis are no
longer cut at the pinhole frame.
engine.panorama.add_panoramas(...) takes equirectangular photos (width twice the height; others
left out) and writes the faces of a cube (front, right, back, left, up, down, or the ones
in faces): pinhole photos of fov degrees (more than 90: neighbouring faces overlap), size px a
side (default: the resolution of the panorama in their centre), in facce_360/<face>/<panorama>.jpg
beside the panoramas, one fixed PINHOLE sensor; camera.meta["panorama"], meta["face"]; the GPS
of the panorama is the reference of its faces.
alignment: a rig per facce_360 folder, faces at their known rotation, no translation: they
keep one centre and their relative rotation, their calibration is not refined (also in
optimize_cameras), pairs of faces of the same panorama are not matched. Depth maps pair a face
with faces of other panoramas looking up to 50 degrees away (the panoramas are turned anyhow).
PyArchInit media¶
PyArchInit.us_media(sito, area, us, unita_tipo=None) and site_media(sito): the media linked to a
stratigraphic unit or a site (media_to_entity_table), each with id_media, filename, filetype,
mediatype, descrizione and the full paths of the original (path), the thumbnail (thumb) and the
resized copy (resize) - None where the file is not on this computer or on a remote store.
Laser scans¶
| Call | Description |
|---|---|
io.scans.read_ptx(path) |
the scans of a PTX file: the grid of each scan with its 4 x 4 registration (empty returns left out) |
io.scans.read_e57(path) |
the data3D scans of an E57 file with their poses, cartesian or spherical (extra scans: pye57) |
io.scans.read_scans(path) |
either reader, by the extension of the file |
io.scans.import_scans(doc, paths, label=None, crs=None, max_points=None) |
a new chunk whose dense cloud is all the scans in their registered frame; meta["scans"], meta["metric"] |
doc.align_chunks(photo_chunk, [scan_chunk], method="points", fix_scale=True) |
the scan on the photogrammetric chunk (global registration, then ICP); not needed when both are in the same CRS |
io.scans.colour_from_photos(target, photos) |
the points of target take the colour of the aligned photo of photos that sees them closest |
io.scans.read_ptx(path), read_e57 and read_scans return the scans of a file as Scan
objects: registered xyz, rgb, intensity 0-1, scanner position.
io.scans.import_scans(...) keeps the colours, else the intensity in grey; meta["metric"]
means metres even in local coordinates (Allinea chunk without scale accepts it).
doc.align_chunks(photo_chunk, [scan_chunk], method="points", fix_scale=True): on the synthetic
trench the heights agree to 0.2 mm; in plan the walls of the dense cloud move the fit by a few
millimetres.
io.scans.colour_from_photos(...) chooses among the photos where the model does not hide the
point (2 % tolerance).
Exports for the web¶
| Call | Description |
|---|---|
chunk.export_orthomosaic(path, region=None, cog=None, quality=85) |
the orthomosaic as a Cloud Optimized GeoTIFF with the compression cog |
chunk.export_dem(path, region=None, cog=None) |
the DEM as a Cloud Optimized GeoTIFF |
io.web.export_cog(src, dst, compression="auto") |
any GeoTIFF as a Cloud Optimized GeoTIFF |
chunk.export_point_cloud("x.copc.laz") |
the dense cloud as a Cloud Optimized Point Cloud 1.0 |
io.copc.write_copc(path, xyz, rgb, classification, confidence, crs_wkt) |
the COPC writer for any points |
chunk.export_model("x.glb") |
binary glTF with the texture (or the vertex colours) |
io.web.export_glb(chunk, path) |
the same as a module function |
engine.normalmap.light_model(chunk, target_faces=50000, texture_size=4096, folder=None) |
a light model for the web and game engines: light.glb, light.obj + .mtl, light_color.jpg, light_normal.png |
engine.normalmap.bake_maps(full_verts, full_faces, light_verts, light_faces, ...) |
the baking alone |
io.flythrough.orbit_video(chunk, path, kind=None, seconds=12, fps=25, size=(1280, 720), pitch=35, zoom=1, turns=1, backend="auto") |
an MP4 of a camera orbiting the model or the cloud |
chunk.export_3dtiles(folder, max_faces=150000) |
Cesium 3D Tiles 1.1: tileset.json and GLB tiles in a quadtree in plan |
io.web.export_3dtiles |
the same as a module function |
chunk.export_i3s(path, mode="global", max_faces=20000, texture_size=2048) |
I3S 1.7 Integrated Mesh scene layer package (.slpk: ArcGIS Pro, ArcGIS Online and Earth, CesiumJS, loaders.gl) |
io.i3s.export_slpk(chunk, path, ...) |
the same as a module function |
io.i3s.read_slpk(path) |
reads a package back (layer, nodes, geometries) |
chunk.export_orthomosaic(..., cog=), export_dem and export_cog write a Cloud Optimized
GeoTIFF (tiled, overviews, read by parts over the network): jpeg (the alpha of the orthomosaic
becomes a mask), webp (keeps the alpha), zstd/deflate/lzw lossless with the predictor
(floating point for DEMs).
chunk.export_point_cloud("x.copc.laz") writes LAZ 1.4 (point format 7, confidence as an extra
byte) arranged in an octree (each node one point per cell of a 128^3 grid, the rest to its
children, down to 100 000 points), one LAZ chunk per node, the copc info VLR and the hierarchy
EVLR; QGIS, Potree and laspy's CopcReader read only what they show.
chunk.export_model("x.glb") keeps the coordinates around the centre of the model (single
precision keeps millimetres), the centre and the CRS in the scene extras and in x.json.
engine.normalmap.light_model(...): quadric decimation (Open3D), faces in cells of an atlas,
and for every texel the light surface moved along its normal onto the full model - its colour
(texture or vertex colours) into light_color.jpg, its normal in the tangent space of the light
face (x along U, y along V, OpenGL green up as glTF wants) into light_normal.png; light.glb (PBR
material with both, local coordinates), light.obj + .mtl (map_Kd, norm).
engine.normalmap.bake_maps(...): on a rippled surface baked onto two triangles the decoded
normals are within 3 degrees (median).
io.flythrough.orbit_video(...) renders with VTK off screen (with the texture) or the software
renderer of the 3D view (points sampled on the surface, z-buffered), OpenCV writer (H.264 or
MPEG-4).
chunk.export_3dtiles(...): each level the model with fewer faces (vertex colours, clustering),
the leaves at full detail; the root transform from east-north-up at the centre of the model to
earth-centred coordinates (pyproj; heights as they are).
chunk.export_i3s(...) builds a quadtree in plan with levels of detail (leaves with at most
max_faces faces, the other nodes clustered to that many, a root without mesh), every node with a
texture of its own, a power of two sized by what it shows up to texture_size (a leaf whose faces
lie together in the model's texture, as with an orthophoto mapping, takes that part as it is and
bakes only its few other faces beside it; other leaves bake the texture pages or vertex colours
face by face; other nodes cast onto the model), node pages, gzip geometry buffers, the MD5 hash
table of the paths last; mode="global" WGS84 with boxes oriented east-north-up and earth-centred
normals, mode="local" the projected CRS of the chunk (it needs an EPSG code in metres).
Measures¶
| Call | Description |
|---|---|
archaeo.measures.surface_area(dem_path, polygon, model=None) |
inside a plan polygon: plan and surface areas from the DEM, mean_slope, z_min, z_max; with a mesh also model_surface, model_plan, model_faces |
measures.slope_aspect_at(dem_path, x, y) |
slope and aspect (degrees; aspect clockwise from north, where the ground faces downhill, -1 flat) from the 3 x 3 cells (Horn) |
measures.aspect_raster(dem_path, out) |
raster of aspect |
archaeo.terrain.slope_degrees(dem_path, out) |
raster of slope |
measures.ground_surface(xyz, classes, cell=None) |
the ground on a grid from the points of class 2 (median of each cell, holes interpolated) |
measures.height_above_ground(xyz, classes) |
the height of every point above that ground |
archaeo.timeline.phases(doc) |
the chunks with a DEM as phases of the excavation in date order |
timeline.phase_date(chunk) |
the date of a phase (meta["date"], else the earliest EXIF date of the photos) |
timeline.set_phase_date(chunk, date) |
the date of a phase set by hand |
timeline.removed(doc, polygon=None) |
volume removed and added from each phase to the next, and inside each unit drawn on the earlier phase |
timeline.suggested_relations(doc) |
units exposed earlier that overlap in plan units exposed later, as Copre / Coperto da |
timeline.timeline(doc) |
the whole report |
timeline.timeline_csv(report) |
the report as CSV |
timeline.write_relations(pa, relations) |
the suggested relations added to the rapporti of the US forms (both ways; those already there left out) |
pa.add_relationships(sito, area, us, rels) |
relations added to the rapporti of one US form |
archaeo.us_proposal.propose_for_chunk(chunk, units, first_us, area=None, sito=None, step=0.02, cuts=True, lang="it") |
US proposed for units without a number: measures, contacts, numbers and relations, each relation with why |
us_proposal.write_proposal(pa, proposal, sito) |
the chosen units as US forms (create_us: node_uuid, entity_uuid; existing forms left) and their relations in both directions |
pa.next_us(sito) |
the number after the highest US of a site |
chunk.provenance(product, path=None, operator=None) |
W3C PROV-O in JSON-LD: the product, the activity that made it, what it derives from, the agents |
core.paradata.provenance(...) |
the same as a module function |
paradata.write_provenance(chunk, product, path) |
<file>.prov.jsonld beside an export or a published copy |
paradata.summary(chunk, product) |
a one-line summary (PyArchInit's note) |
io.qfield.export_package(chunk, folder, resolution=0.02, project=True) |
QField package: ortofoto.tif, dem.tif, rilievo.gpkg, <chunk>.qgs and ppg_qfield.json |
qfield.read_edits(folder) |
the units and spot heights added, changed and deleted on the tablet |
qfield.import_edits(chunk, folder) |
those edits applied to chunk.meta["drawings"] |
io.gpkg.read_table(path, table) |
features of a GeoPackage table read with sqlite3 |
gpkg.geometry(blob) |
a geometry from the GeoPackage binary format |
archaeo.munsell.calibrate_chart(rgb) |
a ColorChecker Classic found in an image (OpenCV mcc): the 3 x 3 correction matrix in linear light (OpenCV ccm, loss as CIEDE2000) |
munsell.correct(rgb, ccm) |
the matrix applied to colours |
munsell.munsell(rgb) |
Munsell notation (illuminant C, colour-science) and nearest soil chart chip |
munsell.area_colour(raster, polygon, ccm=None) |
the median colour of a raster inside a polygon, shadows and glare left out |
archaeo.stones.find_stones(raster, segmenter, bounds=None, min_size=0.05, max_size=1.0) |
stone-by-stone drawing: SAM's automatic masks over any RGB raster by tiles, a partition, then keep_stones |
stones.export_stones(stones, path, scale=20) |
the outlines as DXF (layer PIETRE), SVG at 1:scale (millimetres on paper) or GeoJSON |
ai.sam.auto_units(..., path=raster) |
segments any raster |
chunk.build_relief(method="svf", radius=None, directions=16, min_size=None, max_size=None, scaling=2.0) |
relief visualisation of the DEM, product method in <data>/terrain/: svf, openness_pos, openness_neg, slrm, msrm, multi_hillshade |
archaeo.relief.build_relief(dem_path, out, method, ...) |
the same on any DEM file (tiles with the margin each method needs, several threads; the result is that of the whole DEM) |
relief.compute(z, resolution, method, ...) |
the same on an array |
measures.ground_dem(chunk) |
a DTM kept as a version of the DEM (built from class 2 only) |
measures.ground_dem_at(path, x, y) |
its height at a point |
archaeo.measures.surface_area(...) counts each cell sqrt(1 + gx^2 + gy^2) times, border
cells by the share inside; the mesh must be in the same coordinates and model_faces are the faces
whose centre is inside.
archaeo.us_proposal.propose_for_chunk(...) runs measure_units (area, perimeter, DEM
elevations, Munsell colour, majority class of the classified cloud), contacts (pairs that touch:
length, share of each outline, enclosure, DEM step across the contact from a strip on each side)
and propose (numbers from the highest unit, USM from the masonry class, fills of cuts with their
cut, Copre from the step, Si appoggia a against masonry; each relation with why).
chunk.provenance(...) describes the product (file, SHA-256, size, CRS, resolution, active
version) as prov:Entity, the prov:Activity that made it with its settings, the entities it
derives from back to the photos and the georeference (control/check RMSE), the program and the
operator as agents.
paradata.write_provenance(...) and summary: pa.publish_orthomosaic/dem/model write both.
io.qfield.export_package(...): ortofoto.tif is a COG JPEG at resolution, rilievo.gpkg
has the layers unita and quote with ppg_id and the PyArchInit columns, <chunk>.qgs is
written by QGIS's Python (relative paths, value maps) and ppg_qfield.json says what was exported.
munsell.munsell(rgb) retries and, if it does not converge, takes the nearest chip in CIELAB.
archaeo.stones.find_stones(...) reads the orthomosaic or the orthophoto of an elevation;
keep_stones keeps an equivalent diameter between the sizes and a solidity >= 0.75.
chunk.build_relief(...) keeps the parameters and the 2nd/98th percentiles in meta["relief"]:
svf sky-view factor (1 - mean sin of the horizon angle), openness_pos / openness_neg (mean
zenith angle of the horizon, degrees), slrm (DEM minus its mean over the radius, metres), msrm
(multi-scale relief: mean difference of low-pass levels of radius k ** scaling from min_size to
max_size), multi_hillshade (0-1); radius default about 20 cells.
Diagnostics of the alignment, calibration, markers¶
| Call | Description |
|---|---|
engine.diagnostics.match_graph(chunk, verified=True) |
{(camera key a, b): matches} of every matched pair, from the COLMAP database; also for photos that did not align |
diagnostics.components(keys, graph, min_matches=1) |
groups of photos linked by pairs with enough matches, largest first (separate groups, isolated photos) |
diagnostics.pair_matches(chunk, a, b) |
keypoints of the two photos and the candidate and verified matches (index pairs) |
diagnostics.shared_tie_points(chunk, a, b) |
the tie points seen by both photos |
diagnostics.residuals(chunk, key) |
measured and reprojected pixels of the tie points of a photo |
Sensor.converted(model) |
the parameters in another camera model (focal length and principal point kept); Chunk.set_sensor_model uses it |
Sensor.fixed |
the calibration is held: alignment (constant_cameras) and camera optimisation do not refine it, as for the faces of 360-degree photos |
chunk.merge_markers(markers, label=None) |
markers that are the same point made one: the first keeps its key |
chunk.remove_markers(markers) |
markers (objects, keys or labels) removed with the scale bars that end at them |
engine.diagnostics.match_graph(...) counts the matches that fit the geometry of the pair,
else (verified=False) all candidates.
Sensor.fixed: imports no longer set it (their calibration is where the alignment starts).
chunk.merge_markers(...): the first marker takes the measurements of all (in a photo measured
in several, the one placed by hand), the first reference, the excluded photos; scale bars that
ended at the others end at it.
Undo and redo (interface)¶
gui.undo.UndoHistory(get_state, set_state, get_chunk, limit=50, file_limit=10): record(text=None)
after a change keeps the parts of get_state() (JSON-able) that differ from the last snapshot;
backup(products) before an edit copies the files of those products of the chunk (gui.undo.clone:
clonefile on macOS, else a copy) into <chunk data>/undo/, and the next record keeps them;
undo()/redo() hand the parts back to set_state(parts, names, products) and swap the files;
rebase() after processing (not an edit); reset() for another project or chunk (copies deleted).
The window records on every change of the project, run_task(..., undo=True) for edits made in
the background.
Batch processing¶
| Call | Description |
|---|---|
batch.step(op, chunks="all", **params) |
a step: one of batch.OPERATIONS with its parameters (the defaults completed, unknown names refused); chunks: "all", "enabled" or labels/keys |
batch.run_batch(doc, steps, resume=True, retries=1, on_error="continue") |
every step on all its chunks, then the next; returns done, failed ("chunk:step" -> error), skipped, seconds |
batch.load_steps(path) |
the steps of a batch file (JSON) |
batch.save_steps(steps, path) |
writes a batch file |
batch.pending(doc, steps=None) |
the state of an unfinished batch |
batch.targets(doc, chunks) |
the chunks a step runs on |
batch.run_step(doc, chunk, op, params) |
one operation on one chunk |
batch.step(...): batch.OPERATIONS are detect_markers, import_reference, align_photos,
match_photos, align_cameras, update_transform, optimize_cameras, field_check,
build_depth_maps, build_point_cloud, build_model, build_texture, build_dem,
build_orthomosaic, calibrate_colours, estimate_precision, export_point_cloud,
export_model, export_dem, export_orthomosaic, generate_report. Paths may use {project},
{name}, {chunk}.
batch.run_batch(...) saves the project after each step of each chunk with the state in
<project>.ppg.files/batch.json; resume: an unfinished batch with the same steps goes on (done
steps are not run again, failed ones are tried again); a failing step is retried retries times,
then the next steps of its chunk are skipped (or BatchError with on_error="stop").
Field check before leaving the site¶
| Call | Description |
|---|---|
engine.fieldcheck.field_check(chunk, min_photos=3, resolution=None, quality=True) |
right after the alignment: the photos not aligned or blurred, the markers measured in too few photos, the coverage of every cell; report in chunk.meta["field_check"] |
engine.fieldcheck.coverage(chunk, resolution=None) |
the counts alone: (counts, transform, heights) |
engine.fieldcheck.field_check(...) lists the photos not aligned, the blurred ones (image
quality estimated where missing), the markers measured in no photo (with a reference), one photo or
two only, and the coverage: on a grid over the middle 98 % of the tie points (400 cells along the
longer side by default), at the height of the DEM or of the tie points of the cell (the empty cells
interpolated), how many aligned photos frame each cell (no occlusion: fast); a GeoTIFF of counts
(product coverage, -1 outside the area). The report in chunk.meta["field_check"] has
weak_fraction, weak_area m2, median_photos, unaligned, blurred, markers,
tie_points_weak.
Precision of the alignment¶
| Call | Description |
|---|---|
chunk.estimate_precision(marker_px=0.5) |
standard deviations in metres along X, Y, Z of the CRS of cameras, tie points and markers, from COLMAP's covariance of the adjustment |
engine.precision.estimate_precision(chunk, ...) |
the same as a module function |
engine.precision.dem_precision(chunk, max_side=1024) |
sigma Z of the tie points interpolated over the DEM, GeoTIFF product dem_precision |
engine.precision.precision_at(chunk, x, y) |
sigma Z of the DEM at a point |
engine.precision.tie_point_precision(chunk) |
the tie points with theirs |
| spot heights | the precision of the DEM where a spot height is taken is kept with it (precisione_q) |
bim.ifc.export_chunk_ifc(..., quote=...) |
the spot heights in the IFC as IfcAnnotation (SpotHeight) with Pset_PyArchInit_Quota (Quota, Precisione, US, ...) |
bim.ifc.spot_heights(quote) |
the spot heights of the project as points for the IFC export |
chunk.estimate_precision(...) sets the adjustment up as the camera optimisation does it
(control points held at their reference, else three tie points; calibration held) and takes
COLMAP's covariance of the camera poses (pycolmap.estimate_ba_covariance; tangent space of
cam_from_world, rotation perturbed as Exp(2d) R, translation added). Cameras: covariance of the
centre. Tie points and markers (two photos or more): the full covariance of the triangulated point,
s^2 H^-1 + H^-1 H_pc S_cc H_cp H^-1 - its measurements (s = the reprojection error of the
adjustment, marker_px for markers) and the uncertainty of the cameras that see it. Datum: the
accuracy of the control points (or of the camera positions that place the chunk),
a^2 (1 + d^2 / r^2) per axis. Standard deviations in metres along X, Y, Z of the CRS:
camera.meta["precision"], chunk.meta["precision"] (datum, sigma0_px, medians, markers),
tie points in <data>/precision/tie_points.npz. With a DEM, also the raster below. A new
alignment, optimisation or georeference drops it (chunk.forget_precision()). Synthetic trench:
tie point height errors / estimated sigma = 1.07 (RMS), cameras 0.5-0.7.
engine.precision.dem_precision(...) interpolates linearly (the nearest outside), in the CRS
of the DEM at most 1024 cells a side (as the precision maps of James et al., 2017, from the
covariance).
spot heights: the precision is written in rilievo_originale ("Fotogrammetria (±1,2 mm)",
PyArchInit has no column for it).
Reconstruction region¶
| Call | Description |
|---|---|
chunk.region = engine.region.Region(center, size, rotation) |
an oriented box in world coordinates (chunk.meta["region"]) that bounds every product |
chunk.region = None |
no region |
chunk.reset_region(source="tie_points", margin=0.05, angle_deg=0) |
around the middle 98 % of the tie points (or the dense cloud), turned about the vertical |
engine.region.region_from_box(chunk, (xmin, ymin, xmax, ymax), zmin=None, zmax=None) |
over a plan rectangle, the heights from the points inside |
Region.contains(world) |
which world points lie inside the box |
Region.corners() |
the 8 corners of the box |
Region.footprint() |
the extent of the box in plan |
engine.region.inside_internal(chunk, pts) |
which points of the internal frame lie inside the region |
engine.region.depth_mask(...) |
the pixels of a depth map whose surface lies inside the region |
engine.region.crop_mesh(...) |
a mesh without the faces whose centre lies outside the region |
chunk.region = engine.region.Region(...): depth maps (CPU and, afterwards, COLMAP's) have no
depth outside it, the dense cloud and the mesh are cut to it (a Poisson surface no longer balloons
beyond the object), so DEM and orthomosaic cover it only.
Editing the DEM¶
| Call | Description |
|---|---|
chunk.edit_dem(polygon, method="plane", value=None, breaklines=None, keep_version=True, version_name=None) |
new heights for the cells inside polygon (chunk CRS) from those in a ring of three cells around it; returns cells, dz_mean, dz_max, version |
engine.dem_edit.fill_area(...) |
the same as a module function |
engine.dem_edit.new_heights(known, target, method, value=None) |
the interpolation alone |
chunk.edit_dem(...): plane (the best-fit plane), linear (triangulation of the ring, IDW
outside it), idw, constant (value, default the median of the ring); breaklines (lines
(N, 2) or (N, 3); without z the DEM at their vertices) keep their heights. The DEM as it was is
kept as a version first; a DEM of another program is edited as a copy in the chunk folder;
overviews refreshed in the window; chunk.meta["dem_edits"] lists the edits.
Elevations: orthophoto and relief on a plane or a cylinder¶
A frame maps a raster cell (u, v) (u along the surface, left to right as the viewer sees it, v up,
metres) and a depth d (towards the viewer) to the world: engine.elevation.Plane(origin, u_axis,
v_axis) (normal = u x v) or Cylinder(centre, axis, radius, inside, start) (inside: seen from the
axis, a well or an apse; outside: a tower or a column). The surface of a cell is the first hit of a
ray from the viewer side on the model, kept within depth=(dmin, dmax) (what stands in front of
dmax - scaffolding, a section left in front of the wall - is passed through).
| Call | Description |
|---|---|
engine.elevation.plane_from_line(chunk, p1, p2, side=None) |
the vertical plane through a wall trace (plan points), seen from side (default: where the photos are) |
plane_from_points(points, toward=None) |
the plane of three or more world points (markers on a wall) |
cylinder_from_circle(chunk, points, inside=True) |
the vertical cylinder through a circle fitted to plan points |
build_elevation(chunk, frame, resolution=None, name=None, depth=None, blending="mosaic", colour_correction=None, ghosting=False, fill_holes=True, max_angle=80) |
orthophoto and relief (d, float32) of the frame as GeoTIFF in frame metres, listed in chunk.meta["elevations"] |
elevations(chunk) |
the elevations of a chunk |
elevation_paths(chunk, entry) |
the orthophoto and relief files of one |
remove_elevation(chunk, id) |
removes one |
build_elevation(...) runs the orthomosaic engine on the frame (same photo choice, blending,
colour correction) and writes the files in <chunk data>/elevations/<id>/ (frame in the tags and
in frame.json).
Masks of the photos¶
A mask is a single-channel PNG as large as its photo, white where the photo counts and black where it
is masked (Metashape's and COLMAP's convention), kept in <chunk data>/masks/<camera key>.png. Masked
areas are left out when matching (COLMAP ignores their keypoints; the photos without a mask get a
white one, since COLMAP skips a photo whose mask is missing), in the depth maps (no depth there, also
after COLMAP's PatchMatch: engine.dense.mask_depth_maps), in the orthomosaic and in the texture (a
photo does not see a masked cell or face).
| Call | Description |
|---|---|
engine.masks.import_masks(chunk, folder, template=None, invert=False) |
from image files named after the photos ({stem}_mask.png, {stem}.png, {name}.png... or template with {name}, {stem}, {label}); folder=None: the alpha channel of the photos |
export_masks(chunk, folder, template="{stem}_mask.png") |
the masks written as files named after the photos |
read_mask(chunk, cam) |
the mask of a photo as an array (keep: True where the photo counts) |
write_mask(chunk, cam, keep) |
stores an array as the mask of a photo |
remove_masks(chunk, cams=None) |
removes the masks (all by default) |
masked_cameras(chunk) |
the photos that have a mask |
masks_from_background(chunk, folder, tolerance=12) |
the object is what differs from photos of the background taken from the same positions |
masks_from_colour(chunk, colour, tolerance=40) |
a backdrop of one colour (RGB) masked |
masks_from_model(chunk, cams=None) |
only what the model covers is kept (after a first reconstruction) |
masks_with_sam(chunk, segmenter, cams=None) |
SAM 2.1: the object at the centre of each photo |
paint_polygon(chunk, cam, polygon, masked=True) |
mask (or give back) a polygon of pixels by hand |
chunk.match_photos(..., exclude_stationary=True) |
verified matches that do not move between two photos dropped: the background of a turntable photographed from a fixed camera |
engine.sfm.remove_stationary_matches(db) |
the same on a COLMAP database |
masks_from_background(...) takes one background photo per photo (same names), or a single one
for a fixed camera.
chunk.match_photos(..., exclude_stationary=True) drops the matches that move less than 2 px
or 0.15 % of the photo.
Patches of the orthomosaic and of the texture¶
A patch takes the colours of an area of the orthomosaic from photos chosen by hand: a dict in
chunk.meta["orthomosaic"]["patches"], {"id", "polygon" [[x, y], ...] (chunk CRS), "photos"
[labels], "mode" "assign"|"exclude", "surface" "dem"|"ground"} (a later patch covers an earlier one).
| Call | Description |
|---|---|
chunk.add_ortho_patch(polygon, photos, mode="assign") |
a new patch |
engine.patches.add_patch(chunk, polygon, photos, mode, surface="dem") |
the same; surface="ground" projects the area on the ground around it instead of the DEM |
chunk.update_orthomosaic(progress=None, skip_unreadable=False) |
redraws only the cells of the patches added, changed or removed since the file was written, then the overviews of those windows |
chunk.ortho_patches |
the patches of the chunk |
chunk.remove_ortho_patch(id) |
removes one |
engine.patches.clear_patches(chunk) |
removes them all |
engine.patches.changed_patches(chunk) |
the patches added, changed or removed since the orthomosaic file was written |
engine.patches.photos_for_area(chunk, polygon, limit=12) |
the photos that frame the area, best first: label, coverage, visible, angle, gsd, score, used |
engine.patches.previews(chunk, polygon, photos, max_side=320) |
({(label, surface): RGBA}, now, bounds): the area from each photo on each surface, the rest as it is |
chunk.patch_texture(photos, polygon=None, faces=None, mode="assign") |
colours again the faces of the textured model inside a polygon (seen from above) or chosen by index, from the chosen photos |
engine.patches.photos_for_faces(chunk, faces) |
the photos that see those faces, best first |
engine.patches.preview_faces(chunk, faces, photo) |
the part of a photo where the faces are seen |
engine.patches.add_patch(..., surface="ground") takes the heights along the polygon,
inverse-distance weighted inside: trees and vehicles as the photo shows them, holes of the DEM
filled.
chunk.update_orthomosaic(...) refreshes the overviews with engine.overviews.refresh (through
rasterio's GDAL; every overview again when it cannot); an orthomosaic of another program is copied
into the chunk first and its pixels come back where a patch is removed.
engine.patches.photos_for_area(...): visible is the share not hidden by the model, used
the share the orthomosaic takes from it now.
chunk.patch_texture(...): texels no chosen photo sees keep their colour.
chunk.build_orthomosaic(..., patches=None) applies the patches of the chunk (pass [] to leave
them out). The photos of every tile are ranked on all its cells, so an update gives the same
pixels as a build.
Scale bars¶
A scale bar is the known distance between two markers (core.marker.ScaleBar: label, marker_a,
marker_b (marker keys), distance in metres, accuracy, enabled, checkpoint).
| Call | Description |
|---|---|
chunk.add_scalebar(marker_a, marker_b, distance, accuracy=0.001, checkpoint=False, label=None) |
markers as objects, labels, keys or target codes; the same pair again changes the bar |
chunk.scalebar(label_or_key) |
one bar |
chunk.remove_scalebar(label_or_key) |
removes one |
chunk.scalebars |
all the bars |
chunk.import_scalebars(path, accuracy=0.001, delimiter=None) |
marker_a, marker_b, distance[, accuracy[, check]] per line; returns {"bars": [labels], "missing": ["A-B"]} |
markers.gcp.scalebar_errors(chunk) |
every bar: label, marker_a, marker_b, distance, accuracy, type (control/check), enabled, estimated, error (metres) |
markers.targets.generate_scale_bar(path, target_type="aruco_4x4_50", codes=None, distance_mm=200, marker_size_mm=40, page="A4", count=1) |
printable bars and their CSV, (pages, csv) |
chunk.update_transform() uses the control bars (enabled, not check) as follows:
- no control point and no camera reference (or camera references that cover less than ten times
their accuracy,
cameras_span_their_accuracy): the scale is the least-squares solution ofscale * |a - b| = distanceweighted by1 / accuracy²(of two bars measured equally well, the longer one counts more); the orientation stays, the origin is the internal one and the CRS becomesLOCAL(method == "scalebars"); - the same on a chunk georeferenced elsewhere (an imported project, CRS not
LOCAL) with no enabled reference: the bars scale it about their centre, keeping its position, orientation and CRS; - three or more control points, or camera references: the similarity is the joint weighted
least-squares fit of the points (
1 / accuracy²on their 3D residuals) and of the bars (1 / accuracy²onscale * length - distance),markers.gcp.similarity_with_bars: the rotation and the position come from the points, the scale from points and bars together ("markers+scalebars","cameras+scalebars","markers+cameras+scalebars");use_cameras=TrueorFalseforces the use of the camera references.
The report adds scale (world units per internal unit), scalebars (as scalebar_errors),
scalebar_control_rmse and scalebar_check_rmse (metres, None without bars of that type).
Markers are placed by chunk.triangulate_markers() (markers.gcp.triangulate_markers): from two
photos or more they are triangulated; measured in one photo only they lie where its ray meets the
surface, gcp.surface_point(chunk, camera, x, y) (the dense cloud, else the tie points, through
core.geometry.point_on_ray: a plane fitted to the points around the ray, first in a wide cylinder,
then in one of about 20 pixels at the depth found; kept per measurement); a marker with no
projection keeps its position. gcp.views_of(chunk, marker) counts the aligned photos that measure
it, gcp.camera_ray(chunk, camera, x, y) is the ray of a pixel. A marker placed in 3D (internal
frame) is projected on the photos that see it with gcp.project_marker(chunk, marker, point,
max_photos=60): inside the frame, in front of the camera, not hidden by the surface near the
marker, as estimated projections.
A projection (core.marker.Projection) is pinned True when placed or confirmed by hand, False
when detected (coded targets), None when only estimated from the model (Metashape's blue flags;
older versions read None as a detection). Projection.measured is True for the first two: a
marker with measured projections is placed from them only, estimates are used only for a marker
placed in 3D, and they never enter the camera optimisation. gcp.photos_seeing(chunk, point,
occlusion=True) lists (camera, x, y, depth) of the photos that see a point, nearest the centre
first (Filter Photos by Point); gcp.project_marker(chunk, marker, point=None, max_photos=60)
replaces the estimated projections of a marker from its position; gcp.views_of(chunk, marker,
estimated=False) counts the photos that measure it (or, with estimated, that only estimate it).
markers.refine.refine_markers(chunk, markers=None, min_score=0.8) looks for every blue flag in its
photo by image correlation: a patch of the photo where the marker is measured (placed by hand
first, else the sharpest view) and the photo of the flag are both sampled on the same grid of
points on the surface at the marker (the plane of the dense cloud, else of the tie points, around
it; one sample per pixel of the coarser photo, smoothed against aliasing), so perspective,
rotation, scale and lens distortion do not matter; a clear peak of the normalised correlation
(at least min_score, 0.1 above any other peak, inside +-10 samples) moves the flag there to a
fraction of a pixel and makes it an automatic measurement (pinned False); the marker is
triangulated again and the flags left are estimated from its new place. Returns {marker key:
{"refined": {camera key: (dx, dy, score)}, "failed": [camera keys], "reference": key}};
refine_marker(chunk, marker, ...) does one marker. On the synthetic trench, flags 2-6 px off
the target come back within 0.3 px of the detected centre.
In the camera optimisation (chunk.optimize_cameras) the control bars are fixed points as the
control points are: engine.sfm.bar_anchors(chunk, bars, fixed=None) moves the markers at their
ends so that the bars get their lengths, least squares of scale * length - distance (weighted
by 1 / accuracy²) and of the shifts, weighted by engine.sfm.marker_precision(chunk, marker)
(the pixel accuracy of its measurements, 0.5 px, carried to its distance from each camera and
combined over the photos); the adjustment then holds those positions, observed in the photos.
It needs three fixed points or more, not on a line: two bars, or bars and control points. On
the synthetic trench, markers measured to 0.5 mm, a bar 6 mm too long with an accuracy of
0.1 mm bends the block by 0.2 mm: the photos hold it, as in a joint adjustment.
Imports: integrations.metashape.import_psx brings the scale bars of the project
(MsScaleBar: two endpoint marker_id, reference d, accuracy sd or the chunk's
accuracy_scalebars, unticked = check bar); integrations.webodm.import_task brings the camera
poses (read_cameras: cameras.json, odm_report/shots.geojson) and the GCPs as markers
(read_gcps), triangulated at once.
Modules¶
| Module | Main functions |
|---|---|
markers.targets |
generate_sheet, generate_turntable_mat, turntable_layout, generate_scale_bar, scale_bar_codes, detect |
markers.gcp |
import_reference, export_reference, triangulate_markers, update_transform, similarity_with_bars, marker_errors, scalebar_errors, import_scalebars, resolve_marker, cameras_span_their_accuracy |
engine.chunks |
align_chunks, merge_chunks, common_markers, common_cameras, pose_similarity, icp, global_guesses, ransac_rigid, registration_quality |
archaeo.sections |
section, sections_along, plan_slice, Section.to_dxf/to_svg, drawing_scale, grid_step |
archaeo.pottery |
estimate_axis, profile_at, best_profile, Profile.measurements, rim (circle of the lip: diameter, arc kept, reliability), measure (profile and measurements of a vessel or sherd, axis moved to the centre of the rim), percent_class, signature, signature_distance, draw (SVG, DXF, PNG) |
archaeo.terrain |
contours (simplified, optional smoothing), suggest_interval, dem_range, export_contours, volume, hillshade, slope_degrees |
archaeo.stratigraphy |
UnitPolygon, mask_to_polygons, export_units |
bim.ifc |
export_ifc, export_chunk_ifc, export_proxies_ifc, split_mesh_by_units, global_id (IFC GlobalId from the node_uuid of a US form), read_ifc_elements |
archaeo.stratigraphy (relations) |
RELATION_EDGES (the relations of PyArchInit's forms in Italian, English and German as Extended Matrix edges), stratigraphic_edges (each declared relation once, from the start of its edge) |
ai.classify |
classify_chunk, classify_by_units, unit_class_name, units_table_csv, assign_class_by_polygons, class_statistics, set_class, clear_manual, class_names |
ai.learn |
point_features, train, predict, ClassifierModel, train_on_chunk, classify_chunk_with_model (see below) |
ai.sam |
Sam2Segmenter, segment_orthomosaic, auto_units, classify_from_masks, segment_photo_and_classify |
pyarchinit |
PyArchInit, register_media, connect |
Coordinate conventions¶
- Internal frame = frame of the SfM solution;
chunk.transform(similarity) maps it to the chunk CRS. Dense cloud and mesh are stored internally; rasters and exports are in the CRS. - Image coordinates follow COLMAP: the centre of the first pixel is (0.5, 0.5).
- Cameras: OpenCV axes (+X right, +Y down, +Z forward);
camera.transformisworld_from_camin the internal frame.
Demo data and interface¶
| Call | Description |
|---|---|
ppg.demo.create_demo(folder, process=False) |
synthetic trench photos + GCPs, project, pottery meshes, PyArchInit DB (DemoData) |
pyarchinit_photogrammetry.gui.main(argv) |
starts the desktop interface (ppg-gui) |
ppg.core.progress.Progress |
progress callback with sub() ranges; cancel() stops every sub-step |
Added in 0.3¶
chunk.crop_point_cloud(zmin=, zmax=, radius=, center=(x, y), bounds=(xmin, ymin, xmax, ymax))keeps the dense points inside the limits (world coordinates) and drops the model products.PyArchInit.connect(url=None, config_path=None, media_root=None),pa.ensure_site(sito, ...),pa.site_exists(sito),pa.label.pyarchinit.demo_db.create_demo_database(home, size="small"|"large", overwrite=False, demo_content=True)with the PyArchInit 5 schema (pyarchinit5_schema.sql).demo.create_demo(..., with_pottery=True)anddemo.process_pottery_demo(doc, profile_svg);testing.synthetic.make_turntable_scene(),render_mesh(),write_pottery_dataset().engine.isolate.run_isolated(fn, *args, timeout=, cancel_check=)runs a module-level function in a fresh interpreter (used by the Poisson reconstruction). Withprogress=callbackit relays whatfnpasses toengine.isolate.report(fraction, message)(used by the fusion of the depth maps).
Trainable point classification¶
Label some points by hand, train a classifier on them, classify the rest of the cloud and,
with the saved model, the clouds of other projects (like CloudCompare's 3DMASC/CANUPO).
Needs scikit-learn (pip install "pyarchinit-photogrammetry[learn]", included in [gui] and [all]).
from pyarchinit_photogrammetry.ai import classify, learn
classify.set_class(chunk, lasso_mask, 64, label="Muro") # class + "labelled by hand" flag
model = learn.train_on_chunk(chunk) # learns from the hand-labelled points
print(model.summary()) # out-of-bag accuracy, precision/recall per class
pc, confidence = learn.classify_chunk_with_model(chunk, model, min_confidence=0.6)
model.save("scavo.ppgclf")
other = learn.ClassifierModel.load("scavo.ppgclf") # apply it to another project
| Call | Description |
|---|---|
classify.set_class(chunk, mask, code, manual=True, label=None) |
gives the selected dense points (boolean mask or indices) a class and flags them as labelled by hand; manual=False removes the flag; returns the count |
classify.label_points(pc, mask, code, manual=True) |
the same on a PointCloud in memory: label several selections, then store the cloud once (chunk.point_cloud = pc) |
classify.clear_manual(chunk, mask=None) |
forgets the hand-labelled flag (classes unchanged) |
learn.point_features(xyz, rgb=None, normals=None, scales=None, ground=None, progress=None) |
(N, F) float32 feature matrix and feature names; world coordinates, z up |
learn.suggest_scales(xyz) |
default core voxel and radii: 5, 14 and 40 times the point spacing |
learn.estimate_spacing(xyz) |
the point spacing of a cloud |
learn.train(xyz, codes, rgb=None, ..., method="random_forest", n_estimators=None, max_per_class=20000, random_state=0) |
ClassifierModel; codes 1-255, 0 or negative = unlabelled; method="gradient_boosting" for smaller files |
learn.predict(model, xyz, rgb=None, ..., min_confidence=0.0) |
(codes uint8, confidence float32); points below min_confidence become 1 (unclassified) |
learn.train_on_chunk(chunk, labels="manual") |
trains on the hand-labelled points (labels="all": every point with a class above 1) |
learn.classify_chunk_with_model(chunk, model, overwrite_manual=False, min_confidence=0.0) |
classifies and stores the dense cloud; returns (point_cloud, confidence) |
model.save(path) |
writes the model file |
ClassifierModel.load(path) |
reads a model file |
learn.read_model_info(path) |
reads the metadata only |
model.summary() |
a readable description: out-of-bag accuracy, precision/recall per class |
model.classes |
the class codes of the model |
model.class_names |
their names |
model.feature_names |
the names of the features |
model.scales |
the neighbourhood radii (metres) |
model.oob_score |
the out-of-bag accuracy (random forest) |
model.training_accuracy |
the accuracy on the training samples |
model.confusion_matrix |
the confusion matrix (rows true class, columns predicted) |
model.stats |
the training statistics |
- Features (per radius): linearity, planarity, sphericity, omnivariance, eigenentropy,
change of curvature, verticality of the normal and of the main axis, density, height above
the lowest and below the highest neighbour, vertical spread, roughness; then height above
ground (progressive morphological filter, or
ground=a DTM GeoTIFF / ground elevations), colour (normalised RGB, HSV, excess green) and colour contrast with the neighbours. They are computed on a core cloud (one point per voxel; voxel = point spacing, coarser abovemax_core_points, default 1 M) and every point takes the values of its voxel; each radius uses its own voxel level and at most 64 neighbours. About 15 s per million core points on 4 threads (n_jobs). - Hand labels:
PointCloud.manual(boolean per point,Nonewhen nothing was labelled) is stored as the PLY propertymanual; files without it load as before. The rule-basedclassify_chunkandclassify_chunk_with_modelkeep the class of these points unlessoverwrite_manual=True(then the flag stays where the new class agrees). - Training: one sample per core voxel (the class most of its labelled points carry), at most
max_per_classper class, classes weighted inversely to their size; the random forest (100 trees, each on at most 10,000 samples) is validated out of bag, gradient boosting on 20 % held out. Deterministic for a givenrandom_state. - Model file (
.ppgclf): a ZIP withmodel.json(format version, classes and names, feature names, radii and settings, core voxel, statistics, versions of the package, scikit-learn and NumPy, training chunk and CRS) andestimator.pkl(the scikit-learn estimator). The estimator is unpickled with an allow-list of scikit-learn and NumPy types, so a file that tries to run other code is refused. The radii are in metres: apply a model to clouds with a similar point spacing and a metric CRS (a local chunk without scale has arbitrary units). - CLI:
ppg classify-train scavo.ppg modello.ppgclf [--labels manual|all] [--method random_forest|gradient_boosting] [--scales 0.05,0.15,0.4] [--trees 100]andppg classify-apply scavo.ppg modello.ppgclf [--min-confidence 0.6] [--overwrite-manual].