Skip to content

fix(scanner): meter exposure on the centre of the frame - #1311

Merged
bmc08gt merged 2 commits into
code/cashfrom
fix/scanner-exposure-metering
Aug 22, 2026
Merged

fix(scanner): meter exposure on the centre of the frame#1311
bmc08gt merged 2 commits into
code/cashfrom
fix/scanner-exposure-metering

Conversation

@bmc08gt

@bmc08gt bmc08gt commented Aug 22, 2026

Copy link
Copy Markdown
Collaborator

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.cpp runs entirely on one binary image:

threshold(greyscale, whitish, 170, 255, THRESH_BINARY);

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):

sweep decodes fails
overexposure (ceiling clipped, floor rising) black level up to 170 180
underexposure (floor at black, ceiling falling) white level down to 175 165
contrast closing on a mid-grey span 86 (85 ↔ 171) span 76 (90 ↔ 166)

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_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 CameraSession was written:

if device.isExposureModeSupported(.continuousAutoExposure) {
    device.exposureMode = .continuousAutoExposure
}
if device.isExposurePointOfInterestSupported {
    device.exposurePointOfInterest = CGPoint(x: 0.5, y: 0.5)
}

This closes that parity gap rather than inventing a new behaviour. The two scanner.cpp copies 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_AE to 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:

  • 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 — the opposite of what a scanner wants. Focus stays in the camera's continuous mode.
  • Applied when the stream is running, not when bind returns. startFocusAndMetering is refused with OperationCanceledException: 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 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.cpp is 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/cash and this is now standalone.

@github-actions github-actions Bot added type: fix Bug fix area: ui Compose UI, theme, components, resources area: scanner QR/Kikcode scanning, camera and removed type: fix Bug fix labels Aug 22, 2026
@bmc08gt
bmc08gt deleted the branch code/cash August 22, 2026 16:26
@bmc08gt bmc08gt closed this Aug 22, 2026
@bmc08gt bmc08gt reopened this Aug 22, 2026
@bmc08gt
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
bmc08gt force-pushed the fix/scanner-exposure-metering branch from d12192a to 4b6bf88 Compare August 22, 2026 16:28
@github-actions github-actions Bot added the type: fix Bug fix label Aug 22, 2026
@bmc08gt
bmc08gt merged commit ee77ee0 into code/cash Aug 22, 2026
3 checks passed
@bmc08gt
bmc08gt deleted the fix/scanner-exposure-metering branch August 22, 2026 16:30
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area: scanner QR/Kikcode scanning, camera area: ui Compose UI, theme, components, resources type: fix Bug fix

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant