A kernel covariate in multiScaleR summarizes the
average value of a continuous raster in the neighborhood of a
point. A surface texture covariate summarizes something different: how
variable that surface is within the neighborhood. Two points
can have the same average elevation, NDVI, or canopy height, yet one can
sit in smooth terrain and the other in rugged terrain. Surface texture
metrics capture that difference.
surface_var() derives these metrics from a continuous
raster within a circular buffer, in the same way that
landscape_var() derives landscape metrics from a
categorical raster. The two functions are deliberately separate because
they expect opposite kinds of raster: landscape_var()
requires integer class codes, while surface_var() requires
continuous values. If you have not used the explicit covariate interface
before, read
vignette("landscape_metric_covariates", package = "multiScaleR")
first; this vignette assumes you are comfortable with
msr_vars().
This release supports six surface texture metrics, all borrowed from
the surface-metrology literature. Two describe dispersion (how spread
out the values are), two describe the shape of the value distribution,
and two describe the geometry of local change (slope and surface area).
Most are matched to the geodiv package so that
geodiv can be used to validate results; the exceptions are
noted below.
Each metric is computed on the continuous values within a circular
buffer centered on a sample point (or on each raster cell when
projecting with kernel_scale.raster()). In the formulas
below, \(z_i\) is a cell value in the
neighborhood, \(\bar z\) is the mean of
those values, \(s\) is their standard
deviation, and \(n\) is the number of
finite cells.
| Code | Name | Definition | Reading |
|---|---|---|---|
sa |
Average roughness | \(S_a = \frac{1}{n}\sum_i \lvert z_i - \bar z \rvert\) | Mean absolute deviation from the neighborhood mean. Robust to a few extreme cells. |
sq |
RMS roughness | \(S_q = \sqrt{\frac{1}{n-1}\sum_i (z_i - \bar z)^2}\) | The sample standard deviation of the neighborhood. More sensitive to
large deviations than sa. |
ssk |
Skewness | \(S_{sk} \propto \frac{1}{n\,s^3}\sum_i (z_i - \bar z)^3\) | Asymmetry of the value distribution. Positive when occasional high values dominate, negative when occasional low values do. |
sku |
Kurtosis | \(S_{ku} = \frac{1}{n\,s^4}\sum_i (z_i - \bar z)^4 - 3\) | Peakedness and tail weight relative to a normal distribution (excess kurtosis, so a normal surface gives 0). |
sdq |
RMS slope | \(S_{dq} = \sqrt{\frac{1}{n}\sum_i \lvert \nabla z_i \rvert^2}\) | Root mean square of the local gradient magnitude: how steeply the surface changes (terrain or structural ruggedness). |
sdr |
Surface area ratio | \(S_{dr} = \left(\frac{A_{3D}}{A_{flat}} - 1\right) \times 100\) | Percentage by which the true 3D surface area exceeds the flat area. A continuous edge-density analog: more relief gives a larger ratio. |
The dispersion metrics (sa, sq) are zero on
a perfectly flat surface and grow as the surface becomes more variable.
They are reported in the units of the raster itself: if
land3 ranges from 0 to 1, a roughness of 0.10 means the
values within the buffer deviate from their local mean by about a tenth
of that range. sa and sq measure the same
underlying idea (local spread) and are usually correlated;
sq weights large deviations more heavily because it squares
them, so it responds more strongly to a few unusual cells.
The shape metrics (ssk, sku) are
dimensionless and describe the form of the value distribution
rather than its spread, so they complement the dispersion metrics. The
geometry metrics (sdq, sdr) describe local
change. sdq measures gradient steepness; it is reported as
a true slope (rise over run), dividing the local elevation change by the
cell resolution. The matched geodiv function
geodiv::sdq() instead uses a cell spacing of one, so the
two agree after multiplying by the resolution. sdr is the
percentage by which the true 3D surface area exceeds the flat projected
area, computed in physical units (real elevation values and the real
cell size), so a tilted plane of slope \(s\) gives exactly \((\sqrt{1 + s^2} - 1)\times 100\). It
behaves as a continuous edge-density analog: terrain that folds and
rises has more surface area per unit ground. Note that
geodiv::sdr() rescales the surface to a unit range and is
therefore dimensionless; the physical definition used here is the
interpretable one for terrain and, unlike the rescaled version, can be
projected by FFT. A few cautions are worth keeping in mind: the shape
metrics are noisier than dispersion and need a reasonable number of
cells in the buffer to be stable, and ssk,
sdq, and sdr can be correlated with
sq on many surfaces, so check their correlations before
interpreting them together.
A surface texture covariate behaves differently from a kernel-weighted mean as you change the radius. A larger radius does not simply smooth the same quantity over a wider area; it changes what is being measured. A small radius asks how rough the surface is in the immediate vicinity of a point (fine-grain texture). A large radius asks how variable the surface is across a broader extent, which folds in any large-scale gradient or patchiness. Keep this in mind when interpreting an optimized radius: the estimated scale is the neighborhood size at which within-neighborhood variability best explains the response, not the scale of an averaging window.
This example reuses the simulated count data, survey locations, and
landscape raster included with multiScaleR. The data
contain 100 spatial sample points, which is enough to demonstrate the
workflow without making the vignette expensive to build.
data("landscape_counts")
dat <- landscape_counts
data("surv_pts")
pts <- vect(surv_pts)
land_rast <- rast(pkg_extdata("landscape.tif"))The raster stack contains two continuous layers suited to surface
texture metrics. land3 is a smooth, highly autocorrelated
surface (think elevation or canopy height), so its roughness varies
meaningfully from place to place. land2 is a noisier,
weakly autocorrelated surface. We also keep land2 because
the example counts were simulated to respond to its kernel-weighted
mean, which gives us a covariate with a genuine, interpretable scale of
effect.
Continuous source surfaces. land3 (right) is smooth; land2 (left) is noisy.
plot(land_rast$land3, main = "land3 with survey points")
points(pts, pch = 19, cex = 0.6, col = "red")Continuous source surfaces. land3 (right) is smooth; land2 (left) is noisy.
How to read these plots: Where land3
transitions sharply between low and high values, a buffer placed on the
transition contains a wide spread of values and will have high
roughness. Where land3 is locally flat (a broad light or
dark patch), a buffer contains similar values and roughness is low. The
roughness surfaces produced later in this vignette make that pattern
explicit.
Use msr_vars() to name the model covariates. Here
land2_mean is an optimized, kernel-weighted mean, while
land3_sq_500 and land3_sa_500 are fixed-radius
surface texture metrics computed within 500 m of each point. Computing
the surface metrics at a fixed radius keeps this example fast: they are
calculated once and are not re-evaluated during optimization.
scale_vars <- msr_vars(
land2_mean = kernel_var("land2"),
land3_sq_500 = surface_var("land3", metric = "sq", radius = 500),
land3_sa_500 = surface_var("land3", metric = "sa", radius = 500)
)
scale_vars
#> multiScaleR variable specifications:
#> covariate source type metric radius optimize weighted base
#> land2_mean land2 kernel <NA> NA TRUE FALSE 2.718282
#> land3_sq_500 land3 surface sq 500 FALSE FALSE 2.718282
#> land3_sa_500 land3 surface sa 500 FALSE FALSE 2.718282
#> classes_max class
#> NA NA
#> NA NA
#> NA NAThe distinction is the same as for landscape metrics:
land2 and land3 are source raster
layers.land2_mean, land3_sq_500, and
land3_sa_500 are model covariates.land2_mean receives an optimized sigma in this
example.Pass the specification to kernel_prep(). The returned
kernel_dat data frame holds the derived covariates, already
centered and scaled to unit variance.
kernel_inputs <- kernel_prep(
pts = pts,
raster_stack = land_rast,
max_D = 1500,
kernel = "gaussian",
scale_vars = scale_vars,
verbose = FALSE
)
head(kernel_inputs$kernel_dat)
#> land2_mean land3_sq_500 land3_sa_500
#> 1 0.07296415 -1.22937472 -1.226248254
#> 2 0.71170849 0.05534524 0.007934204
#> 3 0.66724731 -0.73597122 -0.715745837
#> 4 0.54731252 -0.48069113 -0.398008289
#> 5 0.80334432 0.19831926 0.204944538
#> 6 -0.11974403 1.05301737 0.989423539Combine the covariates with the response and fit the starting model.
df <- data.frame(dat, kernel_inputs$kernel_dat)
mod <- glm(
counts ~ site + land2_mean + land3_sq_500 + land3_sa_500,
family = poisson(),
data = df
)Only land2_mean was left free, so optimization estimates
a single scale parameter while the two surface metrics stay fixed at 500
m. This keeps the example fast and mirrors a common analysis pattern:
optimize the covariate whose scale is the scientific question, and hold
the others at a biologically motivated buffer.
opt <- multiScale_optim(
fitted_mod = mod,
kernel_inputs = kernel_inputs,
verbose = FALSE
)
#>
#>
#> Optimization completesummary(opt)
#>
#> Call:
#> multiScale_optim(fitted_mod = mod, kernel_inputs = kernel_inputs,
#> verbose = FALSE)
#>
#>
#> Kernel used:
#> gaussian
#>
#> ***** Optimized Scale of Effect -- Sigma *****
#>
#> Mean SE 2.5% 97.5%
#> land2_mean 417.5643 35.16722 347.7486 487.3801
#>
#>
#> ====================================
#>
#> ***** Optimized Scale of Effect -- Distance *****
#> 90% Kernel Weight
#>
#> Mean 2.5% 97.5%
#> land2_mean 686.83 572 801.67
#>
#>
#> ====================================
#>
#> ***** Fitted Model Summary *****
#>
#>
#> Call:
#> glm(formula = counts ~ site + land2_mean + land3_sq_500 + land3_sa_500,
#> family = poisson(), data = data)
#>
#> Coefficients:
#> Estimate Std. Error z value Pr(>|z|)
#> (Intercept) 0.46270 0.08676 5.333 9.64e-08 ***
#> site 0.07375 0.07911 0.932 0.351
#> land2_mean 0.68555 0.07751 8.845 < 2e-16 ***
#> land3_sq_500 -0.77677 1.07138 -0.725 0.468
#> land3_sa_500 0.98629 1.06937 0.922 0.356
#> ---
#> Signif. codes: 0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1
#>
#> (Dispersion parameter for poisson family taken to be 1)
#>
#> Null deviance: 224.24 on 99 degrees of freedom
#> Residual deviance: 137.90 on 95 degrees of freedom
#> AIC: 344.58
#>
#> Number of Fisher Scoring iterations: 5
#>
#>
#>
#> ***** Follow-up recommendation *****
#> Recommended `max_D`: 1500
#> Recommended starting `sigma` values:
#> land2_mean
#> 417.5643
#>
#> Action:
#> Use the optimized parameters as efficient starting values if refitting.How to read the summary() output: The
scale section reports the estimated sigma for land2_mean,
with its standard error and 95% confidence interval, in map units
(meters here). Below it is the effective distance, the radius
that captures a stated proportion (default 90%) of the Gaussian kernel
weight. That converts sigma into a distance you can interpret on the
ground. The two fixed-radius surface metrics do not appear in the scale
section because their radius was specified rather than estimated; they
enter the model only as ordinary covariates.
The model coefficients are on the Poisson log (link) scale, so they must be back-transformed to be interpretable. Because every covariate was centered and scaled, each coefficient describes the effect of a one standard deviation change in that covariate. Exponentiating gives the multiplicative change in expected count.
cf <- coef(summary(opt$opt_mod))
## Multiplicative change in expected count per 1 SD increase in each covariate
round(exp(cf[, "Estimate"]), 3)
#> (Intercept) site land2_mean land3_sq_500 land3_sa_500
#> 1.588 1.077 1.985 0.460 2.681For example, an exp() value of 1.20 for a surface metric
would mean that a one standard deviation increase in roughness is
associated with a 20 percent higher expected count, holding the other
covariates constant. Note an important caveat: these example counts were
simulated to respond to kernel-weighted means of land1 and
land2, not to surface roughness. The recovered scale for
land2_mean is therefore the meaningful quantity to
interpret here, and the roughness coefficients are best read as a
demonstration of the mechanics. In your own analysis, a surface texture
coefficient is interpreted exactly like any other scaled predictor.
kernel_scale.raster() understands
surface_var() specifications just as it does kernel and
landscape covariates. When a fitted multiScaleR object is
supplied, the optimized covariate uses its fitted scale and the
fixed-radius surface metrics use their specified radius. Setting
scale_center = TRUE returns the projected covariates on the
same centered and scaled footing as the model, ready for
terra::predict().
projected <- kernel_scale.raster(
raster_stack = land_rast,
multiScaleR = opt,
scale_center = TRUE,
clamp = TRUE,
verbose = FALSE
)
plot(projected)Projected model covariates at their fitted or fixed scales.
How to read these plots: land2_mean is
a smoothed average surface, so it looks like a blurred version of the
source layer. The roughness layers (land3_sq_500,
land3_sa_500) look different: they are bright where
land3 changes rapidly within 500 m and dark where it is
locally uniform. Roughness maps highlight transitions and texture, not
high or low values. The stack also includes a constant site
layer filled with zeros: because site is a field-measured
covariate with no raster source, kernel_scale.raster()
appends a placeholder so the output can be passed directly to
terra::predict(). Replace it with a real surface if you
want to predict at a non-reference value of site.
You do not need a fitted model to produce a roughness surface. Pass a
scale_vars object in which every covariate has a fixed
radius, and kernel_scale.raster() returns the
metric surfaces directly. This is useful for exploring how texture
varies across a landscape, or for comparing scales before committing to
a model.
sq_multi <- kernel_scale.raster(
raster_stack = land_rast["land3"],
scale_vars = msr_vars(
sq_200 = surface_var("land3", metric = "sq", radius = 200),
sq_500 = surface_var("land3", metric = "sq", radius = 500),
sq_900 = surface_var("land3", metric = "sq", radius = 900)
),
verbose = FALSE
)
plot(sq_multi, main = c("sq - 200 m", "sq - 500 m", "sq - 900 m"))RMS roughness (sq) of land3 at three radii. Larger radii fold in broader-scale variation.
How to read these plots: At 200 m the surface picks out fine, local texture, so roughness is patchy and concentrated near sharp transitions. At 900 m each buffer spans more of the underlying gradient, so roughness is smoother and generally higher. This is the practical face of the scale caution from the introduction: the radius changes the quantity, not just its resolution.
The sa and sq metrics emphasize roughness
slightly differently. Comparing them at a single radius shows where a
few extreme cells (which sq weights more heavily) drive the
signal.
sa_sq <- kernel_scale.raster(
raster_stack = land_rast["land3"],
scale_vars = msr_vars(
sa_500 = surface_var("land3", metric = "sa", radius = 500),
sq_500 = surface_var("land3", metric = "sq", radius = 500)
),
verbose = FALSE
)
plot(sa_sq, main = c("sa - 500 m", "sq - 500 m"))Average roughness (sa) versus RMS roughness (sq) of land3 at 500 m.
Projection of sq uses fast Fourier transform
convolution, and projection of sa uses a compiled focal
pass. Both reproduce the per-point calculation exactly; sq
is the faster of the two on very large rasters.
The same standalone projection works for the shape metrics
(ssk, sku) and the geometry metrics
(sdq, sdr). They answer different questions
than dispersion: skewness and kurtosis describe the form of the
value distribution in the neighborhood, sdq describes how
steeply the surface changes, and sdr describes how much
extra surface area that change creates.
shape_geom <- kernel_scale.raster(
raster_stack = land_rast["land3"],
scale_vars = msr_vars(
ssk_500 = surface_var("land3", metric = "ssk", radius = 500),
sku_500 = surface_var("land3", metric = "sku", radius = 500),
sdq_500 = surface_var("land3", metric = "sdq", radius = 500),
sdr_500 = surface_var("land3", metric = "sdr", radius = 500)
),
verbose = FALSE
)
plot(shape_geom,
main = c("ssk - 500 m", "sku - 500 m", "sdq - 500 m", "sdr - 500 m"))Skewness, kurtosis, RMS slope, and surface area ratio of land3 at 500 m.
How to read these plots: The skewness surface is
positive where the neighborhood is dominated by occasional high values
and negative where low values dominate. The kurtosis surface is high
where a few extreme cells stretch the tails of the local distribution.
The sdq and sdr surfaces are both bright where
land3 changes steeply from cell to cell: sdq
as a slope and sdr as the extra 3D surface area that slope
produces, so they resemble ruggedness maps and are often correlated.
Because shape and geometry are noisier than dispersion, they are most
useful when a hypothesis specifically concerns asymmetry, tail behavior,
gradient steepness, or surface complexity rather than overall
variability.
To estimate the scale of a surface texture covariate rather than fix
it, omit radius from surface_var(). The
covariate then receives its own sigma, which
multiScale_optim() estimates alongside the model.
scale_vars_opt <- msr_vars(
land2_mean = kernel_var("land2"),
land3_sq = surface_var("land3", metric = "sq")
)
kernel_inputs_opt <- kernel_prep(
pts = pts,
raster_stack = land_rast,
max_D = 1500,
scale_vars = scale_vars_opt,
verbose = FALSE
)Optimizing a surface texture radius is well defined, but it costs
more than optimizing a kernel-weighted mean: at each candidate radius
the metric is recomputed for every sample point, since (unlike a kernel
mean) it cannot be read off a precomputed distance binning. Surface
metrics are continuous in the radius, so they tend to optimize smoothly.
Start with fixed radii to confirm the workflow, then optimize once the
model structure is settled. Optimized unweighted surface
metrics are not currently supported with kernel = "expow",
because the exponential-power shape parameter does not apply to a
hard-edged buffer. The weighted form below does use the kernel, so it is
supported with every kernel.
The surface metrics above summarize a hard-edged circular
neighborhood: every cell within the radius counts equally. Setting
weighted = TRUE instead weights each cell by the model’s
distance kernel, the same Gaussian (or exponential) kernel used for a
kernel_var() mean, and optimizes the kernel scale (sigma)
rather than a hard radius.
This changes both the question and the statistics. A weighted
sq answers “at what spatial scale does the
variability of this surface, measured with a smoothly decaying
neighborhood, best predict the response?” Because the kernel weights
change smoothly as sigma changes (cells do not jump in and out of a hard
edge), the likelihood is smooth in sigma, so
multiScale_optim() recovers the scale with a standard error
just as it does for a kernel-weighted mean. This is the natural way to
ask about the scale of effect of heterogeneity, as opposed to the scale
of effect of the mean.
weighted_vars <- msr_vars(
land3_sq_weighted = surface_var("land3", metric = "sq", weighted = TRUE)
)Weighting is available for the distribution metrics (sa,
sq, ssk, sku) but not the slope
metric (sdq), and the scale is always optimized, so no
radius is supplied. To see the difference a weighting
makes, the projection below compares a hard-radius sq at
500 m with a Gaussian-weighted sq at the same nominal
scale.
sq_hard <- kernel_scale.raster(
raster_stack = land_rast["land3"],
scale_vars = msr_vars(sq_hard = surface_var("land3", metric = "sq",
radius = 500)),
verbose = FALSE
)
sq_weighted <- kernel_scale.raster(
raster_stack = land_rast["land3"],
scale_vars = weighted_vars,
sigma = 500, # the kernel scale for the weighted metric
kernel = "gaussian",
verbose = FALSE
)
plot(c(sq_hard, sq_weighted), main = c("sq hard radius 500 m", "sq weighted sigma 500"))Hard-radius versus kernel-weighted RMS roughness of land3 at a 500 m scale.
How to read these plots: Both maps show local
roughness at a 500 m scale, but the weighted version transitions more
smoothly because nearby cells contribute more than distant ones. In a
model, the practical payoff is the optimization: the weighted form gives
multiScale_optim() a smooth objective in sigma and returns
the scale of heterogeneity with a confidence interval. Use the weighted
form when the scale of heterogeneity is the scientific question; the
hard-radius form remains convenient when you have a biologically
motivated buffer or want a metric that matches the geodiv
definitions exactly.
sq when large local deviations are ecologically
important, and sa when you want a measure that is less
sensitive to a few extreme cells.weighted = TRUE (for sa,
sq, ssk, sku) when you want the
scale of heterogeneity estimated with a smooth likelihood under the
model’s kernel; keep the default hard-radius form when you want a fixed
buffer or exact agreement with geodiv.## 1. Define covariates: an optimized kernel mean plus fixed-radius roughness
scale_vars <- msr_vars(
land2_mean = kernel_var("land2"),
land3_sq_500 = surface_var("land3", metric = "sq", radius = 500),
land3_sa_500 = surface_var("land3", metric = "sa", radius = 500)
)
## 2. Extract and prepare the covariates around each point
kernel_inputs <- kernel_prep(pts, land_rast, max_D = 1500,
scale_vars = scale_vars, verbose = FALSE)
## 3. Fit the starting model
df <- data.frame(dat, kernel_inputs$kernel_dat)
mod <- glm(counts ~ site + land2_mean + land3_sq_500 + land3_sa_500,
family = poisson(), data = df)
## 4. Optimize the free scale parameter(s)
opt <- multiScale_optim(fitted_mod = mod, kernel_inputs = kernel_inputs)
summary(opt) # sigma + effective distance, on map units
## 5. Project covariates for prediction (centered and scaled like the model)
projected <- kernel_scale.raster(land_rast, multiScaleR = opt,
scale_center = TRUE, clamp = TRUE)
## Standalone roughness surface, no model required
sq_500 <- kernel_scale.raster(
land_rast["land3"],
scale_vars = msr_vars(sq_500 = surface_var("land3", metric = "sq", radius = 500))
)| Function | Purpose |
|---|---|
surface_var() |
Define a continuous surface texture covariate (sa or
sq). |
msr_vars() |
Collect covariate specifications into one object. |
kernel_prep() |
Extract and prepare covariates around each point. |
multiScale_optim() |
Estimate the scale of effect for optimized covariates. |
kernel_scale.raster() |
Project covariates to rasters, with or without a fitted model. |
vignette("landscape_metric_covariates", package = "multiScaleR")
for categorical landscape metrics and the msr_vars()
interface.vignette("multiScaleR_Guide", package = "multiScaleR")
for the full modeling workflow and model interpretation.vignette("spatial_projection_clamping", package = "multiScaleR")
for projecting fitted models to prediction surfaces.