Version 2.1.2: Ships the v2.1.1 work, which never reached plugins.qgis.org. No code changes beyond release tooling. **Fixed** - **A single percent sign in the changelog was silently breaking every upload.** `qgis-plugin-ci` injects CHANGELOG.md into `metadata.txt`'s `changelog=` field at release time, and the QGIS plugin registry parses that file with configparser's `BasicInterpolation`, which treats the percent sign as a control character. One that is not doubled, or followed by an opening parenthesis, makes the entire `metadata.txt` unparseable — so the registry refused the package. Because `qgis-plugin-ci` calls `raise_for_status()` without ever reading `response.text`, the only symptom was a bare `HTTP 400` with no reason given. v2.1.1 failed three times over the phrase "an 84 percent reduction". Every percent sign is now gone from CHANGELOG.md, and `scripts/check_changelog.py` rejects undoubled ones with an explanation, so `./test.sh` and the release workflow both catch this before it reaches the registry. A test additionally proves the real changelog survives interpolation, using the same parser the registry uses. Older entries carried the same character for months without incident, only because they sit below the slice of changelog that `qgis-plugin-ci` injects. The trap was always armed, just out of reach. **Added** - **Releases now verify they actually reached QGIS users.** A "successful" release job has not been a reliable signal: the v2.1.0 run reported success and logged *"Plugin uploaded on plugins.qgis.org"*, yet the public repository carried on serving 2.0.22 — the version was accepted but never approved, so it never became installable, and nothing said so. The v2.1.1 upload was then refused with a bare HTTP 400 whose response body `qgis-plugin-ci` discards. Both states were invisible from the workflow. `scripts/verify_published.py` now queries the public plugin repository after every release and reports whether the released version is genuinely being served, writing a warning annotation and a run summary. It is advisory rather than fatal, because approval is a human queue on the QGIS side and a red tick for normal moderation latency would just train everyone to ignore it. It runs even when the upload step fails, since that is precisely when the current published state is worth knowing. A network failure reports "unknown", never "published". Version 2.1.1: Documentation and packaging release. No algorithm behaviour changes. **Fixed** - **The user guide was never in the published plugin.** `package.sh` copied `CHANGELOG.md` and `USER_GUIDE.md` into the plugin folder before zipping, so a locally built package contained them — but CI publishes through `qgis-plugin-ci`, which builds its archive with `git archive`, seeing only tracked files. Those temporary copies were invisible to it, so every release since 2.0 shipped without the documentation the local script was carefully adding, and the two packaging paths silently produced different artefacts. `USER_GUIDE.md` is now tracked at `lidar_relief/USER_GUIDE.md` and ships by virtue of being a plugin file; `package.sh` no longer copies anything, so both paths produce the same package. (`CHANGELOG.md` is deliberately not shipped as a file — `qgis-plugin-ci` already injects its content into `metadata.txt`, which is what QGIS Plugin Manager renders.) - **The plugin icon was a JPEG named `.png`.** 1024×1024 and 590 KB, roughly half the entire download, for something QGIS draws at about 32 px. Converted to a real 256×256 PNG: 93 KB, a reduction of about 84 percent, with no visible change. **Added** - **Every algorithm dialog now has a Help button.** All 30 algorithms had `shortHelpString`, but none implemented `helpUrl()`, so nothing in QGIS pointed at the user guide — a user who never visited the GitHub repository had no route to the documentation at all. Seventeen algorithms deep-link to their own section of the guide; the rest open it at the top. - **A guard against dead help links.** `test_help_urls.py` checks that every algorithm mixes in the help behaviour and that every anchor matches a real heading in the shipped guide, so a renamed section fails the suite instead of quietly dropping readers at the top of the page. The QGIS smoke test additionally asserts, in a real QGIS runtime, that every registered algorithm returns a help URL and that the guide is present in the installed plugin. - **Plugin Manager copy now points somewhere.** The `about=` text suggests the Contact Sheet as a starting point and names both the in-app Help button and the online guide. The README links to the guide from the top; it never did. Version 2.1.0: Minor release: two new Processing algorithms, semantic segmentation, radii in metres and provenance sidecars, plus repairs to three features that did not work at all. Horizon-scanning visualisations are roughly twice as fast. **Added** - **Visualisation Contact Sheet.** A new algorithm renders several visualisations of the same DEM as one labelled multi-panel PNG, so you can see which technique reveals your features before committing to a full-resolution run. The DEM is downsampled first, so a sheet returns in seconds; eleven visualisations over a 300×300 preview take under half a second. Panels are previews for *choosing* a technique — each is contrast-stretched independently, so brightness is not comparable between them. - **Search radii in metres.** Sky-View Factor, Topographic Openness, ASVF, SLRM and RVT Openness now accept their radius in metres as well as pixels, and every run reports the radius in both units. Archaeological features have a real-world size, but a pixel radius does not: 20 px is 20 m on a 1 m DEM and only 5 m on the 0.25 m LiDAR common in UK and NL archaeology. Pixels remain the default so saved Processing models are unaffected. - **Semantic segmentation for AI Feature Detection.** U-Net, SegFormer and DeepLab-style models now produce a class-index raster plus per-class polygons carrying area, pixel count and mean confidence — better suited to archaeology than bounding boxes, since ditches, banks and field systems are linear or areal. Model type is detected from the output signature rather than a substring in an output name. - **Provenance sidecars.** Terrain outputs are written with a `<output>.lidar-relief.json` recording the plugin version, algorithm, the parameters actually used (including the resolved pixel radius, not just the metres typed), source path, source checksum, CRS and cell size. A new **Inspect Provenance Record** algorithm reads one back and verifies the source DEM is unchanged, so a result can be audited or regenerated later by someone else. Writing a sidecar can never fail the run that produced the raster. **Changed** - **Sky-View Factor, Openness and ASVF are about 2× faster.** `np.hypot` accounted for 1.54 s of the 2.00 s spent on array arithmetic per 1024² tile; it rescales operands to stay overflow-safe, which is pointless at DEM magnitudes (float32 overflow needs |Δz| above ~1.8e19 m against a ~2e4 m terrestrial range). Replaced with `sqrt(Δz² + d²)`, agreeing to float32 epsilon. SVF at 16 directions and radius 20 went from 2.30 s to 1.09 s. - **Tiled processing runs tiles concurrently.** Roughly 1.4–1.7× on large DEMs. Deliberately only two workers by default: this workload is memory-bandwidth bound, not CPU bound, and measurement showed four workers can be *slower* than two on some shapes, with processes no better than threads. Reads and writes stay on the calling thread because GDAL is not thread-safe. - **Landscape presets scale with resolution.** Preset distances are now stored in metres and converted using the DEM's cell size. They were stored in pixels, so the "research-validated" presets were only valid on a 1 m DEM. `get_preset(name)` with no cell size still returns the historical numbers. - **Local Dominance renders in degrees** rather than a hard-coded byte scale, and is displayed with the standard deviation stretch used by the other angular visualisations. **Fixed** - **Local Dominance produced an all-zero raster.** It returned `arctan(...)` in radians — roughly 0.04–0.24 on real terrain — then byte-scaled with `(v − 0.5) / (1.8 − 0.5) × 255`, limits that only make sense for a degree-scale quantity. Every pixel fell below the 0.5 floor and clipped to zero, so one of the advertised visualisations emitted a constant raster for anything gentler than a cliff. Found by rendering the new contact sheet, where its panel was blank while the other ten showed the test earthworks. Its tests passed throughout because the only shaped fixture was a 45° cone steep enough to survive the clipping. - **Tiled segmentation normalised each tile in isolation.** Per-tile min/max scaling made the same terrain present differently depending on which tile it landed in, and a uniform tile — flat ground, or the interior of a large feature — scaled to all zeros regardless of its elevation. Segmentation now scales every tile against raster-wide percentiles. - **GDAL error messages were unreachable.** Once `gdal.UseExceptions()` is active — which QGIS sets, and which becomes the default in GDAL 4 — `gdal.Open` raises instead of returning `None`, so code checking `if dataset is None` never reached its own message about paths and permissions. Opening now raises `ValueError` with the intended guidance under either error mode. - **CSF Ground Filter (LAS/LAZ → DEM) now works.** DEM generation failed on every run with `TypeError: sequence must contain strings`: the interpolation step passed `zfield` as a column index instead of the field *name* GDAL requires, and handed `gdal.Grid` a plain `.xyz` text file, which OGR cannot open as a vector datasource. Ground points are now exposed through a CSV + OGR VRT wrapper with a named `z` field. No test covered this path, so the failure survived from 2.0 to 2.0.22; `test_csf_dem_export.py` now guards it. - **CSF DEM interpolation no longer smears elevations across data gaps.** Switched from unbounded `invdist` — which weights every input point for every output cell — to `invdistnn` with a bounded search radius. Cells with no ground point in range are written as nodata instead of being extrapolated, and the output declares that nodata value so QGIS masks them. - **CSF DEM generation is bounded.** A small cell size over a wide extent previously requested an arbitrarily large raster. Requests above 20 000 cells per side now raise a clear error naming the cell size instead of exhausting memory. - **GPU acceleration could not complete a single run.** `_shift_array_gpu` computed the overlap between source and destination as `size` for a positive shift and `size − 2×|shift|` for a negative one, instead of `size − |shift|`. Every horizon step therefore raised `ValueError: could not broadcast input array`, so the CuPy path failed immediately on real CUDA hardware. It now mirrors `core.array_utils._shift_array` exactly. - **GPU acceleration produced different results from the CPU.** The CuPy kernels derived directions with `round(cos θ)` / `round(sin θ)`, which quantises every azimuth to one of eight integer directions — 16- and 32-direction requests silently collapsed to 8 — and stepped along whole-pixel multiples instead of the supersampled ray the CPU walks. Both GPU kernels now consume the same `_build_horizon_samples` geometry as the NumPy path. The existing equivalence tests only run under CUDA, so this was invisible in CI; `test_gpu_parity.py` asserts the shared-geometry contract on any machine. - **A broken CUDA install could stop the plugin loading.** `cupy.is_available()` raises rather than returning `False` when the driver is missing or mismatched, and the import guard only caught `ImportError`. Any probe failure now falls back to NumPy with a logged warning. - **Tiled outputs always declare a nodata value.** When the source DEM carried no nodata tag, `process_in_tiles` wrote raw NaN into an untagged float band; QGIS folded those NaNs into layer statistics and the contrast stretch collapsed. It now falls back to −9999, matching `write_array_to_raster`. - **Multi-temporal DoD export used a deprecated, no-op CRS call.** The CRS block looped over `["crs", "transform"]` without using the loop variable and called `rio.set_crs()`, which returns a copy and discards the result. Replaced with `rio.write_crs(..., inplace=True)`, clearing the `FutureWarning` the test suite emitted. **Added** - **GPU acceleration is now reachable from the interface.** The CuPy backend shipped in 2.0 but nothing in the plugin ever called it, so the advertised feature did not exist for users. Sky-View Factor and Topographic Openness gained a *Use GPU acceleration* checkbox. It never fails a run: without CuPy, with a broken driver, or when SVF noise removal is enabled (CPU-only), the algorithm falls back to NumPy and logs the reason. - **DEM geometry validation.** Every tiled algorithm now warns before processing when the input DEM uses a geographic (lat/lon) CRS — where cell size is in degrees, making slope, SVF, openness and search radii meaningless — has no CRS at all, or has non-square pixels, which biases every distance-based result because a single averaged cell size is used. - **Last deprecated boolean accessor removed.** `ml_export_algorithm` still called `parameterAsBool`; the 2.0.22 QGIS 4 / Qt6 pass had converted the other call sites to `parameterAsBoolean`. **Repository** - **Removed the checked-in `published/` snapshot.** It was a copy of the whole plugin frozen at v2.0.17, committed once by accident, five versions stale (it predated TRI entirely), referenced by no build script or CI workflow, and duplicating every symbol in the repository — it was even being swept into unrelated `ruff format` commits. Released builds are on GitHub Releases and every release is tagged. Added to `.gitignore` so it cannot return. - **Cleared release ZIPs from the working tree.** Twenty-two build artefacts (~18 MB, untracked) that are all reproducible from a git tag or downloadable from GitHub Releases. Pre-2.0 builds with neither a tag nor a release are the only surviving copies of those versions and were kept in `_archive/`. **Testing** - **End-to-end LAS → CSF → DEM coverage.** `test_csf_integration.py` drives `filter_las_file` — the function the Processing algorithm actually calls — over a synthetic wooded site, asserting that the DEM is written, the CRS comes from the LAS header rather than a default, canopy returns are removed, and a 1.5 m mound survives filtering. - **`test.sh` reports what it cannot test.** It now installs `rvt-py`, `laspy` and a `gdal` build pinned to `gdal-config --version`, then names any gating dependency still missing. Previously an interpreter without GDAL or rvt-py skipped seven modules and reported a green run. **Documentation** - **USER_GUIDE.md brought up to date for v2.1.** Documents the Contact Sheet, radii in metres, semantic segmentation, provenance sidecars and the GPU toggle; adds the two RVT algorithms, which the algorithm table had never listed; and restates the Batch preset radii in metres with a note that output produced by an earlier version on a non-1 m DEM was computed at a different scale than those figures imply. - **Removed a false capability claim.** The user guide advertised "Instance segmentation (Mask R-CNN): Returns polygons" as a supported model type. That was never implemented. The AI section now states exactly which two model types are supported, how each is detected, and what each produces. - **Algorithm count corrected to 31** across README, `metadata.txt` and both QGIS smoke tests. The smoke tests assert the count exactly, which is what caught the stale figures — a provider that fails to register an algorithm and a release that forgets to update the docs both surface the same way. - **Corrected the Sky-View Factor formula note.** The docstring claimed the implementation deviated from Zakšek et al. (2011) by using a linear `1 − mean(sin(horizon))` instead of a `sin²` form. Verified against `rvt-py` 2.x: the published and reference formula *is* the linear one, and it is what this plugin already computed. Output has always been comparable with other RVT installations. - Removed the stale "known SVF horizon rounding bug" note from the golden regression suite — that bug was fixed by the supersampled ray-cast. - Corrected the `rvt_openness` return-range documentation, which contradicted itself ([0, 90] / [−90, 0] versus the actual Yokoyama [0, 180]). - Added the missing CodeDNA header to `core/ruggedness.py`, the only core module without one.
yes
Token
2026-07-25T08:26:33.302803+00:00
3.0.0
4.99.0
None
no
Plugin Tags