Imaging and color measurement · Methods
Spectral group analysis: formulas and implementation
Four modules are published here, and the split between them is the design:
| Module | Responsibility |
|---|---|
sampled_spectrum |
Unit-neutral level/shape separation, shared with other spectral work |
spectro_measurement |
The typed reading and its group statistics |
spectro_analysis |
Level, shape, and chromaticity as three separate outputs |
spectro_colorimetry |
Same-record XYZ closure |
What is not here is equally deliberate: the MATLAB reader, the archive ingest, the identity ledger, and the report writers. The published code begins with typed in-memory measurements. How the archive was admitted is explained in the report; the measurement type does not depend on the format the measurement arrived in.
Level
For reading r on a uniform grid with step Δλ:
level_r = Δλ · Σᵢ radiance_r[i]
Every retained sample, including both endpoints, receives the same weight. This is the rectangular sum used by the archive, not trapezoidal integration.
The implementation uses compensated summation with exponent scaling, so a finite, representable answer survives a wide numeric range. A non-positive integral is rejected, because it cannot normalize a spectrum.
Across a group, level variation is the sample standard deviation with n − 1,
divided by the mean:
CV = s / mean
The n − 1 divisor is why a singleton has no value rather than zero: with one
reading the denominator is zero, and reporting 0 would describe perfect
agreement from a single measurement.
Shape
Each spectrum is divided by its own integral before comparison, which removes level completely:
normalized_r[i] = radiance_r[i] / level_r
shape_residual_r = ‖normalized_r − mean_normalized‖₂ / ‖mean_normalized‖₂
The group reports the maximum residual over its readings. Mathematically, a pure scale change leaves this at zero. The test uses a dyadic fixture for which the finite-precision result is also exactly zero, then uses a fixed-integral redistribution to prove the implementation does not simply return zero. Level and shape are therefore separately responsive quantities rather than two names for the same calculation.
Chromaticity
From the recorded XYZ, not from a recomputation:
x = X / (X + Y + Z)
y = Y / (X + Y + Z)
u′ = 4X / (X + 15Y + 3Z)
v′ = 9Y / (X + 15Y + 3Z)
The group reports the largest pairwise distance in u′v′, chosen because that
space is closer to perceptually uniform than xy, so a given numeric separation
means something more consistent across the diagram.
Recorded CCT and Duv are carried as metadata and never recomputed: the files do not identify the locus and distance conventions that would make a recomputation unambiguous.
Same-record XYZ closure
Integrating the spectrum against a CIE observer with the same equal sample weights should reproduce the recorded XYZ up to one proportional constant:
XYZ_computed[c] = Δλ · Σᵢ radiance[i] · x̄_c[i]
One scale k is fitted across every reading and every channel simultaneously —
not per reading, and not per channel, because a per-channel scale would absorb
exactly the disagreement the check exists to detect. Signed relative residuals
are then reported.
The result labels its own scale derived_from_recorded_xyz. That label is load
bearing: the fitted value is close to a familiar constant, and the analysis
does not label it as one.
Invalid inputs
| Condition | Behavior |
|---|---|
| Empty group or closure input | rejected |
| Grid shorter than two samples, non-uniform grid, or mismatched repeat axes | rejected |
| Radiance length mismatch or non-finite spectrum/XYZ sample | rejected |
| Spectrum with non-positive integral or non-representable normalization | rejected |
| XYZ that cannot form chromaticity | rejected |
| Observer/measurement axis mismatch or malformed observer table | rejected |
| Non-positive fitted closure scale or undefined relative residual | rejected |
| Non-representable mean, spread, integral, fit, or residual | rejected |
A rejection is a whole-group failure, not a partial result with some fields filled in.
What the tests pin
Four module-focused executables build every fixture in their own source files. They do not read the private archive and do not test command-level or archive-level behavior.
The central case is the separation property. A group holding one spectrum and
its exact double must show a level coefficient of variation of √32 / 12 — the
n − 1 value for {8, 16} — with shape residual and chromaticity separation
both exactly zero.
That case alone would pass on an implementation that always reports zero shape, so a second case redistributes the spectrum while holding its integral fixed: level variation must then be zero and shape residual must move. A deliberate XYZ change separately requires nonzero chromaticity movement.
test_sampled_spectrum.cpp covers
the rectangular integral, level/shape separation, a negative sample with a
positive total, subnormal preservation, cancellation, and its invalid-input cases.
test_spectro_measurement.cpp
covers pointwise n−1 spread, high-offset accuracy, extreme finite range,
cancellation, permutation, and unrepresentable spread. The analysis test covers
the three-axis and singleton contracts plus high-range and subnormal cases.
test_spectro_colorimetry.cpp
recovers one global scale, proves that a channel-specific disagreement leaves a
residual, exercises high-range and cancellation paths, and checks the observer
table and closure rejections.
One note on the closure fixture, because it caught a mistake while being written: the recorded XYZ must include the grid’s sample width, since the integral does. Omitting it makes the recovered scale differ from the intended one by exactly the step — a fixture error that looks like an implementation bug.
Source file: methods/spectral-group-analysis.md