A champ-select screenshot most often gets misread for one practical reason: JPEG's compression ripples flip pixels across the OCR pipeline's binary threshold, which is exactly why a JPEG re-encode of one reference screenshot dropped recognition from 10 of 10 champions to 7 of 10. Upload PNG rather than JPEG, and capture at native, uncropped resolution — the pipeline's upscale, inversion, and dual thresholds are all calibrated against an unmodified, roughly 16:9 capture.
A screenshot survives several transformations before anything reads it
Should I Dodge's recognition is entirely local: Tesseract OCR, no external service, no API key. Before Tesseract ever sees a pixel, though, the screenshot is decoded, upscaled, greyscaled, inverted, and thresholded into pure black and white — twice, at two different settings — and only then handed to the OCR engine. Each of those steps exists to fix a specific way champion-select text otherwise fails to read, and each one has its own way of going wrong if the input isn't what it expects. This is the pipeline, and where a bad read actually comes from.
Upscaling, and why it's capped on two axes
Champion-select labels are small relative to the whole screen, so the image is scaled up 3x
before recognition — more pixels per glyph, which Tesseract needs to read small text
reliably. That factor isn't applied unconditionally, though.
TesseractOcrEngine.ResolveScale clamps it against two separate bounds: a
per-dimension limit (10,000 px on a side) and a total-pixel limit (20 megapixels, roughly
80 MB as a decoded Rgba32 buffer). Both exist because the file-upload path
accepts screenshots far larger than a typical screen capture, and neither bound alone is
enough — an 8K PNG upscaled 3x with no clamp at all would decode to a buffer around 1.2 GB,
and the per-dimension bound alone still lets a square-ish source scale up to 10,000×10,000,
which is 400 MB by itself. In ordinary use this never matters: both committed reference
screenshots, and the in-app screen-capture path's largest possible output, comfortably clear
both bounds and keep the full 3x factor. It's specifically an oversized manual upload that
gets scaled down less than 3x, trading some of the resolution that helps small text read
cleanly for staying inside a memory budget the app has to hold for every concurrent analysis.
Inversion isn't cosmetic
Tesseract's LSTM recognition models are trained on dark text over a light background. Champion select renders the opposite — light text over dark portraits and UI — and Tesseract's own documentation states inversion as a hard requirement for these models, not an optional tweak. Skipping this step doesn't produce a slightly worse read; it produces a fundamentally wrong input for the model that's about to run.
One threshold can't serve two different brightnesses of text
After greyscale and inversion, the image is reduced to pure black and white by a binary threshold — but champion select draws two meaningfully different brightnesses of text over the same artwork, and no single global cutoff reads both correctly. Champion names are rendered near-white, directly over the champion's own splash art. Role labels (TOP, JUNGLE, SUPPORT, and so on) are a noticeably dimmer grey.
A tight threshold (0.35) keeps only the brightest pixels, which is exactly what the near-white champion names need — at a looser cutoff, the mid-bright artwork behind a glyph binarises into the glyph itself and Tesseract gives up mid-word. But that same tight cutoff destroys the dimmer role labels outright: on the reference fixture, the SUPPORT, TOP, and JUNGLE labels are gone entirely at 0.35 — not misread, not truncated, simply absent from the output, and no confidence setting recovers them. A looser threshold (0.55) keeps those mid-tone role labels, but at the cost of the champion names: measured truncations at 0.55 include "FIDDLESTICKS" read as "FIDDLESTI", "IRELIA" as "IRELI", "SUPPORT" as "SUPP", and "RENATA GLASC" as "RENATA, GLE". Every value between 0.10 and 0.60 was swept in small steps against the committed fixtures, and no single value passed all of them.
The fix is running both thresholds as separate full passes and unioning the results, keeping whichever read is longer wherever the two passes cover the same physical text — the failure mode this exists to fix is truncation, so between two reads of the same label the longer one is, by construction, the more complete one. It costs a second full Tesseract pass over the image, which is not free, but nothing cheaper was found that reads both brightnesses correctly. When a role label is one of the casualties despite this — a genuinely unusual capture, a client theme the fixtures didn't cover — the champion still gets recognised, but its role has to be inferred rather than read; see how the app assigns a role when nothing on screen says so for what happens next, including the specific real case this exact failure mode produced.
Why the intermediate image is a BMP, not a PNG
Once a pass has been through the binary threshold, the image is pure black and white — every pixel is one of exactly two colours. Encoding that as PNG means running its deflate compression over an image that has nothing left to compress, and Tesseract's own reader decodes it right back out a moment later anyway. The pipeline instead encodes each pass as a 1-bit-per-pixel BMP: a straight bit-pack instead of a deflate pass, which Tesseract's underlying library reads directly as a 1 bpp image and — as a side effect — skips its own internal re-binarisation of an 8- or 24-bit input entirely. This detail matters more than it sounds like it should: the BMP's 2-colour palette has to be pinned explicitly to [white, black], because left to its default behaviour the encoder orders a 2-entry palette by which colour is more frequent in that particular pass, and that frequency flips from pass to pass — one threshold keeps a mostly-black frame, the other keeps more white. Left unpinned, that flip silently swaps which colour means "text" from one pass to the next, and was measured causing 8 of 13 acceptance fixtures to read back as empty or garbage.
What gets rejected before any of that even starts
None of the above runs at all if the upload doesn't pass ScreenshotValidator
first — a separate, deliberately cheap-first set of checks against hostile or malformed
input, since every screenshot arrives from an anonymous, unauthenticated visitor. Byte length
is checked first (8 MB), then magic bytes (PNG and JPEG only — enough to catch a renamed
executable without touching an image decoder at all), then Image.Identify for
the declared pixel count (capped at 16 megapixels) with a cross-check that the sniffed format
agrees with the magic bytes, and finally frame count.
Image.Identify rather than a full decode is the load-bearing choice here: it
reads only the file's header, never allocating a pixel buffer, which is exactly what lets a
small file with a lying header get caught before it costs anything. A PNG a few hundred
kilobytes in size can declare dimensions of 40,000×40,000 in its header — decoding that
would allocate roughly 6.4 GB before the pixel-count check ever ran, if the check happened
after decoding instead of before it. The frame-count check exists for a narrower but related
reason: an animated GIF or WebP, or a multi-page TIFF, can carry a file extension and even
magic bytes that pass the PNG/JPEG sniff if mislabelled, and champion select only ever
produces a single still frame. This validator runs twice on purpose — once in the web layer,
so a rejected upload gets a specific, friendly message, and again inside the recogniser
itself, so no future caller of the recognition interface can skip the guard by going around
the web layer.
What actually helps, practically
Upload a PNG rather than a JPEG. This isn't a stylistic preference: the reference screenshot re-encoded at JPEG quality 70 dropped recognition from 10 of 10 champions to 7 of 10, losing the ally Vel'Koz and the enemy Jax and Yunara entirely; quality 85 did better but still wasn't lossless. JPEG's compression artefacts land as small pixel-value ripples around edges, and a binary threshold has to make a hard yes/no call at exactly those edges — a ripple that a human eye never notices can flip a pixel across the 0.35 or 0.55 cutoff and cost a whole glyph. The in-app screen-capture button always encodes PNG and is unaffected by this; it's specifically the manual file-upload path, which accepts JPEG, where this matters.
Capture at native resolution and don't pre-crop or pre-rescale the file before uploading it. The recognition bands this engine scans are calibrated against a full, uncropped ~16:9 capture and only activate within a specific aspect-ratio range; feeding it an already-cropped or resized image shifts everything those bands assume about where slot text actually sits on the page, with no guarantee the crop lines up with what the calibration expects.
Finally, it's worth knowing that even a clean capture isn't reading against a blank canvas. Champion select is full of small decorative UI elements — jungle-camp matchup icons, portrait borders, ability-icon glyphs — that Tesseract confidently misreads as short garbage tokens, and a documented pair of those tokens happened to land close enough to a real champion name to fuzzy-match it: a misread summoner tag read as "ATLAS" matched "Sylas," and a misread matchup-icon caption read as "Ni og" matched "Sion." The 70-point confidence floor applied per word exists specifically because genuine text on these fixtures consistently scores 85 or above while surviving noise tops out at 67 — a 15-point margin, not a coincidence. None of this is something a user can fix by capturing more carefully; it's the reason a result occasionally looks like it read a champion that was never actually in the lobby, and it's a separate failure mode from the missing-role-label case above, even though both can produce a suspicious-looking reading from the same screenshot.