Slanted-Edge SFR

How a camera's sharpness is actually measured. The page begins with what MTF and SFR mean and why the ISO 12233 method uses a slanted edge; sets out the synthetic chart the measurements come from and the assumptions the method rests on; follows the analysis pipeline step by step, including where the resolution comes from and why the conventional tilt is conventional; asks whether the pipeline recovers an answer it was never given; and ends with what five common image-quality conditions — contrast, noise, vignetting, distortion and gamma — do to the result. Every section states its conclusion plainly, including the ones that do not flatter the tool.

  • 98 tests passing
  • Python 3.8+
  • MIT
  • NumPy · SciPy · OpenCV

What MTF and SFR actually measure

A camera does not have a resolution. It has a response, and that response depends on how fine the detail is. A lens that renders a 10-cycle-per-degree pattern faithfully may render a 40-cycle pattern as uniform grey. "How sharp is it" is therefore not a number until you say at what spatial frequency.

MTF — the property of the system

The Modulation Transfer Function is contrast out divided by contrast in, as a function of spatial frequency. It is a curve, not a score.

$$\mathrm{MTF}(f) = \frac{\text{contrast}_{\text{out}}(f)}{\text{contrast}_{\text{in}}(f)}$$

SFR — the quantity you measure

ISO 12233's measurable: the edge response normalised to unity at zero frequency. The estimated MTF is the SFR divided frequency-by-frequency by the input edge's own modulation.

$$\mathrm{SFR}(0) \equiv 1, \qquad \mathrm{MTF}_{\text{est}}(f) = \frac{\mathrm{SFR}(f)}{M_{\text{edge}}(f)}$$

SFR is a system measurement — it does not separate lens from sensor

Physically the chain is a product: light passes the lens, then lands on silicon that integrates it over a finite pixel and lets charge spread to neighbours. So the system response is approximately

SFR_system ≈ MTF_lens × MTF_sensor × MTF_processing where MTF_sensor ≈ MTF_aperture × MTF_diffusion

The aperture term is the sinc of the pixel's finite size — a pixel is an averaging box, and averaging blurs. The diffusion term is charge wandering into neighbouring pixels, which is why the effect is called crosstalk.

But the standard measures the product, never the parts. A slanted-edge SFR is a number for the whole camera. Nothing in this repository — or in ISO 12233 — decomposes it into lens and sensor contributions. Treating a measured SFR as a lens MTF is the most common misreading of this measurement, and it is wrong.

One further caution: aperture and diffusion are not independently separable by simple multiplication. The real sensor MTF is somewhat higher than the product, and the discrepancy grows with crosstalk — so the convenient model above is optimistic rather than exact.

Where ISO 12233 comes in

ISO 12233 is the standard that defines how to measure this from a single photographic capture. It specifies a high-contrast edge tilted a few degrees from the pixel grid, and the analysis chain that turns that edge into an SFR curve. This repository implements that method in full, over a synthetic chart whose correct answer is known in advance — which is what makes the validation section a validation rather than a comparison against another black box.

Notation and units

Two frequency units appear on this page and they are not interchangeable. Everything internal is cycles per pixel; lp/mm appears only where a sensor's physical pitch is known.

QuantityUnitRange hereReference points
Frequency axiscycles / pixel0 – 0.5Nyquist is 0.5 by definition
mtf50cycles / pixel0 – 0.5cannot exceed Nyquist
ny4_sfrMTF, unitless0 – 1Ny/4 = 0.125 cy/px
ny2_sfrMTF, unitless0 – 1Ny/2 = 0.25 cy/px
Edge tiltdegrees from axis2 – 15shipped default 5
Blurpixels (σ, Gaussian)0 – 30 is an ideal edge
mtf50_lpmmline pairs / mmsensor-dependent$\text{mtf50}\times 1000 / \text{pitch}_{\mu m}$

The last row is why the 0.5 ceiling matters. On a 2.2 µm sensor, 0.5 cy/px is 227 lp/mm — so a system sharper than Nyquist is not measurable here, only unresolvable. That distinction is the subject of the validation section.

Why the edge has to be slanted

A sensor answers one question per pixel: how much light landed here. A vertical edge lands on the same sub-pixel position in every row — so every row measures the same phase, and the edge profile is known only at one sample per pixel. You cannot resolve detail finer than that, and you cannot even represent it.

