Upscaling One Axis: What Anisotropic Scale Actually Costs

Every upscaling guide talks about 2x, 4x, 8x — a single number. But a lot of real jobs are not square: a 4:3 clip that has to become 16:9, a phone photo whose width is fine and whose height is soft, an anamorphic source that was squeezed twice on the way in. This post measures what happens when you stretch one axis and leave the other alone.

15 November 2026 · synthetic 1080×720 reference + x264 encodes, measured with numpy and ffmpeg

The honest summary is in the first two lines and the rest of the article is the evidence. Kernel choice is worth 1.5 dB and the axis question is worth up to 16 dB — but which way it goes depends entirely on where the detail sits. Experiment 1 below reports +2.2 dB for touching one axis; Experiment 6 finds that number was measured with twice the source pixels and reverses sign when the budget is held equal. And the "better" image costs more bytes at a fixed CRF, which is the part that bites when you ship a video instead of a JPEG.

Why this question is not academic

Most interpolation code takes two scale factors and most articles assume they are equal. They are equal in the resampler — you can pass (2.0, 2.0) or (2.0, 1.0) to anything — but they are emphatically not equal in information terms. Each axis carries its own frequency budget. If a source lost detail only horizontally, upscaling it vertically is doing work that was not needed, and interpolation smooths, which is irreversible.

So the practical question is:

  1. Is a one-axis stretch really cheaper in quality than a square one with the same area gain?
  2. Can you tell from the output which axis was damaged?
  3. Does it matter once a codec gets involved?
  4. Is the old "do 2x in two single-axis passes" advice worth anything?
  5. If the one-axis version costs 5.8% more bytes, does its advantage survive a fixed byte budget — and if not, how many bytes would close it?

Five questions answered below, then a sixth experiment that re-tests the answer to the first one and finds it was measured against a budget handicap. Three of the results are negative and I report them as measured, because a negative result is what stops you optimising the wrong thing for a year.

The test scene: known answer, both axes exercised

PSNR is only meaningful against a reference that actually contains the detail you are trying to reconstruct, so the ground truth is generated at the target size, not upscaled from something smaller. It is 1080×720 and contains structure on both axes:

Degrading this three ways and reconstructing back to 1080×720 isolates the variable: all three start from the same pixel budget and end at the same size, and the only difference is which axis lost the information.

import numpy as np
from PIL import Image

def psnr(a, b):
    mse = np.mean((np.asarray(a, np.float64) - np.asarray(b, np.float64)) ** 2)
    return 99.0 if mse == 0 else 10 * np.log10(255.0 ** 2 / mse)

# GT is 1080x720. Build the three sources, then push each back to 1080x720.
srcs = {
    "both axes 2x":       GT.resize((540, 360), Image.LANCZOS),
    "horizontal only 2x": GT.resize((540, 720), Image.LANCZOS),
    "vertical only 2x":   GT.resize((1080, 360), Image.LANCZOS),
}
for label, s in srcs.items():
    for name, k in (("nearest", Image.NEAREST), ("bilinear", Image.BILINEAR),
                    ("bicubic", Image.BICUBIC), ("lanczos", Image.LANCZOS)):
        rec = s.resize((1080, 720), k)
        print(label, name, round(psnr(rec, GT), 2))

Experiment 1 — axis count beats kernel choice

source degradationnearestbilinearbicubiclanczos
both axes 2×28.7529.2430.0730.27
horizontal only 2×31.1631.6332.3532.50
vertical only 2×31.0431.6632.3932.60

PSNR in dB against the 1080×720 ground truth, higher is better.

Read the gap between rows, then along a row. Rows: neighbour (28.75) to Lanczos (30.27) is +1.52 dB. Columns: both-axes to single-axis, at any kernel, is +2.2 to +2.4 dB. The axis decision is worth more than picking the best resampler in the world — and unlike the kernel, you often cannot choose it, because the delivery format sets it.

Horizontal-only and vertical-only land within 0.1 dB of each other (32.50 vs 32.60 at Lanczos). The scene has detail on both axes, so this is a fair test: the benefit comes from leaving one axis untouched, not from the scene favouring one direction.

Experiment 2 — can you tell which axis was damaged?

