Skip to content

Cropping and geometry

NuclearMeltdown edited this page Sep 8, 2026 · 7 revisions

Cropping and geometry

src/render/video_renderer.cpp (AnalyzeContentBounds), src/render/shaders.h (pass 2)

Crop, line doubling and rotation all happen in pass 2, before scaling. That ordering is what makes them appear in recordings and screenshots as well as in the window — outputWidth() / outputHeight() report the size after all three, and that is the size everything outside the shader works with.

Manual crop

Dragged on the picture itself. The four edges are stored per profile in source pixels, and the overlay converts between window coordinates and source coordinates through videoRect().

The dimming used to swallow the toolbar

Dragging a crop edge over the overlay's own Apply and Cancel controls turned them too dark to read — precisely when you were reaching for them. Fixed in 1.9, and it was one word:

ImDrawList* dl = ImGui::GetBackgroundDrawList();   // was GetForegroundDrawList

ImGui assembles its draw data as background lists, then windows, then foreground lists. Both extra lists sit over the video regardless — the picture does not come from ImGui at all, it is already in the back buffer before any of this is drawn — but only the background list sits under the toolbar, which is an ordinary window.

On the foreground list, the four dimming rectangles were painted over the toolbar. The idle screen in overlay.cpp had the ordering right all along; the crop overlay simply asked for the wrong list.

Automatic crop: the Detect button

AnalyzeContentBounds() runs continuously on the CPU over the raw capture buffer, and Detect applies what it has measured — unless too much of the picture would go, which is its own section below.

It is on three things, because the moment you want it is rarely the moment you have a settings window open: the Detect button in the settings, the entry in the right-click menu, and F8.

What counts as picture

static const int kContentLuma = 24;

Anything above that is picture rather than border. Analogue black does not arrive at zero and it is not quiet, so the bar sits a little above where the blacker-than-black of a limited range signal would be.

Rows first

const int minRun = max(w / (50 * kBoundsColumnStep), 2);

A row counts as picture only when a decent stretch of it — about 2 % of the columns sampled — is above black. One bright speck of analogue noise in the letterbox must not widen the crop.

Rows are scanned every second line, columns every second pixel (kBoundsColumnStep = 2). Each lit pixel also votes for its column in columnHits_.

Then columns

const int minCol = max(litRows / 20, 1);

A column has to be lit in at least 5 % of the rows that carry picture at all. Same idea the other way round.

An entirely black frame returns early and says nothing.

The edge of the signal is not picture

An analogue frame does not stop cleanly at its last line. Measured on a GameCube in PAL60 through a PEXHDCAP60L: the bottom line of the 480-line frame is green 82 across the full width, then fourteen black lines, then the picture.

A search for what is not black reads that line as picture. Until 3.6.2 it did: the bottom border came out as nothing, and since the line is lit in every column it dragged both side edges out to the frame as well — x 0..718 on a signal whose picture ends at 681.

static const int kEdgeNoiseMax = 4;  // how thick the strip may be, in lines
static const int kEdgeNoiseGap = 8;  // and how much black has to follow it

SkipEdgeNoise() walks in from one end of the lit/unlit mask. A strip up to four lines thick with at least eight black lines behind it is the signal ending, and the search for picture starts on the far side of it. A thicker strip, or one joined to the picture, is picture. Both scans use it, from all four sides.

If there is nothing at all behind the strip, the frame holds the signal ending and no picture, and it is discarded like an entirely black one. Fades and scene changes are full of these, and with the union below taking five frames, one of them was enough to put the edge back on the last line — which is why the same signal would crop correctly for a while and then stop.

The rows dropped this way have already voted in columnHits_. Rather than scan the picture a second time, which this analysis cannot afford, those few lines are read again and subtracted.

Accumulated as a union

if (left < accL_) accL_ = left;   // and so on for all four edges

The result is the union across five sampled frames, because a fade to black is not evidence that the picture got smaller. Only after kBoundsFramesWanted = 5 windows is the accumulated rectangle published, and the accumulator resets.

The bounds are logged only when they move, not every window — the border is a fixed property of the signal, so a line that keeps reappearing in the log means something is wrong.

Why it samples so sparsely

kBoundsSampleEvery = 9 — every ninth frame, and every second column.

This is the only one of the three analyses that never finishes. The range and interlace verdicts latch and stop; this one keeps running for the whole session, so it is the only one still costing anything once the picture has settled.

And it was costing enough to matter. A full-width scan of every third frame lands ten times a second, and measured against the deinterlacer, ten per cent of its field switches were arriving more than ten milliseconds late. That is a stutter you can see. The border is in no hurry, so the same answer arrives just as reliably from a sixth of the work.

Bottom-up layouts

RGB layouts arrive bottom-up. The scan reads buffer order, so the two vertical edges are swapped on the way out:

if (source_.bottomUp) { const int t = h - 1 - bottom; bottom = h - 1 - top; top = t; }

Both scans step, so each far edge can fall one step short and is nudged outward by the step size before being stored.

When Detect refuses

The measurement finds the edges of what is not black. It cannot tell a border from a part of the picture that simply is not lit at this moment — and a console sitting on its home screen or a splash logo is exactly that case. Cropping there throws away picture area and keeps the logo.

So DetectCrop() asks how much would survive, and the statistic is area:

if (partW * partH < 0.5 || partW < 0.4 || partH < 0.4) { /* refuse, and say why */ }

A real border eats one direction. A logo on black eats both. Measured, on a 720×480 signal:

Width Height Area
GameCube home screen 48 % 77 % 37 % refused
2.35:1 in 4:3 — the widest border that is still a border 100 % 57 % 57 % applied
4:3 in 16:9 75 % 100 % 75 % applied
PAL overscan 93 % 93 % 87 % applied

Over the edges alone that would be five points of margin; over the area it is twenty. The second condition is only against the degenerate case — a narrow strip can hold half the area and still be no border.

The refusal names the number it refused on, so it can be checked rather than believed. Six consecutive live measurements of the GameCube home screen came out between 35 % and 37 %; that they moved at all is its own evidence, since a border does not wander and a rotating cube does.

A crop is dropped when the source changes size

UpdateCropForFormat()

The four numbers are counts of source pixels — cropLeft = 20 means twenty of 720 — so they stop meaning anything the moment the source is a different size. A crop measured on a 720×576 PAL picture, left in place when the console switches to 60 Hz and the capture becomes 720×480, is no longer describing a border; it is cutting into the picture at coordinates that belong to a different frame.

So when the source size changes, the crop is set to zero. The old values go to the log, and a notice says what happened:

Video format changed (720×480) — crop reset

This happens on its own during an automatic standard change, which is exactly when a line count changes without anyone touching a setting.

It is deliberately not re-measured automatically. The moment a standard changes is the worst possible moment to measure a border: the graph has just been rebuilt, the console is often mid-boot, and a logo on black is precisely the case the refusal above exists for. Sending the measurement straight into its own guard would achieve nothing but a refusal in the log. F8 is there for when there is a real picture to measure.

Unless it is remembered per size

Remember per picture size, cropPerFormat, off by default.

One console can deliver two sizes. A PAL GameCube gives 720×576 at 50 Hz and 720×480 in 60 Hz mode, and each has its own border. Resetting is right when the crop has stopped meaning anything, but it is the wrong answer to a source that keeps coming back to a size it has been set up for once already — and so is keeping two profiles for one console.

Ticked, each size keeps its own four numbers:

struct CropForFormat {
  int width, height;
  int left, right, top, bottom;
};

On a format change the current crop is written to the entry for the size being left, and if there is an entry for the size arriving it is put in place, with a notice saying so instead of the reset one. If there is none, the reset happens as before — an unknown size has no border on record, and a crop from a different one is exactly what must not be carried over.

Two details are worth stating, because both are places where a simpler version misbehaves:

  • A crop of zero is stored as no entry at all, and an existing entry is removed when its crop is cleared. Otherwise "reset it and it stays reset" and "reset it and it comes back" would be two different things depending on which order the sizes had been seen in.
  • The entry for the current size is kept up to date every frame, not at the moment of editing. There are six ways to change a crop — four drag fields, the frame on the picture, Detect, the reset button, the menu item — and hooking each one means a seventh can be forgotten. Four integer comparisons per frame cannot be.

At the first format of a session the stored entry wins, because the plain crop fields belong to whatever size qBlank was last showing, which is not necessarily the one it starts on.

The settings show what is on record, under the checkbox:

Stored for: 720x480, 720x576

The list is per profile and goes in the config file with everything else. Unticking the box leaves it there, so turning the feature off and on again does not cost what was measured.

Line doubling

For 240p and 288p sources that arrive half as tall as they should. gLineDouble in the pass-2 constant buffer; the logical picture becomes twice as tall and the row index is ly >> 1.

This is a different thing from the co-sited field case in Deinterlacing: line doubling is for a source that genuinely arrives at half height, where co-siting is for a half-height source the card has already packed into a full-height interlaced frame.

Rotation

Rotation in src/config.h: None, Cw90, Half, Ccw90.

Rotation is undone first in pass 2, before anything else:

if (gRotation == 1)      { lx = op.y;                 ly = logicalH - 1 - op.x; }
else if (gRotation == 2) { lx = logicalW - 1 - op.x;  ly = logicalH - 1 - op.y; }
else if (gRotation == 3) { lx = logicalW - 1 - op.y;  ly = op.x; }
else                     { lx = op.x;                 ly = op.y; }

Everything below that line works in the picture's own orientation, which is the only one in which "the line above" means anything — a deinterlacer running in rotated coordinates would be reconstructing across columns.

Note that logicalH accounts for line doubling but a quarter turn swaps the axes of the render target only, not of the logical picture.

Clone this wiki locally