Tilt the edge and the arithmetic changes. Each row crosses the boundary at a different fraction of a pixel, so the rows collectively sample the transition at many different sub-pixel positions. The tilt is not a convenience — it is the measurement.

The phase advance

Moving down one pixel row shifts where the edge crosses it by tan θ pixels. Consecutive rows therefore land on different phases.

$$\Delta_{\text{phase}} = \tan\theta \ \ \text{pixels per row}$$

The phase span

Over an edge L rows long, the phases sweep this far. It must exceed one pixel for the sampling to interleave at all.

$$N_{\text{phases}} \approx L\tan\theta \ \ \text{pixels}$$

Effective oversampling

The equivalent sampling interval is the pixel pitch divided by this — at the conventional 5° over a 32-row edge, roughly 11× finer than the sensor could otherwise manage.

$$\text{oversampling} = \frac{1}{\tan\theta}$$

Two failure modes bound the usable angle, and they pull in opposite directions. Too shallow and the phase coverage becomes non-uniform — angles whose slope is a simple rational number (0°, tan⁻¹(1/4) ≈ 14.04°, tan⁻¹(1/2) ≈ 26.57°, 45°) repeat a small set of phases and leave ESF bins empty. Too steep and the binning corrections the method relies on grow, biasing the result. ISO 12233 prescribes roughly 2–10° and gives surprisingly little justification; the section on the tilt sweep measures where this implementation's optimum actually falls.

Method and assumptions

Every number on this page comes from a chart this repository generates. Because the generator knows exactly what it applied, the correct answer is known in advance — that is the whole basis for the validation.

The test chart

  • Grayscale, single channel. No colour filter array, no demosaic, no colour processing. A real camera's SFR is measured on luminance for exactly this reason: it keeps the measurement independent of colour reconstruction.
  • 8-bit. 256 levels. The generator supports 8, 10, 12, 14 and 16.
  • Dark level 38, light level 191 — a 5:1 contrast ratio. Dark is pinned at 15% of full scale; light is dark × ratio, clamped at 85%. The clamp is why the contrast sweep stops at ratio ≈ 5.6: beyond that the chart is byte-identical.
  • 3 × 3 grid of slanted squares, 88 px across on a 266 px pitch in an 800 × 800 field — 36 measurable edges.
  • 8× supersampled and box-downsampled, so the ideal edge is anti-aliased rather than staircased. This is the one blur present even at “zero” blur, and the validation section has to account for it.

Assumptions the method rests on

These are not caveats bolted on at the end — they are the conditions under which the numbers mean anything. The image-quality metrics section tests each of them.

  • The edge is straight. The analysis fits one angle and one projection axis. A curved edge has no single angle, so the projection smears.
  • The blur is constant across the region of interest. The profile measured in one part of the ROI is assumed to hold for all of it.
  • The signal is linear in light. Contrast ratios and MTF only compose as ratios on a linear scale. ISO 12233's procedure linearises via the OECF first; this implementation does not, and the gamma results show what that costs.
  • You know where the edge is. The measurement is only as good as the registration under it.

What can be varied

KnobWhat it doesRange used here
resolutionField size in pixels800 × 800
slant_angleEdge tilt; sets the phase coverage2 – 15°
contrast_ratioLight/dark ratio of the squares1.2 – 5.6
blur_type, blur_sigmaGaussian blur, in pixelsσ = 0 – 3
defocus_radiusPillbox (disk) blur alternativenot used on this page
snr_dbSignal-to-noise ratio15 – 60 dB
vignetting_strengthCorner darkening0 – 0.35
distortion_type, distortion_kBarrel / pincushion warpingk = 0 – 0.25
gammaTone curve applied to the chart1.0 – 2.7
bit_depthQuantisation8
square_size, square_pitchSquare geometryauto (11% of field)

The pipeline, step by step

Thirty-six edges, four per square. Each step below gives the operation, the mathematics, and what the code actually does — the step that matters most is the fourth.

detect_squares()

Find the chart. Otsu threshold separates squares from background; contours give candidate blobs; a rotated bounding box filters them by area and aspect ratio. Squares are then grouped into rows by clustering the gaps in their y coordinates, so the code adapts to any grid layout rather than assuming 3×3. When the chart is synthetic the known geometry is passed in instead and this step is skipped entirely.

_refine_edge_position()

