Skip to content

Colour range and matrix

NuclearMeltdown edited this page Aug 24, 2026 · 3 revisions

Colour range and matrix

src/render/video_renderer.cpp, VideoRenderer::AnalyzeRange()

Both default to automatic. The matrix is worked out in three steps; the range is measured from the pixels, and that is the part worth explaining.

The matrix

Automatic asks three questions in order, in VideoRenderer::Draw():

  1. Does the media type say? DXVA_ExtendedFormat::VideoTransferMatrix in a VIDEOINFOHEADER2 — 1 is BT.709, 2 is BT.601. Believed when present.
  2. Is the subtype HDYC? That is UYVY carrying BT.709 by definition.
  3. Otherwise, height decides. croppedHeight_ >= 720 means BT.709, below it BT.601. This is the case that actually fires on most cards.

Note that it is the cropped height, not the capture height — a 480i console letterboxed inside a 1080-line capture is still standard definition, and cropping to it is what tells the renderer so.

The setting can be forced either way.

The range: why it cannot be read

What a capture card hands over is limited range (16–235) or full range (0–255) depending on what the source is sending. It is a console setting, not a property of the pixel format the card was asked for: a console set to full range delivers full range whether the card produces NV12 or RGB32.

DirectShow has a place to say which one it is — DXVA_ExtendedFormat::NominalRange in a VIDEOINFOHEADER2 — and plenty of cards, this one included, attach no colour description at all. There is nothing to read, so the answer has to come from the pixels.

The measurement

AnalyzeRange() runs on the raw capture buffer before upload, on the CPU, and costs almost nothing because it samples rather than scans.

Where the luma is. LumaLayout() returns an offset and a stride into the buffer for each packed format:

Format Offset Step
YUY2, YVYU 0 2
UYVY 1 2
NV12, planar 4:2:0 0 1
RGB24 / RGB32 0 1 (all channels, whole frame)

How it samples. Every third frame (kRangeSampleEvery = 3), 8192 samples per frame (kRangeSamplesPerFrame), over 40 analysed frames (kRangeFramesWanted = 40) — roughly four seconds at 30 fps.

The sample stride is forced odd:

size_t stride = count / kRangeSamplesPerFrame;
if (stride < 1) stride = 1;
if (stride % 2 == 0) ++stride;

A stride that divides the row length evenly lands on the same column every row, which on a UI-heavy picture reads one vertical stripe and calls it the whole frame. A prime-ish stride walks across.

What it counts. Minimum, maximum, samples below 16, samples above 235.

Two ways the evidence is worthless

Both are checked before any verdict:

if (rangeMin_ > 40 || (rangeMax_ - rangeMin_) < 64) { /* discard, start over */ }
  • No dark pixels at all (min > 40). There is nothing to tell the two apart: limited range piles its blacks at exactly 16, full range goes below it. With no blacks in the picture, neither statement can be checked.
  • No contrast at all (max − min < 64). A console asleep, a card between signals. An entirely black frame reads as 100 % below 16 and would otherwise be written down as a confident verdict of full range on no evidence whatsoever.

In both cases everything is thrown away and the measurement starts fresh, so whatever appears later is judged on its own. Until something decides, Draw() falls back to the rule of thumb for the pixel format — limited for YUV, full for RGB.

The verdict

const double below = (double)rangeBelow16_ / (double)rangeSamples_;
rangeVerdict_ = below > 0.002 ? RangeVerdict::Full : RangeVerdict::Limited;

The black end decides, and only the black end. Values above 235 prove nothing: limited-range signals are allowed to carry superwhites and plenty of sources do. Two tenths of one per cent of samples below 16 is enough, because in a genuine limited-range signal there should be none at all.

Why it is frozen

Once decided, the verdict is not revisited until the format changes or ResetAnalysis() is called. A verdict that keeps changing with the content would be worse than a wrong one held steady — the picture would visibly shift brightness as scenes came and went.

ResetAnalysis() is called on a format change and on a crossbar switch. The latter is the important one: a crossbar switch puts a different signal on the same pins without the media type changing, so nothing measured about the old signal survives it.

What the shader does with it

Pass 1 (kConvertPS in src/render/shaders.h) reads the decision out of its constant buffer as three numbers — yOffset, yScale, cScale. Limited range sets them to 16/255, 255/219 and 255/224; full range sets them to 0, 1 and 1, so the same shader code runs either way with no branch. The matrix follows as four coefficients from MatrixCoefficients().

The statistics overlay at detail level 2 shows what was decided, so a wrong verdict is visible rather than merely felt.

Clone this wiki locally