Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions lib/utilities/camera_preview_frame.dart
Original file line number Diff line number Diff line change
Expand Up @@ -75,3 +75,20 @@ img.Image imageFromMirroredPreviewRgba(
order: img.ChannelOrder.rgba,
);
}

/// A 64-bit FNV-1a hash of [rgba], for telling a new camera frame from a
/// repeat of the last one.
///
/// Frames from a live sensor never repeat byte for byte, so two captures
/// with the same fingerprint mean the preview has stopped updating. This is
/// a full pass over the buffer; an indexed loop keeps it to a few
/// milliseconds for a 1280 px frame.
int previewFrameFingerprint(Uint8List rgba) {
var hash = 0xcbf29ce484222325;
final length = rgba.length;
for (var i = 0; i < length; i++) {
hash ^= rgba[i];
hash *= 0x100000001b3;
}
return hash;
}
22 changes: 21 additions & 1 deletion lib/utilities/qr_image_decoder.dart
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import 'dart:math' as math;
import 'dart:typed_data';

import 'package:image/image.dart' as img;
Expand All @@ -18,7 +19,26 @@ String? decodeQrFromImageBytes(Uint8List bytes) {

/// Decodes a QR code from [image], whatever its bit depth, channel count or
/// palette. Returns null if no QR code is found.
String? decodeQrFromImage(img.Image image) {
///
/// If [maxDimension] is set, larger images are scaled down to fit it first.
/// Some webcams hand back full sensor-size photos (3264x2448 from a USB
/// webcam on Windows), which took 2-3 s per frame to scan on an older laptop.
String? decodeQrFromImage(img.Image image, {int? maxDimension}) {
if (maxDimension != null &&
math.max(image.width, image.height) > maxDimension) {
image = image.width >= image.height
? img.copyResize(
image,
width: maxDimension,
interpolation: img.Interpolation.average,
)
: img.copyResize(
image,
height: maxDimension,
interpolation: img.Interpolation.average,
);
}

final LuminanceSource source;
try {
source = RGBLuminanceSource(image.width, image.height, _argbPixels(image));
Expand Down
89 changes: 80 additions & 9 deletions lib/widgets/desktop/qr_code_scanner_dialog.dart
Original file line number Diff line number Diff line change
Expand Up @@ -62,9 +62,14 @@ class _QrCodeScannerDialogState extends State<QrCodeScannerDialog> {
Size? _windowsPreviewSize;
bool _loggedBlankWindowsFrame = false;
bool _hasWindowsFrame = false;

// Bigger captures cost more to read back and scan without helping zxing.
static const _maxWindowsFrameDimension = 1280;
// camera_windows keeps showing the last frame after the capture engine
// fails, and toImage() keeps returning it. The fingerprint tells a new
// frame from a repeat; the error event tells us the camera is gone at once.
int? _lastWindowsFrameFingerprint;
bool _loggedRepeatedWindowsFrame = false;
bool _windowsCameraLost = false;
StreamSubscription<CameraErrorEvent>? _windowsErrorSub;
bool _cameraStopped = false;

@override
void initState() {
Expand Down Expand Up @@ -174,6 +179,11 @@ class _QrCodeScannerDialogState extends State<QrCodeScannerDialog> {
await _cameraWindowsPlugin.dispose(_cameraId);
return false;
}
// The plugin's only closing event comes from our own dispose(), so
// the error event is the one that matters.
_windowsErrorSub = _cameraWindowsPlugin
.onCameraError(_cameraId)
.listen((e) => _onWindowsCameraLost(e.description));
Logging.instance.d(
"Windows Camera initialized with ID: $_cameraId, "
"preview $_windowsPreviewSize",
Expand Down Expand Up @@ -231,27 +241,56 @@ class _QrCodeScannerDialogState extends State<QrCodeScannerDialog> {
return false;
}

/// Shows an error once no usable frame has arrived for [_frameTimeout].
/// Shows an error once no new frame has arrived for [_frameTimeout].
void _checkFrameTimeout() {
if (_sinceLastFrame.elapsed >= _frameTimeout &&
_cameraError == null &&
mounted) {
setState(() {
_cameraError =
"The camera is not returning images. You can still "
"The camera preview has stopped updating. You can still "
"use \"Select file\" to scan a QR code image.";
});
}
}

/// The plugin reported a capture engine error. Its preview keeps the last
/// frame and it never restarts the stream, so stop scanning and say so now
/// rather than waiting for the frame timeout.
void _onWindowsCameraLost(String description) {
// The engine can report one failure several times, and an event already
// queued can still arrive after the dialog started closing.
if (_windowsCameraLost || !_isScanning) return;
_windowsCameraLost = true;
_isScanning = false;
Logging.instance.w("Windows camera error: $description");
// FormatMessage text ends in ".\r\n", and unknown errors come through
// empty.
final detail = description.trim().replaceFirst(RegExp(r"\.$"), "");
if (!mounted) return;
setState(() {
_cameraError =
"The camera stopped working"
"${detail.isEmpty ? "" : " ($detail)"}. Close and reopen the "
"scanner to try again, or use \"Select file\" to scan a QR code "
"image.";
});
}

Future<void> _stopCamera() async {
_isScanning = false;
// Runs from PopScope and from dispose(); the second call has nothing to
// do.
if (_cameraStopped) return;
_cameraStopped = true;

try {
if (Platform.isLinux && _cameraLinuxPlugin != null) {
_cameraLinuxPlugin!.stopCamera();
Logging.instance.d("Linux Camera stopped");
} else if (Platform.isWindows && _cameraWindowsPlugin != null) {
await _windowsErrorSub?.cancel();
_windowsErrorSub = null;
// if (_cameraId >= 0) {
await _cameraWindowsPlugin.dispose(_cameraId);
Logging.instance.d("Windows Camera stopped with ID: $_cameraId");
Expand All @@ -273,8 +312,16 @@ class _QrCodeScannerDialogState extends State<QrCodeScannerDialog> {
}
}

@override
void dispose() {
// PopScope normally gets here first; this covers a route removed without
// a pop.
unawaited(_stopCamera());
super.dispose();
}

/// Grabs the frame the Windows preview is showing, in memory. Returns null
/// when there is nothing to scan yet.
/// when there is nothing to scan yet, or nothing new since the last call.
Future<img.Image?> _captureWindowsPreviewFrame() async {
final boundary = _windowsPreviewKey.currentContext?.findRenderObject();
// Not laid out or painted yet, e.g. just after the camera opened.
Expand All @@ -294,7 +341,7 @@ class _QrCodeScannerDialogState extends State<QrCodeScannerDialog> {
if (preview != null && preview.width > 0 && preview.height > 0) {
final fit = min(
1.0,
_maxWindowsFrameDimension / max(preview.width, preview.height),
_cameraFrameMaxDimension / max(preview.width, preview.height),
);
// toImage() rounds each side up, and the laid-out box is rarely exactly
// the camera's aspect ratio, so take the smaller ratio and stay a hair
Expand Down Expand Up @@ -336,6 +383,21 @@ class _QrCodeScannerDialogState extends State<QrCodeScannerDialog> {
}
_loggedBlankWindowsFrame = false;

// A repeat of the last capture means the preview has stopped updating,
// so treat it like no frame and let the frame timeout notice.
final fingerprint = previewFrameFingerprint(rgba);
if (fingerprint == _lastWindowsFrameFingerprint) {
if (!_loggedRepeatedWindowsFrame) {
_loggedRepeatedWindowsFrame = true;
Logging.instance.w(
"Windows camera preview frame has not changed, waiting for a new one",
);
}
return null;
}
_lastWindowsFrameFingerprint = fingerprint;
_loggedRepeatedWindowsFrame = false;

// camera_windows mirrors the preview, so flip it back for zxing.
return imageFromMirroredPreviewRgba(rgba, width: width, height: height);
}
Expand Down Expand Up @@ -406,7 +468,10 @@ class _QrCodeScannerDialogState extends State<QrCodeScannerDialog> {
});
}

final String? scanResult = await _scanImage(image);
final String? scanResult = await _scanImage(
image,
maxDimension: _cameraFrameMaxDimension,
);
if (scanResult != null && scanResult.isNotEmpty) {
await _stopCamera();

Expand Down Expand Up @@ -441,7 +506,13 @@ class _QrCodeScannerDialogState extends State<QrCodeScannerDialog> {
}
}

Future<String?> _scanImage(img.Image image) async => decodeQrFromImage(image);
// Camera frames bigger than this are scaled down before scanning, and
// Windows preview captures are taken at no more than this. Files picked
// with "Select file" are scanned at full size.
static const _cameraFrameMaxDimension = 1280;

Future<String?> _scanImage(img.Image image, {int? maxDimension}) async =>
decodeQrFromImage(image, maxDimension: maxDimension);

Widget _buildCameraView(BuildContext context) {
if (Platform.isWindows && _isCameraOpen) {
Expand Down
38 changes: 38 additions & 0 deletions test/utilities/camera_preview_frame_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -128,4 +128,42 @@ void main() {
);
});
});

group("previewFrameFingerprint", () {
test("is the same for identical frames", () {
final a = Uint8List.fromList(
List.generate(4 * 64, (i) => (i * 7) & 0xff),
);
final b = Uint8List.fromList(a);
expect(previewFrameFingerprint(a), previewFrameFingerprint(b));
});

test("changes when one byte changes", () {
final a = Uint8List.fromList(
List.generate(4 * 64, (i) => (i * 7) & 0xff),
);
final b = Uint8List.fromList(a);
b[b.length ~/ 2] ^= 1;
expect(previewFrameFingerprint(a), isNot(previewFrameFingerprint(b)));
});

test("changes when a pixel is moved", () {
// Same bytes, different order: a plain sum would miss this.
final a = _rgba([
[1, 2, 3, 255],
[4, 5, 6, 255],
]);
final b = _rgba([
[4, 5, 6, 255],
[1, 2, 3, 255],
]);
expect(previewFrameFingerprint(a), isNot(previewFrameFingerprint(b)));
});

test("distinguishes frames of different lengths", () {
final a = Uint8List(16);
final b = Uint8List(20);
expect(previewFrameFingerprint(a), isNot(previewFrameFingerprint(b)));
});
});
}
15 changes: 15 additions & 0 deletions test/utilities/qr_image_decoder_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,21 @@ void main() {
expect(decodeQrFromImage(decoded), _address);
});

test("decodes a large camera-sized frame scaled down to maxDimension", () {
// A QR code held up to a 3264x2448 webcam, filling about a third of the
// frame's height.
final frame = img.Image(width: 3264, height: 2448);
img.fill(frame, color: img.ColorRgb8(200, 200, 200));
final code = _qrImage(_address, scale: 18);
img.compositeImage(frame, code, dstX: 1200, dstY: 700);
final jpeg = img.decodeJpg(img.encodeJpg(frame, quality: 85))!;
expect(decodeQrFromImage(jpeg, maxDimension: 1280), _address);
});

test("leaves images within maxDimension alone", () {
expect(decodeQrFromImage(rgb, maxDimension: 1280), _address);
});

test("decodes a JPEG", () {
final bytes = Uint8List.fromList(img.encodeJpg(rgb, quality: 85));
expect(decodeQrFromImageBytes(bytes), _address);
Expand Down
Loading