Skip to content

Commit ee77ee0

Browse files
authored
fix(scanner): meter exposure on the centre of the frame (#1311)
1 parent 8ae91d4 commit ee77ee0

4 files changed

Lines changed: 330 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+
}

‎vendor/kik/scanner/src/androidTest/kotlin/com/kik/scan/AnalysisResolutionTest.kt‎

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,10 @@ import android.util.Size
66
import android.view.Surface
77
import androidx.camera.core.CameraSelector
88
import androidx.camera.core.ImageAnalysis
9+
import androidx.camera.core.Camera
10+
import androidx.camera.core.FocusMeteringAction
911
import androidx.camera.core.Preview
12+
import androidx.camera.core.SurfaceOrientedMeteringPointFactory
1013
import androidx.camera.core.resolutionselector.AspectRatioStrategy
1114
import androidx.camera.core.resolutionselector.ResolutionSelector
1215
import androidx.camera.core.resolutionselector.ResolutionStrategy
@@ -230,6 +233,72 @@ class AnalysisResolutionTest {
230233
)
231234
}
232235

236+
/**
237+
* The centre exposure region the scanner asks for is actually accepted by this camera.
238+
*
239+
* `startFocusAndMetering` is a request, not a command: a device that does not support AE
240+
* regions reports zero `maxMeteringPointsAe` and the call resolves as unsuccessful, leaving
241+
* whole-frame metering in place. That failure is completely silent at runtime -- the scanner
242+
* keeps working, just as badly as before -- so the only way to know the fix took is to ask.
243+
*
244+
* Logs rather than fails on an unsupported device: not every camera offers AE regions, and
245+
* that is a fact about the hardware rather than a defect in the scanner.
246+
*/
247+
@Test
248+
fun centreExposureMeteringIsSupported() {
249+
val context = InstrumentationRegistry.getInstrumentation().targetContext
250+
val provider = ProcessCameraProvider.getInstance(context).get(10, TimeUnit.SECONDS)
251+
val owner = TestLifecycleOwner()
252+
val instrumentation = InstrumentationRegistry.getInstrumentation()
253+
val analysis = shippingAnalysis()
254+
val executor = Executors.newSingleThreadExecutor()
255+
var camera: Camera? = null
256+
257+
instrumentation.runOnMainSync {
258+
provider.unbindAll()
259+
camera = provider.bindToLifecycle(owner, selector, Preview.Builder().build(), analysis)
260+
owner.resume()
261+
}
262+
263+
try {
264+
val bound = requireNotNull(camera) { "camera did not bind" }
265+
266+
// Wait for a real frame before asking. Binding returns long before the camera opens,
267+
// and `startFocusAndMetering` on a camera that is not yet active fails with
268+
// OperationCanceledException -- which would look identical to an unsupported device.
269+
val frame = CountDownLatch(1)
270+
analysis.setAnalyzer(executor) { image -> frame.countDown(); image.close() }
271+
val active = frame.await(15, TimeUnit.SECONDS)
272+
assertTrue(active, "camera never delivered a frame, so metering could not be tested")
273+
274+
val centre = SurfaceOrientedMeteringPointFactory(1f, 1f).createPoint(0.5f, 0.5f)
275+
val action = FocusMeteringAction.Builder(centre, FocusMeteringAction.FLAG_AE)
276+
.disableAutoCancel()
277+
.build()
278+
279+
val supported = bound.cameraInfo.isFocusMeteringSupported(action)
280+
val result = runCatching {
281+
bound.cameraControl.startFocusAndMetering(action).get(5, TimeUnit.SECONDS)
282+
}
283+
284+
Log.i(
285+
TAG,
286+
// No `isFocusSuccessful` here: it reports the *focus* outcome and is
287+
// documented to return false whenever the action carries no AF flag, so on an
288+
// AE-only request it says nothing and reads like a failure.
289+
"centre AE metering: supported=$supported " +
290+
"requestOutcome=${result.exceptionOrNull()?.toString() ?: "accepted"}",
291+
)
292+
} finally {
293+
instrumentation.runOnMainSync {
294+
analysis.clearAnalyzer()
295+
provider.unbindAll()
296+
owner.destroy()
297+
}
298+
executor.shutdown()
299+
}
300+
}
301+
233302
private companion object {
234303
const val TAG = "KikCodeRange"
235304
}
Lines changed: 200 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,200 @@
1+
package com.kik.scan
2+
3+
import android.graphics.Bitmap
4+
import android.graphics.Canvas
5+
import android.graphics.Color
6+
import android.util.Log
7+
import androidx.core.content.ContextCompat
8+
import androidx.test.ext.junit.runners.AndroidJUnit4
9+
import androidx.test.platform.app.InstrumentationRegistry
10+
import com.getcode.codes.kikcode.LuminancePlane
11+
import com.kik.kikx.kikcodes.ScanQuality
12+
import com.kik.kikx.kikcodes.implementation.KikCodeScannerImpl
13+
import com.kik.kikx.kincodes.KikCodeContentRendererImpl
14+
import com.kik.kikx.models.ScannableKikCode
15+
import kotlinx.coroutines.runBlocking
16+
import org.junit.Test
17+
import org.junit.runner.RunWith
18+
import kotlin.test.assertTrue
19+
20+
/**
21+
* How much exposure error the detector survives.
22+
*
23+
* Candidate finding is built entirely on one line of `scanner.cpp`:
24+
*
25+
* threshold(greyscale, whitish, 170, 255, THRESH_BINARY);
26+
*
27+
* Every contour, every ellipse, and therefore every code the scanner ever finds comes out of that
28+
* binary image. The cutoff is an absolute luminance value, not a local or adaptive one, and it runs
29+
* after two unsharp passes that push highlights further up. So the detector does not ask whether the
30+
* code has contrast -- it asks whether the code's light ink lands above 170 and its dark ink lands
31+
* below it. A frame can be perfectly sharp, perfectly framed and perfectly in focus and still be
32+
* undetectable simply for sitting at the wrong exposure.
33+
*
34+
* That makes auto-exposure part of the scanner, and the two platforms configure it differently:
35+
* iOS pins `exposurePointOfInterest` to the centre of the frame, Android leaves metering to the
36+
* camera's whole-frame default and never requests AE at all. Pointed at a bright phone screen in a
37+
* dim room, whole-frame metering exposes for the dark surround and blows the screen out.
38+
*
39+
* This measures the window rather than deriving it, because the unsharp passes move the boundary
40+
* and only a measurement says by how much.
41+
*
42+
* Frames are synthetic and otherwise ideal, so these bounds are the generous end of what a real
43+
* camera sees.
44+
*/
45+
@RunWith(AndroidJUnit4::class)
46+
class WashoutToleranceTest {
47+
48+
private val renderer = KikCodeContentRendererImpl().apply {
49+
badge = requireNotNull(
50+
ContextCompat.getDrawable(
51+
InstrumentationRegistry.getInstrumentation().context,
52+
com.kik.kikx.test.R.drawable.ic_logo_round_white,
53+
)
54+
)
55+
}
56+
private val scanner = KikCodeScannerImpl()
57+
58+
private fun encodeRemoteCode(seed: Int): Pair<ByteArray, ByteArray> {
59+
val payload = ByteArray(REMOTE_PAYLOAD_BYTES) { ((it * 7 + seed) and 0xFF).toByte() }
60+
return payload to requireNotNull(Scanner.encode(payload)) { "native encode returned null" }
61+
}
62+
63+
/**
64+
* Renders a code into a Y plane whose full black-to-white range has been squeezed into
65+
* [blackLevel]..[whiteLevel].
66+
*
67+
* This is what an exposure error does to a frame. Overexposure lifts the whole range towards
68+
* white and clips it there; underexposure crushes it towards black; veiling glare off a bright
69+
* emissive panel lifts the floor without moving the ceiling. All three are the same
70+
* transformation with different endpoints, and all three are applied to the whole frame,
71+
* because a camera's exposure is a property of the frame and not of the subject.
72+
*/
73+
private fun renderAtLevels(
74+
encoded: ByteArray,
75+
width: Int,
76+
height: Int,
77+
codePx: Int,
78+
blackLevel: Int,
79+
whiteLevel: Int,
80+
): ByteArray {
81+
val bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888)
82+
val canvas = Canvas(bitmap)
83+
canvas.drawColor(Color.BLACK)
84+
85+
canvas.save()
86+
canvas.translate((width - codePx) / 2f, (height - codePx) / 2f)
87+
renderer.render(encoded, codePx, canvas)
88+
canvas.restore()
89+
90+
val pixels = IntArray(width * height)
91+
bitmap.getPixels(pixels, 0, width, 0, 0, width, height)
92+
bitmap.recycle()
93+
94+
// Precomputed so the remap is a table lookup rather than arithmetic per pixel; these
95+
// sweeps render hundreds of multi-megapixel frames.
96+
val span = whiteLevel - blackLevel
97+
val map = ByteArray(256) { luma ->
98+
(blackLevel + luma * span / 255).coerceIn(0, 255).toByte()
99+
}
100+
101+
val plane = ByteArray(width * height)
102+
for (i in 0 until width * height) {
103+
val p = pixels[i]
104+
val luma = (
105+
(
106+
77 * ((p shr 16) and 0xFF) +
107+
150 * ((p shr 8) and 0xFF) +
108+
29 * (p and 0xFF)
109+
) shr 8
110+
)
111+
plane[i] = map[luma]
112+
}
113+
return plane
114+
}
115+
116+
private fun decodesAtLevels(
117+
encoded: ByteArray,
118+
payload: ByteArray,
119+
blackLevel: Int,
120+
whiteLevel: Int,
121+
): Boolean {
122+
val plane = renderAtLevels(encoded, WIDTH, HEIGHT, CODE_PX, blackLevel, whiteLevel)
123+
val converted = LuminancePlane.unpad(plane, WIDTH, HEIGHT, WIDTH, 1)
124+
val result = runBlocking {
125+
scanner.scanKikCode(converted, WIDTH, HEIGHT, ScanQuality.Best).getOrNull()
126+
}
127+
return result is ScannableKikCode.RemoteKikCode && result.payloadId.contentEquals(payload)
128+
}
129+
130+
/**
131+
* Overexposure: the highlights are already clipped at white and the floor keeps rising.
132+
*
133+
* This is the reported failure. A phone screen at full brightness in a dim room is the worst
134+
* case a payment scanner has, because whole-frame metering averages in all that surrounding
135+
* darkness and drives the exposure up until the screen is a white slab.
136+
*/
137+
@Test
138+
fun overexposureFloor() {
139+
val (payload, encoded) = encodeRemoteCode(3)
140+
var highestDecodable = -1
141+
for (black in 0..250 step 10) {
142+
val ok = decodesAtLevels(encoded, payload, black, 255)
143+
Log.i(TAG, "overexposed black=$black white=255 decoded=$ok")
144+
if (ok) highestDecodable = black else break
145+
}
146+
Log.i(TAG, "RESULT overexposure: highest decodable black level = $highestDecodable")
147+
148+
assertTrue(
149+
highestDecodable >= 0,
150+
"the detector failed even on a correctly exposed frame -- the sweep is measuring " +
151+
"something other than exposure",
152+
)
153+
}
154+
155+
/** Underexposure, for symmetry: the floor is at black and the ceiling keeps falling. */
156+
@Test
157+
fun underexposureCeiling() {
158+
val (payload, encoded) = encodeRemoteCode(3)
159+
var lowestDecodable = -1
160+
for (white in 255 downTo 5 step 10) {
161+
val ok = decodesAtLevels(encoded, payload, 0, white)
162+
Log.i(TAG, "underexposed black=0 white=$white decoded=$ok")
163+
if (ok) lowestDecodable = white else break
164+
}
165+
Log.i(TAG, "RESULT underexposure: lowest decodable white level = $lowestDecodable")
166+
}
167+
168+
/**
169+
* Contrast held around a mid-grey, so the band closes in on 128 from both sides.
170+
*
171+
* If the detector adapted to the frame it would keep working here until the contrast fell into
172+
* the noise. If it is pinned to an absolute cutoff it will instead fail the moment the band
173+
* stops straddling that cutoff, while the code is still obviously legible. Which of those two
174+
* happens is the whole question.
175+
*/
176+
@Test
177+
fun contrastAroundMidGrey() {
178+
val (payload, encoded) = encodeRemoteCode(3)
179+
var lowestDecodable = -1
180+
for (half in 128 downTo 5 step 5) {
181+
val ok = decodesAtLevels(encoded, payload, 128 - half, 128 + half)
182+
Log.i(TAG, "midgrey black=${128 - half} white=${128 + half} span=${2 * half} decoded=$ok")
183+
if (ok) lowestDecodable = 2 * half else break
184+
}
185+
Log.i(TAG, "RESULT mid-grey: smallest decodable contrast span = $lowestDecodable of 255")
186+
}
187+
188+
private companion object {
189+
const val TAG = "KikCodeRange"
190+
const val REMOTE_PAYLOAD_BYTES = 20
191+
const val WIDTH = 1920
192+
const val HEIGHT = 1080
193+
194+
/**
195+
* A comfortably large code -- roughly 40% of the frame height, far above the size floor
196+
* measured in `KikCodeRangeTest`. Size must not be the thing under test here.
197+
*/
198+
const val CODE_PX = 432
199+
}
200+
}

0 commit comments

Comments
 (0)