Snap to the true edge. A bounding box locates an edge only to within a pixel or so. This searches ±10 px along the edge normal for the position of maximum gradient — cheap, and it removes most registration error. Everything downstream inherits this accuracy or its absence.

_extract_edge_roi()

Cut a region shaped for the method. Long along the edge (0.5 × the square) and shallow across it (0.4 ×, minimum 20 px). The asymmetry is deliberate: many independent cross-sections are useful, but only the pixels near the transition carry information, and a deeper ROI would only add flat regions that contribute nothing but noise to the derivative.

compute_esf()

Project, then bin. The step that turns a photograph into a measurement. Detailed below.

$$p = y - x\tan\theta \quad\text{(horizontal edge)}$$ $$p = x - y\tan\theta \quad\text{(vertical edge)}$$
compute_lsf()

Differentiate, then taper. The Line Spread Function is the derivative of the ESF — the edge's step response differentiated gives the impulse response, which is what the transform needs.

$$\mathrm{LSF}(u) = \frac{\mathrm{d}\,\mathrm{ESF}}{\mathrm{d}u}$$

The differentiated profile is then multiplied by a Tukey window (α = 0.25). The window is at full height where the LSF has signal and tapers only where the LSF is already near zero, which suppresses spectral leakage without attenuating the peak.

compute_sfr()

Transform and normalise. An FFT with 2× zero padding for finer frequency sampling, magnitude normalised to the DC term, restricted to $0 \le f \le 0.5$ cy/px.

$$\mathrm{SFR}(f) = \left|\mathcal{F}\{\mathrm{LSF}\}\right|(f) \Big/ \left|\mathcal{F}\{\mathrm{LSF}\}\right|(0)$$

From that curve: mtf50 by linear interpolation of the 0.5 crossing, and the values at Ny/4 and Ny/2 read off directly.

Project and bin, in detail

This is where the resolution comes from, so it is worth following slowly.

1 · Every pixel gets a coordinate

Each pixel in the ROI is assigned a single number: its position along the edge normal, $p$. Because the edge is tilted, two pixels in the same column but different rows receive different values of $p$ — that is the entire trick. In a 32-row ROI at 5°, the $p$ values advance by 0.0875 px per row.

2 · The rows become cross-sections

Read a single row and you have a one-dimensional cut through the edge, sampled once per pixel — coarse and phase-dependent. But every row is a cut through the same edge at a slightly different offset. Projecting them all onto the common $p$ axis stacks those cuts on top of one another, and what was 1 sample per pixel becomes many samples per pixel.

3 · Bin at ¼ pixel

The projected samples are collected into bins 0.25 px wide, and each bin reports the mean of the pixels that landed in it. The bin width is the reconstruction grid: the ESF is being rebuilt on a grid four times finer than the sensor's own sample spacing. That factor of four is the oversampling, and it is what allows the FFT in the next step to report frequencies up to 0.5 cy/px instead of stopping at the edge of what the pixel grid could represent.

At 5° with a 32-row ROI the phases sweep 2.7 px, so roughly eleven distinct positions fall inside each 0.25 px bin — enough that the mean is a real average rather than a single sample in disguise.

4 · Normalise and orient

Bins with no samples are filled by interpolation. The profile is then scaled to [0, 1] and forced ascending, so a dark-to-light edge and a light-to-dark edge produce the same LSF sign. Polarity is detected from the ROI's own halves, not assumed.

Why ¼ pixel? It is the ISO 12233 convention — a fixed reconstruction grid, independent of the tilt. The tilt controls something different: how many distinct phases actually populate those bins. A steep edge starves the bins; a shallow one over-samples a narrow phase range and risks rational-slope degeneracy. The tilt sweep measures where the optimum falls for this implementation, and it lands on the conventional value.

So why 5°? Swept

Error against the 5° reference for seven angles from 2° to 15°, each the mean of 36 edges at 1 px blur. Effective oversampling falls from 28.6× at 2° to 3.7× at 15°.

The conventional value is the measured optimum here

Error is at its minimum near 4–5° and climbs monotonically to 2.96% at 15°, tracking the collapse in phase coverage. The penalty at 2° is small — 0.22% — and much weaker than the high-angle effect.

