<p>Getting sRGB values outside [0, 1] is expected and normal when using spectral rendering.</p>

<p>The sRGB gamut only covers a triangle in the middle of the CIE chromaticity space:</p>

<p><a href="https://i.sstatic.net/Y9z7W.png" rel="nofollow noreferrer"><img src="https://i.sstatic.net/Y9z7W.png" alt="CIE chromaticity diagram with sRGB gamut highlighted"></a>
(diagram from <a href="https://en.wikipedia.org/wiki/SRGB#/media/File:Cie_Chart_with_sRGB_gamut_by_spigget.png" rel="nofollow noreferrer">Wikipedia</a>)</p>

<p>The big "horseshoe" shape is the set of all physically possible chromaticities (CIE xy coordinates) from any possible spectrum. So your xy coordinates should be expected to land somewhere in the horseshoe...but only the ones that land inside the triangle are representable as sRGB! The others are "too saturated" for sRGB, and will have to be clamped or remapped somehow. (Tone mapping per se doesn't fix this, as it's concerned with luminance rather than chromaticity.)</p>

<p>There are a variety of approaches to deal with out-of-gamut colors depending on how sophisticated you want to be:</p>

<ul>
<li>You can of course simply clamp the sRGB values, but this may change the hue and/or the luminance of the color in the process.</li>
<li>To preserve both hue and luminance, you can edit the xy coordinates before calculating <code>scaledXyz</code>. If the initial xy coordinates are outside the sRGB triangle, find the line segment from the xy coordinates to the white point (e.g. D65); then find the intersection of that line segement with the sRGB triangle. That's the closest sRGB point that has the same luminance and hue as the original point, but lower saturation. (The vertices of the sRGB triangle can be obtained by converting RGB(1, 0, 0), RGB(0, 1, 0), and RGB(0, 0, 1) to CIE xy space.)</li>
<li>Alternatively, rather than clamping (which throws away any detail in the saturation outside the sRGB gamut), you could try doing a linear or non-linear remapping of saturation values in the image, similar to how you do for luminance. By "saturation" here I mean distance from the white point. The idea would be to squish the whole image toward the white point so as to bring everything within the gamut. But that may affect the apparent saturation of in-gamut colors in visually undesirable ways.</li>
</ul>

CC BY-SA 4.0
2019-01-27T00:06:35.283Z
2019-01-27T00:06:35.283Z
Nathan Reed
<p>I'm trying to write my own spectral path tracer and a bit stuck with converting computed SPDs to LDR RGB values. What I'm doing right now is convert sampled SPDs (in <code>W/m^-1</code>) to XYZ using CIE 1931 color matching functions:</p>

<pre class="lang-cpp prettyprint-override"><code>Vec3 xyz(0, 0, 0);
float norm = 0;

for (int i = 0; i &lt; SPD_SAMPLE_COUNT; ++i) {
    const float wavelength =
        lerp(MIN_WAVELENGTH, MAX_WAVELENGTH, i / SPD_SAMPLE_COUNT);
    const Vec3 cieMatchingFunctionValue = cie1931(wavelength);
    xyz += cieMatchingFunctionValue * spd[i];
    norm += cieMatchingFunctionValue.y;
}

xyz /= norm;
</code></pre>

<p>Here implementation of <code>cie1931</code> is an anylitic approximation from <a href="http://jcgt.org/published/0002/02/01/" rel="nofollow noreferrer">Wyman et al</a>.</p>

<p>Then XYZ to xyY (i.e. chromaticity and luminance), normalise luminance to be in [0, 1] range across the whole image (for now by dividing my max luminance just to keep it simple):</p>

<pre class="lang-cpp prettyprint-override"><code>float maxLuminance = 0;

for (auto &amp;xyz : image) {
    maxLuminance = std::max(maxLuminance, xyz.y);
}

for (auto &amp;xyz : image) {
    Vec2 chromaticity = Vec2(xyz.x, xyz.y) / (xyz.x + xyz.y + xyz.z);
    float luminance = xyz.y / maxLuminance;

    // then to "scaled" XYZ and RGB, see below
}
</code></pre>

