-
Notifications
You must be signed in to change notification settings - Fork 0
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.
Automatic asks three questions in order, in VideoRenderer::Draw():
-
Does the media type say?
DXVA_ExtendedFormat::VideoTransferMatrixin aVIDEOINFOHEADER2— 1 is BT.709, 2 is BT.601. Believed when present. -
Is the subtype
HDYC? That is UYVY carrying BT.709 by definition. -
Otherwise, height decides.
croppedHeight_ >= 720means 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.
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.
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.
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.
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.
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.
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.
The same "is there anything in this frame" question, asked continuously rather than once, is what drives the empty state — see Signal detection.