fix(scanner): meter exposure on the centre of the frame - #1311
Merged
Conversation
bmc08gt
changed the base branch from
fix/scanner-analysis-resolution
to
code/cash
August 22, 2026 16:27
Scanning failed outright when a bright screen washed the code out, and the
reason is that auto-exposure is effectively part of the detector.
Candidate finding in scanner.cpp runs entirely on one binary image:
threshold(greyscale, whitish, 170, 255, THRESH_BINARY);
Every contour, ellipse and code the scanner ever finds comes out of that. The
cutoff is an absolute luminance, not a local or adaptive one, so the detector
never asks whether the code has contrast -- it asks whether the light ink lands
above 170 and the dark ink below it. Measured on a Solana Seeker, decoding
survives up to a black level of exactly 170 and dies at 180; from the other
side it survives down to a white level of 175 and dies at 165. Squeezed around
a mid-grey it decodes an 85-to-171 span and fails at 90-to-166 -- a code that
is still plainly legible, and completely invisible to the scanner, purely for
sitting on the wrong side of a constant.
So a clipped code is not a degraded input, it is a uniform white slab with no
contours to find. Nothing downstream can recover it.
Android left metering to the camera's whole-frame default and never requested
AE at all, not at bind and not on tap, where the existing action carried
FLAG_AF alone. Pointed at a code on a phone screen in a dim room, whole-frame
metering exposes for the room and drives the screen into clipping. iOS has
pinned exposurePointOfInterest to the centre since the session was written;
this closes that gap rather than inventing a new behaviour.
Request AE on the centre of the frame and keep it there, and add FLAG_AE to
tap-to-focus so a tap fixes exposure too -- on a washed-out code the exposure
is the half that is actually broken. Because auto-cancel drops all 3A regions
rather than only the ones the tap set, the tap re-arms the baseline afterwards
instead of silently costing it.
Two details that are load-bearing and do not read as such:
- AE only, no AF. Adding FLAG_AF to a request with auto-cancel disabled would
fire a one-shot autofocus and leave focus locked wherever it landed, which is
the opposite of what a scanner wants.
- Applied when the preview stream is actually running, not when bind returns.
startFocusAndMetering is refused with "Camera is not active" until the camera
has opened, and it refuses silently -- the first cut of this fix asked at
bind time and was cancelled every time while looking, from the source, like
it worked.
The 170 constant itself is the deeper problem, but scanner.cpp is maintained in
parallel on iOS and changing the threshold trades against false positives in a
way synthetic frames cannot measure. That is a cross-platform decision, not a
drive-by.
The washout failure was diagnosed from a constant in scanner.cpp, and a constant is a hypothesis. WashoutToleranceTest renders codes through the tone mapping an exposure error actually applies -- the full black-to-white range squeezed into a narrower band and clipped -- and sweeps the endpoints until decoding stops. Three sweeps, because the interesting result is the relationship between them. Overexposure raises the floor with the ceiling already clipped; underexposure lowers the ceiling with the floor at black; the third closes the band in on a mid-grey from both sides. If the detector adapted to the frame, the third would keep working until contrast fell into the noise. It instead fails the moment the band stops straddling 170, which is what makes this an exposure bug rather than a contrast one, and what makes centre-weighted metering the fix. Also checks on device that the camera accepts the AE region the scanner now asks for. startFocusAndMetering is a request: a device without AE regions resolves it unsuccessfully and leaves whole-frame metering in place, entirely silently. That check earned its place immediately -- it caught the metering request being cancelled with "Camera is not active" because the first version of the fix asked at bind time instead of waiting for the stream. It waits for a real frame before asking, since otherwise a not-yet-open camera is indistinguishable from an unsupported one, and it logs rather than fails on a device without AE regions, which is a fact about the hardware. Frames are synthetic and otherwise ideal -- perfect focus, no noise, dead-on perpendicular -- so the measured window is the generous end of what a real camera sees.
bmc08gt
force-pushed
the
fix/scanner-exposure-metering
branch
from
August 22, 2026 16:28
d12192a to
4b6bf88
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Scanning fails outright when a bright screen washes the code out. The cause is that auto-exposure is effectively part of the detector, and Android never configured it.
The detector has no exposure tolerance
Candidate finding in
scanner.cppruns entirely on one binary image:Every contour, every ellipse, and therefore every code the scanner ever finds comes out of that. The cutoff is an absolute luminance, not a local or adaptive one. So the detector never asks whether the code has contrast — it asks whether the code's light ink lands above 170 and its dark ink below it.
Measured on a Solana Seeker (
WashoutToleranceTest):The third row is the one that matters. A code rendered at 90 ↔ 166 is plainly legible and completely invisible to the scanner — not because contrast ran out, but because the band stopped straddling 170. This is an exposure bug, not a contrast one.
A clipped code is therefore not a degraded input. It is a uniform white slab with no contours to find, and nothing downstream can recover it.
Android never asked for exposure metering
Metering was left to the camera's whole-frame default, and AE was never requested — not at bind, and not on tap, where the existing action carried
FLAG_AFalone. Pointed at a code on a phone screen in a dim room, whole-frame metering exposes for the room and drives the screen into clipping.iOS has pinned
exposurePointOfInterestto the centre sinceCameraSessionwas written:This closes that parity gap rather than inventing a new behaviour. The two
scanner.cppcopies are otherwise identical here — the divergence was entirely in the camera configuration.The fix
Request AE on the centre of the frame and keep it there, and add
FLAG_AEto tap-to-focus so a tap fixes exposure too. Since auto-cancel drops all 3A regions rather than only the ones the tap set, the tap re-arms the baseline afterwards instead of silently costing it.Two details are load-bearing and don't read as such:
FLAG_AFto a request with auto-cancel disabled would fire a one-shot autofocus and leave focus locked wherever it landed — the opposite of what a scanner wants. Focus stays in the camera's continuous mode.startFocusAndMeteringis refused withOperationCanceledException: Camera is not activeuntil the camera has opened, and it refuses silently. The first cut of this fix asked at bind time and was cancelled every time, while looking from the source exactly like a working fix. The device test caught it.Not done here
The 170 constant is the deeper problem — an adaptive or Otsu threshold would make the detector exposure-agnostic. But
scanner.cppis maintained in parallel on iOS, and changing the threshold trades against false positives in a way synthetic frames cannot measure. That's a cross-platform decision, not a drive-by.Was stacked on #1310, which has since landed; rebased onto
code/cashand this is now standalone.