<p>Also I've tried Reinhard tone mapping operator:</p>

<pre class="lang-cpp prettyprint-override"><code>float logSum = 0;

for (auto &amp;xyz : image) {
    logSum += std::log(xyz.y);
}

float logAvgLuminance = std::exp(logSum / image.size());

for (auto &amp;xyz : image) {
    Vec2 chromaticity = Vec2(xyz.x, xyz.y) / (xyz.x + xyz.y + xyz.z);
    const float a = 0.18; // constant from Reinhard's paper.
    float luminance = a * xyz.y / logAvgLuminance;
    luminance /= luminance + 1;

    // then to "scaled" XYZ and RGB, see below
}
</code></pre>

<p>Then back to XYZ, than to linear RGB (wia <a href="https://en.wikipedia.org/wiki/SRGB#The_forward_transformation_(CIE_XYZ_to_sRGB)" rel="nofollow noreferrer">this</a> transform):</p>

<pre class="lang-cpp prettyprint-override"><code>for (auto &amp;xyz : image) {
    // XYZ to chromaticity and "scaled luminance", see above

    Vec3 scaledXyz(
        chromaticity.x * luminance / chromaticity.y,
        luminance,
        (1 - chromaticity.x - chromaticity.y) * luminance / chromaticity.y
    );

    float r = dot(Vec3( 3.2406, -1.5372, -0.4986), scaledXyz);
    float g = dot(Vec3(-0.9689,  1.8758,  0.0415), scaledXyz);
    float b = dot(Vec3( 0.0557, -0.2040,  1.0570), scaledXyz);

    // Output RGB, i.e. std::cout &lt;&lt; Vec3(r, g, b);
}
</code></pre>

<p>After that I still get RGB values outside of [0..1]. What am I doing wrong? It seems that "max" luminance should depend on chromaticities of the color. Is there an algorithm to account for that?</p>

CC BY-SA 4.0
2019-01-23T19:54:24.597Z
Hello and welcome to the site. You are using quite a few equations there which means not many people know all of them well enough to answer. I suggest you flesh out the question more by adding the relevant equations and/or code.
bernie
CC BY-SA 4.0
2019-01-24T10:53:03.003Z
@bernie Thank for that comment! Added code to illustrate what I've tried so far.
Kirill Dmitrenko
<color><spectral>
Computing sRGB color from SPDs
Kirill Dmitrenko
# Computing sRGB color from SPDs

(Asked by: username_0 on 2019-01-21)

I'm trying to write my own spectral path tracer and a bit stuck with converting computed SPDs to LDR RGB values. What I'm doing right now is convert sampled SPDs (in `W/m^-1`) to XYZ using CIE 1931 color matching functions:

```cpp
Vec3 xyz(0, 0, 0);
float norm = 0;

for (int i = 0; i < SPD_SAMPLE_COUNT; ++i) {
    const float wavelength =
        lerp(MIN_WAVELENGTH, MAX_WAVELENGTH, i / SPD_SAMPLE_COUNT);
    const Vec3 cieMatchingFunctionValue = cie1931(wavelength);
    xyz += cieMatchingFunctionValue * spd[i];
    norm += cieMatchingFunctionValue.y;
}

xyz /= norm;

```