If you are handed a soft file and no metadata, the useful diagnostic is the ratio of horizontal to vertical gradient energy. Ground truth is 0.956 — slightly more vertical detail than horizontal, because of those 8 horizontal strokes.

reconstructionmean |dx|mean |dy|anisotropy |dx|/|dy|edge xedge y
ground truth1.6411.7170.956406.60243.86
both axes 2× → lanczos2.1062.1930.960406.44243.86
horizontal 2× → lanczos2.0941.9551.071406.44243.86
vertical 2× → lanczos1.8802.1810.862406.60243.86

Gradient means are 0–255 units; edge positions are sub-pixel centroids of the gradient profile.

The ratio moves in the predicted direction and by about the same magnitude both ways: 0.862 when vertical detail was destroyed, 1.071 when horizontal was — a 7–12% tilt away from the 0.956 baseline, small in absolute terms but one-directional and reproducible. A symmetric upscale leaves the ratio alone (0.960).

Two things surprised me here, both worth knowing before you trust a "sharpness" number:

  1. Both-axes reconstruction has more gradient energy than the ground truth — 2.106 and 2.193 against 1.641 and 1.717, roughly +28%. Lanczos overshoot does not recover detail; it manufactures contrast at the reconstruction edges. Any metric built on gradient magnitude ranks this smoothed-then-overshot image as "sharper" than the real thing. That trap is documented on the main upscaler guide, and it is why the table reports the ratio rather than absolute values.
  2. Edge position does not move. 406.60 px in ground truth, 406.44 px after reconstruction — one-sixth of a pixel, identical for both one-axis cases. Single-axis interpolation does not shift geometry here, so the fear that stretching one axis "pulls" the picture sideways is not supported. A shift in production comes from elsewhere in the chain.

Experiment 3 — the better image costs more bytes

This is the measurement that changes how you ship. The same three reconstructions, encoded to H.264 with libx264 at CRF 20, one frame each at 1080×720:

reconstructionfile bytesPSNR after encode
both axes 2×18,59729.55
horizontal only 2×19,33431.07
vertical only 2×19,66831.25

x264, CRF 20, preset medium, yuv420p. PSNR measured after decode, against the ground truth.

+1.7 dB of quality costs +5.8% bytes (19,668 vs 18,597) at an identical CRF setting. That is not a bug — it is entropy. An image with more genuine detail has more to encode, and CRF holds quality per pixel rather than size per file, so a cleaner input produces a bigger file. The smoothed both-axes version is cheaper precisely because it is emptier.

The practical consequence. If you have a byte budget, the quality gain is real but you pay for it in bitrate. Decide which constraint is binding before you upscale, not after you look at the file size.

Experiment 4 — "do it in two single-axis passes" changes nothing

Long-standing advice says a 2× upscale should run horizontal-then-vertical (or the reverse) because two 1D passes are cheaper and "cleaner" than one 2D kernel. The cost claim is trivially true; the quality claim is what interests me.

