Skip to content

Commit 9fbb0a0

Browse files
committed
fix(scanner): meter exposure on the centre of the frame
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.
1 parent efbd16c commit 9fbb0a0

2 files changed

Lines changed: 61 additions & 4 deletions

File tree

‎ui/scanner/src/main/kotlin/com/getcode/ui/scanner/CodeScanner.kt‎

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -225,9 +225,19 @@ fun CodeScanner(
225225
mutableStateOf(PreviewView.StreamState.IDLE)
226226
}
227227

228-
LaunchedEffect(streamState) {
228+
LaunchedEffect(streamState, gestureController) {
229229
if (streamState == PreviewView.StreamState.STREAMING) {
230230
trace("camera ready")
231+
232+
// Exposure is metered on the centre of the frame rather than the whole of it, so a
233+
// bright code in a dark room is exposed for the code and not for the room. See
234+
// `applyBaselineMetering` -- overexposure does not degrade detection, it ends it.
235+
//
236+
// Deliberately keyed on the stream actually running, not on the bind returning:
237+
// `startFocusAndMetering` is refused with "Camera is not active" until the camera has
238+
// opened, and it refuses *silently*. Asking any earlier would leave whole-frame
239+
// metering in place while looking, from the source, exactly like a fix.
240+
gestureController?.applyBaselineMetering()
231241
}
232242
}
233243

‎ui/scanner/src/main/kotlin/com/getcode/ui/scanner/internal/CameraGestureController.kt‎

Lines changed: 50 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import androidx.camera.core.CameraControl
1010
import androidx.camera.core.CameraInfo
1111
import androidx.camera.core.FocusMeteringAction
1212
import androidx.camera.core.MeteringPoint
13+
import androidx.camera.core.SurfaceOrientedMeteringPointFactory
1314
import androidx.compose.ui.geometry.Offset
1415
import java.util.concurrent.TimeUnit
1516
import kotlin.math.pow
@@ -88,11 +89,24 @@ internal class CameraGestureController(
8889

8990
override fun onSingleTapUp(event: MotionEvent): Boolean {
9091
val point = onTap(Offset(event.x, event.y))
91-
val action = FocusMeteringAction.Builder(point, FocusMeteringAction.FLAG_AF)
92-
.setAutoCancelDuration(5, TimeUnit.SECONDS)
92+
// AE as well as AF: a tap means "read this", and on a washed-out code the exposure
93+
// is the half that is actually broken.
94+
val action = FocusMeteringAction.Builder(
95+
point,
96+
FocusMeteringAction.FLAG_AF or FocusMeteringAction.FLAG_AE,
97+
)
98+
.setAutoCancelDuration(TAP_METERING_SECONDS, TimeUnit.SECONDS)
9399
.build()
94100

95101
cameraControl.startFocusAndMetering(action)
102+
103+
// Auto-cancel drops *all* 3A regions, not just the ones this tap set, so without
104+
// re-arming, the first tap would cost the centre exposure region permanently.
105+
handler.removeCallbacks(restoreBaselineMetering)
106+
handler.postDelayed(
107+
restoreBaselineMetering,
108+
TimeUnit.SECONDS.toMillis(TAP_METERING_SECONDS),
109+
)
96110
return true
97111
}
98112

@@ -114,6 +128,35 @@ internal class CameraGestureController(
114128
}
115129
)
116130

131+
private val restoreBaselineMetering = Runnable { applyBaselineMetering() }
132+
133+
/**
134+
* Meter exposure on the centre of the frame, where the code is, and keep it there.
135+
*
136+
* With no region set the camera meters the whole frame, which for a scanner is the wrong
137+
* subject: a code on a phone screen is a small bright rectangle in a mostly dark scene, so
138+
* whole-frame metering exposes for the room and drives the screen into clipping.
139+
*
140+
* That is fatal rather than merely degrading, because the native detector splits light from
141+
* dark at a fixed luminance of 170 with no adaptive fallback -- measured in
142+
* `WashoutToleranceTest`, the code's dark ink must land below 170 and its light ink above it,
143+
* and contrast beyond that barely matters. A clipped code is not a poor input, it is a uniform
144+
* white slab with no contours to find, and detection stops dead. iOS pins
145+
* `exposurePointOfInterest` to the centre for the same reason.
146+
*
147+
* AE only, deliberately. Adding FLAG_AF would fire a one-shot autofocus and, with auto-cancel
148+
* disabled, leave focus locked at whatever distance it happened to land on -- the opposite of
149+
* what a scanner wants. Focus stays in the camera's own continuous mode.
150+
*/
151+
fun applyBaselineMetering() {
152+
val centre = SurfaceOrientedMeteringPointFactory(1f, 1f).createPoint(0.5f, 0.5f)
153+
val action = FocusMeteringAction.Builder(centre, FocusMeteringAction.FLAG_AE)
154+
.disableAutoCancel()
155+
.build()
156+
157+
cameraControl.startFocusAndMetering(action)
158+
}
159+
117160
fun onTouchEvent(event: MotionEvent) {
118161
if (gesturesEnabled) {
119162
if (initialZoomLevel == -1f) {
@@ -156,4 +199,8 @@ internal class CameraGestureController(
156199
}
157200
})
158201
}
159-
}
202+
203+
private companion object {
204+
const val TAP_METERING_SECONDS = 5L
205+
}
206+
}

0 commit comments

Comments
 (0)