That asymmetry matches the physics: the high-angle penalty comes from the binning and derivative corrections, which grow steadily with tilt, while the low-angle penalty only bites when the phase coverage becomes degenerate. The 5° default is not a knife-edge optimum — anything from about 3° to 7° would measure equivalently well here — but it sits comfortably inside the safe region, and it is what ISO 12233 prescribes.

Watching it happen

Every image below is produced by running this repository's own code.

A synthetic test chart: a three-by-three grid of dark slanted squares on a light background, with region-of-interest boxes drawn around each edge and labels of the form index underscore position.
Thirty-six edges, indexed {square}_{T|B|L|R}. Squares run 0–8 left-to-right, top-to-bottom. The labels are the handles every number on this page is reported against, which is why a per-edge CSV is more useful than a summary: it lets you see which edges disagree.
A single edge region at 6x magnification, dark on the left and light on the right, with the boundary visibly tilted by about five degrees.
One edge, magnified. The boundary crosses pixel rows at a different phase in each row — that is the entire mechanism. A perfectly axis-aligned edge would sample the same phase forever and the ESF would be a staircase.
Two panels. Left: pixel values scattered against their projection onto the edge normal, zoomed to a twelve-pixel window, showing points arriving at finely spaced positions across the transition. Right: the same points with a red curve showing the values averaged into quarter-pixel bins.
Where the sub-pixel information comes from. Left, each pixel lands at its own fractional position along the edge normal — no two rows share a phase. Right, averaging into ¼-pixel bins recovers a continuous ESF from a sensor that can only report integers. The tilt is not a convenience; it is the measurement.
Three stacked panels. Top: a smooth S-shaped edge spread function. Middle: a single-peaked line spread function with a dashed Tukey window drawn over it, flat across the peak and falling to zero at both ends. Bottom: a monotonically decreasing SFR curve crossing 0.5 at a marked MTF50 value, with Ny/4 and Ny/2 marked.
The chain, end to end. Note the middle panel: the Tukey window is at full height exactly where the LSF has signal and tapers only where the LSF is already zero. That is why this window rather than a Hamming — it suppresses spectral leakage without paying for it in the region you care about.
Four crops of the same edge at increasing Gaussian blur, labelled sigma equals 0, 1, 2 and 3 pixels. The leftmost shows a sharp transition within about one pixel; each successive panel shows the transition spreading over more pixels, until the rightmost is a gradual gradient.
What the blur sweep actually varies. The same edge at σ = 0, 1, 2 and 3 px. Every number in the validation section is read off charts like these — the transition widening is the only thing that changes, which is what makes the comparison against theory meaningful.

Try the tilt yourself

This is step 4 of the pipeline, running live. The left panel is the chart at the pixel level — the boundary is drawn in red and the blue ticks in the margin mark where each row crosses it. The right panel is the ESF those same pixels produce when you project and bin them. Drag the angle and watch the two panels stay in step.

The readout reports phase step — how far the boundary advances per row — and phase span — how much of a pixel it sweeps in total. Both are measured against the 0.25 px bin: once the step exceeds the bin width, samples can skip bins entirely, and the readout says so.

The chart, at the pixel level. Every row crosses the boundary at a different fraction of a pixel.

The ESF those pixels produce. Fewer distinct phases means a coarser reconstruction.

Validation

The generator knows what blur it applied, so the correct MTF50 is known in advance. Seven blur levels, thirty-six edges each. The question is not whether the curve looks plausible — it is whether the measurement recovers a number it was never given.

What theory predicts

Two factors form the prediction: the pixel's finite size, which averages light over its area (a sinc), and the applied Gaussian blur. A third, INHERENT_BLUR_SIGMA = 0.2, accounts for the chart's own anti-aliasing — the edge is not a mathematical step, so it carries a little blur of its own.

$$\mathrm{MTF}_{\text{model}} = \underbrace{\left|\frac{\sin(\pi f)}{\pi f}\right|}_{\text{aperture}} \cdot \underbrace{e^{-2\pi^{2}\sigma^{2}f^{2}}}_{\text{applied blur}} \cdot \underbrace{e^{-2\pi^{2}\sigma_{\text{inh}}^{2}f^{2}}}_{\text{chart}}$$

Measured MTF50 against theory Validated above σ = 1 px

Where the validator can score, measurement and theory agree to between 0.01% and 0.22%. The marked point at σ = 0 is not a comparison at all.

Above σ = 1 px: the pipeline recovers the answer

