"""DTM generation from classified LiDAR point clouds. Handles ground classification (IGN supplier pre-classification extracted directly with laspy, PDAL as a fallback; or PDAL SMRF / CSF), vertical alignment of the flight strips, and DTM rasterisation (GPU-first via gpu.bin_mean_2d, scipy binned_statistic_2d as fallback). Gaps between points are filled only within the envelope of the measured points; larger holes without LiDAR data stay nodata. """ import json import logging import subprocess import time from pathlib import Path import numpy as np import rasterio from rasterio.transform import from_bounds from scipy.stats import binned_statistic_2d from .gpu import bin_mean_2d logger = logging.getLogger("lidar") # Usable LAS classes of the LiDAR HD pre-classification (names → codes) IGN_CLASS_NAMES = { "sol": 2, "unclassified": 1, "non-classe": 1, "eau": 9, "virtuel": 66, "pont": 17, "sursol": 64, } # Vertical alignment of flight strips (strip alignment). A LiDAR HD tile is # covered by several acquisition passes, each carried by one or two # PointSourceIds (strips). Passes are well aligned horizontally, but some # strips carry a vertical bias of a few cm (measured up to ~5 cm on # LHD_FXX_1000_6882: the two strips of one pass were ±2.5 cm apart). At # 0.2 m/px these offsets create steps and noise along the seams of overlap # areas. The robust offset of each strip is measured on the ground points of # the tile itself (PointSourceIds change between campaigns, nothing is # hard-coded) and subtracted before rasterisation. Offsets drift along a # flight line (sign measured to flip between neighbouring tiles), so the # computation is per tile, never global. # # 2nd pass: intra-strip jitter. The vertical offset can also vary along ONE # pass (sensor vibration / high-frequency trajectory noise): successive scan # lines of a strip then appear randomly shifted vertically. Each strip is cut # into GPS time windows, the robust offset of each window is measured against # the median surface of the other strips (same 1 m cell), the series is # smoothed (rolling median) and then interpolated at each point's GPS time. # Requires the gps_time dimension (silently skipped otherwise). # # 3rd pass: line-by-line offset. The 0.1 s windows (smoothed over 0.5 s) # group ~15 scan lines (~150 lines/s) and only work in overlaps. Yet two # SUCCESSIVE lines of one pass can differ by 1 to 2 cm (alternating pattern, # measured on LHD_FXX_0999_6882): fine stripes perpendicular to the flight # direction over the whole DTM. Each strip is cut into lines (sawtooth jumps # of scan_angle), the robust offset of each line (offset AND tilt along the # line: roll tilts the lines) is measured against the surface of ITS OWN # strip (0.5 m cell, 1.5 m box, local plane fitted at the point's actual # position, 3 iterations so the line does not bias its own reference), and # only the line-to-line component is removed (series − Gaussian smoothing # σ 3 lines; a rolling median would follow an alternating pattern instead of # erasing it): slow variations are left to the previous passes. Works without # overlap; requires gps_time and scan_angle. # # Joint adjustment (takes precedence): a strip's own surface absorbs any error # wider than its reference box; where several strips overlap, each line # (offset + tilt) is therefore re-aligned against the consensus of the OTHER # strips, at every scale (slow roll of a whole pass, isolated strongly # shifted lines), with damped, iterated steps. The correction against the # strip's own surface is now only used for lines without overlap. STRIP_ALIGN_VERSION = 3 STRIP_ALIGN_THRESHOLD = 0.005 # m: minimum offset to correct a strip (0.5 cm) STRIP_ALIGN_CELL = 1.0 # m: strip comparison cell STRIP_ALIGN_MIN_SHARED = 500 # minimum shared ground cells to validate an offset STRIP_JITTER_BIN = 0.1 # s: length of a GPS time window (jitter) STRIP_JITTER_SMOOTH = 5 # windows: width of the rolling median STRIP_JITTER_MIN_CELLS = 40 # minimum shared ground cells to validate a window STRIP_JITTER_MAX = 0.10 # m: maximum amplitude of a jitter correction (safeguard) STRIP_LINE_CELL = 0.5 # m: cell of a strip's reference surface STRIP_LINE_BOX = 3 # cells: reference smoothing (1.5 m box) STRIP_LINE_WINDOW = 3 # lines: σ of the removed Gaussian smoothing (keeps line-to-line) STRIP_LINE_ITERS = 3 # iterations (attenuation by the line itself < 1 %) STRIP_LINE_MAX = 0.05 # m: maximum correction of a line (safeguard) STRIP_LINE_MIN_POINTS = 100 # minimum ground points to measure a line STRIP_LINE_GAP = 0.05 # s: time gap that breaks a line (end of pass) STRIP_LINE_MIN_RMS = 0.002 # m: strip left untouched below this level STRIP_LINE_MODEL = "conjoint+decalage+inclinaison+profil-angle" # model (recorded, invalidates the cache) _SCAN_ANGLE_UNIT = 0.006 # ° per scan_angle unit (LAS 1.4) STRIP_ANGLE_BIN = 1.0 # °: step of the per-strip correction profile by angle STRIP_ANGLE_MIN_POINTS = 500 # minimum overlap points per angle bin (else neighbour value) STRIP_LINE_SUBSAMPLE = 3 # estimate on 1 point in 3 (correction applied to all) STRIP_JOINT_CELL = 1.0 # m: cell of the other strips' consensus STRIP_JOINT_ITERS = 8 # maximum iterations of the joint adjustment STRIP_JOINT_DAMPING = 0.5 # damped step: two strips converge without crossing STRIP_JOINT_TOL = 0.001 # m: stop when the mean step falls below 1 mm STRIP_JOINT_MIN_POINTS = 30 # minimum overlap points to re-align a line STRIP_JOINT_MAX = 0.15 # m: maximum correction of a point (safeguard) # Per-file memo of offsets: classification is shared between resolutions, # the same ground LAS is rasterised at 0.5 m and then 0.2 m. _STRIP_OFFSETS_CACHE = {} _STRIP_JITTER_CACHE = {} _STRIP_LINES_CACHE = {} def _strip_surface_grid(x, y, z, inv, n_sources, cell=STRIP_ALIGN_CELL): """Per-strip ground surfaces on a 1 m grid (mean of the low points). For each strip and each cell: mean of the points lying within 0.5 m of the strip's minimum in that cell (robust to residual vegetation), cells with ≥ 2 points only. Returns: (allcells, grid, key): sorted cells, grid[strip, cell] = mean Z (NaN if absent) and the cell key of EVERY point; None if no surface is populated. """ x0 = np.floor(np.min(x) / cell) * cell y0 = np.floor(np.min(y) / cell) * cell xi = ((x - x0) / cell).astype(np.int64) yi = ((y - y0) / cell).astype(np.int64) ny = int(yi.max()) + 1 key = xi * ny + yi def _surface(k): m = inv == k kk, zz = key[m], z[m] order = np.argsort(kk, kind='stable') k_s, z_s = kk[order], zz[order] starts = np.flatnonzero(np.r_[True, k_s[1:] != k_s[:-1]]) mins = np.minimum.reduceat(z_s, starts) ukey = k_s[starts] thr = mins[np.searchsorted(ukey, kk)] sel = np.flatnonzero(zz <= thr + 0.5) k2, z2 = kk[sel], zz[sel] cnt = np.bincount(k2) sums = np.bincount(k2, weights=z2) v = np.flatnonzero(cnt >= 2) return v, sums[v] / cnt[v] surfaces = [_surface(k) for k in range(n_sources)] populated = [c for c, _ in surfaces if len(c)] if not populated: return None allcells = np.unique(np.concatenate(populated)) grid = np.full((n_sources, len(allcells)), np.nan) for k, (c, zs) in enumerate(surfaces): grid[k, np.searchsorted(allcells, c)] = zs return allcells, grid, key def _strip_vertical_offsets(x, y, z, psid, cell=STRIP_ALIGN_CELL, threshold=STRIP_ALIGN_THRESHOLD, min_shared=STRIP_ALIGN_MIN_SHARED): """Measure the relative vertical offsets between the strips of a tile. Method: ground surface per strip (mean of the points within 0.5 m of the minimum of each 1 m cell, robust to residual vegetation), then the offset of each strip = median of its deviation from the median reference surface (iterated 3 times), on cells covered by at least two strips only. Corrects nothing but the vertical: the horizontal alignment measured on the data is excellent (≤ 1 cm). Args: x, y, z, psid: coordinates and PointSourceId of the ground points. cell: comparison cell size (m). threshold: threshold (m) below which an offset is ignored. min_shared: minimum shared cells to validate a strip. Returns: dict {psid: offset} of the offsets to SUBTRACT (z - offset), holding only |offset| >= threshold; empty if there is nothing to correct (single strip, no overlap, tile already aligned). """ us, inv = np.unique(psid, return_inverse=True) if len(us) < 2: return {} built = _strip_surface_grid(x, y, z, inv, len(us), cell) if built is None: return {} allcells, grid, _key = built comparable = np.sum(~np.isnan(grid), axis=0) >= 2 offsets = np.zeros(len(us)) for _ in range(3): ref = np.nanmedian(grid + offsets[:, None], axis=0) for k in range(len(us)): m = ~np.isnan(grid[k]) & comparable if int(m.sum()) >= min_shared: offsets[k] = np.median(grid[k][m] - ref[m]) return {int(p): round(float(offsets[k]), 3) for k, p in enumerate(us) if abs(offsets[k]) >= threshold} def _rolling_median(values, window): """Rolling median (window truncated at the edges) over a small vector.""" n = len(values) if window <= 1 or n == 0: return np.array(values, dtype=np.float64) half = window // 2 return np.array([np.median(values[max(0, i - half):i + half + 1]) for i in range(n)]) def _strip_jitter_offsets(x, y, z, psid, t, cell=STRIP_ALIGN_CELL, bin_seconds=STRIP_JITTER_BIN, smooth=STRIP_JITTER_SMOOTH, min_cells=STRIP_JITTER_MIN_CELLS, max_corr=STRIP_JITTER_MAX, threshold=STRIP_ALIGN_THRESHOLD): """Measure intra-strip vertical jitter over GPS time windows. The constant alignment removes one offset per strip, but the vertical offset can also vary along a single pass (sensor vibration, high-frequency trajectory noise). Each strip is cut into GPS time windows; the robust offset of each window is measured against the median surface of the OTHER strips (1 m cell, same low-point selection as the constant alignment; with a two-strip overlap, a reference including the tested strip would reveal only half of the offset), then the series is smoothed (rolling median) so as not to follow measurement noise. z must already be corrected for the constant offsets. Args: x, y, z, psid: coordinates, Z (constant-aligned) and PointSourceId of the ground points. t: GPS time (s) of each point. cell: comparison cell size (m). bin_seconds: length of a time window (s). smooth: width of the rolling median (windows). min_cells: minimum shared cells to validate a window. max_corr: maximum amplitude of a correction (safeguard, m). threshold: series amplitude below which a strip is considered stable (no correction). Returns: dict {psid: (times, corrections)} of the corrections to SUBTRACT, linearly interpolable at each point's GPS time; empty if nothing is measurable (single strip, no time, no overlap). """ us, inv = np.unique(psid, return_inverse=True) if len(us) < 2: return {} t = np.asarray(t, dtype=np.float64) if t.size != z.size or not np.isfinite(t).all(): return {} built = _strip_surface_grid(x, y, z, inv, len(us), cell) if built is None: return {} allcells, grid, key = built covered = np.sum(~np.isnan(grid), axis=0) >= 2 # Reference of a strip = median of the other strips. ref = np.full(grid.shape, np.nan) if len(us) == 2: ref[0], ref[1] = grid[1], grid[0] else: import warnings with warnings.catch_warnings(): warnings.simplefilter("ignore", RuntimeWarning) # all-NaN slices for k in range(len(us)): ref[k] = np.nanmedian(np.delete(grid, k, axis=0), axis=0) # Vertical residual of each point against its strip's reference. idx = np.minimum(np.searchsorted(allcells, key), len(allcells) - 1) ref_point = ref[inv, idx] hit = (allcells[idx] == key) & covered[idx] & np.isfinite(ref_point) residual = np.full(np.asarray(z).shape, np.nan) residual[hit] = np.asarray(z)[hit] - ref_point[hit] # Per-strip time origin: passes over one tile can be hours apart, so the # windows stay dense around the actual flight (and a GPS week rollover # does not create huge empty ranges). origin = np.full(len(us), np.inf) np.minimum.at(origin, inv, t) tb = np.floor((t - origin[inv]) / bin_seconds).astype(np.int64) n_bins = int(tb.max()) + 1 group = inv * n_bins + tb # Robust median per (strip, window) group: finite residuals are sorted to # the head of each segment, NaNs (no reference) are ignored. order = np.lexsort((np.where(np.isfinite(residual), residual, np.inf), group)) g_s, r_s = group[order], residual[order] starts = np.flatnonzero(np.r_[True, g_s[1:] != g_s[:-1]]) ends = np.r_[starts[1:], len(g_s)] gids, times_c, med = [], [], [] for s, e in zip(starts, ends): finite = np.isfinite(r_s[s:e]) if int(finite.sum()) < min_cells: continue gids.append(int(g_s[s])) med.append(float(np.median(r_s[s:e][finite]))) if not gids: return {} gids = np.asarray(gids, dtype=np.int64) med = np.asarray(med, dtype=np.float64) result = {} for k in range(len(us)): selk = gids // n_bins == k if int(selk.sum()) < 3: # series too short: unreliable correction continue b = gids[selk] % n_bins cs = np.clip(_rolling_median(med[selk], smooth), -max_corr, max_corr) if float(np.max(np.abs(cs))) < threshold: continue result[int(us[k])] = (origin[k] + (b + 0.5) * bin_seconds, cs) return result def _module_of(arr): """Array module (numpy or cupy) of an array.""" mod = type(arr).__module__ if mod.startswith("cupy"): import cupy return cupy return np def _array_module(use_gpu): """cupy if the GPU is active and requested, numpy otherwise.""" if use_gpu: from . import gpu as _gpu if _gpu.is_gpu_active() and _gpu._cp is not None: return _gpu._cp return np def _scan_line_ids(t, angle, gap=STRIP_LINE_GAP): """Scan-line id of the points of ONE strip, sorted by time. A new line starts at every scan_angle jump larger than half the amplitude in the direction opposite to the sweep (sawtooth flyback) or at every time gap > gap (end of pass). Holes left by removed vegetation inside a line do not break it. """ m = _module_of(angle) angle = m.asarray(angle, dtype=m.float64) if len(angle) == 0: return m.zeros(0, dtype=m.int64) amp = float(m.percentile(angle, 99) - m.percentile(angle, 1)) d = m.diff(angle) moving = d[d != 0] sweep = float(m.sign(m.median(moving))) if len(moving) else 1.0 # Sawtooth flyback: a large jump OPPOSITE to the sweep direction. A # vegetation hole also makes the angle jump, but in the sweep direction: # it does not break the line. new = m.concatenate([m.ones(1, dtype=bool), (d * sweep < -0.5 * amp) | (m.diff(t) > gap)]) return m.cumsum(new) - 1 def _group_median(values, groups, n_groups, min_count): """Median of the finite values per group (vectorised), NaN below min_count.""" finite = np.isfinite(values) order = np.lexsort((np.where(finite, values, np.inf), groups)) g = groups[order] v = values[order] nf = np.bincount(groups[finite], minlength=n_groups) start = np.searchsorted(g, np.arange(n_groups)) med = np.full(n_groups, np.nan) ok = nf >= max(1, min_count) lo = start[ok] + (nf[ok] - 1) // 2 hi = start[ok] + nf[ok] // 2 med[ok] = 0.5 * (v[lo] + v[hi]) return med def _line_fit(r, w, line, u, n_lines, min_points): """Weighted least squares per line: r ≈ a + b·u (sums via bincount). Returns: (a, b, ok); b is 0 where the spread of u does not allow a tilt to be estimated (a alone, weighted mean). """ m = _module_of(r) rw = m.where(w, r, 0.0) wf = w.astype(m.float64) s0 = m.bincount(line, weights=wf, minlength=n_lines) s1 = m.bincount(line, weights=wf * u, minlength=n_lines) s2 = m.bincount(line, weights=wf * u * u, minlength=n_lines) sr = m.bincount(line, weights=rw, minlength=n_lines) sur = m.bincount(line, weights=rw * u, minlength=n_lines) det = s0 * s2 - s1 * s1 ok = s0 >= min_points tilt = ok & (det > 1e-2 * m.maximum(s0, 1) ** 2) a = m.where(ok, sr / m.maximum(s0, 1), 0.0) b = m.zeros(n_lines) safe = m.where(tilt, det, 1.0) a = m.where(tilt, (s2 * sr - s1 * sur) / safe, a) b = m.where(tilt, (s0 * sur - s1 * sr) / safe, b) return a, b, ok def _robust_mask(r, floor=0.03, k=5.0): """Finite residuals below k MAD (with a floor): rejects low vegetation, misclassified points and hole edges without sorting each line.""" m = _module_of(r) f = m.isfinite(r) if not bool(f.any()): return f mad = 1.4826 * float(m.median(m.abs(r[f]))) return f & (m.abs(r) < max(floor, k * mad)) def _scan_line_corrections_beam(x, y, z, t, angle, cell=STRIP_LINE_CELL, box=STRIP_LINE_BOX, window=STRIP_LINE_WINDOW, iters=STRIP_LINE_ITERS, max_corr=STRIP_LINE_MAX, min_points=STRIP_LINE_MIN_POINTS, subsample=STRIP_LINE_SUBSAMPLE): """Line-by-line corrections of a strip against ITS OWN surface (to SUBTRACT). Each line is modelled by an offset AND a tilt along the line (a + b·u, u = normalised scan_angle): a roll error tilts the line, one end higher than the other. Only the line-to-line component is removed (series − Gaussian of σ window lines). Estimated on 1 point in subsample, with vectorised trimmed least squares (5 MAD). Returns: (corr per point in input order, offsets per line, tilts per line at the swath edge). """ from scipy.ndimage import gaussian_filter1d, uniform_filter n = len(z) o = np.argsort(t, kind="stable") line_all = np.empty(n, dtype=np.int64) line_all[o] = _scan_line_ids(t[o], angle[o]) nl = int(line_all.max()) + 1 if n else 0 tot_a, tot_b = np.zeros(nl), np.zeros(nl) if nl < 10 * window: return np.zeros(n), tot_a, tot_b u_all = np.asarray(angle, dtype=np.float64) / max(np.percentile(np.abs(angle), 99), 1e-9) sel = np.arange(n) % max(1, int(subsample)) == 0 xs, ys, zs = x[sel], y[sel], np.asarray(z, dtype=np.float64)[sel] line, u = line_all[sel], u_all[sel] # Reference = local plane: (x, y, z) means per box of cells, slope of the # smoothed surface; evaluated at the point's actual position (a cell's # mean is not at its centre: on a slope, interpolating at the centre # creates a bias that depends on where the line lies). x0, y0 = xs.min(), ys.min() ix = np.floor((xs - x0) / cell).astype(np.int64) iy = np.floor((ys - y0) / cell).astype(np.int64) W, H = int(ix.max()) + 1, int(iy.max()) + 1 flat = iy * W + ix count_b = uniform_filter(np.bincount(flat, minlength=W * H).reshape(H, W).astype(np.float64), box) valid = count_b > 0 inv_c = np.where(valid, 1.0 / np.maximum(count_b, 1e-12), np.nan) mean_x = uniform_filter(np.bincount(flat, weights=xs - x0, minlength=W * H).reshape(H, W), box) * inv_c mean_y = uniform_filter(np.bincount(flat, weights=ys - y0, minlength=W * H).reshape(H, W), box) * inv_c dxp = (xs - x0) - mean_x.ravel()[flat] dyp = (ys - y0) - mean_y.ravel()[flat] min_pts = max(10, min_points // max(1, int(subsample))) z_work = zs.copy() for _ in range(iters): mean_z = uniform_filter(np.bincount(flat, weights=z_work, minlength=W * H).reshape(H, W), box) * inv_c gz_y, gz_x = np.gradient(np.where(valid, mean_z, np.nanmean(mean_z)), cell) r = z_work - (mean_z.ravel()[flat] + gz_x.ravel()[flat] * dxp + gz_y.ravel()[flat] * dyp) a, b, ok = _line_fit(r, _robust_mask(r), line, u, nl, min_pts) if ok.sum() < 10 * window: break idx = np.flatnonzero(ok) a = np.interp(np.arange(nl), idx, a[ok]) b = np.interp(np.arange(nl), idx, b[ok]) # Only the line-to-line component is removed (a rolling median would # follow an alternating pattern instead of erasing it) da = a - gaussian_filter1d(a, window, mode="nearest") db = b - gaussian_filter1d(b, window, mode="nearest") da[~ok] = 0.0 db[~ok] = 0.0 tot_a += da tot_b += db z_work = zs - np.clip(tot_a[line] + tot_b[line] * u, -max_corr, max_corr) corr = np.clip(tot_a[line_all] + tot_b[line_all] * u_all, -max_corr, max_corr) return corr, tot_a, tot_b def _joint_line_corrections(x, y, z, psid, t, angle, cell=STRIP_JOINT_CELL, iters=STRIP_JOINT_ITERS, damping=STRIP_JOINT_DAMPING, tol=STRIP_JOINT_TOL, min_points=STRIP_JOINT_MIN_POINTS, subsample=STRIP_LINE_SUBSAMPLE, max_corr=STRIP_JOINT_MAX, use_gpu=True): """Joint adjustment of the lines of all strips against the consensus of the OTHER strips (offset + tilt per line), plus a per-strip profile by scan-angle bin. At each iteration, a point's residual is measured against the mean of the other strips in its cell (brought to its position by the surface slope), each line is fitted by trimmed least squares, and a damping fraction of the step is applied to all lines at once; the mean elevation is re-centred (no overall drift). Runs on GPU (CuPy) when available, falling back to numpy on any error. Returns: (corr to SUBTRACT per point, global line id per point, re-aligned lines (bool per line), number of iterations), as numpy arrays. """ m = _array_module(use_gpu) if m is not np: try: return _joint_line_corrections_impl(m, x, y, z, psid, t, angle, cell, iters, damping, tol, min_points, subsample, max_corr) except Exception as e: logger.warning(f" GPU joint adjustment failed ({e}), falling back to CPU") return _joint_line_corrections_impl(np, x, y, z, psid, t, angle, cell, iters, damping, tol, min_points, subsample, max_corr) def _joint_line_corrections_impl(m, x, y, z, psid, t, angle, cell, iters, damping, tol, min_points, subsample, max_corr): def host(a): return a.get() if m is not np else a n = len(z) psid_h = np.asarray(psid) beams, bidx_h = np.unique(psid_h, return_inverse=True) # Angle bin per strip: calibration profile shared by all lines of a strip # (non-linear deviation across the swath). abin_h = np.round(np.asarray(angle, dtype=np.float64) * _SCAN_ANGLE_UNIT / STRIP_ANGLE_BIN).astype(np.int64) amin = int(abin_h.min()) if n else 0 n_ab = int(abin_h.max()) - amin + 1 if n else 1 abin_h = bidx_h * n_ab + (abin_h - amin) x, y = m.asarray(x, dtype=m.float64), m.asarray(y, dtype=m.float64) z, t = m.asarray(z, dtype=m.float64), m.asarray(t, dtype=m.float64) angle = m.asarray(angle, dtype=m.float64) bidx = m.asarray(bidx_h) gl = m.zeros(n, dtype=m.int64) u = m.zeros(n) base = 0 for i in range(len(beams)): idx = m.flatnonzero(bidx == i) o = m.argsort(t[idx]) gl[idx[o]] = _scan_line_ids(t[idx][o], angle[idx][o]) + base base = int(gl[idx].max()) + 1 u[idx] = angle[idx] / max(float(m.percentile(m.abs(angle[idx]), 99)), 1e-9) n_lines = base touched = m.zeros(n_lines, dtype=bool) if len(beams) < 2 or n == 0: return np.zeros(n), host(gl), host(touched), 0 abin = m.asarray(abin_h) n_bins = len(beams) * n_ab sel = m.arange(0, n, max(1, int(subsample))) xs, ys, zs = x[sel], y[sel], z[sel] gs, us, bs, cs = gl[sel], u[sel], bidx[sel], abin[sel] x0, y0 = float(xs.min()), float(ys.min()) ix = m.floor((xs - x0) / cell).astype(m.int64) iy = m.floor((ys - y0) / cell).astype(m.int64) W, H = int(ix.max()) + 1, int(iy.max()) + 1 nk = W * H key = iy * W + ix bkey = bs * nk + key dxc = (xs - x0) - (ix + 0.5) * cell dyc = (ys - y0) - (iy + 0.5) * cell n_all = m.bincount(key, minlength=nk) n_own = m.bincount(bkey, minlength=len(beams) * nk) n_other = n_all[key] - n_own[bkey] overlap = n_other >= 2 min_pts = max(10, min_points // max(1, int(subsample))) A = m.zeros(n_lines) B = m.zeros(n_lines) C = m.zeros(n_bins) min_bin = max(30, STRIP_ANGLE_MIN_POINTS // max(1, int(subsample))) it = 0 for it in range(1, iters + 1): zc = zs - m.clip(A[gs] + B[gs] * us + C[cs], -max_corr, max_corr) s_all = m.bincount(key, weights=zc, minlength=nk) s_own = m.bincount(bkey, weights=zc, minlength=len(beams) * nk) mean = m.where(n_all > 0, s_all / m.maximum(n_all, 1), m.nan).reshape(H, W) gy, gx = m.gradient(m.where(m.isfinite(mean), mean, m.nanmean(mean)), cell) ref = ((s_all[key] - s_own[bkey]) / m.maximum(n_other, 1) + gx.ravel()[key] * dxc + gy.ravel()[key] * dyc) r = m.where(overlap, zc - ref, m.nan) w = m.zeros(len(r), dtype=bool) for i in range(len(beams)): mb = bs == i w[mb] = _robust_mask(r[mb]) a, b, ok = _line_fit(r, w, gs, us, n_lines, min_pts) touched |= ok # Profile per strip and angle bin: trimmed mean of the residual left # after the line step; its mean (carried by the lines) is removed # strip by strip. Its slope is kept: when the swath only partly # crosses the tile, the line tilt is undetermined and only the # profile can carry it. r2 = m.where(w, r - (a[gs] + b[gs] * us), 0.0) wf = w.astype(m.float64) cnt = m.bincount(cs, weights=wf, minlength=n_bins) prof = m.where(cnt >= min_bin, m.bincount(cs, weights=r2, minlength=n_bins) / m.maximum(cnt, 1), 0.0) su = m.bincount(cs, weights=wf * us, minlength=n_bins) ub = m.where(cnt > 0, su / m.maximum(cnt, 1), 0.0) for i in range(len(beams)): sl = slice(i * n_ab, (i + 1) * n_ab) k = cnt[sl] >= min_bin if int(k.sum()) >= 3: wk = cnt[sl][k] uk, pk = ub[sl][k], prof[sl][k] um, pm = float((wk * uk).sum() / wk.sum()), float((wk * pk).sum() / wk.sum()) fitted = prof[sl] - pm # Bin too sparse (swath edge, incomplete bin): value of the # nearest valid bin, not zero; the deviation is precisely # strongest at the edge. idx = m.arange(n_ab, dtype=m.float64) prof[sl] = m.interp(idx, idx[k], fitted[k]) else: prof[sl] = 0.0 A += damping * a B += damping * b C += damping * prof A -= float(m.mean(A[gs] + B[gs] * us + C[cs])) # no overall drift step_lines = float(m.mean(m.abs(a[ok]))) * damping if bool(ok.any()) else 0.0 step_prof = float(m.max(m.abs(prof))) * damping if max(step_lines, step_prof) < tol: break corr = m.clip(A[gl] + B[gl] * u + C[abin], -max_corr, max_corr) return host(corr), host(gl), host(touched), it def _rolling_median_fast(values, window): """Centred rolling median (edges repeated), vectorised.""" from numpy.lib.stride_tricks import sliding_window_view half = window // 2 return np.median(sliding_window_view(np.pad(values, half, mode="edge"), window), axis=1) def _scan_line_corrections(x, y, z, psid, t, angle, min_rms=STRIP_LINE_MIN_RMS): """Line-by-line corrections of all strips of a tile. 1. Joint adjustment against the other strips (lines in overlaps). 2. Lines without overlap: line-to-line component against the surface of their own strip (only when more than 5 % of the strip's points are in such lines). z must already be aligned (constant offsets and, if computed, time jitter). Returns: (corrections to SUBTRACT per point, {psid: (lines, rms, max)} of the corrected strips). """ psid = np.asarray(psid) z = np.asarray(z, dtype=np.float64) corr, gl, touched, _ = _joint_line_corrections(x, y, z, psid, t, angle) stats = {} for p in np.unique(psid): m = psid == p if int(m.sum()) < 50 * STRIP_LINE_MIN_POINTS: continue alone = ~touched[gl[m]] if alone.mean() > 0.05: c_self, per_line, _ = _scan_line_corrections_beam( x[m], y[m], z[m] - corr[m], t[m], angle[m]) c_beam = corr[m] + np.where(alone, c_self, 0.0) else: c_beam = corr[m] rms = float(np.sqrt(np.mean(c_beam ** 2))) if m.any() else 0.0 if rms < min_rms: corr[m] = 0.0 continue corr[m] = c_beam n_lines = int(len(np.unique(gl[m]))) stats[int(p)] = (n_lines, rms, float(np.max(np.abs(c_beam)))) return corr, stats def _scan_angle(las): """scan_angle (LAS 1.4) or scan_angle_rank (LAS ≤ 1.3), None if absent.""" for name in ("scan_angle", "scan_angle_rank"): try: return np.asarray(getattr(las, name), dtype=np.float64) except AttributeError: continue return None def _scan_lines_for_file(las_file, las, z_aligned, t): """Line-by-line corrections of a ground LAS, memoised by (path, mtime).""" try: p = Path(las_file) cache_key = (str(p), p.stat().st_mtime_ns) except OSError: cache_key = (str(las_file), 0) if cache_key in _STRIP_LINES_CACHE: return _STRIP_LINES_CACHE[cache_key] angle = _scan_angle(las) result = (np.zeros(len(z_aligned)), {}) if angle is not None and len(angle) == len(z_aligned): result = _scan_line_corrections( np.asarray(las.x, dtype=np.float64), np.asarray(las.y, dtype=np.float64), z_aligned, np.asarray(las.point_source_id), t, angle) _STRIP_LINES_CACHE[cache_key] = result return result def _apply_strip_jitter(psid, t, jitter): """Jitter corrections interpolated at each point's GPS time. Args: psid, t: PointSourceId and GPS time (s) of each point. jitter: dict {psid: (times, corrections)} from _strip_jitter_offsets. Returns: array of the corrections to SUBTRACT (0 for points without a series). """ corr = np.zeros(len(t)) if not jitter: return corr psid = np.asarray(psid) t = np.asarray(t, dtype=np.float64) for p, (times, cs) in jitter.items(): m = psid == p if m.any(): corr[m] = np.interp(t[m], times, cs) return corr def _strip_jitter_for_file(las_file, las, offsets): """Time jitter of a ground LAS (after the measured constant offsets), memoised.""" try: p = Path(las_file) cache_key = (str(p), p.stat().st_mtime_ns) except OSError: cache_key = (str(las_file), 0) if cache_key in _STRIP_JITTER_CACHE: return _STRIP_JITTER_CACHE[cache_key] jitter = {} try: t = np.asarray(las.gps_time, dtype=np.float64) psid = np.asarray(las.point_source_id) except AttributeError: t, psid = None, None # missing dimensions: no measurable jitter if (t is not None and psid is not None and len(t) == len(las.points) == len(psid)): z = np.asarray(las.z, dtype=np.float64) if offsets: lut = np.zeros(65536) for p_, off in offsets.items(): lut[int(p_) & 0xFFFF] = off z = z - lut[np.asarray(las.point_source_id, dtype=np.int64)] jitter = _strip_jitter_offsets( np.asarray(las.x, dtype=np.float64), np.asarray(las.y, dtype=np.float64), z, psid, t) _STRIP_JITTER_CACHE[cache_key] = jitter return jitter def _strip_offsets_for_file(las_file, las): """Alignment offsets of a ground LAS, memoised by (path, mtime).""" try: p = Path(las_file) cache_key = (str(p), p.stat().st_mtime_ns) except OSError: cache_key = (str(las_file), 0) if cache_key in _STRIP_OFFSETS_CACHE: return _STRIP_OFFSETS_CACHE[cache_key] try: psid = np.asarray(las.point_source_id) except AttributeError: psid = None # missing dimension (third-party producer): no alignment if psid is not None and len(psid) == len(las.points): offsets = _strip_vertical_offsets( np.asarray(las.x, dtype=np.float64), np.asarray(las.y, dtype=np.float64), np.asarray(las.z, dtype=np.float64), psid) else: offsets = {} _STRIP_OFFSETS_CACHE[cache_key] = offsets return offsets def _write_strip_align_sidecar(dtm_dir, basename, output_suffix, offsets, jitter=None, lines=None): """Record the applied alignments (version, threshold, offsets, jitter, lines). The sidecar doubles as a cache tracker: a DTM without a sidecar, or one produced with a different version/threshold/jitter or line parameters, is regenerated. It is written even when no correction was applied, so a tile already known to be well aligned is not measured again. """ jitter_payload = {} for p, (times, cs) in (jitter or {}).items(): cs = np.asarray(cs, dtype=np.float64) jitter_payload[str(p)] = { "bins": int(len(times)), "rms_m": round(float(np.sqrt(np.mean(cs ** 2))), 4), "max_m": round(float(np.max(np.abs(cs))), 4), "series_m": [round(float(c), 4) for c in cs], } payload = { "version": STRIP_ALIGN_VERSION, "threshold": STRIP_ALIGN_THRESHOLD, "offsets": offsets, "jitter_bin": STRIP_JITTER_BIN, "jitter_smooth": STRIP_JITTER_SMOOTH, "jitter": jitter_payload, "line_window": STRIP_LINE_WINDOW, "line_cell": STRIP_LINE_CELL, "line_model": STRIP_LINE_MODEL, "lines": {str(p): {"lines": n, "rms_m": round(r, 4), "max_m": round(m, 4)} for p, (n, r, m) in (lines or {}).items()}, } try: sidecar = Path(dtm_dir) / f"{basename}_dtm{output_suffix}_stripalign.json" sidecar.write_text(json.dumps(payload, ensure_ascii=False), encoding="utf-8") except Exception as e: logger.warning(f" Could not write the strip alignment sidecar: {e}") def _strip_lidar_ext(path): """Extract base name from a LAZ/LAS file (mirrors pipeline._file_basename).""" name = Path(path).name for ext in ('.copc.laz', '.copc.las', '.laz', '.las'): if name.lower().endswith(ext): return name[:-len(ext)] return Path(path).stem def parse_ign_classes(spec): """Convert a list of IGN classes (names or codes) into sorted LAS codes. Args: spec: Comma-separated string, e.g. "sol,unclassified" or "2,1". Returns: Sorted list of unique LAS codes. Raises: ValueError: If an item is neither a known name nor a LAS code 0-255, or if the list is empty. """ codes = set() for token in str(spec).split(","): token = token.strip().lower() if not token: continue if token in IGN_CLASS_NAMES: codes.add(IGN_CLASS_NAMES[token]) else: try: code = int(token) except ValueError: raise ValueError( f"Unknown IGN class: '{token}' " f"(names: {', '.join(sorted(IGN_CLASS_NAMES))} or LAS code 0-255)") if not 0 <= code <= 255: raise ValueError(f"LAS code out of range (0-255): {code}") codes.add(code) if not codes: raise ValueError("No IGN class given") return sorted(codes) def ign_method_label(codes): """Method label encoding the IGN classes (e.g. 'ign_1_2'). 'ign' alone = ground only (code 2), backward compatible with existing classification files. Any other combination is encoded in the name to invalidate the cache and trigger reclassification. """ codes = sorted(codes) if codes == [2]: return "ign" return "ign_" + "_".join(str(c) for c in codes) def _create_ground_pipeline(input_laz, output_las, method, ign_codes=None): """Create a PDAL pipeline JSON for ground classification. All methods include a ReturnNumber/NumberOfReturns >= 1 filter to handle LiDAR HD files that may contain points with invalid return numbers. Pre-processing steps (PDAL recommended workflow): 1. Reset Classification to 0 2. ELM (Extended Local Minimum) — mark low outliers as noise (Classification=7) 3. Statistical outlier removal 4. Ground classification (SMRF or CSF) 5. Extract ground points (Classification=2) Args: input_laz: Path to input LAZ/LAS file. output_las: Path to output classified LAS file. method: Ground classification method ('ign', 'smrf' or 'csf'). ign_codes: LAS class codes to extract with the 'ign' method (default: [2] = sol). Multiple ranges on Classification are logically ORed by filters.range (documented PDAL semantics). Returns: JSON string of the PDAL pipeline. """ # Common ReturnNumber filter for LiDAR HD compatibility return_filter = { "type": "filters.range", "limits": "ReturnNumber[1:],NumberOfReturns[1:]" } # Classification filter (ground points only) ground_filter = { "type": "filters.range", "limits": "Classification[2:2]" } # IGN LiDAR HD: the file is pre-classified by the supplier. The # classification is reused as is (pure mode): the extracted classes are # configurable, ground only (2) by default, but e.g. unclassified (1) can # be added to fill holes without reprocessing. Multiple ranges on # Classification are ORed by filters.range (documented PDAL semantics). if method == 'ign': codes = sorted(ign_codes) if ign_codes else [2] ign_filter = { "type": "filters.range", "limits": ",".join(f"Classification[{c}:{c}]" for c in codes) } pipeline = { "pipeline": [ str(input_laz), return_filter, ign_filter, { "type": "writers.las", "filename": str(output_las), "extra_dims": "all" } ] } return json.dumps(pipeline) # Reset Classification to 0 before preprocessing reset_classification = { "type": "filters.assign", "assignment": "Classification[:]=0" } # ELM (Extended Local Minimum) — mark low outliers as noise # Parameters tuned for rocky limestone terrain with low vegetation: # - cell=5.0m: fine resolution to capture rocky relief # - threshold=2.0m: high threshold to avoid marking rock outcrops as noise elm_filter = { "type": "filters.elm", "cell": 5.0, "threshold": 2.0 } # Statistical outlier removal outlier_filter = { "type": "filters.outlier", "method": "statistical", "mean_k": 8, "multiplier": 3.0 } # Method-specific ground classification filter if method == 'smrf': ground_step = { "type": "filters.smrf", "ignore": "Classification[7:7]", "slope": 1.0, "window": 16.0, "threshold": 0.5, "scalar": 1.25 } elif method == 'csf': # resolution 1.0 m: a 0.5 m cloth (= 4 M particles per km²) makes # classification ~4× slower with no visible gain on the DTM (the # final DTM resolution comes from rasterisation, not from the cloth). ground_step = { "type": "filters.csf", "resolution": 1.0, "rigidness": 3, "smooth": True, "threshold": 0.5 } else: raise ValueError(f"Unknown classification method: {method}") pipeline = { "pipeline": [ str(input_laz), return_filter, reset_classification, elm_filter, outlier_filter, ground_step, ground_filter, { "type": "writers.las", "filename": str(output_las), "extra_dims": "all" } ] } return json.dumps(pipeline) def create_smrf_pipeline(input_laz, output_las): """Create a PDAL pipeline JSON for SMRF ground classification.""" return _create_ground_pipeline(input_laz, output_las, 'smrf') def create_ign_pipeline(input_laz, output_las): """Create a PDAL pipeline JSON using the IGN supplier pre-classification.""" return _create_ground_pipeline(input_laz, output_las, 'ign') def create_csf_pipeline(input_laz, output_las): """Create a PDAL pipeline JSON for CSF ground classification.""" return _create_ground_pipeline(input_laz, output_las, 'csf') def validate_laz(laz_file): """Integrity check for a LAZ/LAS file. Verifies that both the header AND point data are readable. Some corrupted COPC files have valid headers but inaccessible point data (LazrsError: failed to fill whole buffer). Such files must be re-downloaded. Returns: True if file is readable and contains accessible points, False otherwise. """ import laspy try: with laspy.open(str(laz_file)) as f: header = f.header point_count = header.point_count if point_count == 0: logger.error(f" ✗ Empty file (0 points): {laz_file.name}") logger.error(f" → Download it again from https://ign.fr/lidar-hd") return False # Verify point data is actually accessible (not just header metadata) try: for _ in f.chunk_iterator(1000): break # Read just one chunk to confirm data integrity except Exception as chunk_err: logger.error(f" ✗ Point data unreadable (corrupted file?): {laz_file.name}") logger.error(f" Error: {chunk_err}") logger.error(f" → Download it again from https://ign.fr/lidar-hd") return False return True except Exception: pass # Fallback: try PDAL (handles COPC v1.1 that laspy can't read) try: result = subprocess.run( ["pdal", "info", str(laz_file), "--summary"], capture_output=True, text=True, timeout=30 ) if result.returncode == 0: # Check point count from PDAL info output import json as _json try: info = _json.loads(result.stdout) count = info.get('summary', {}).get('num_points', 0) if count == 0: logger.error(f" ✗ Empty file (0 points per PDAL): {laz_file.name}") logger.error(f" → Download it again from https://ign.fr/lidar-hd") return False except Exception: pass # Can't parse — assume valid # Verify PDAL can actually read point data (not just header) try: test_result = subprocess.run( ["pdal", "info", str(laz_file), "--point", "1"], capture_output=True, text=True, timeout=60 ) if test_result.returncode != 0: logger.error(f" ✗ Point data unreadable (PDAL): {laz_file.name}") logger.error(f" → Download it again from https://ign.fr/lidar-hd") return False except (subprocess.TimeoutExpired, FileNotFoundError): pass # Timeout — assume valid, will fail later if corrupted return True logger.error(f" ✗ Unreadable file: {laz_file.name}") logger.error(f" PDAL: {result.stderr.strip()[:200]}") except (subprocess.TimeoutExpired, FileNotFoundError): logger.error(f" ✗ Could not check the file: {laz_file.name}") logger.error(f" → Download it again from https://ign.fr/lidar-hd") return False def _read_with_pdal(laz_file): """Read a LAZ/LAS file via PDAL when laspy fails (e.g. COPC v1.1). Returns a laspy.LasData object, or None on failure. """ import subprocess import tempfile import os tmp_path = None try: # Convert COPC to LAS via PDAL, then read with laspy with tempfile.NamedTemporaryFile(suffix='.las', delete=False) as tmp: tmp_path = tmp.name pipeline = json.dumps({ "pipeline": [ str(laz_file), {"type": "writers.las", "filename": tmp_path} ] }) result = subprocess.run( ["pdal", "pipeline", "--stdin"], input=pipeline, capture_output=True, text=True, timeout=300 ) if result.returncode != 0: logger.warning(f" PDAL conversion failed: {result.stderr[:200]}") return None import laspy las = laspy.read(tmp_path) if len(las.points) == 0: logger.warning(f" PDAL: conversion succeeded but produced 0 points") return None return las except Exception as e: logger.warning(f" PDAL fallback failed: {e}") return None finally: # Always delete the temporary LAS, even on an exception (subprocess # timeout, failed laspy.read...): otherwise ~GB are leaked. if tmp_path: try: os.unlink(tmp_path) except Exception: pass def detect_ground_method(laz_file): """Detect the best ground classification method based on point cloud statistics. Auto-selects between IGN, SMRF and CSF: - IGN: supplier pre-classification, chosen when >= 20 % of the points are class 2 (ground) - SMRF: fast, robust for most natural terrain (PDAL recommended default) - CSF: cloth simulation, better for complex/urban terrain Falls back to SMRF if the file cannot be read or attributes are missing. Args: laz_file: Path to input LAZ/LAS file. Returns: String: 'ign', 'smrf' or 'csf' """ import laspy # Try laspy first, then PDAL for COPC files las = None try: las = laspy.read(str(laz_file)) except Exception as e: logger.warning(f" laspy: {e}") logger.info(f" → Reading via PDAL for auto-detection...") las = _read_with_pdal(laz_file) if las is None: logger.info(f" → Method: SMRF (default, file unreadable)") return 'smrf' _LAST_READ.clear() _LAST_READ[str(laz_file)] = las # reused by the IGN extraction total_points = len(las.points) if total_points == 0: logger.warning(f" Empty point cloud (0 points), default method: SMRF") return 'smrf' # IGN LiDAR HD: the delivered data is pre-classified by the supplier # (class 2 = ground). It is the fastest base (~10 s) and the reference. # Holes left by the pre-classification (dense forest / relief) are then # filled by create_dtm_fast (bounded gap filling + interpolation), so it # is preferred as soon as a reasonable share of the points is classified # as ground, rather than filtering again. try: cls = np.asarray(las.classification, dtype=np.int32) ground_ratio = float(np.mean(cls == 2)) except Exception: ground_ratio = 0.0 if ground_ratio >= 0.2: logger.info(f" → Method: IGN (supplier pre-classification, " f"{ground_ratio * 100:.1f}% class 2 points)") return 'ign' z = np.array(las.z) # Height variance (always available) z_std = float(np.std(z)) z_range = float(np.max(z) - np.min(z)) # Try to get NumberOfReturns (may not exist in all point formats) single_return_ratio = 0.0 try: num_returns = np.array(las.NumberOfReturns) single_return_count = int(np.sum(num_returns == 1)) single_return_ratio = single_return_count / total_points if total_points > 0 else 0 except AttributeError: logger.debug(" NumberOfReturns unavailable, using Z variance only") logger.info(f" Point cloud analysis: {total_points:,} points, " f"single_return_ratio={single_return_ratio:.2f}, " f"z_std={z_std:.1f}m, z_range={z_range:.1f}m") # Decision logic: # - High single-return ratio (>0.6) → urban (buildings, roads) → CSF (cloth simulation) # - High elevation variance (>30m) → complex/mountainous terrain → CSF # - Default → SMRF (fast, robust for most natural terrain) if single_return_ratio > 0.6: method = 'csf' reason = f"single-return ratio={single_return_ratio:.2f} > 0.6 → urban area" elif z_std > 30: method = 'csf' reason = f"z_std={z_std:.1f}m > 30m → complex terrain" else: method = 'smrf' reason = f"standard natural terrain" logger.info(f" → Method: {method.upper()} ({reason})") return method _LAST_READ = {} def _extract_ign_ground(laz_file, output_las, codes): """Direct extraction (laspy) of the selected IGN classes into a LAS. Same result as the PDAL pipeline (ReturnNumber ≥ 1, NumberOfReturns ≥ 1 and class filters) but with no re-read or conversion: ~4.9 s instead of ~13.5 s per tile, and the read done by auto-detection is reused. Point format, scales and offsets of the source file are preserved. Returns: True if the file was written with at least one point. """ import laspy las = _LAST_READ.pop(str(laz_file), None) if las is None: las = laspy.read(str(laz_file)) keep = ((np.asarray(las.return_number) >= 1) & (np.asarray(las.number_of_returns) >= 1) & np.isin(np.asarray(las.classification), np.asarray(sorted(codes)))) if not keep.any(): return False header = laspy.LasHeader(point_format=las.header.point_format, version=las.header.version) header.scales = las.header.scales header.offsets = las.header.offsets out = laspy.LasData(header) out.points = las.points[keep] out.write(str(output_las)) return True def classify_ground(laz_file, temp_dir, method='auto', force=False, ign_classes="sol"): """Classify ground points using PDAL ground classification filter. Args: laz_file: Path to input LAZ/LAS file. temp_dir: Directory for temporary files (pipeline.json, ground.las). method: Ground classification method ('auto', 'ign', 'smrf' or 'csf'). force: If True, reclassify even if output file already exists. ign_classes: LAS classes extracted by the IGN method (comma-separated names or codes, e.g. "sol,unclassified"). Ignored by the other methods. Returns: Path to classified ground LAS file, or None on failure. """ import laspy # Auto-detect method if requested if method == 'auto': method = detect_ground_method(laz_file) logger.info(f" Ground classification: {method.upper()} (auto)") else: logger.info(f" Ground classification: {method.upper()} (forced)") # The IGN classes are encoded in the file name (e.g. ign_1_2) so that a # change of classes invalidates the cache and triggers reclassification. ign_codes = parse_ign_classes(ign_classes) if method == 'ign' else None method_label = ign_method_label(ign_codes) if ign_codes else method laz_base = _strip_lidar_ext(laz_file) output_las = temp_dir / f"{laz_base}_ground_{method_label}.las" if output_las.exists() and not force: logger.info(f" {method.upper()} classification already done, reusing the existing file") return output_las if force and output_las.exists(): logger.info(f" Forced reclassification, deleting {output_las.name}") output_las.unlink() # IGN pre-classification: direct extraction, PDAL as a fallback. if method == 'ign': try: if _extract_ign_ground(laz_file, output_las, ign_codes or [2]): logger.info(f" ✓ IGN ground classification done (direct extraction)") return output_las logger.warning(" No point in the requested IGN classes, falling back to SMRF") output_las.unlink(missing_ok=True) return _fallback_to_smrf(laz_file, temp_dir, laz_base, force, source=method_label) except Exception as e: logger.warning(f" Direct IGN extraction failed ({e}), using the PDAL pipeline") output_las.unlink(missing_ok=True) _LAST_READ.clear() pipeline_json = _create_ground_pipeline(laz_file, output_las, method, ign_codes=ign_codes) pipeline_file = temp_dir / f"pipeline_{method_label}.json" with open(pipeline_file, 'w') as f: f.write(pipeline_json) try: subprocess.run( ["pdal", "pipeline", str(pipeline_file)], capture_output=True, check=True ) # Verify that ground file has points if output_las.exists() and output_las.stat().st_size < 100: logger.error(f" ✗ Empty ground file (size < 100 bytes)") output_las.unlink(missing_ok=True) # Fallback: if the method yields no ground point, retry with SMRF if method in ('csf', 'ign'): return _fallback_to_smrf(laz_file, temp_dir, laz_base, force, source=method_label) return None logger.info(f" ✓ {method.upper()} ground classification done") return output_las except subprocess.CalledProcessError as e: error_msg = e.stderr.decode() if e.stderr else str(e) logger.warning(f" ✗ PDAL classification error ({method.upper()}): {error_msg}") # Fallback: if CSF or the pre-classification fails, retry with SMRF if method in ('csf', 'ign'): return _fallback_to_smrf(laz_file, temp_dir, laz_base, force, source=method_label) # Try repairing file with laspy if PDAL fails on EVLR/VLR if 'VLR' in error_msg or 'Invalid' in error_msg: logger.info(f" → Trying to repair the file with laspy...") repaired_las = temp_dir / f"{laz_base}_repaired.las" if _repair_laz_with_laspy(laz_file, repaired_las): # Retry PDAL pipeline with repaired file pipeline_json = _create_ground_pipeline(repaired_las, output_las, method) with open(pipeline_file, 'w') as f: f.write(pipeline_json) try: subprocess.run( ["pdal", "pipeline", str(pipeline_file)], capture_output=True, check=True ) logger.info(f" ✓ {method.upper()} ground classification done (repaired file)") return output_las except subprocess.CalledProcessError as e2: error_msg2 = e2.stderr.decode() if e2.stderr else str(e2) logger.error(f" ✗ Classification failed even after repair: {error_msg2}") else: logger.error(f" ✗ Could not repair the file") return None def _fallback_to_smrf(laz_file, temp_dir, laz_base, force=False, source='csf'): """Retry ground classification with SMRF when CSF/IGN fails. CSF (Cloth Simulation Filter) can fail on certain terrain types where SMRF (Simple Morphological Filter) succeeds, and a file without usable pre-classification produces an empty ground extract. This fallback ensures processing continues even when the selected method fails. Args: laz_file: Path to input LAZ/LAS file. temp_dir: Directory for temporary files. laz_base: Base name for the file. force: If True, reclassify even if output exists. source: Method that failed ('csf' or 'ign'). Returns: Path to classified ground LAS file, or None on failure. """ logger.info(f" → Switching {source.upper()} → SMRF (fallback)") # Clean up failed output if it exists failed_output = temp_dir / f"{laz_base}_ground_{source}.las" if failed_output.exists(): failed_output.unlink(missing_ok=True) output_las = temp_dir / f"{laz_base}_ground_smrf.las" if output_las.exists() and not force: logger.info(f" SMRF classification already exists, reusing the file") return output_las pipeline_json = _create_ground_pipeline(laz_file, output_las, 'smrf') pipeline_file = temp_dir / "pipeline_smrf.json" with open(pipeline_file, 'w') as f: f.write(pipeline_json) try: subprocess.run( ["pdal", "pipeline", str(pipeline_file)], capture_output=True, check=True ) if output_las.exists() and output_las.stat().st_size < 100: logger.error(f" ✗ Empty SMRF ground file (size < 100 bytes)") output_las.unlink(missing_ok=True) return None logger.info(f" ✓ SMRF ground classification done (fallback from {source.upper()})") return output_las except subprocess.CalledProcessError as e: error_msg = e.stderr.decode() if e.stderr else str(e) logger.error(f" ✗ SMRF classification failed (fallback): {error_msg}") return None def _repair_laz_with_laspy(input_laz, output_las): """Try to repair a corrupt LAZ file by re-reading with laspy and saving as LAS. Works around PDAL errors like 'Invalid Extended VLR size' by stripping problematic VLR/EVLR metadata during re-save. Args: input_laz: Path to corrupt LAZ/LAS file. output_las: Path for repaired LAS output. Returns: True if repair succeeded, False otherwise. """ import laspy try: las = laspy.read(str(input_laz)) las.write(str(output_las)) logger.info(f" ✓ File repaired via laspy ({len(las.points):,} points)") return True except Exception as e: logger.warning(f" ✗ laspy repair failed: {e}") return False # ------------------------------------------------------------ # Filling the gaps between points (small holes) # ------------------------------------------------------------ # At 0.2 m, ~80 % of the pixels receive no point: the DTM is filled between # the measured points. A "fixed-distance" fill (every pixel within 1 m of a # point) grew each isolated point into a flat 2 m patch and extrapolated a # 1 m band into every hole (copied plateaus, false slopes: pink patches and # coloured fringes in the oriented relief). The fill is now bounded to the # ENVELOPE of the points: a morphological closing whose radius follows the # local point spacing (short in dense areas, long under sparse cover), # followed by removal of isolated islands. GAP_FILL_TAG = "LIDAR_GAP_FILL" # GeoTIFF tag: gap-fill version GAP_FILL_VERSION = 3 # 1 (absent) = fixed 1 m distance; 2 = envelope; 3 = + density file GAP_RADIUS_K = 1.5 # radius = K × local point spacing GAP_RADII_M = (1.0, 1.5, 2.0, 3.0) # radius steps (m); 1 m minimum: holes < 2 m filled GAP_DENSITY_WINDOW_M = 5.0 # window for measuring the local density GAP_MIN_ISLAND_M2 = 1.0 # smaller data islands are removed def _morph_step(mask, square, erode): """One 3×3 step (square or cross) of binary dilation/erosion using numpy slices (~10× faster than scipy.ndimage). At the border, the missing neighbour counts as the pixel itself.""" op = np.logical_and if erode else np.logical_or out = mask.copy() op(out[1:], mask[:-1], out=out[1:]) op(out[:-1], mask[1:], out=out[:-1]) src = out.copy() if square else mask # square: separable (columns then rows) op(out[:, 1:], src[:, :-1], out=out[:, 1:]) op(out[:, :-1], src[:, 1:], out=out[:, :-1]) return out def _morph_disk(mask, radius_px, erode=False, first_step=1): """Dilation/erosion by an approximate disc: an octagon, alternating square (odd) and cross (even) steps. `first_step` continues a chain.""" for step in range(first_step, first_step + radius_px): mask = _morph_step(mask, step % 2 == 1, erode) return mask def _gap_fill_envelope(valid, resolution): """Mask of the pixels to fill or keep: envelope of the measured points. Morphological closing (dilation then erosion) with a variable radius: gaps BETWEEN neighbouring points are filled, nothing is extended outwards (an isolated point stays one pixel). The radius is GAP_RADIUS_K × the local point spacing, measured over GAP_DENSITY_WINDOW_M within the covered area (the edge of a hole does not drag the density down), rounded to the nearest GAP_RADII_M step. Islands smaller than GAP_MIN_ISLAND_M2 are removed (sparse returns on water, in a courtyard...). """ from scipy import ndimage as nd radii_px = sorted({max(1, int(round(r / resolution))) for r in GAP_RADII_M}) r_max = radii_px[-1] # Mask extended by mirroring: the grid border neither erodes the data nor # fills empty bands. A single chain of dilations serves every step (disc # of radius r = the first r steps). pad = 2 * r_max dilated = {} grown = np.pad(valid, pad, mode="symmetric") for step in range(1, r_max + 1): grown = _morph_disk(grown, 1, first_step=step) if step in radii_px: dilated[step] = grown del grown # Local density: share of measured pixels within the covered area (within # the largest radius of a point), converted into a mean point spacing. # Computed on a ~1 m grid (5 m window: more than enough). covered = dilated[r_max][pad:-pad, pad:-pad] h, w = valid.shape f = max(1, int(round(1.0 / resolution))) hc, wc = -(-h // f), -(-w // f) def _block_mean(a): full = np.zeros((hc * f, wc * f), dtype=np.float32) full[:h, :w] = a return full.reshape(hc, f, wc, f).mean(axis=(1, 3)) win = max(3, int(round(GAP_DENSITY_WINDOW_M / (resolution * f))) | 1) frac = nd.uniform_filter(_block_mean(valid), win, mode="nearest") frac_cov = nd.uniform_filter(_block_mean(covered), win, mode="nearest") with np.errstate(divide="ignore", invalid="ignore"): # wanted radius in pixels = K × spacing / resolution wanted = GAP_RADIUS_K / np.sqrt(frac / np.maximum(frac_cov, 1e-6)) # Wanted radius upsampled bilinearly (otherwise step changes form 1 m # staircases along hole edges), then rounded to the nearest step. wanted = np.nan_to_num(np.clip(wanted, 0, 2 * r_max), nan=2 * r_max) if f > 1: wanted = nd.zoom(wanted.astype(np.float32), f, order=1, grid_mode=True, mode="nearest")[:h, :w] mids = [(a + b) / 2 for a, b in zip(radii_px, radii_px[1:])] level = np.digitize(wanted, mids).astype(np.uint8) del covered, frac, frac_cov, wanted # Erosion per step, restricted to the bounding box of its pixels (large # radii only concern sparse areas) envelope = valid.copy() for i, r in enumerate(radii_px): sel = (level == i) & ~valid rows = np.flatnonzero(sel.any(axis=1)) if rows.size == 0: continue cols = np.flatnonzero(sel.any(axis=0)) y0, y1 = rows[0], rows[-1] + 1 x0, x1 = cols[0], cols[-1] + 1 m = 2 * r # margin: erosion at the crop edge stays outside the box crop = dilated[r][y0 + pad - m:y1 + pad + m, x0 + pad - m:x1 + pad + m] closed = _morph_disk(crop, r, erode=True)[m:-m, m:-m] envelope[y0:y1, x0:x1] |= sel[y0:y1, x0:x1] & closed del dilated, level # Islands too small: removed (8-connectivity, area in m²) labels, n = nd.label(envelope, structure=np.ones((3, 3), dtype=bool)) if n: area = np.bincount(labels.ravel()) * resolution * resolution small = area < GAP_MIN_ISLAND_M2 small[0] = False if small.any(): envelope &= ~small[labels] return envelope def _fill_small_gaps(dtm, resolution): """Fill the gaps between points within the data envelope. Returns: Tuple (dtm, filled_count, removed_count): filled pixels, and measured pixels removed along with isolated islands. """ valid = ~np.isnan(dtm) if valid.all() or not valid.any(): return dtm, 0, 0 envelope = _gap_fill_envelope(valid, resolution) from rasterio.fill import fillnodata # Every envelope pixel lies within the largest radius of a point max_px = max(1, int(round(max(GAP_RADII_M) / resolution))) # fillnodata writes into the array it is given: pass a copy filled = fillnodata(dtm.copy(), mask=valid, max_search_distance=max_px) to_fill = envelope & ~valid & ~np.isnan(filled) removed = valid & ~envelope out = dtm.copy() out[to_fill] = filled[to_fill] out[removed] = np.nan return out, int(to_fill.sum()), int(removed.sum()) # Ground point density ("densite_sol" layer): count of the points kept for # the DTM in DENSITY_CELL_M cells, averaged over DENSITY_SMOOTH × # DENSITY_SMOOTH cells (pts/m² over 9 m²: less small-integer noise). Written # next to the DTM (*_dtm*_density.tif): the visualisations only receive the # DTM, no longer the points. DENSITY_CELL_M = 1.0 DENSITY_SMOOTH = 3 def density_path(dtm_path): """Density companion file of a DTM.""" dtm_path = Path(dtm_path) return dtm_path.with_name(f"{dtm_path.stem}_density.tif") def _write_density(xs, ys, bounds, dtm_path): """Write the ground point density (pts/m², float32 GeoTIFF at 1 m).""" from scipy import ndimage as nd min_x, min_y, max_x, max_y = bounds cell = DENSITY_CELL_M nx = max(1, int(np.ceil((max_x - min_x) / cell - 1e-6))) ny = max(1, int(np.ceil((max_y - min_y) / cell - 1e-6))) top = max_y counts, _, _ = np.histogram2d(top - ys, xs - min_x, bins=(ny, nx), range=[[0, ny * cell], [0, nx * cell]]) density = nd.uniform_filter(counts, DENSITY_SMOOTH, mode="nearest") / (cell * cell) out = density_path(dtm_path) with rasterio.open( out, 'w', driver='GTiff', height=ny, width=nx, count=1, dtype='float32', crs='EPSG:2154', transform=from_bounds(min_x, top - ny * cell, min_x + nx * cell, top, nx, ny), compress='deflate', ) as dst: dst.write(density.astype('float32'), 1) return out def read_dtm_gap_fill(dtm_path): """Gap-fill version recorded in a DTM (1 if absent/unreadable).""" try: with rasterio.open(dtm_path) as src: return int(src.tags().get(GAP_FILL_TAG, 1) or 1) except Exception: return 1 def _interpolate_holes(dtm, downsample=8): """Fill remaining NaN holes with a terrain-aware surface interpolation. In complex / rocky terrain the ground under-classification leaves interior holes far too large for the bounded gap fill, which otherwise become flat nearest-neighbor patches in the downstream layers. This helper triangulates the valid cells on a downsampled grid (linear, nearest as a fallback for cells outside the data hull) and bilinearly upsamples the result, keeping the operation fast even for large rasters. Args: dtm: 2-D float array (may contain NaN holes). downsample: Coarsening factor for the interpolation grid. Not called by create_dtm_fast (large holes are deliberately left as nodata there); kept as a standalone helper. Returns: Tuple (filled_array, filled_count). """ holes = np.isnan(dtm) if not holes.any(): return dtm, 0 valid = ~holes if not valid.any(): return dtm, 0 height, width = dtm.shape step = max(1, downsample) coarse = dtm[::step, ::step].astype(np.float64) c_valid = ~np.isnan(coarse) c_holes = np.isnan(coarse) if not c_holes.any() or not c_valid.any(): return dtm, 0 from scipy.interpolate import griddata from scipy.ndimage import map_coordinates cy, cx = np.where(c_valid) c_coords = np.column_stack([cx, cy]).astype(np.float64) c_vals = coarse[c_valid] hy, hx = np.where(c_holes) h_coords = np.column_stack([hx, hy]).astype(np.float64) interp = griddata(c_coords, c_vals, h_coords, method='linear') bad = np.isnan(interp) if bad.any(): interp[bad] = griddata(c_coords, c_vals, h_coords[bad], method='nearest') coarse_filled = coarse.copy() coarse_filled[c_holes] = interp # Coarse cell i represents fine column/row i*step, so fine index c maps to # coarse coordinate c/step (no half-cell offset). rows = np.arange(height) / step cols = np.arange(width) / step grid_y, grid_x = np.meshgrid(rows, cols, indexing='ij') upsampled = map_coordinates(coarse_filled, [grid_y, grid_x], order=1) filled = dtm.copy() filled[holes] = upsampled[holes] return filled, int(holes.sum()) # ------------------------------------------------------------ # Edge matching (edge buffer with the adjacent tiles) # ------------------------------------------------------------ # Large-kernel visualisations (openness/SVF: radii up to 100 m, LRM: 15 m) # truncate their window at the tile edge: renders then show a band of # artefacts at every tile boundary. With edge_buffer > 0, the DTM is # rasterised on the nominal 1 km tile EXTENDED by a band of `edge_buffer` # metres filled with the ground points of the 8 neighbouring LAZ tiles # (streamed PDAL read: crop + class filter). The visualisations thus see the # real terrain beyond the edge, and the final images are cropped back to the # exact tile (see rendering.py). EDGE_BUFFER_TAG = "LIDAR_EDGE_BUFFER" # GeoTIFF tag: buffer used (m) # Subfolder of input/ where the neighbouring tiles downloaded only for edge # matching are kept apart: they are not part of the corpus of tiles to render # (global scans only list input/ itself, non-recursively). EDGE_NEIGHBORS_DIRNAME = "edge_neighbors" # Offsets of a tile's 8 neighbours (col, row) in km _NEIGHBOR_OFFSETS = [(-1, -1), (0, -1), (1, -1), (-1, 0), (1, 0), (-1, 1), (0, 1), (1, 1)] def _tile_coords(name): """(col, row) coordinates in km of an LHD file name, or None.""" from .index import parse_basename_coords return parse_basename_coords(Path(name).name) def _neighbor_laz_files(source_laz): """List the (up to) 8 LAZ/LAS files adjacent to `source_laz`. Looks in the source's folder, then in its edge_neighbors/ subfolder: the neighbouring tiles downloaded only for edge matching are kept there; otherwise they would pile up directly in input/ and every global pass would add an extra ring of tiles to render. """ coords = _tile_coords(source_laz) if coords is None: return [] col, row = coords directory = Path(source_laz).parent search_dirs = [directory, directory / EDGE_NEIGHBORS_DIRNAME] neighbors = [] for dcol, drow in _NEIGHBOR_OFFSETS: nc, nr = col + dcol, row + drow # LHD names: col/row in km zero-padded to 4 digits (e.g. 0638_6628); # the unpadded variant is also accepted for unusual tiles. names = {f"LHD_FXX_{nc:04d}_{nr:04d}", f"LHD_FXX_{nc}_{nr}"} matches = [] for search_dir in search_dirs: for name in names: matches += list(search_dir.glob(f"{name}_*.las")) matches += list(search_dir.glob(f"{name}_*.laz")) if matches: neighbors.append(sorted(matches)[0]) else: logger.debug(f" Missing neighbour: LHD_FXX_{nc:04d}_{nr:04d} (empty edge band)") return neighbors def _neighbor_ground_points(source_laz, bounds, classes): """Ground points of the neighbouring tiles within `bounds` (edge matching). Streamed PDAL read per neighbour: crop to the extended footprint, then class filter (same codes as the DTM). Best-effort: an unreadable or missing neighbour is skipped and the corresponding band stays empty. Args: source_laz: LAZ of the processed tile (used to locate the neighbours). bounds: (min_x, min_y, max_x, max_y) of the extended footprint. classes: LAS codes to extract (e.g. [2] = ground). Returns: Concatenated (xs, ys, zs) (empty arrays if there is no neighbour). """ import tempfile neighbors = _neighbor_laz_files(source_laz) if not neighbors: return (np.empty(0),) * 3 min_x, min_y, max_x, max_y = bounds codes = sorted(set(int(c) for c in classes)) or [2] limits = ",".join(f"Classification[{c}:{c}]" for c in codes) xs, ys, zs = [], [], [] found = 0 for neighbor in neighbors: tmp_path = None try: with tempfile.NamedTemporaryFile(suffix='.las', delete=False) as tmp: tmp_path = tmp.name pipeline = json.dumps({ "pipeline": [ {"type": "readers.las", "filename": str(neighbor)}, {"type": "filters.crop", "bounds": f"([{min_x},{max_x}],[{min_y},{max_y}])"}, {"type": "filters.range", "limits": limits}, {"type": "writers.las", "filename": tmp_path}, ] }) result = subprocess.run( ["pdal", "pipeline", "--stdin"], input=pipeline, capture_output=True, text=True, timeout=300 ) if result.returncode != 0: raise RuntimeError(result.stderr[:200]) import laspy las = laspy.read(tmp_path) if len(las.points) > 0: xs.append(np.asarray(las.x, dtype=np.float64)) ys.append(np.asarray(las.y, dtype=np.float64)) zs.append(np.asarray(las.z, dtype=np.float64)) found += 1 except Exception as e: logger.debug(f" Neighbour {Path(neighbor).name} skipped: {e}") finally: if tmp_path: try: Path(tmp_path).unlink(missing_ok=True) except Exception: pass if not found: logger.warning(" No readable neighbour, edge band left empty") return (np.empty(0),) * 3 logger.info(f" Edge matching: {found} neighbour(s), " f"{sum(len(a) for a in xs):,} ground pts") return np.concatenate(xs), np.concatenate(ys), np.concatenate(zs) def read_dtm_edge_buffer(dtm_path): """Edge buffer recorded in a DTM (m; 0 if absent/unreadable).""" try: with rasterio.open(dtm_path) as src: tags = src.tags() return float(tags.get(EDGE_BUFFER_TAG, 0.0) or 0.0) except Exception: return 0.0 def create_dtm_fast(las_file, basename, dtm_dir, resolution, force=False, output_suffix="", source_laz=None, pure=False, strip_align=True, edge_buffer=0.0, neighbor_classes=None): """Create DTM using fast binning method with gap filling. Args: las_file: Path to classified ground LAS file. basename: Base name for output file. dtm_dir: Directory for output DTM GeoTIFF. resolution: Grid resolution in meters per pixel. force: If True, regenerate even if DTM already exists. output_suffix: Suffix for output filename (e.g. '_r0p2' for additional resolutions). source_laz: Optional: full LAZ of the processed tile (used to read the 8 neighbouring tiles for edge matching, see edge_buffer). pure: No effect (kept for compatibility). Gaps between points are filled within the data envelope (_fill_small_gaps); large holes stay nodata (rendered as no-data in the outputs). strip_align: If True (default), measure and correct the vertical offsets between flight strips (PointSourceId) before rasterisation: constant offsets ≥ STRIP_ALIGN_THRESHOLD (0.5 cm), then GPS time-window jitter (only when scan_angle is missing), then the line-by-line joint adjustment (gps_time and scan_angle required); everything is recorded in a *_dtm*_stripalign.json sidecar. edge_buffer: Edge buffer in metres (0 = disabled). The DTM then covers the nominal 1 km tile extended by this band, filled with the ground points of the 8 neighbouring LAZ tiles (source_laz required); the visualisations compute on the extended footprint, then the images are cropped back to the exact tile (rendering.py). The buffer is written to the LIDAR_EDGE_BUFFER GeoTIFF tag for cache invalidation. neighbor_classes: LAS codes extracted from the neighbours (default [2]; the pipeline passes the DTM's IGN classes). Neighbours are read with their supplier pre-classification, even when the central tile is classified with SMRF/CSF (context band, a few cm of deviation at worst). Returns: Path to output DTM GeoTIFF, or None on failure. """ output_tif = dtm_dir / f"{basename}_dtm{output_suffix}.tif" if output_tif.exists() and not force: logger.info(f" DTM already exists, reusing the file: {output_tif.name}") return output_tif import laspy logger.info(" → Generating DTM...") try: t_read = time.perf_counter() las = laspy.read(str(las_file)) logger.info(f" Read {len(las.points):,} points " f"({time.perf_counter() - t_read:.1f}s)") except Exception as e: # laspy can't read COPC v1.1 — try PDAL conversion logger.warning(f" laspy: {e}") logger.info(f" → Converting via PDAL to read COPC...") las = _read_with_pdal(las_file) if las is None: logger.error(f" ✗ Could not read {las_file.name}") return None if len(las.points) == 0: logger.error(f" ✗ Empty file (0 points): {las_file.name}") return None # Vertical alignment of the flight strips before rasterisation: # best-effort, if the measurement fails we carry on unaligned (never abort). strip_offsets = {} strip_jitter = {} gps_time = None t_align = time.perf_counter() if strip_align: try: strip_offsets = _strip_offsets_for_file(las_file, las) except Exception as e: logger.warning(f" Strip alignment measurement failed ({e}), DTM left unaligned") strip_offsets = {} if strip_offsets: logger.info(" Strip alignment: " + ", ".join( f"PSID {p} {off:+.3f} m" for p, off in sorted(strip_offsets.items()))) else: logger.debug(" Strip alignment: no offset >= " f"{STRIP_ALIGN_THRESHOLD * 100:.1f} cm, nothing to correct") # 2nd pass: intra-strip jitter (GPS time required, skipped otherwise). try: gps_time = np.asarray(las.gps_time, dtype=np.float64) except AttributeError: gps_time = None # The line-by-line joint adjustment (3rd pass) already re-aligns every # line against the other strips, at every scale: time-window jitter # is only computed when scan_angle is missing. lines_possible = _scan_angle(las) is not None if gps_time is not None and len(gps_time) == len(las.points) and not lines_possible: try: strip_jitter = _strip_jitter_for_file(las_file, las, strip_offsets) except Exception as e: logger.warning(f" Intra-strip jitter measurement failed ({e}), jitter left uncorrected") strip_jitter = {} if strip_jitter: for p_, (jt, jc) in sorted(strip_jitter.items()): logger.info(f" Jitter PSID {p_}: ±{np.max(np.abs(jc)) * 100:.1f} cm " f"(rms {np.sqrt(np.mean(np.asarray(jc) ** 2)) * 100:.1f} cm, " f"{len(jt)} windows of {STRIP_JITTER_BIN:g} s)") else: logger.debug(" Intra-strip jitter: nothing to correct") logger.info(f" Strip alignment: {time.perf_counter() - t_align:.1f}s") try: min_x, max_x = float(las.header.min[0]), float(las.header.max[0]) min_y, max_y = float(las.header.min[1]), float(las.header.max[1]) width = int(np.ceil((max_x - min_x) / resolution)) height = int(np.ceil((max_y - min_y) / resolution)) # Edge matching: footprint = nominal 1 km tile (aligned on the # multi-tile grid) + a band of edge_buffer metres filled with the # neighbours' ground points. Otherwise: header bounds (historical). used_edge_buffer = 0.0 if edge_buffer > 0: coords = _tile_coords(source_laz or las_file) if coords is not None: buffer_px = max(1, int(round(edge_buffer / resolution))) buffer_m = buffer_px * resolution col_km, row_km = coords # LHD grid: (col, row) = north-west corner in km → # X ∈ [col, col+1] km, Y ∈ [row-1, row] km (north edge = row). min_x = float(col_km) * 1000.0 max_x = min_x + 1000.0 max_y = float(row_km) * 1000.0 min_y = max_y - 1000.0 ext_bounds = (min_x - buffer_m, min_y - buffer_m, max_x + buffer_m, max_y + buffer_m) width = int(round(1000.0 / resolution)) + 2 * buffer_px height = width min_x, min_y, max_x, max_y = ext_bounds used_edge_buffer = float(edge_buffer) else: logger.warning(" Edge matching impossible: not an LHD " f"file name ({basename}), tile alone") logger.debug(f" Bounds: X[{min_x:.1f}, {max_x:.1f}] Y[{min_y:.1f}, {max_y:.1f}]") logger.debug(f" Grid: {width}x{height} pixels ({len(las.points):,} points)") logger.info(f" Rasterising {width}x{height} ({len(las.points):,} points)...") xs = np.asarray(las.x, dtype=np.float64) ys = np.asarray(las.y, dtype=np.float64) zs = np.asarray(las.z, dtype=np.float64) if strip_offsets: lut = np.zeros(65536) for p, off in strip_offsets.items(): lut[int(p) & 0xFFFF] = off zs = zs - lut[np.asarray(las.point_source_id, dtype=np.int64)] if strip_jitter and gps_time is not None: zs = zs - _apply_strip_jitter(las.point_source_id, gps_time, strip_jitter) # 3rd pass: line-by-line offset (after the first two). strip_lines = {} if strip_align and gps_time is not None and len(gps_time) == len(zs): t_lines = time.perf_counter() try: line_corr, strip_lines = _scan_lines_for_file(las_file, las, zs, gps_time) zs = zs - line_corr except Exception as e: logger.warning(f" Line-by-line offset measurement failed ({e}), left uncorrected") strip_lines = {} for p_, (nl_, rms_, max_) in sorted(strip_lines.items()): logger.info(f" Lines PSID {p_}: rms {rms_ * 100:.1f} cm, max {max_ * 100:.1f} cm " f"({nl_} lines)") logger.info(f" Line-by-line offset: {time.perf_counter() - t_lines:.1f}s") # Ground points of the neighbouring tiles in the edge band # (best-effort, not strip-aligned: context band, the final image is # cropped to the tile before delivery). if used_edge_buffer > 0 and source_laz is not None: t_neigh = time.perf_counter() nx, ny, nz = _neighbor_ground_points( source_laz, (min_x, min_y, max_x, max_y), neighbor_classes if neighbor_classes is not None else [2]) logger.info(f" Neighbours (edge matching): {len(nx):,} points " f"({time.perf_counter() - t_neigh:.1f}s)") if len(nx): xs = np.concatenate([xs, nx]) ys = np.concatenate([ys, ny]) zs = np.concatenate([zs, nz]) # Density of the kept ground points (densite_sol layer), best-effort try: _write_density(xs, ys, (min_x, min_y, max_x, max_y), dtm_dir / f"{basename}_dtm{output_suffix}.tif") except Exception as e: logger.warning(f" Point density not written ({e})") t_raster = time.perf_counter() dtm = bin_mean_2d(xs, ys, zs, width, height, (min_x, max_x), (min_y, max_y)) if dtm is not None: # Output already (height, width): only the Y flip is left dtm = dtm[::-1, :] # north at the top logger.info(f" ✓ Rasterised {width}x{height} GPU " f"({time.perf_counter() - t_raster:.1f}s)") else: stat = binned_statistic_2d( xs, ys, zs, statistic='mean', bins=[width, height], range=[[min_x, max_x], [min_y, max_y]] ) dtm = stat.statistic.T dtm = dtm[::-1, :] # Flip Y so north is at top logger.info(f" ✓ Rasterised {width}x{height} CPU " f"({time.perf_counter() - t_raster:.1f}s)") # Only small holes close to the data are filled; large holes stay # nodata in the renders. Deliberately NO lowest-return floor: under # dense canopy that return is vegetation, which would imprint the # trees into the DTM. # Gaps between points filled within the data envelope (radius adapted # to the local density), isolated islands removed nan_count = np.count_nonzero(np.isnan(dtm)) if nan_count > 0: total = dtm.size nan_pct = 100.0 * nan_count / total logger.info(f" {nan_count:,} pixels without data ({nan_pct:.1f}%)") t_fill = time.perf_counter() dtm, filled_count, removed_count = _fill_small_gaps(dtm, resolution) logger.info(f" {filled_count:,} pixels filled between points, " f"{removed_count:,} isolated-island pixels removed " f"({time.perf_counter() - t_fill:.1f}s)") # Save as GeoTIFF output_tif = dtm_dir / f"{basename}_dtm{output_suffix}.tif" transform = from_bounds(min_x, min_y, max_x, max_y, width, height) t_write = time.perf_counter() with rasterio.open( output_tif, 'w', driver='GTiff', height=height, width=width, count=1, dtype='float32', crs='EPSG:2154', transform=transform, nodata=float('nan'), compress='lzw' ) as dst: dst.write(dtm.astype('float32'), 1) # Gap-fill version: a DTM from an earlier version is regenerated # (patches and fringes of the old gap fill). dst.update_tags(**{GAP_FILL_TAG: str(GAP_FILL_VERSION)}) if used_edge_buffer > 0: # Edge buffer written into the file: changing --edge-buffer # ⇒ automatic invalidation of the DTM cache. dst.update_tags(**{EDGE_BUFFER_TAG: f"{used_edge_buffer:g}"}) logger.info(f" GeoTIFF write: {time.perf_counter() - t_write:.1f}s") if strip_align: _write_strip_align_sidecar(dtm_dir, basename, output_suffix, strip_offsets, strip_jitter, strip_lines) logger.info(f" ✓ DTM created: {output_tif.name}") return output_tif except Exception as e: logger.error(f" ✗ DTM error: {e}", exc_info=True) return None