A Product Shot and a Face Fail in Opposite Directions
One preset cannot serve both, and the reason is measurable: the same sharpening kernel does 1.63× damage to smooth skin and 1.31× damage to a hard product edge. The kernel is identical. The content is not.
"Which upscale preset should I use?" is usually answered with a list. Portrait for faces, product for objects, anime for line art. The lists are not wrong, but they are memorised rather than derived, so they fail the moment a photo is two things at once — a person holding a product, which is most commercial photography.
Here is what is actually different between the two classes, measured.
The statistics that separate them
I took three regions from a real image and computed five quantities on each: mean gradient magnitude, 99th-percentile gradient (the hard edges), mean local standard deviation in 8×8 blocks, the fraction of blocks that are nearly flat, and edge density (percentage of pixels with gradient > 40).
| Region | meanGrad | p99Grad | blkStd | flat% | edge% |
|---|---|---|---|---|---|
| Portrait-like (skin gradient) | 6.21 | 69.5 | 11.57 | 18.9 | 2.85 |
| Graphic / text | 6.25 | 97.6 | 11.43 | 34.4 | 4.68 |
| Product edge (hard contour) | 6.87 | 91.1 | 13.12 | 32.5 | 5.19 |
Look at the first two columns. The mean gradient is almost identical across all three (6.21, 6.25, 6.87) — that is why simple "sharpness" sliders cannot tell them apart. But p99Grad differs by 40% between portrait and graphic, and edge density nearly doubles.
In plain terms: a face is mostly smooth with a few deliberate edges (eyes, lips, nostril). A product shot is mostly flat with many hard edges (panel seams, printed text, cutout boundary). The average is the same; the distribution is not.
What a single sharpening kernel does to each
Apply one unsharp mask (5×5, amount 1.5, radius equivalent) to all three and measure the ratio before/after:
| Region | mean gradient gain | p99 (halo) gain |
|---|---|---|
| Portrait-like | ×1.502 | ×1.63 |
| Graphic / text | ×1.581 | ×1.31 |
| Product edge | ×1.608 | ×1.67 |
The important pair is the last column, not the first. Mean gradient gain is similar everywhere (1.50–1.61) — the kernel is doing roughly the same total work. But the p99 gain, which is where visible halos live, is 1.63 on the smooth region and 1.31 on the text region.
Why the smooth region suffers more from halos. Unsharp mask adds a negative ring around every edge. On a hard-edged product, that ring lands on already-dark or already-bright pixels and reads as "more contrast" — acceptable, sometimes desirable. On a face, the same ring lands on a smooth cheek gradient with nothing to hide it, and reads as an outline around the jaw. Same kernel, same magnitude, opposite verdict.
The mean-gradient column also explains the other half of the problem. The product shot gains the most on mean gradient (1.608) — sharpening it looks like it is working, because the metric that a "sharpness" slider optimises goes up the most. The face gains the least (1.502) while taking the largest halo penalty (1.63). So a single preset tuned to feel sharp is tuned by the product shot, and paid for by the face.
The practical rule this produces
Not "use the portrait preset for faces." That is the conclusion; here is the derivation, which handles the mixed case:
- Count hard edges first. Edge density above roughly 4% means the image is dominated by graphic or product content. Below 3% means it is dominated by smooth gradients. Between 3% and 4% — the portrait-holding-product case — the image has two regimes and needs two treatments.
- For mixed images, mask before sharpening. Sharpen the product region, leave the skin region at a lower amplitude. This is one extra mask and it is the only approach that does not compromise both — with two caveats I measured below: the mask line must sit on a real edge, and a soft per-pixel amount works better than a binary mask at all.
- Never evaluate on the region you tuned. If you tuned on the product, check the face at 100%. The metric that improved is the one you tuned; the one that got worse is the one you did not look at.
Why "just use 4K" does not rescue it
Upscaling increases pixel count, not edge density. If you upscale a 1080p product shot to 4K, the edges get more pixels along them but their sharpness in absolute terms is set by the source. The ratio between the two regions does not change, so the preset conflict does not resolve. It is a content-classification problem, and resolution is not a classification.
The exception is genuine: if the target is print at a specific physical size, resolution does matter, because the viewing distance changes what counts as a hard edge. That is a different problem with a different calculation, and treating it as "more pixels is better" gets the answer wrong in both directions.
Does masking the two regions actually work?
Step 2 of the rule above says "mask before sharpening" as if that were the end of the story. It is not, and I built a controlled mixed image to find out why. Left half: a smooth skin-like surface — a 2D gradient, a soft cheek falloff, three deliberately soft arcs with a 1.4-pixel ramp, and σ=2.0 sensor noise. Right half: a product-like panel — flat mid-grey, 1-pixel white seams, near-black text bars, one hard outer contour, same noise. 1200×800, upscaled 2× with lanczos to 2400×1600, one unsharp mask (5×5, amount 1.5, gaussian σ=2).
The two halves are separated by distribution, not by average
| Half | meanGrad | p99Grad | blkStd | flat% | edge% |
|---|---|---|---|---|---|
| Smooth (skin-like) | 1.34 | 3.3 | 1.54 | 2.3 | 0.00 |
| Hard (product-like) | 3.99 | 60.5 | 6.78 | 0.2 | 2.62 |
Mean gradient differs 3× here, but that is a property of how much flat area I drew, not a law. What is stable is p99Grad: 3.3 vs 60.5, a factor of 18. The 99th percentile is the quantity that survives a change of framing, which is exactly why it is the right classifier and mean gradient is not.
Finding 1 — the halo gap narrows as you sharpen later in the pipeline
I ran the identical kernel at three points: on the native image, after a 2× upscale, and after a 4× upscale. Same amount, same radius, same content.
| Sharpened at | skin p99 gain | product p99 gain | gap |
|---|---|---|---|
| native 1200×800 | 2.224 | 1.449 | 1.53× |
| after 2× lanczos | 2.260 | 1.736 | 1.30× |
| after 4× lanczos | 1.827 | 1.546 | 1.18× |
The gap column is skin gain ÷ product gain. At native resolution the smooth half takes 53% more halo than the hard half. After a 2× upscale it is 30%. After 4×, 18%. The disparity does not disappear, but it compresses — because the upscaler has already smoothed the product edges, so the kernel finds less to over-drive there, while the smooth half's p99 is dominated by noise that the kernel amplifies regardless.
Practical consequence, and it is the opposite of the usual advice: if you must apply one global preset, apply it after upscaling, not before. The cost of the conflict drops from 1.53× to 1.18×. The total sharpening effect also drops (skin gain 2.22 → 1.83), so you will need a larger amount — but the ratio between the two regions, which is what produces the "this preset is wrong for one of them" feeling, is what improves.
Finding 2 — a hard mask does not create a brightness step, and that is not the bug
My first hypothesis was that a binary mask leaves a visible brightness step where the sharpened side is brighter than the untouched side. Measured at a mask line drawn through pure smooth area, comparing mean luma 25 px either side:
| Pipeline | step (levels) | grain left | grain right | grain jump |
|---|---|---|---|---|
| untouched baseline | 4.762 | 1.012 | 1.026 | 1.01× |
| global unsharp 1.5 | 4.798 | 2.583 | 2.610 | 1.01× |
| hard mask, feather 0 px | 4.779 | 1.012 | 2.610 | 2.58× |
| feather 8 px | 4.779 | 1.012 | 2.610 | 2.58× |
| feather 16 px | 4.779 | 1.012 | 2.550 | 2.52× |
| feather 32 px | 4.776 | 1.012 | 2.233 | 2.21× |
The step column is flat: 4.762 → 4.779, a change of 0.017 levels out of 255. Unsharp mask is a zero-mean filter, so a band that contains no edge gains contrast but no net offset. The brightness-step hypothesis is wrong. I kept the row in the table rather than deleting it, because a hypothesis that failed is still a measurement.
The actual artifact is in the last column. Read the jump as the ratio of grain either side of the line: the untouched image is 1.01× (left and right are the same surface), the globally sharpened image is also 1.01× — grain is raised everywhere, so there is nothing to see at that line. But the hard mask gives 2.58×: high-pass residual σ of 1.012 on the protected side against 2.610 on the sharpened side. That is a 2.58-fold discontinuity in visible grain sitting in the middle of an otherwise smooth area. Your eye picks up a change of texture far more readily than a 0.02-level offset.
Feathering barely helps. Going from a 0 px to a 32 px feather moves the jump from 2.58× to 2.21×, and it costs real detail along the way (see the feather warning below). Feathering spreads the grain transition over a band, it does not remove it.
So the corollary to "mask before sharpening" is: the mask boundary must lie on a real edge, never inside a smooth region — a hairline across a cheek is the single worst place to put it, and a perfectly good segmentation still produces a visible artifact if its contour is drawn there.
Finding 3 — automatic masking is a tradeoff curve, not 100%/100%
The obvious objection: hand-masking every product photo does not scale. So I tried to derive the mask automatically — gradient magnitude, gaussian-blurred by 16 px, thresholded at a percentile, then dilated. The truth mask is known, so coverage can be scored honestly.
| Threshold | % sharpened | product covered | skin protected | skin p99 gain | product p99 gain |
|---|---|---|---|---|---|
| p90 + 31 px dilate | 17.7 | 35.4 | 100.0 | 1.002 | 1.736 |
| p80 + 51 px | 31.2 | 61.0 | 98.6 | 1.094 | 1.736 |
| p70 + 81 px | 42.8 | 80.4 | 94.7 | 1.417 | 1.736 |
| p60 + 101 px | 63.1 | 100.0 | 73.8 | 1.942 | 1.736 |
| p50 + 121 px | 65.1 | 100.0 | 69.8 | 1.979 | 1.736 |
Look at the two middle columns together. Every row that full-covers the product also sharpens 26–30% of the skin; every row that fully protects the skin covers at most 35% of the product. There is no threshold that gets both. The best joint setting — p80 — protects 98.6% of the smooth area while sharpening only 61% of the hard-edge area.
That 61% is not a failure of tuning, it is the resolution of the goal. A dilated gradient mask cannot reach an object's interior, because the interior is flat and flat regions look exactly like skin. Any purely local, edge-based mask has this ceiling. Getting to 100% requires a semantic segmentation model that knows "this region is a product", which is a different piece of software with a different cost.
Finding 4 — a soft amount beats a binary mask
Since the binary mask is a hard switch and the artifact is a hard switch, the natural fix is to not switch. Instead of masking, scale the sharpening amount per pixel by the blurred gradient magnitude — full amount where content is busy, near zero where it is flat.
| Amount / threshold | mean amount | skin p99 gain | product p99 gain | skin grain gain |
|---|---|---|---|---|
| 1.5 / 0.05 | 0.612 | 1.320 | 1.730 | 1.36× |
| 1.5 / 0.10 | 0.372 | 1.161 | 1.625 | 1.18× |
| 1.5 / 0.20 | 0.188 | 1.081 | 1.432 | 1.09× |
| 1.5 / 0.35 | 0.107 | 1.047 | 1.265 | 1.05× |
At threshold 0.10 the soft version holds the product at 1.625 — 93.6% of the 1.736 a global kernel delivers there — while lifting skin halo to only 1.161 and skin grain to 1.18×. Compare with the best binary setting, which held the full 1.736 on 61% of the product and 1.094 on 98.6% of the skin but left a 2.58× grain step at its boundary. The soft version has no boundary at all, which is precisely the point: the artifact was never caused by the mask being in the wrong place, it was caused by the mask existing.
And a warning about feather width that comes out of the same run. Feathering is not free — measured as high-pass microcontrast inside the transition band, relative to what a global kernel puts there: 1.000 at feather ≤4 px, 0.997 at 16 px, 0.991 at 32 px, then 0.876 at 64 px. A 64-pixel feather throws away 12% of the microcontrast in a 64-pixel band along every object outline. Feather wide enough to hide the seam and you have drawn a soft, wide, unsharp border around the product. If a mask needs more than ~32 px of feather to look natural, the mask is in the wrong place and no feather value will rescue it.
Reproducing it
# region statistics
a = np.asarray(img.convert("L"), dtype=np.float64)
gy, gx = np.gradient(a); grad = np.hypot(gx, gy)
blocks = a.reshape(h//8, 8, w//8, 8)
blk_std = blocks.std(axis=(1,3))
edge_density = (grad > 40).mean()
# one kernel, three regions
ffmpeg -i region.png -vf "unsharp=5:5:1.5:5:5:0.0" sharp.png
# then recompute p99(grad) before/after
# masked / soft-amount sharpening, no ffmpeg needed
from PIL import Image, ImageFilter
Lb = np.asarray(Image.fromarray(img_L.astype(np.uint8))
.filter(ImageFilter.GaussianBlur(2.0)), dtype=np.float64)
resid = Lb * 0 + (img_L - Lb) # zero-mean high-pass residual
gm = np.hypot(*np.gradient(img_L))
gm = np.asarray(Image.fromarray(np.clip(gm / gm.max() * 255, 0, 255)
.astype(np.uint8)).filter(ImageFilter.GaussianBlur(16)),
dtype=np.float64) / 255.0
amt = 1.5 * np.clip(gm / 0.10, 0, 1) # soft, per-pixel amount
out = np.clip(img_L + amt * resid, 0, 255)
# grain onset metric at a mask line x0: high-pass std either side
hp = img_L - gaussian_blur(img_L, 1.5)
jump = hp[:, x0+10:x0+40].std() / hp[:, x0-40:x0-10].std()
Try both on one file
The fastest way to feel this rather than read it is to run the same source through more than one mode back to back. A browser-based upscaler makes that cheap: UpgoLAB exposes eight modes over the same input for photos and video, so you can compare portrait-style and product-style treatment on the actual image instead of guessing from a preset name.
If you only remember one number from this page: the halo penalty is 1.63 on smooth and 1.31 on hard edges, from an identical kernel. That gap is the entire argument against one-preset-for-everything.