Research
LYR Performance Note #029 OCR

False fires on UI elements: 44% → 22%, without adding a single outer if

Don't let an outer if do the model's job — how I changed the definition of the ground truth from "anywhere on the screen" to "inside the subtitle region"

2026-07-23 Part 2 10 min edge inferenceOCRlabel designpower efficiencymeasurement

Killing a model’s mistakes with an outer if is easy. Drop things by position. Don’t let it fire again for a while. Add an exception. The numbers improve right away, too.

But if that distinction is something you could make from the input, the outer if may just be hiding the model’s weakness.

In LYR’s subtitle gate, I was suppressing false reactions to UI with rules. When I traced the cause, what was wrong wasn’t the model but the definition of “correct.” And fixing the labels wasn’t enough on its own either — because the problem was decided by position, and I was never handing the model any position.

This article is a case study in the manifesto’s fourth principle, Eliminate, Don’t Accelerate. The point being that the if you add on the outside usually never gets counted as “work you could eliminate.”

Last time I wrote about putting a cheap, always-on gate (the Awake Layer) in front of OCR to cut how often it gets called (Part 1). It’s a layer that looks at “is there text on screen worth reading right now?” and decides whether to wake the expensive work.

Once it was running, a problem showed up immediately. This layer reacts to text that isn’t subtitles.

The column of on-screen controls down the right side of a video app. A browser’s URL bar. The ”⋮” menu in the top right. A game’s HUD. A human can tell at a glance that none of it is a subtitle, but to the gate it was all the same “something that looks like text.” React, and OCR fires. You pay the power, and on top of that fragments of UI end up mixed into the translation.

A comparison of three phone screens. Left = ground-truth labels (subtitles only); center = the model without position information (fires on the subtitle plus navigation, the account name, the search bar, and lights in a night scene); right = the model with the position channel (firing on the subtitle is preserved while firing on UI areas drops).
Figure 1: firing cells before and after adding position information. Left is the ground-truth labels, center is without position information, right is with the position channel. Without position information it fired on the subtitle plus the navigation, the account name, the search bar, and the lights in a night scene. After the position channel was added, firing on the subtitle is held at 130/130 while firing on the UI areas drops. Across 9 held-out clips, with recall held constant, the firing rate on non-subtitle cells goes 6.2% → 3.9% (-37%).
Note: this figure reproduces the same phenomenon as the 44%→22% discussed in the article using data that can be published; both the corpus and the implementation differ, so the numbers don't match. The account name and icon are blurred.

Killing it on the outside was easy

My first fix was, of course, a filter on the outside.

Drop by position. Drop anything too short. Don’t fire again for a fixed interval after a fire. Suppress when the same thing repeats — every one of them is a few lines of code, and every one of them works. In fact, a one-line change that lengthened the firing-suppression interval cut redundant firing by -32 to -78% across all five recordings, and the power proxy by -27 to -31%.

On the numbers alone, that’s a win. But it came with a price. The harder I suppressed, the later the response when a subtitle got swapped out — up to 2 seconds later — and misses ticked up slightly. So this wasn’t an improvement, it was a dial. All it did was shift the balance between wasted firing on one side and lag and misses on the other.

If you cut redundant firing and added latency, that’s not an improvement — you just moved the operating point on a trade-off.

Then it hit me. Every one of these ifs can be described by the same sentence.

I was taking it as given that the model gets it wrong, and buying the cleanup on the outside.

The premium is paid every frame, on the device. You can’t cancel the policy until you fix the cause, and every new exception stacks another policy on top. Position filters, suppression intervals, merge conditions, exceptions to exceptions — outer logic keeps growing while the weakness inside stays hidden.

I changed the definition of the ground truth

So I stopped touching the outside and went to look at what the model had actually been taught was “correct.”

The labels were defined like this — “if text is readable anywhere on the screen, a subtitle is present.”

Under that definition, firing on UI isn’t a malfunction. It’s doing exactly what it was taught. The text in the URL bar and the on-screen controls are both “text that was readable somewhere on the screen.” The model was right; my label definition was wrong.

The fix wasn’t adding one more outer if. It was rewriting the definition.

“readable anywhere on the screen” → “readable inside the subtitle region”

That alone turns UI text into a negative example (something it must not fire on) as far as the model is concerned. The distinction I’d been enforcing on the outside moves inside the training.

The result: false fires on text outside the subtitle region were corrected from “present” to “absent” on 20% of frames in game footage and 23% in drama. Without adding a single outer if.

I was training a position problem without position

To be honest, my first attempt here was a dud.

Retraining with nothing but the label definition fixed had zero effect (no change). The reason is obvious once you think about it: the input I was handing the model was only what the screen looked like, and the information “where on the screen that text sits” was never being passed at all. I was trying to teach a distinction that’s decided by position without ever showing it position.

