dichromatic oprgbimage → rgbimageimport fullseye as fs; fs.ledger.specular_diffuse_split(image_rgb, illuminant_rgb=(1.0, 1.0, 1.0), body_rgb=None, max_rank_ratio=0.1, max_negative_frac=0.02) (実装を直接呼ぶなら import specularity; specularity.specular_diffuse_split(image_rgb, illuminant_rgb=(1.0, 1.0, 1.0), body_rgb=None, max_rank_ratio=0.1, max_negative_frac=0.02)、台帳から引くなら opsspecular.get("specular_diffuse_split"))fullseye.ledger.specular_diffuse_split(...) は宣言 out 型 rgbimage の値だけを返す(本体は補助情報も返す)。捨てられた側が要るときは fullseye.ledger.specular_diffuse_split.raw(...)、または specularity.specular_diffuse_split を直接呼ぶ。Split a linear-RGB image into its diffuse (body) and specular (interface) parts. → (diffuse, specular), both (H, W, 3).
Shafer’s dichromatic reflection model writes the radiance of a dielectric as
I(x) = m_d(x) * L(x) + m_s(x) * G: a body term carrying the surface
colour L and an interface term carrying the illuminant colour G.
The specular part therefore occupies a single direction in RGB, and
separating it is a projection with a closed form — no iteration, no
optimisation, no learned prior.
Two regimes, chosen by body_rgb:
body_rgb given — a (3,) colour or an (H, W, 3) map. Each
pixel solves the 3-equation, 2-unknown least-squares system exactly. This
is the textured-surface path: on a synthetic image built from a known
(m_d, m_s) it returns them with a maximum absolute error of 4.0e-15
for a uniform body colour and 2.9e-15 for a per-pixel colour map
(measured in tests/test_specularity.py).body_rgb omitted — one material is assumed. The
illuminant-orthogonal part of the image is then exactly rank one, so the
body direction is its leading singular vector; the unobservable component
of L along G is fixed by requiring m_s >= 0 with the minimum
over the image equal to zero. Maximum absolute error 5.0e-16 on the same
synthetic image. At least one lit pixel must be specular-free — see
below, this is the assumption that actually bites.illuminant_rgb is a direction; only its orientation matters and it is
unit-normalised internally. (1, 1, 1) is the white-balanced case. Get it
from :func:illuminant_from_dichromatic_planes when you have two or more
materials in frame.
Two guards protect the uniform-body path, and both are needed — the adversarial pass found the first one alone lets a two-material image through:
None
disables it.None disables it.Both guards bound gross violations only, and that is not fixable by a
better threshold. A texture whose chromaticity drifts along the body
direction rather than away from it measured a rank ratio of 0.0641 — under
the default — with every body coefficient positive, so neither guard fires,
and the returned diffuse map was wrong by 0.198. It cannot be separated from
noise by any threshold, because it is the same measurement: 1% Gaussian
noise on that scene gives 0.0348 and 2% gives 0.0694, and the texture sits
between them. The answer for a surface that might be textured is
body_rgb, not a cleverer number here.
Honest limits. (1) Without body_rgb, one lit pixel must be
specular-free. The rendered-lobe measurement shows exactly what it costs
when none is: for a Blinn-Phong highlight on a Gaussian bump the maximum
diffuse error is 6.5e-11 at shininess 200 (where the lobe tail underflows to
9.1e-11), 0.0019 at shininess 48 (tail 0.0026) and 0.175 at shininess 8
(tail 0.243) — the error is the darkest highlight in the frame, because
that is the constant the constraint cannot see. (2) The known-body path is
conditioned by 1/(1 - b^2) where b is the cosine between the body
and illuminant colours. A texture reaching |b| = 0.99999 (an almost
neutral grey under a white lamp, amplification 6.4e+04) measured 5.9e-12
against 2.9e-15 for the same texture kept at |b| <= 0.965. Near-grey
surfaces are where colour-based separation is weakest, and no amount of
arithmetic care changes that.
Raises ValueError: image_rgb is not (H, W, 3), is complex /
masked / non-finite / string-typed, or exceeds :data:MAX_PIXELS;
illuminant_rgb is not a non-zero 3-vector; the image is identically zero;
the image has no component orthogonal to the illuminant (body colour
parallel to it, so no split exists); either guard above fires; body_rgb
has the wrong shape, a zero-length colour, or is parallel to the
illuminant.
Returns (diffuse, specular) with diffuse + specular == image_rgb to
machine precision in both regimes: measured 1.1e-16 on the uniform-body
route, which forms the diffuse as image - specular, and 2.1e-15 on the
known-body route, which forms both parts from the solved coefficients and
so accumulates a little more.
py -3.11 examples/specular_photometric.pyrgbimage を入力に取れる)specular_coefficient_map · specular_free_transform · illuminant_from_dichromatic_planes
dichromatic)specular_coefficient_map · specular_free_transform · illuminant_from_dichromatic_planes
Provenance: specularity.py — SPECULAR operator registry. この per-op ノートは tools/opdocs.py md が自動生成(手編集しない)。
© 2026 Kazufumi Furuse — Fullseye operator documentation. Licensed under Apache-2.0.