The worst error across the whole measurable band is 0.22%, at σ = 1.5 px it is 0.01%. Nothing in the measurement was given the correct value — it is recovered from the image alone. This is the validation.

sigma measured theory error verdict 0.0 0.4570 0.5000* -- *clamped, not a prediction 0.5 0.3512 0.3056 14.95% scored; model least accurate here 1.0 0.1771 0.1767 0.22% scored 1.5 0.1216 0.1216 0.01% scored 2.0 0.0921 0.0923 0.16% scored 2.5 0.0742 0.0742 0.10% scored 3.0 0.0620 0.0620 0.09% scored

Below σ ≈ 0.1 px there is nothing to compare against

Pixel aperture alone crosses MTF = 0.5 at 0.603 cy/px, and with the chart's own blur included the true MTF50 at σ = 0 is 0.5155 — both beyond the 0.5 the frequency axis can represent. The validator searches only to 0.5, so it returns that limit instead of a crossing, and the 8.59% gap there is the validator running out of range rather than the analyzer being wrong.

The region is narrow: the true MTF50 drops back inside Nyquist at σ ≈ 0.1 px, so a sweep in 0.5 px steps catches it at exactly one point. That is why the chart above shows the theory curves touching the dashed line rather than a shaded band.

How image quality metrics impact SFR measurement

Five conditions, each tested the same way: what it does to the image, what that does to the measurement, the number, and the mechanism behind it. Two of them barely matter, one sets a hard floor, and two are measurable artifacts that have nothing to do with sharpness.

Summary. MTF50 is normalised, so anything that scales the whole image — contrast, gain, vignetting — largely cancels. What does not cancel is anything that changes the shape of the edge (gamma), the scale of the image (distortion), or the noise on top of it (SNR). That single distinction predicts every result below.

Four test charts side by side. The first is clean. The second is visibly grainy from added noise. The third is bright in the centre and markedly darker at the corners from vignetting. The fourth has its squares displaced outward and a black border around the frame from barrel distortion.
The five conditions, four of them visible. Same chart, four captures: clean, noise, vignetting, distortion. Gamma is the fifth and is not visible by eye — which is precisely why it needs measuring rather than inspecting.

1 · Contrast

TL;DR  Contrast does not change MTF50 — the metric is normalised, so gain cancels exactly. It only sets a floor: below about 2:1 no edge is measurable at all.

Physically: the luminance difference between the square and its background. To the measurement: because MTF is normalised to unity at zero frequency, contrast cancels out of the ratio — so contrast should not change MTF50 at all, only how reliably it can be measured.

The number: contrast sets a floor, not a trend

From ratio 2 upward MTF50 is flat at ≈ 0.177 cy/px across a 2.8× contrast range — exactly as a normalised metric should behave.

Below that it collapses as a measurement, not as a value: at ratio 1.2 and 1.5, 0 of 36 edges pass the quality gate. The edge gradient falls below the minimum the detector requires, so the pipeline is measuring edges it has already judged unfit.

contrast ratio valid edges MTF50 1.2 0 / 36 unreliable 1.5 0 / 36 unreliable 2.0 36 / 36 0.1798 3.0 36 / 36 0.1794 5.0 36 / 36 0.1772

Mechanism: a low-contrast edge carries less signal above the noise, so the gradient used to locate and bin the edge becomes unreliable. Note also the ceiling: the generator clamps the light level at 85% of full scale, so ratios above ≈ 5.6 produce a byte-identical chart. The usable contrast window is narrow — roughly 2:1 to 5.6:1.

2 · Signal-to-noise ratio

TL;DR  Noise leaves the MTF50 value almost untouched and destroys its reliability: the spread across edges falls about 100× from 15 dB to 60 dB, while the mean barely moves.

Physically: how much of the signal survives above the noise floor. To the measurement: noise does not shift MTF50 so much as make it unstable — the error is in the spread across edges, not the mean.

The number: precision scales with SNR, over more than two orders of magnitude

SNR (dB) MTF50 scatter across 36 edges 15 21.3% (0 edges valid) 20 9.7% 30 4.2% 40 1.3% 60 0.2%

The spread falls by a factor of ~100 from 15 dB to 60 dB, while the mean MTF50 stays near 0.177 throughout. Noise degrades the confidence in the measurement, not its value — which is why a single-edge MTF number from a noisy image should not be trusted even when it looks reasonable.

