What the problem looks like
Ask the Steam store API for an app's screenshots and you get, among other things, a path_full and a path_thumbnail for each one. The full-size version is typically 1920x1080, so it is the obvious thing to drop into a hero image slot. The catch is that not all of them are 16:9 photographs scaled up. A screenshot whose native aspect ratio is wider or narrower can arrive placed on a flat template, with the game occupying a band across the top and empty grey below.
On a page full of cards this reads as a bug in your own CSS. It is not; it is the source asset. The Steam store asset documentation does not promise any particular composition for user-submitted or developer-submitted screenshots, and in practice the variety is wide.
Detecting it without viewing anything
The grey band is uniform and bright, and it sits at the bottom of the frame. That is enough to write a heuristic that works on the first pass:
from PIL import Image
import statistics
def is_grey_screen(path):
im = Image.open(path).convert("L")
w, h = im.size
top = im.crop((0, 0, w, int(h * 0.20)))
bottom = im.crop((0, int(h * 0.70), w, h))
return (statistics.mean(bottom.getdata()) > 200
and statistics.mean(top.getdata()) < 180)
The logic: if the bottom thirty per cent is near-white and the top twenty per cent is not, the real content is confined to the top of the frame. It is a heuristic, so it will misclassify a legitimately bright snow level or a pale menu screen. The two thresholds are the part to tune, and they should be tuned against a sample from your own catalogue — a catalogue of dark interiors needs different numbers from one of daylight exteriors, so the values above are a starting point rather than a standard.
The fix: use the thumbnail, not the full image
The useful discovery is that Steam's path_thumbnail is not a scaled-down copy of the full image. It is a properly cropped 600x338 frame — exactly 16:9, filled edge to edge with actual game content, because Steam crops to fill rather than letterboxing. Upscaling that to 1280x720 gives a clean hero image with no grey band, at a resolution that is entirely adequate for a card or a header.
from PIL import Image
import urllib.request
thumb = "https://cdn.cloudflare.steamstatic.com/steam/apps/<appid>/ss_<hash>.600x338.jpg"
urllib.request.urlretrieve(thumb, "tmp.jpg")
im = Image.open("tmp.jpg")
Image.Image.resize(im, (1280, 720), Image.LANCZOS).save("hero.jpg", quality=86)
LANCZOS matters here: you are enlarging by a factor of slightly more than two, and the default resampling filter makes the result visibly soft. We use Pillow for this, and its Image.resize documentation covers the filter options properly. The API endpoint itself is straightforward — https://store.steampowered.com/api/appdetails?appids=<appid>&filters=screenshots — and returns both paths for every screenshot.
What the two measurements actually show
It is worth being precise about the evidence here, because "most Steam screenshots are broken" is the kind of claim that sounds authoritative and is easy to repeat without checking. Two things are reproducible, and one thing is not.
The resolution asymmetry is real. Querying the store API for ten titles and downloading every frame returns path_full at 1920x1080 for 69 of 78 images, 1280x720 for eight, and one odd 1828x1080. path_thumbnail is consistently 600x338. The gap between the two is the entire basis of the technique, and it holds across catalogues.
The repair leaves a fingerprint you can measure. Our own library now holds 43 hero images at exactly 1280x720. Scaling each one down to 600x338 and back up reproduces the original with a mean absolute error between 0.4 and 2.7 out of 255 across every file we checked — meaning those images carry almost no detail beyond the thumbnail's width. That is what an upscaled path_thumbnail looks like, and the check is worth running on any image library, because it tells you what resolution your assets were really sourced at.
The rate is not something we can restate. Running the detector over that fresh sample of 78 full-size frames, and over all 481 image files in our five game properties, flagged none. The mechanism is real and the fix is verifiable, but we cannot put a percentage on how common the problem is from our own catalogue — so treat the detector as a guard for the next bulk pull rather than as a statistic. If you want the number for your own source, run it over your own assets; it takes a minute.
Handle it in CSS as well as in the asset
Even with clean images, a fixed-height card will crop unpredictably when the aspect ratio varies. Two properties solve most of it: an explicit aspect-ratio: 16 / 9 on the container, and object-fit: cover with object-position: top on the image, so any residual cropping removes the least interesting part of the frame. It is a belt-and-braces measure, but it means a single missed image degrades gracefully instead of visibly.
The general lesson
This is not really about Steam. Any time you consume images in bulk from a third party, the largest available size is not necessarily the best-framed one, and a platform's "full" asset may be a composited derivative rather than a source file. Before writing any machine-learning solution to a bulk media problem, check whether the provider exposes a differently-derived version of the same asset — the answer is often already in the API response, and it is free. That principle applies to more than images; our notes on web performance go into the same territory for page assets.
Running it across a whole catalogue
Detecting and repairing one image is a script of ten lines. Doing it across a whole catalogue is a different problem, and nearly all of the extra complexity is bookkeeping rather than image processing. Our five game properties hold 136 base images, or 481 files once the WebP and AVIF variants are counted.
The first thing that breaks is rate limiting. Pulling screenshots sequentially is polite but slow; pulling them in parallel without a cap gets you throttled or temporarily blocked. We settled on a small fixed concurrency with a short pause between batches, which finished the run in minutes rather than hours without tripping anything.
The second is naming. Steam screenshot hashes are unique per image, but the same hash can appear in more than one app's feed, and a naive script that writes hero.jpg into a shared folder will happily overwrite its own output. We key every output file on the app id and the screenshot hash, which makes collisions visible instead of silent.
The third is idempotency, and it matters more than it sounds. A bulk repair that re-downloads everything on every run wastes hours and re-uploads identical bytes. We keep a small record of which images have already been processed and what the detector decided, so a second run only touches new or failed entries. It also means a change in the detection thresholds can be applied only to the images that were previously borderline, rather than to the whole library at once.
What we tried first and abandoned
Our initial instinct was to solve this as a segmentation problem: detect the grey region, crop to the remaining content, and re-frame whatever was left. It works, and it is far more work than it needs to be. Edge detection introduces its own failure modes on genuinely bright images, cropping to an arbitrary bounding box changes the composition in ways that look wrong on a card, and every additional rule needs its own tuning pass.
The thumbnail path sidesteps all of that because somebody else already did the cropping, using information we do not have — namely, which part of the frame the uploader considered important. Before building a clever solution to a media problem, it is worth checking whether the platform exposes another derivation of the same asset. Here it did, and the entire fix collapsed into a resample and a rename. The detection heuristic still earns its place, because it tells you which images need replacing at all; it just is not doing the repair any more.