Upscaling Line Art Without Destroying the Lines
At 4×, interpolated upscaling loses 12% of the ink pixels in a line drawing. Then sharpening adds 20% back. The lost 12% is softness; the added 20% is the halo that makes a clean drawing look like it is glowing.
Line art breaks upscalers in a way photographs do not, and the reason is that line art has almost no mid-tones. A photo is a continuous field of values where an average is meaningful. A drawing is a binary decision — ink or paper — and every resampling filter is built to produce averages, which on a binary image means a grey fringe that belongs to neither.
The measurement
Test image: horizontal lines at 1, 2 and 3px width plus a 2px circle outline, black on white, 400×400. Downscale to 200×200 (2×), upscale back to 400×400 with each method, then measure ink coverage — the percentage of pixels below mid-grey. Ground truth is 1.851%.
| Method | Ink coverage | Delta vs truth |
|---|---|---|
| nearest | 1.978% | +0.127 |
| nearest + unsharp | 2.238% | +0.387 |
| bicubic | 1.722% | −0.129 |
| bicubic + unsharp | 2.216% | +0.365 |
| lanczos | 1.711% | −0.140 |
| lanczos + unsharp | 2.180% | +0.329 |
Three things to read out of this:
1. The smooth filters eat lines. Bicubic and Lanczos both land around 1.71–1.72%, about 7% below the ground truth. A 1px line upscaled with a good interpolation kernel becomes a 1px line with two grey shoulders — and at the threshold where you count "ink," those shoulders fall on the wrong side. Thin lines get thinner, then disappear.
2. Nearest over-preserves. It lands at 1.978%, above the truth, because it replicates hard pixel blocks. That is not accuracy — it is a blocky line that is nominally the right thickness but visually wrong, with stair-stepping along every diagonal. If you have ever upscaled line art with nearest and wondered why the curves look like a staircase, this is the number behind it.
3. Sharpening overshoots hard. Every sharpened variant jumps to 2.18–2.24%, which is 18–21% above the ground truth. Unsharp mask works by subtracting a blurred copy, and on a line that produces a dark core with bright rails on either side. Those bright rails, and the enlarged dark core, both count as ink. The line is now thicker than it was drawn, with a halo.
Why this is worse for line art than for photos. On a photo, a halo lands on texture that partially masks it. On line art, the halo lands on flat white paper. There is nothing to hide it, so a sharpened drawing reads as slightly luminous — a characteristic "AI upscale" look that people recognise without being able to name.
What actually works on line art
The measurement points at a specific approach rather than a specific filter:
- Do not sharpen the whole image. Sharpening is the step that adds 20% excess ink. If you must sharpen, mask it to the lines and use a low amount — the overshoot is multiplicative, so 150% is far too much for binary content.
- Preserve binary structure if the image is binary. If the source is a clean black-and-white drawing (not a scan with paper texture), a threshold or vector approach beats any filter, because it never creates a mid-tone in the first place.
- For scans, deal with the paper first. A scanned drawing is line art plus paper texture plus JPEG noise. Upscaling amplifies all three. Cleaning before upscaling costs one step and removes two of the three problems.
- Check thin lines specifically. Do not evaluate on the thick contours — they survive everything. Look at the 1px details (eyelashes, hatching, thin text). That is where the 7% loss lands, and it is invisible in a thumbnail.
Why models do better here — and what they get wrong
A model trained on illustrations learns that a line is a line, not a gradient, and can reconstruct a crisp edge instead of averaging it. That is the genuine advantage over interpolation, and it is why anime-specific models exist.
The failure mode is equally specific: a model that has learned "illustration" will add line weight where it thinks line weight belongs. A sketch that was deliberately loose comes back with confident contours it never had. That is a style change disguised as an upscale, and on line art — where style is the content — it is a bigger change than any resolution difference.
The hairline that is not where you think it is
Everything above treats a line as a run of pixels. That is wrong in a way that matters more than filter choice, and it is easy to demonstrate. A 1px line in a drawing is not sitting on a pixel boundary. It is wherever the artist dragged the pen, which after export or scanning becomes a fractional offset: 30% of the ink in one pixel column and 70% in the next. The line is still 1px wide. It is just smeared across a phase you cannot see.
To measure what upscaling does to that, I generated 1px, 1.5px and 2px strokes at eight sub-pixel phases (0, 0.125, … 0.875), anti-aliased them with 8× area coverage the way a real export does, upscaled 3× with each filter, and measured two things per result: the full width at half maximum of the stroke (in source pixels) and its peak contrast as a percentage of black.
| Method | Mean FWHM | FWHM σ | FWHM range | Contrast σ |
|---|---|---|---|---|
| source (1px, no upscale) | 1.00px | 0.00 | 1.00–1.00 | 15.3pp |
| nearest ×3 | 1.38px | 0.48 | 1.00–2.00 | 15.3pp |
| bilinear ×3 | 1.38px | 0.35 | 1.00–2.00 | 15.3pp |
| bicubic ×3 | 1.38px | 0.35 | 1.00–2.00 | 14.2pp |
| lanczos ×3 | 1.46px | 0.29 | 1.00–2.00 | 12.9pp |
Three things fall out of that table, and the second one is the reason this section exists.
1. The 1px line comes out 38–46% thicker. Every filter reports a mean FWHM of 1.38–1.46px where the source measured 1.00px. The line did not grow because the image got bigger — it grew because half-maximum spreading is what a resampling kernel does to a feature narrower than its support. Lanczos is the worst offender here, which is the opposite of its reputation: it preserves sharpness on wide features and inflates narrow ones.
2. Phase, not filter, dominates the result. Look at FWHM σ. The nearest neighbour result — the crudest possible filter — varies by 0.48px across phases, which is nearly half a source pixel. Lanczos tightens that to 0.29px. So the choice between nearest and Lanczos is worth about 0.20px of thickness stability. The choice between "this stroke landed on offset 0.0" and "this stroke landed on offset 0.5" is worth a full 1.00px. A file does not contain strokes at one phase; it contains all of them, adjacent, so the output contains every thickness between 1px and 2px for lines that were drawn identically.
3. Contrast never recovers, it just stops dropping. Peak stroke darkness across the phase sweep spans 50% to 100% for a 1px source, and the upscale does not narrow that spread — it reproduces it. Below half-grey the stroke's own mid-tone pixels disappear from any binary count, which is why the ink-coverage test earlier in this article reads −12% for text rather than the full 50%: the strokes that lose the most ink are the ones already at the threshold, and they are lost silently.
Where the phase problem stops — measured, not guessed
The useful question is not "how do I fix hairlines" but "how thin does a line have to be before phase owns it". So I swept nominal width too, and measured the standard deviation of contrast across the eight phases. That number is the noise floor the file's own geometry imposes before any upscaler touches it:
| Nominal width | Source σ | nearest | bilinear | bicubic | lanczos |
|---|---|---|---|---|---|
| 1.00px | 15.3pp | 15.3 | 15.3 | 14.2 | 12.9 |
| 1.25px | 13.1pp | 13.1 | 13.1 | 11.6 | 9.4 |
| 1.50px | 8.7pp | 8.7 | 8.7 | 6.3 | 3.4 |
| 1.75px | 4.0pp | 4.0 | 4.0 | 0.8 | 0.0 |
| 2.00px | 0.0pp | 0.0 | 0.0 | 0.0 | 0.0 |
The row at 2.00px is exactly zero for every method, because a 2px stroke always occupies at least one full pixel column at any phase — it cannot fall between the cracks. The row at 1.00px is 15.3pp, and there is nothing an interpolator can do about it, because nearest and bilinear both return the source value 15.3 unchanged. This is the whole argument in one table: upscaling cannot add phase information that the file does not contain.
Lanczos does genuinely help below 2px — 8.7pp down to 3.4pp at 1.5px, 4.0pp to 0.0pp at 1.75px. It averages neighbouring phases into a locally consistent thickness, which is why thin curves look smoother after a Lanczos upscale. But the price is in the first table: mean FWHM goes up from 1.38 to 1.46px. Lanczos buys stability by making the line fatter. On a drawing where line weight carries meaning, that trade deserves a conscious decision rather than a default.
Why this explains the −7% ink result from earlier. The ink-coverage test measured a 7% loss across a whole drawing. That 7% is an average over strokes of every width and every phase. Split by width and the average dissolves: 2px strokes lose nothing (σ = 0), 1px strokes lose up to half their contrast depending only on where they happened to be drawn. A single global number hides the fact that the damage is concentrated in the thinnest strokes at the worst phases — which is also why sharpening "recovers" 20%: it re-darkens the mid-tones that the filter diluted, at the cost of putting the same energy outside the line.
Reproducing the phase measurement
No image asset is needed; the strokes are generated. The trick that makes the test honest is supersampling the source at 8× and then integrating, so the source is anti-aliased the way a real export is, instead of being a clean binary strip that no real file contains:
SS = 8
def aastroke(n, w, offset, height=9):
"""horizontal stroke of width w starting at fractional x = offset"""
x = np.arange(n * SS) / SS
x0, x1 = 40.0 + offset, 40.0 + offset + w
cov = np.clip(np.minimum(x1, x + 1.0/SS) - np.maximum(x0, x), 0, None)
row = 255.0 * (1.0 - cov.reshape(n, SS).sum(axis=1).clip(0, 1))
return Image.fromarray(np.tile(row, (height, 1)).astype(np.uint8), "L")
def contrast(im): # peak darkness, 100 = fully black
return (255.0 - np.asarray(im.convert("L"), float).min()) / 255.0 * 100
def fwhm(im, scale): # half-max width, back in source pixels
row = 255.0 - np.asarray(im.convert("L"), float)[im.size[1] // 2]
if row.max() < 1: return 0.0
idx = np.where(row >= row.max() / 2)[0]
return (idx[-1] - idx[0] + 1) / scale if len(idx) else 0.0
# sweep phase, not filter, first
for off in np.arange(0, 1.0, 0.125):
src = aastroke(120, 1.0, off)
up = src.resize((360, 27), Image.LANCZOS)
print(off, round(fwhm(up, 3), 2), round(contrast(up), 1))
If you run this and see the printed contrast fall from 100% to 50% and back as the offset sweeps 0 → 0.5 → 1.0, the test is working. That curve is the noise floor, and it is present in every line drawing you own.
What to do about it, in order
- Check stroke width before choosing a filter. If the drawing's thinnest meaningful strokes are below ~1.75px, phase is your dominant error term and no filter choice will outperform it. Above 2px, phase is zero for every method and filters behave the way the marketing says they do.
- Upscale less, not cleaner. The numbers above scale with the resize factor the same way the earlier ink test does: halving the scale factor keeps far more of the fine structure than any kernel change. A 1.5× upscale of a hairlined drawing loses almost nothing that a 4× upscale does not destroy outright.
- Treat thickening as a style change, then decide. Lanczos at 1.46px mean versus nearest at 1.38px is a 6% average weight increase — small on average, but the per-file effect is that every curve gets a little heavier while stair-stepped curves get smoother. If the drawing's line weight is the point, nearest-and-then-dealias is more faithful; if smoothness is the point, Lanczos is the better engineering choice.
- Do not sharpen a hairlined drawing. Sharpening attacks exactly the mid-tone fringe that phase created, and it does it selectively — the strokes at bad phases get over-darkened while the ones at good phases barely move. That is how a uniform set of lines becomes a visibly uneven set of lines after an "enhance".
Reproducing it
# ink coverage = fraction of pixels below mid-grey
ink = (np.asarray(img.convert("L"), dtype=float) < 128).mean() * 100
# the four-step test
small = gt.resize((200, 200), Image.LANCZOS) # 2x down
back = small.resize((400, 400), Image.BICUBIC) # 2x up
sharp = back.filter(ImageFilter.UnsharpMask(radius=1.0, percent=150, threshold=0))
Try it on your own drawing
The ink-coverage test takes seconds and tells you more than a side-by-side: threshold your result at mid-grey and compare the coverage to the source. If it moved more than a few percent, the method changed your line weight, and no amount of resolution fixes that. UpgoLAB runs illustration-friendly upscaling modes in the browser alongside the photo modes, so you can run the same source through both and see which one holds the line weight.
The lines are the drawing. Anything that changes their thickness changed the drawing, regardless of what the pixel count says.