Here implementation of `cie1931` is an anylitic approximation from [Wyman et al](http://jcgt.org/published/0002/02/01/).

Then XYZ to xyY (i.e. chromaticity and luminance), normalise luminance to be in [0, 1] range across the whole image (for now by dividing my max luminance just to keep it simple):

```cpp
float maxLuminance = 0;

for (auto &xyz : image) {
    maxLuminance = std::max(maxLuminance, xyz.y);
}

for (auto &xyz : image) {
    Vec2 chromaticity = Vec2(xyz.x, xyz.y) / (xyz.x + xyz.y + xyz.z);
    float luminance = xyz.y / maxLuminance;

    // then to "scaled" XYZ and RGB, see below
}

```

Also I've tried Reinhard tone mapping operator:

```cpp
float logSum = 0;

for (auto &xyz : image) {
    logSum += std::log(xyz.y);
}

float logAvgLuminance = std::exp(logSum / image.size());

for (auto &xyz : image) {
    Vec2 chromaticity = Vec2(xyz.x, xyz.y) / (xyz.x + xyz.y + xyz.z);
    const float a = 0.18; // constant from Reinhard's paper.
    float luminance = a * xyz.y / logAvgLuminance;
    luminance /= luminance + 1;

    // then to "scaled" XYZ and RGB, see below
}

```

Then back to XYZ, than to linear RGB (wia [this](https://en.wikipedia.org/wiki/SRGB#The_forward_transformation_(CIE_XYZ_to_sRGB)) transform):

```cpp
for (auto &xyz : image) {
    // XYZ to chromaticity and "scaled luminance", see above

    Vec3 scaledXyz(
        chromaticity.x * luminance / chromaticity.y,
        luminance,
        (1 - chromaticity.x - chromaticity.y) * luminance / chromaticity.y
    );

    float r = dot(Vec3( 3.2406, -1.5372, -0.4986), scaledXyz);
    float g = dot(Vec3(-0.9689,  1.8758,  0.0415), scaledXyz);
    float b = dot(Vec3( 0.0557, -0.2040,  1.0570), scaledXyz);

    // Output RGB, i.e. std::cout << Vec3(r, g, b);
}

```

After that I still get RGB values outside of [0..1]. What am I doing wrong? It seems that "max" luminance should depend on chromaticities of the color. Is there an algorithm to account for that?

**Tags:** color, spectral

> **Comments:**
> **username_1**: Hello and welcome to the site. You are using quite a few equations there which means not many people know all of them well enough to answer. I suggest you flesh out the question more by adding the relevant equations and/or code.  
> **username_0**: @username_1 Thank for that comment! Added code to illustrate what I've tried so far.  

---

## 1 answers

### Answer by username_2 on 2019-01-27. Score: 2 | Accepted answer

Getting sRGB values outside [0, 1] is expected and normal when using spectral rendering.

The sRGB gamut only covers a triangle in the middle of the CIE chromaticity space:

[![CIE chromaticity diagram with sRGB gamut highlighted](https://i.sstatic.net/Y9z7W.png)](https://i.sstatic.net/Y9z7W.png)
(diagram from [Wikipedia](https://en.wikipedia.org/wiki/SRGB#/media/File:Cie_Chart_with_sRGB_gamut_by_spigget.png))

The big "horseshoe" shape is the set of all physically possible chromaticities (CIE xy coordinates) from any possible spectrum. So your xy coordinates should be expected to land somewhere in the horseshoe...but only the ones that land inside the triangle are representable as sRGB! The others are "too saturated" for sRGB, and will have to be clamped or remapped somehow. (Tone mapping per se doesn't fix this, as it's concerned with luminance rather than chromaticity.)

There are a variety of approaches to deal with out-of-gamut colors depending on how sophisticated you want to be:

* You can of course simply clamp the sRGB values, but this may change the hue and/or the luminance of the color in the process.
* To preserve both hue and luminance, you can edit the xy coordinates before calculating `scaledXyz`. If the initial xy coordinates are outside the sRGB triangle, find the line segment from the xy coordinates to the white point (e.g. D65); then find the intersection of that line segement with the sRGB triangle. That's the closest sRGB point that has the same luminance and hue as the original point, but lower saturation. (The vertices of the sRGB triangle can be obtained by converting RGB(1, 0, 0), RGB(0, 1, 0), and RGB(0, 0, 1) to CIE xy space.)
* Alternatively, rather than clamping (which throws away any detail in the saturation outside the sRGB gamut), you could try doing a linear or non-linear remapping of saturation values in the image, similar to how you do for luminance. By "saturation" here I mean distance from the white point. The idea would be to squish the whole image toward the white point so as to bring everything within the gamut. But that may affect the apparent saturation of in-gamut colors in visually undesirable ways.


---

