fit_zernike — 3D curvilinear op

• Data kinds: image2d → table

• Call: import fullseye as fs; fs.ledger.fit_zernike(disk_image, n_max=6, device='cpu', nr=48, nt=72, center=None, radius=None) (to call the implementation directly, import match3d; match3d.fit_zernike(disk_image, n_max=6, device='cpu', nr=48, nt=72, center=None, radius=None); from the registry, ops3d.get("fit_zernike"))

• GPU: this op has a GPU path (device="cuda")

Usage

A disc image → Zernike coefficients (the polar-coordinate surface fit of optics and wavefront metrology). Returns {(n,m): coef}.

Reduces a surface on a disc (wavefront aberration, lens shape) to a few coefficients with orthogonal polynomials. tilt/defocus/astigmatism/ coma/spherical etc. correspond to specific (n,m), and rotation mixes m (band = rotation invariant).

The pupil (unit disc ρ=1) is placed by default at the image centre ((H-1)/2, (W-1)/2) with radius min(H, W)/2 - 1 pixels. If the pupil in the image differs from this, pass center=(row, col) and radius (pixels) —— if you do not, the radius mismatch leaks directly into the low-order modes (reading a pure defocus with pupil radius (H-1)/2 at 129×129 with the defaults gives (2,0)=0.984 / (0,0)=−0.015; passing radius=(H-1)/2 gives 0.999998). A center / radius whose pupil circle sticks out of the image is a ValueError.

Honest disclosure (corrected 2026-10-07): this used to say "crosstalk of up to ~10% from discrete sampling (nr=48, nt=72); raising nr/nt reduces it", but the cause was not the resolution. If the pupil radius and centre match and the image continues beyond the pupil, the coefficients return to almost 1 (0.999998 at 129×129) with the default nr/nt. The actual causes of the leakage are

(1) the pupil radius/centre mismatch above (raising nr/nt does not reduce it by even 1 order of magnitude), and (2) in images that are 0 outside the pupil,

the outermost ring (ρ=1) sits on the edge and bilinear interpolation sucks in the 0 outside (measured in examples/poc_zernike_aberrations.py).

★Correction 2026-10-14 (area weights): the polar grid (ρ evenly spaced from 0 to 1) used to be solved by least squares without weights. Every ring has the same number of samples, so the small central area counted too much and truncated higher orders leaked into lower ones (reading Z80 + 0.3·Z20 with n_max=4 gave Z40 = 0.39; the true projection is 0). Now ρ sits on nr Gauss–Legendre nodes (excluding 0 and 1) and the fit is weighted by w_i·ρ_i (the area element ρ dρ dθ), so the projection of an in-band polynomial is exact. Since no node lies on ρ = 1, less of the zero outside the pupil is drawn in (it does not vanish: the bilinear footprint still straddles the rim).

Raises ValueError: input not 2-D, smaller than 2x2, NaN/Inf/float32 overflow, or the pupil sticking out of the image.

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.

Runnable examples (verified samples that actually call this op)

• curvilinear_proj — py -3.11 examples_3d/curvilinear_proj.py

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

fuse_to_voxel · mesh_select_lod

Same category (curvilinear)

polar_unwrap · cylinder_unwrap


*Provenance: match3d.py — 3D 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.