small = img.resize((W // 2, H // 2), Image.LANCZOS)          # degrade
a = small.resize((W, H), Image.LANCZOS)                       # one pass
b = small.resize((W, H // 2), Image.LANCZOS).resize((W, H), Image.LANCZOS)  # horizontal 1st
c = small.resize((W // 2, H), Image.LANCZOS).resize((W, H), Image.LANCZOS)  # vertical 1st
pass orderPSNR, synthetic 800×520PSNR, real photo 1080×1620
one pass34.78033.252
horizontal first34.78033.252
vertical first34.78033.258

The real photo is a 1080×1620 JPEG, degraded to half size and reconstructed. Same code path for all three.

On the synthetic scene the three results are identical to three decimals. On the real photograph the largest difference is 0.006 dB — far below visibility, in the direction that favours the more expensive arrangement by an amount indistinguishable from rounding. The pixel-difference census agrees: 59.68% of pixels differ by more than 2 levels from the original in all three cases, max error 15, 15, 14.

So use two passes for speed, never for quality — on this evidence there is none, and the folklore that made you restructure your pipeline bought you nothing.

Experiment 5 — at equal bytes the advantage survives, and bitrate cannot buy it back

Experiment 3 has an obvious hole: CRF holds quality per pixel, so the clean reconstruction got more bytes (19,668 vs 18,597). Its +1.7 dB came bundled with +5.8% more data. A fair test against a delivery budget spends the same bytes on all three, which means a different CRF for each — found by binary search to ±0.5%:

def hit_bytes(arr, target, lo=4.0, hi=45.0):
    """Binary-search CRF until |size - target| / target <= 0.5%."""
    for _ in range(18):
        mid = 0.5 * (lo + hi)
        size, psnr_after = enc_bytes(arr, mid)      # libx264, medium, yuv420p, 1 frame
        if abs(size - target) / target <= 0.005:
            return mid, size, psnr_after
        lo, hi = (mid, hi) if size > target else (lo, mid)
reconstructionCRF neededfile bytesPSNR at CRF 20PSNR at equal byteschange
both axes 2×20.0218,62529.5529.52−0.03
horizontal only 2×20.6218,65731.0731.01−0.06
vertical only 2×20.7018,62531.2531.12−0.13

Budget = 18,597 bytes. Target size held to ±0.5%. PSNR measured after decode against the 1080×720 ground truth.

The axis advantage is structural, not monetary: 1.60 dB of the original 1.70 dB survives at equal size — 94% of it. The correction costs the one-axis version four times as much PSNR (−0.13 vs −0.03 dB), because it must be encoded more conservatively to fit. The 5.8% byte penalty is a smaller tax than the smooth image pays for being smooth: the two-axis version cannot convert its cheapness into quality, because it already spent it removing detail.

So could bitrate close the gap anyway? Give the two-axis reconstruction a bigger budget:

byte budget (× base)both-axes CRFPSNR reachedbilinear CRFbilinear PSNR
1× (18,597 B)19.9229.5318.5828.73
2× (37,194 B)12.2429.8110.9128.94
4× (74,388 B)6.9729.866.2028.98
8× (148,776 B)2.2729.891.7629.00
PNG, no codec—30.27——

CRF chosen per budget so the file fills 99–100% of it. Vertical-only at 1× = 31.16 dB (CRF 20.71) for comparison.

Eight times the bytes buys 0.36 dB, and 1.19 Mbit for a single 1080×720 frame still lands at 29.89 dB — 0.4 dB below the no-codec PNG of that reconstruction, and 1.3 dB below the one-axis version at one eighth the rate. You cannot pay your way out of a blur: the bits that would carry the missing high frequency do not exist, so x264 spends an enormous rate encoding a smooth ramp with more and more precision.

The near-lossless floor confirms the gap is not a codec artefact:

operating pointboth-axes bytesPSNRone-axis bytesPSNRgap
CRF 1625,69029.7427,03131.49+1.76
CRF 1048,12029.8348,24531.67+1.84
CRF 0192,50729.91182,90331.78+1.88
PNG (no codec)—30.27—32.60+2.33

-crf 0 and -qp 0 produced byte-identical files (192,507 B), as they should. The residual 0.36 dB from PNG to CRF 0 is yuv420p subsampling, not the quantiser.

The gap is narrowest at the loosest setting (+1.76 dB at CRF 16) and widest with no codec at all (+2.33 dB) — the opposite of what "the codec is hiding the difference" predicts. The two-axis image loses nothing extra to compression; it enters the encoder with less detail and the encoder correctly does not invent any.

Unexpected: nearest is the most expensive kernel. At CRF 20 it is the largest file — 21,175 bytes against Lanczos at 18,597, +13.9% — for 1.04 dB less quality. Blocky output is full of hard 2-pixel steps, and hard steps are expensive to encode, so the "fast" resampler hands the codec a harder job. Full ladder: nearest 21,175 B / 28.51 dB, bicubic 17,836 B / 29.37 dB, bilinear 16,636 B / 28.63 dB, Lanczos 18,597 B / 29.55 dB. At equal bytes it settles into bilinear 28.73, bicubic 29.42, Lanczos 29.52 — a 0.10 dB spread between the top two, which is not worth arguing about. The 1.6 dB axis difference is.

Experiment 6 — the recommendation this page gives was never measured

Recommendation #1 in the list above reads: "Minimise the number of axes you touch. It is worth 2.2 dB. If the delivery format allows it, crop or pad rather than stretch when only one axis is short."

Those two sentences disagree with each other, and I only noticed while re-reading my own numbers. Crop touches two axes. Stretch touches one. If axis count really is the lever, the recommendation should be stretch, not crop. One of the two claims had to be wrong, so I re-ran the experiment and found a bug in the original — in my own methodology, not in the images.

The budget was not held equal

Experiment 1 above states that "all three start from the same pixel budget and end at the same size". Check the arithmetic of the three sources it names, all reconstructed to 1080×720:

case in Experiment 1source dimssource pixelsvs control
both axes 2×540×360194,4001.00×
horizontal only 2×540×720388,8002.00×
vertical only 2×1080×360388,8002.00×

Same numbers Experiment 1 reports, with the pixel counts added.

The single-axis cases hold twice the input pixels of the control they were compared against. So the +2.2 dB did not measure "fewer axes". It measured "twice as much source data", which is a much less interesting claim — of course more input pixels score better.

Holding the budget actually equal

Fix the source pixel count at 388,800 and reconstruct to 1080×720. The one-axis case can only be 540×720. To match its budget, the two-axis case has to be 764×509 — a squarer source than any real pipeline would pick, but that is the point: it is what a fair test looks like. Eight scenes, identical source pixels, identical output size:

casesource dimssource pixelsPSNR (mean of 8)vs one-axis
one axis540×720388,80041.50—
both axes, equal pixels764×509388,87645.77−4.27

Lanczos-3 for degradation and reconstruction. Negative = the two-axis case is better. Spread across the 8 scenes: sd 0.01 dB, one-axis won 0/8.

Swap the pixels and the sign swaps with them. Give the two-axis case exactly 194,400 pixels and the one-axis case the same 194,400 (270×720), and the two-axis case wins by 7.19 dB (sd 0.01, 0/8 for one-axis). The correction is not a rounding effect; it is the entire size of the original claim, reversed, at both budgets.

So is "minimise axes" simply wrong? No — and this is the part worth keeping. At equal source pixels, the two options destroy different frequency bands:

one axis, 270×720 → x Nyquist 0.125 cyc/px, y Nyquist 0.500 (y untouched)
both axes, 540×360 → 0.250 both

Which one wins depends on where the scene's detail lives, not on how many axes you touched.

Where the rule actually flips

Sweep the share of detail energy that varies along x, holding source pixels identical at 194,400 and output identical at 1080×720. Anisotropy 0.0 means all detail is horizontal lines (only y varies); 1.0 means all vertical bars.

anisotropy (x share)one axis 270×720both 540×360one − bothwinner
0.0047.0931.03+16.07one axis
0.1535.0230.94+4.08one axis
0.2532.8630.90+1.96one axis
0.3531.4530.88+0.57one axis
0.4030.9030.87+0.03tie
0.5029.9730.86−0.89both
0.8027.9430.87−2.93both
1.0026.9930.93−3.94both

PSNR mean of 6 scenes per row. The one-axis column is stable to sd ≤ 0.03 dB; the two-axis column varies up to sd 1.10 dB between scenes, because it always resamples both axes and so its score depends on how much detail each axis actually carried. Ties at anisotropy ≈ 0.40. Full span 20 dB, at identical source pixels and identical output.

The rule is not "minimise axes". It is: if the detail sits on one axis, do not resample the other; if it sits on both, spread the loss across both. The 2.2 dB on this page was the first half of that rule accidentally measured with a 2× budget handicap, which is why it came out looking like a universal law.

The job the recommendation was written for

All of the above is PSNR on a synthetic budget. The recommendation, though, is about a specific transformation, so it has to be measured as one. Take a 1440×1080 capture with square pixels — genuinely 4:3 content, not a squeezed 16:9 frame — that must ship as 1920×1080. Three deliveries, and the capture is the reference in every case, so no option is scored against the picture it discarded:

optionwhat it doesaxes resampled
pad1:1 paste, 240 px pillars0
cropcentre 1440×810 → 1920×10802
stretch1440×1080 → 1920×10801

Measure detail retention per axis, at frequencies stated per capture pixel. One full-frame sinusoid per test, amplitude read as a projecting sum at the exact known frequency — no sampling windows to get wrong.

f (cyc/capture px)pad Hcrop Hstretch Hpad Vcrop Vstretch V
0.101.0000.9980.9981.0000.9981.000
0.201.0001.0111.0111.0001.0111.000
0.301.0000.9890.9891.0000.9891.000
0.351.0000.9270.9271.0000.9271.000
0.401.0000.8210.8211.0000.8211.000
0.451.0000.6750.6751.0000.6761.000

Detail retained relative to the capture, 1.000 = nothing lost. Mean over all 9 frequencies tested: pad 1.000 / 1.000, crop 0.9385 / 0.9386, stretch 0.9385 / 1.000 (H / V). Values above 1.000 are Lanczos overshoot at frequencies the resampler can still pass, not recovered detail.

Three things fall out, and the third is the one that matters.

  1. Crop and stretch are identical on the horizontal axis — 0.9385 either way, to four decimals. Of course they are: both perform the same 1440→1920 horizontal resample. The axis count differs, the horizontal work does not.
  2. On the vertical axis they diverge completely. Stretch leaves it at 1.000 at every frequency. Crop takes it to 0.9386 mean, and to 0.676 at 0.45 cyc/px — a third of the vertical detail gone, because crop resamples an axis that never needed it.
  3. So "crop rather than stretch" costs you detail and buys you nothing on the axis that was soft. The 4:3→16:9 problem is horizontal. Crop fixes it by throwing away 25% of the frame and resampling both axes; stretch fixes it by resampling only the broken one. On detail retention, stretch dominates crop by 0.9386 → 1.000 on the vertical axis at equal horizontal cost.

Which is exactly what the equal-pixel experiment predicted, one level down: the axis you should not touch is the one that has detail on it.

What crop does buy, and what stretch costs

Stretch is not free. It is a geometric error, and geometry is not a PSNR question — which is why the original experiment could not see it. Three true circles, bounding-box aspect, 1.0000 being round:

deliverycircle 1circle 2circle 3framing costbytes @ CRF 20
capture1.00001.00001.0000——
pad1.00001.00001.000075% of frame is picture91,838
crop1.00001.00001.0000270 rows discarded (25%)100,720
stretch1.33601.33601.3360full frame kept115,153

Circles measured on a separate flat-ground test image. Bytes on a band-limited photographic test frame, same encoder settings as Experiments 3 and 5.

Stretch turns every circle into an ellipse with a 1.336 axis ratio — the resample ratio itself, 1920/1440 = 1.3333, plus 0.003 of bounding-box quantisation. That is not a subtle defect; it is the first thing a viewer notices, and no amount of detail retention compensates for it. It is also the most expensive option in bytes (+25% over pad, +14% over crop), because a stretched frame carries the extra detail that crop threw away.

Pad is the cheapest and the only option that loses no detail at all on either axis — 1.000 everywhere, measured, not assumed — but it spends 25% of the frame on black bars and leaves the content at 4:3 inside a 16:9 canvas.

The rule, now with the missing half.

Do not resample an axis that still has detail. That is the whole of it, and it cuts both ways: it says stretch when the framing allows it and the softness is on one axis, and it says crop or pad when you cannot tolerate the aspect change — because then you are choosing between a geometric error and a resampled axis, not between one axis and two.

My own three mistakes in this section

Since the point of the section is that the earlier number was wrong, the errors are worth recording rather than tidying away.

  1. Measured a 1:1 paste with a shifted window. The first version sampled pad at the same coordinates as the resampled variants, so the un-resampled case read 0.750 instead of 1.000 — the pillar fraction, not a detail measurement. Caught by asserting that a path which resamples nothing must read exactly 1.000.
  2. Compared crop against a reference that had been resampled the same way. That returns 99 dB (identical images) no matter what crop does, which is a tautology dressed as a result. Every reference here is now built from the capture or the world, never from the transform being tested.
  3. Reported Nyquist as 1.000 cyc/px for a 540→1080 reconstruction. The correct value is 0.250 — the formula was inverted. It mattered: the first threshold sweep put the crossover in the wrong band entirely.

Each of the three produced numbers that looked plausible. Only the assertions caught them — "a path that resamples nothing must read 1.000", "a reference must not be the thing under test", "a downscaled axis cannot carry more than half its input rate". A measurement harness without those three checks would have published all of it.

Reproduce it

import numpy as np
from PIL import Image

W, H = 1080, 720

def psnr(a, b):
    d = np.asarray(a, np.float64) - np.asarray(b, np.float64)
    return 99.0 if d.max() == 0 else 10 * np.log10(255.0 ** 2 / np.mean(d ** 2))

def scene(seed=1, hf=14.0):
    rng = np.random.default_rng(seed)
    yy, xx = np.mgrid[0:H, 0:W].astype(np.float64)
    a = np.full((H, W), 128.0)
    a += 30 * np.sin(2 * np.pi * (0.015 * xx + 0.011 * yy) + rng.uniform(0, 6.3))
    a += 18 * np.sin(2 * np.pi * (0.040 * xx + 0.033 * yy) + rng.uniform(0, 6.3))
    a += 11 * np.sin(2 * np.pi * (0.085 * xx + 0.070 * yy) + rng.uniform(0, 6.3))
    a += hf * np.sin(2 * np.pi * 0.40 * xx)     # horizontal high frequency
    a += hf * np.sin(2 * np.pi * 0.38 * yy)     # vertical high frequency
    a += rng.normal(0, 1.5, (H, W))
    return Image.fromarray(np.clip(a, 0, 255).round().astype(np.uint8))

def recon(gt, src):
    return gt.resize(src, Image.LANCZOS).resize((W, H), Image.LANCZOS)

gt = scene()
one, both_equal, both_half = (540, 720), (764, 509), (540, 360)

print("page setup  ", round(psnr(recon(gt, one), gt) - psnr(recon(gt, both_half), gt), 2))
print("equal pixels", round(psnr(recon(gt, one), gt) - psnr(recon(gt, both_equal), gt), 2))
print("same pixels ", round(psnr(recon(gt, (270, 720)), gt) - psnr(recon(gt, both_half), gt), 2))

Prints +2.99 / +2.26 / +2.92 dB on this scene — one-axis ahead in all three, because this particular scene is horizontal-detail-heavy. Change hf or the octave layout toward isotropic content (the anisotropy sweep above) and the last two invert. That sensitivity is the finding, not a bug in the snippet: the axis question has no answer without a statement about where the detail is.

Which is the practical instruction. Before deciding whether to stretch, crop, or pad, look at which axis actually went soft — a gradient ratio does it well enough, as Experiment 2 shows — and then protect the other one. The multiplier in the UI says nothing about that, and neither did my first attempt at measuring it.

What to actually do

  1. Do not resample an axis that still has detail. Experiment 6 shows the original "+2.2 dB from fewer axes" was a source-pixel budget artefact: at equal input pixels the two-axis case wins by 4.3 dB on isotropic content, and the one-axis case wins by 16 dB when the detail is all on the other axis. The rule has no universal sign — it depends on where the detail is. Protect the axis that carries it: stretch when only one axis is soft and the framing allows it, crop or pad when you cannot accept the aspect change.
  2. Never use nearest to save time on a gradient-heavy source. It is 1.5 dB behind Lanczos and the gap is consistent at every axis count measured here.
  3. Diagnose direction with the gradient ratio, not gradient magnitude. A sharpened or overshot image beats the ground truth on |dx| by 28% while being visibly softer. The ratio |dx|/|dy| is stable to about 1% and moves 7–12% when one axis is damaged.
  4. Budget bytes after the upscale, not before. A cleaner input raises the encoded size at fixed CRF. 5.8% in this test; assume the same order of magnitude on yours.
  5. Do not split the pass for quality. Measured difference: ≤0.006 dB. Split it only for speed.
  6. Do not raise bitrate to fix a soft axis. Eight times the bytes bought 0.36 dB and still finished 1.3 dB short of the one-axis version at one eighth the rate. Fix the geometry before the encoder; the encoder can only preserve what reaches it.
  7. Compare at equal bytes, not at equal CRF. CRF charges the cleaner image more, making a real gain look like a byte regression. Hold the budget fixed and search the CRF.

The wider point keeps showing up in these measurements: the number in the tool's UI ("4×") describes an output size, not a recovered amount. Which axis you scaled, and whether the source ever had detail there, decides the result — the multiplier on the label does not.

To run this on your own file rather than my synthetic scene: degrade a known-good version along one axis, reconstruct it, and compare against the original instead of against the upscale you were about to ship. It takes a minute and tells you whether the axis you planned to stretch is the one that actually went soft — or whether, like the browser upscaler at upgolab.com, you should re-run the whole frame rather than stretch one direction of it.