Version: [5284] LiDAR Relief Visualization 2.1.2

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

Version management

Plugin details