surface_form_remove — ROUGHNESS prepare op

• Data kinds: depth → depth

• Call: import fullseye as fs; fs.ledger.surface_form_remove(z, dx, order=1, method='ls', thresh=None, dy=None, iters=200, seed=0) (to call the implementation directly, import roughness; roughness.surface_form_remove(z, dx, order=1, method='ls', thresh=None, dy=None, iters=200, seed=0); from the registry, opsroughness.get("surface_form_remove"))

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

• Underlying return: (residual, coeffs)

Usage

Removes a plane / quadric surface directly on the grid. Returns (residual, coeffs).

Arguments

z height field.

dx, dy sampling intervals. dy=None means square pixels.

order 1 = plane, 2 = quadric surface.

method "ls" (least squares) or "ransac" (robust).

thresh RANSAC inlier threshold (in height units). With None, the robust spread of the least-squares residuals

2.5 x 1.4826 x MAD is used.

iters number of RANSAC trials. Deterministic via seed.

coeffs are monomial coefficients in physical coordinates with the field center as origin. For order=1 they are [c0, gx, gy], with gx/gy directly the gradient; for order=2 they are [c0, gx, gy, cxx, cxy, cyy] (in the order x^2, xy, y^2).

★ Why it takes the grid as a grid

The existing fit_plane / fit_plane_ransac accept only (N,3) point clouds.

Passing a 512² height field to them requires expanding it every time with column_stack into 262144x3 = 6.3 MB

(measured). This op takes the grid as a grid and builds the basis

from outer products of 1-D vectors, so no expansion is needed.

★ Cost (measured, 1024x1024, iters=200)

method="ls" 47 ms versus method="ransac" 1.8 s (38 times).

RANSAC recounts the residuals of 1024² points on every trial, so it scales linearly with iters.

If you only look at roughness, "LS + λc high-pass" gives the same answer at 1/40 of the cost

(see the measurements below) — this is why RANSAC is not the default.

★ Pitfall — robustness is needed not for "deep scratches" but for "wide outliers".

But once the outliers become the majority, RANSAC breaks too.

Measured on a 512x512 surface with 4 scratches of depth 3 µm gathered on one side, varying only the width of the scratches

(a tilt of 0.050/-0.025 is injected and removed; the ground truth is the surface itself including the scratches):

scratch HWHM area ratio LS Sq error RANSAC Sq error gain

4 µm 5.6 % -1.94 % -0.39 % 5.0x

12 µm 16.3 % -6.47 % -3.03 % 2.1x

24 µm 31.0 % -13.79 % -11.53 % 1.2x

40 µm 46.0 % -23.80 % -23.56 % 1.0x

3 things can be read off:

• What matters is not the depth but the area ratio. "Deep scratch = robust fit required" is a hasty conclusion; the correct form is "wide outliers = robust fit required".

• The error always comes out on the low side — because least squares absorbs into the plane even "the genuine asymmetry caused by the scratches being gathered on one side". In pass/fail decisions it fails in the dangerous direction.

• At an area ratio of 46 % even RANSAC does not help (gain 1.0x). When outliers occupy nearly half, RANSAC's majority vote itself picks the scratches. Robust fitting is a tool premised on "outliers are a minority", and where that premise breaks can only be shown with a table.

★ Pitfall — putting λc downstream makes the difference between LS and RANSAC vanish

Applying a λc=80 µm high-pass afterwards to the same surface (HWHM 4 µm),

the Sq error is LS +0.00 % / RANSAC -0.00 % (a 5.0x difference in the table above).

The extra plane removed is pure long wavelength, so the high-pass merely throws the same thing away

once more. Robustness of the fit matters in workflows that apply no λc (flatness, form deviation). If you only look at roughness, the ordering saves you.

Note that the plane itself is recovered well even by LS: for the gradients injected into the surface above,

0.050 / -0.025, it returns 0.050006 / -0.025127 (measured).

fail-closed

• order other than 1 or 2 / method other than "ls" or "ransac".

• thresh non-positive or non-finite.

• RANSAC cannot gather the required number of inliers (degenerate surface).

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_polish_wipe_measure — py -3.11 examples/poc_polish_wipe_measure.py

• poc_pv_thermal_survey — py -3.11 examples/poc_pv_thermal_survey.py

• poc_surface_roughness — py -3.11 examples/poc_surface_roughness.py

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

surface_filter · surface_params · surface_psd

Same category (prepare)

surface_filter


*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.