Mechanism: the ESF is an average over the pixels in each bin. Noise averages down as √N, so more binning or more signal both tighten it — but at low SNR the edge position itself becomes ambiguous, which biases as well as scatters it.

3 · Vignetting

TL;DR  Almost harmless — 2.4% centre to corner — because MTF is a ratio and vignetting is a gain. The measurement’s real limit is edge-to-edge spread at 12.4%, five times larger.

Physically: illumination and aperture fall off toward the corners, so the same scene produces less signal at the edge of the field. To the measurement: this is a gain change, and MTF is a ratio — so it should largely cancel.

Thirty-six measurements plotted at their positions on the sensor, forming a three-by-three arrangement of four edges each, shaded by MTF50. Colours vary edge to edge with no strong centre-to-corner pattern.
The same measurement as a map. Each point is one edge at its physical position. The colour varies substantially between edges of the same square — the spread is local, not radial.

The number: 2.4% centre-to-corner, and 12.4% between neighbours

effect magnitude centre -> corner falloff 2.42% edge-to-edge spread 12.43%

Strong vignetting (0.35) moves MTF50 by only 2.42% from centre to corner — because the ESF is normalised to [0,1] and the MTF to its own DC term, and both divisions remove exactly what vignetting changes. Measuring MTF through a vignetted field is legitimate because the metric is contrast-normalised.

The far more useful number is the second one. Edge-to-edge spread is five times the field trend, which is the quantitative reason real MTF testing averages over many edges and reports a distribution rather than one number.

Mechanism: the residual 2.4% is consistent with the noise term — less light at the corners means a noisier ESF. That is the expected mechanism, not a measured one: no vignetting-off control was run.

4 · Distortion

TL;DR  Distortion moves the chart, so the region of interest must follow it or you are measuring registration error. Even correctly registered, the local magnification change shifts what “cycles per pixel” means.

Physically: the lens maps the field with a position-dependent magnification, bending straight lines and moving the chart. To the measurement: the region of interest must follow that map. If it does not, the analysis window sits on the wrong part of the edge and you are measuring registration error rather than optics.

Three things follow, and only the first is obvious

The ROI must track the distortion map. Placing regions of interest at the pre-distortion positions produced a non-monotonic mess; inverting the map and placing them where the squares actually landed produced a clean, monotonic result. Registration is not a refinement here — it is the difference between a measurement and noise.

Automatic detection fails outright, at any non-zero level. Even k = 0.02 returns zero edges. The remap fills the frame border with black — the same value as the dark squares — so thresholding turns that border into foreground and contour retrieval returns one box covering the whole image, which fails the area filter. Given known positions, all 36 edges are still measured.

Even correctly registered, cycles per pixel changes meaning. Barrel distortion demagnifies the periphery, so the same edge is drawn across fewer pixels and its detail lands at higher cycles per pixel. MTF50 rises from 0.177 to 0.203 across this sweep — a 14% apparent improvement in sharpness from a defect that cannot improve sharpness. The metric is reporting local scale, not local resolution.

5 · Gamma

TL;DR  The one metric that genuinely breaks the measurement. With a near-black edge against a bright one the bias reaches −13%, because the signal is analysed without the linearisation ISO 12233 prescribes.

Physically: a tone curve applied to the signal, as every camera does to fit a wide luminance range into a limited code space. To the measurement: MTF is defined on a linear signal. Unlike contrast, which cancels, a tone curve changes the shape of the edge and does not cancel.

Method note. This is the one metric measured on a constructed edge rather than a generated chart: the generator pins dark at 15% of full scale and clamps light at 85%, capping any chart it makes at 5.67:1. The gamma model (apply_gamma) and the measurement (analyze_edge) are still this repository's own — only the input image is built directly.

This is a named deviation from ISO 12233

The ISO 12233 slanted-edge procedure includes an OECF linearisation step — the opto-electronic conversion function is measured and the edge linearised before it is differentiated. This implementation omits that step and measures the encoded signal directly, which is the situation of analysing sRGB output without decoding it. Everything below follows from that.

Bias against the dark edge's level Up to −13%

Light level fixed at 85% of full scale; dark level swept from 0.39% to 50%. The green marker is where the generator's own chart sits — the mild end, and the only place a chart-based sweep could have looked.

The number: it scales with how dark the dark edge is