Only once I added position information to the input did false firing on decorations and UI halve, from about 44% to about 22%.

That, I think, is the most practically important lesson here.

If you’ve decided the model should solve it, hand it what it needs to solve it. Withholding that and telling yourself “training will sort it out” is the same sloppiness as an outer if.

When outer logic is justified

That said, “outer logic is always bad” isn’t right either. Around the same time there was a case where solving it on the outside was clearly correct.

In the mode for reading manga in a browser, the ”⋮” menu in the top right was being misrecognized as a single character and mixed into the translation. Nothing in the character itself distinguishes it from a subtitle. The move I took here wasn’t guessing at a position threshold, and it wasn’t training the model — it was asking the OS for the actual drawing region. The OS holds exact coordinates for the content area. Just discarding everything outside it made the ”⋮” and the URL bar disappear on all three runs on a real device, leaving only the body text translated.

The difference between these two is clear-cut.

ProblemWhere it’s solvedWhy
Browser UI regionsOutsideOnly the OS has the exact content boundary. Information the model cannot see in principle
Telling subtitles from UI on screenThe model sideIt can be told apart from the image and the position
Rate-controlling repeated fires (deduplication)The system side”Don’t reprocess what hasn’t changed” is a product requirement over time
Cleaning up after the model’s false firesThe model side, as a ruleIt can be decided from the input, yet it’s being patched on the outside

Here’s where the line falls.

Outer logic earns its place when you’re injecting information the model cannot possibly know, or a constraint that has to be applied deterministically. If you’re handling something on the outside that the model could decide from its input, that’s debt hiding the model’s weakness.

To be clear: deduplication itself is not debt. Keeping state so that the same subtitle isn’t reprocessed while it hasn’t changed is a product spec, not cleanup after the model. What was debt in this article was lengthening that suppression interval in order to hide false fires. The same code means something different depending on what it’s hiding.

And a position heuristic built on guesswork and the real region the OS hands you are completely different things, however similar they look. The first breaks every time the device or the screen orientation changes; the second doesn’t.

And still, I haven’t won this outright

I’d like to end it cleanly, but let me put the facts down.

Fixing the gate’s signal definition did not win across every genre. Compared against ground truth I built by hand, subtitle capture on lecture footage went 35% → 63% (statistically clear, p≈1e-4) — a real win. But on news footage, just raising the existing model’s threshold matched it or beat it — which means that win wasn’t retraining’s doing, it was confounding. The retrained model didn’t win; the operating points of the two things being compared simply weren’t held constant. Until I put in a control with either recall or the threshold held constant, I can’t say “the training worked” (Don’t talk about “why” until you’ve taken a control). No significant difference on the other genres. As of now there is still no basis that justifies adopting it across production.

Even so, I’m not going back to stacking ifs on the outside. The reason is simple.

Outer patches accumulate; they never go away. Fix the model, and the outer patches disappear. The first keeps adding work, the second reduces it. Both count as “it worked,” but the balance moves in opposite directions.

Lessons

  1. When you see a malfunction, read the definition of “correct” first. The model may not have been wrong at all — it may be correctly learning the wrong objective. Before you go beat on it from the outside, suspect the definition.
  2. If the model is going to solve it, hand it the information it needs. You cannot train a problem that’s decided by position without position. Performance is set by label definition × input information × model capability.
  3. Justify outer rules by where the information lives. Information only the OS has, you inject from the outside. Take over a distinction the model could make from its own input, and work and exceptions pile up.

This is the manifesto’s fourth principle — Eliminate, Don’t Accelerate — showing itself from slightly around the back. An outer if looks like a “fix” at the moment you add it. It only became visible to me as work I could eliminate once I could put into words what it was hiding.


Appendix: raw data

ExperimentImprovementPrice paid & caveats
Firing suppression 500→2000ms (one-line change)redundant firing -32 to -78% (all five recordings), power proxy -27 to -31%subtitle-swap lag up to 2 seconds, slight rise in misses = a dial
Label redefinition (anywhere on screen → inside the subtitle region)corrected 20% of game frames / 23% of drama frames from “present” to “absent”the region approximated as a fixed rectangle (moving subtitles can be over-counted as “absent”); visual spot-checks only on part of the drama set
Retraining with only the labels fixedno change (wash)the input carried no position information
Adding the position channelfalse firing on decorations and UI about 44% → about 22%compared with recall held constant
Exclusion via the OS display regionUI contamination gone in 3 of 3 runs on a real deviceonly where the display region can be obtained (otherwise a safety-net band filter)
Retraining the signal definition (hand-built ground truth, one recording held out at a time)lecture capture 35% → 63% (p≈1.2e-4)news is confounded (threshold tuning matches or beats it); no significant difference on other genres. Not yet enough to justify adoption across the board

Note: part of the power proxy is a relative value of firings × cost (accumulated on the harness), not a measured value from a power meter on a real device.