surface_psd — ROUGHNESS measure op

• Data kinds: depth → pairs

• Call: import fullseye as fs; fs.ledger.surface_psd(z, dx, kind='areal', dy=None) (to call the implementation directly, import roughness; roughness.surface_psd(z, dx, kind='areal', dy=None); from the registry, opsroughness.get("surface_psd"))

• Return value through the ledger: fullseye.ledger.surface_psd(...) returns only the declared out type pairs (the underlying function also returns auxiliary values). When you need what was dropped, use fullseye.ledger.surface_psd.raw(...) or call roughness.surface_psd directly.

• Underlying return: (q, C) -> (N,2)

Usage

Power spectral density of the height field. Returns (q, C). The conventions are made explicit through the arguments.

Arguments

z Height field (the mean is subtracted internally; the DC bin is not returned).

dx, dy Sample spacing. dy=None means square pixels.

kind "areal" ... ring average of the 2-D PSD C_2D(q).

"radial" ... 1-D radial PSD C_1D(q) = 2π q C_2D(q).

Conventions (without writing these down it cannot be read without calibration ―― the existing radial_power_spectrum does not have them in its docstring)

• q is in cycles / length (not the angular frequency 2π/λ). q = 1/λ.

• C_2D(q) = dx dy |F|² / (ny nx). Normalisation matches Parseval: ∫ C_2D d²q = <h²> and ∫ C_1D dq = <h²>.

• The slope for a self-affine surface H is -2(H+1) for the areal PSD and -2H-1 for the radial PSD (they differ by exactly 1).

★ Measured —— H is recovered without calibration under either convention

H was injected with surface_synth_psd, a straight line fitted in log-log over q ∈ [2/λ_hi, 0.4/dx],

and H recovered (512², band 2-64 µm):

injected H areal PSD slope recovered H radial slope recovered H slope diff

0.30 -2.600 0.300 -1.600 0.300 1.0000

0.50 -3.000 0.500 -2.000 0.500 1.0000

0.80 -3.600 0.800 -2.600 0.800 1.0000

0.95 -3.901 0.950 -2.901 0.950 1.0000

The error is within ±0.0003 in all 4 cases, and the slope difference between the two conventions is

exactly 1.0000 for every H. Readable without calibration.

★ This is where re-measuring the PoC changed the conclusion. The PoC found, with the existing

radial_power_spectrum, a uniform systematic error of -0.013 (1.6 % of H) and

concluded it was "a systematic error from discretising the ring average". Doing the same cross-check with this

implementation makes the error vanish. Isolating the cause showed it was the difference between using the bin index i·Δq as the representative q of a ring, or the mean of q within the ring

—— with the bin index a negative offset of -0.0007 to -0.0010 appears (measured, same surface, same

fitting band). The direction matches the PoC's observation but the size is 1/15, so

the rest of the -0.013 comes not from ring-average discretisation but from other implementation differences in that function.

This function returns the mean of q within the ring.

★ Pitfall —— rings beyond Nyquist consist of "corners only"

The maximum |q| of a square grid is √2/(2 dx), but rings with q > 1/(2 dx)

contain only the four corners of the frequency plane. Measured (512²): of all 362 bins, the top

107 bins (29.6 %) are beyond Nyquist. How far the point count in a ring falls short of the ideal 2πq/Δq

drops to at least 0.838x even below Nyquist

(grid discretisation), but beyond Nyquist it drops to 0.

Do not use beyond-Nyquist bins for the slope fit.

★ How far the Parseval cross-check can be trusted (measured, 512², Sq²=6.400e-03)

• Taking Σ C_2D Δqx Δqy over the full 2-D grid gives +0.0000 % (exact).

• Trapezoidal integration of the returned radial PSD gives -0.55 %. This amount is lost the moment the data are averaged per ring and collapsed to 1-D —— the ring average is a summary and not invertible. For uses needing accuracy better than 0.5 %, integrate the 2-D C_2D yourself.

• This surface's band tops out at Nyquist, so truncating at Nyquist leaves it at -0.55 % unchanged (the corners have no content). For surfaces whose corners have content (band beyond Nyquist), truncation loses it.

fail-closed

• Not 2-D / smaller than 8x8 / contains NaN or Inf / dx, dy not positive.

• kind other than "areal" or "radial".

Detailed usage guide

• surface_roughness family guide

References (sample data, literature)

• Sample-data catalog (download URLs / licences) — 2-D uses skimage.data (BSD/public domain) plus synthetic images; 3-D lists download URLs for real data sources (Stanford, PDS, …).

• Operator provenance and references — the sources of the research/methods this op family came from.

• The canonical algorithm (author, year) and its uses are named in the family usage guide above.

Runnable examples (verified samples that actually call this op)

• poc_surface_roughness — py -3.11 examples/poc_surface_roughness.py

Ops the type connects to (they accept pairs as input)

—

Same category (measure)

surface_params · profile_params


*Provenance: roughness.py — ROUGHNESS operator registry. This per-op note is generated by tools/opdocs.py md (do not hand-edit).*

© 2026 Kazufumi Furuse — Fullseye operator documentation. Licensed under Apache-2.0.