dark % of full scale contrast ratio bias at gamma 2.2 1 0.39% 217:1 -13.30% 2 0.78% 108:1 -11.36% 8 3.14% 27:1 -6.97% 16 6.27% 14:1 -4.75% 32 12.55% 7:1 -2.75% 64 25.10% 3:1 -1.18% 128 50.20% 2:1 -0.23%

That is a 58× span. For reference, the same gamma applied to the generator's own chart — a 5:1 ratio — costs 1.97%, which is the figure a chart-based sweep reports as the whole story. At the regime that matters in practice, a dark object against a bright background, the error is an order of magnitude larger.

Mechanism. Encoding applies $v^{1/\gamma}$, whose slope $(1/\gamma)\,v^{1/\gamma-1}$ diverges as $v \to 0$. Near zero the curve is both steepest and most curved, so a dark edge sits exactly where the encoding distorts the edge profile most.

Two panels showing an edge spread function before and after gamma 2.2 encoding. Left, at the generator's own 5 to 1 contrast, the gamma curve lies close to the linear one. Right, at 217 to 1 with a nearly black dark edge, the gamma curve is displaced far to the left of the linear one, so the transition begins earlier.
The distortion itself, same gamma in both panels. On the left the chart's own 5:1 edge barely moves. On the right, a near-black edge against the same bright background is pulled visibly earlier and steeper — which is the whole effect. Only the edge levels differ; the encoding and the measurement are identical.
Two panels. Left: three transfer curves from scene luminance to stored pixel value — a dashed straight diagonal for linear, and two curves for gamma 1.8 and 2.2 that bow sharply upward near zero and flatten toward the top. Right: the measured edge spread function at gamma 1.0 and gamma 2.2, both normalised, with the gamma 2.2 transition visibly displaced.
Why the dark end is where it bites. The curves climb steeply out of the origin and flatten toward the top — so an edge near black is remapped hard while an edge near white is barely touched. That asymmetry skews the LSF and moves MTF50.

It is the ratio that matters, and most of the damage is done by gamma 1.8

A power law is scale-invariant, so the distortion depends on the ratio between the two edge levels, not on where they sit in absolute terms. Holding the ratio fixed while dropping the dark level 20× gives an identical bias every time — −0.22% at dark levels of 24, 48, 96, 192 and 384 alike. At a fixed full scale the two coincide, since a darker object is a higher ratio, but the mechanism is the ratio.

gamma 1.0 1.5 2.0 2.2 2.7 bias 0.0 -6.8% -9.9% -11.4% -12.1% (dark 0.78%, light 85%)

Most of the damage arrives by gamma 1.8 and the curve flattens after that. So the practical question is not which gamma value a camera uses — it is whether the pipeline linearises at all.

Using it

Install

git clone https://github.com/pengzhao-ai/SFR_Slanted.git
cd SFR_Slanted
python -m venv .venv && source .venv/bin/activate
pip install -r requirements.txt
pip install -e .          # optional: enables the sfr-analyzer entry point

Generate a chart, then measure it

# 3x3 slanted-square chart, 1 px of Gaussian blur, with its config saved
python -m sfr_analyzer generate --resolution 400 400 --angle 5 \
    --blur-sigma 1.0 --output test.png --save-config

# measure all 36 edges; writes overlays, SFR curves and results.csv
python -m sfr_analyzer analyze --input test.png --pixel-pitch 2.2 --output results/

# compare the measurement against theory for the parameters that made it
python -m sfr_analyzer validate --input test.png --config test.json --output report.txt

API

from sfr_analyzer import SyntheticImageGenerator, SFRAnalyzer, ValidationModule

gen = SyntheticImageGenerator(resolution=(800, 800), slant_angle=5.0,
                              blur_type="gaussian", blur_sigma=1.0)
gen.generate()

an = SFRAnalyzer(pixel_pitch_um=2.2)
an.set_image(gen.generate())
results = an.analyze(gen.get_square_info())   # dict keyed by "0_T" ... "8_R"
an.export_csv("results.csv")

v = ValidationModule(pixel_pitch_um=2.2, blur_sigma=1.0, blur_type="gaussian")
print(v.validate(results).summary())

Passing gen.get_square_info() uses known geometry and skips detection entirely — faster and more accurate, but only available when you control the chart. Omit it to exercise the automatic path, which is what the distortion finding above concerns.