@mleonard9/vin-scanner
v1.5.2
Published
High-performance VIN scanner for React Native Vision Camera powered by Google ML Kit barcode + text recognition.
Maintainers
Readme
@mleonard9/vin-scanner
High-performance VIN detection for React Native powered by Google ML Kit barcode + text recognition and react-native-vision-camera.
Compiled from a combination of other community frame processing plugins
Requirements
react-native-vision-camera>= 4.6.0react-native-worklets-core>= 1.3.3react-native-gesture-handler>= 2.0.0 (for tap-to-focus)react-native-reanimated>= 3.0.0 (for tap-to-focus)- iOS 13+ / Android 21+
Compatibility
This package keeps peer dependency ranges broad so consuming apps can control their React Native, VisionCamera, and Worklets versions.
Tested matrix:
| Package | Recommended |
| --- | --- |
| react-native-vision-camera | 4.7.3 |
| react-native-worklets-core | 1.3.3 |
Notes:
- The package's own dev/test environment stays pinned to the matrix above, but published peer ranges remain open to support newer app stacks.
react-native-worklets-core1.6.xhas caused frame-processor HostObject compatibility issues with VisionCamera v4 in some apps.- The package includes a frame unwrap compatibility shim to improve compatibility with newer Worklets wrappers.
- If your app is on newer Worklets or React Native, test carefully and prefer keeping VisionCamera and Worklets aligned to versions already validated in your app.
Installation
yarn add @mleonard9/vin-scanner
# or
npm install @mleonard9/vin-scanner
# If you need a known-good baseline, start with:
# yarn add [email protected] [email protected]
# Optional (for haptic cues)
# yarn add react-native-haptic-feedback
# iOS
cd ios && pod installUsage
import React, { useMemo, useState } from 'react';
import { StyleSheet, View } from 'react-native';
import { useCameraDevice } from 'react-native-vision-camera';
import {
Camera as VinScannerCamera,
useVinScanner,
type VinCandidate,
} from '@mleonard9/vin-scanner';
export function VinScannerExample(): JSX.Element {
const device = useCameraDevice('back');
const [results, setResults] = useState<VinCandidate[] | null>(null);
const options = useMemo(
() => ({
// Include QR for corner codes on auction tags
barcode: { formats: ['code-39', 'code-128', 'pdf-417', 'qr'] },
onResult: (candidates, event) => {
setResults(candidates);
console.log(`Scan took ${event.duration}ms`);
},
}),
[]
);
const { frameProcessor } = useVinScanner(options);
if (device == null) {
return null;
}
return (
<View style={StyleSheet.absoluteFill}>
<VinScannerCamera
style={StyleSheet.absoluteFill}
device={device}
frameProcessor={frameProcessor}
callback={(event) => setResults(event.candidates)}
/>
</View>
);
}Every frame, the camera runs ML Kit barcode + text recognition, extracts 17-character VIN candidates, validates them (checksum included), and routes a payload to callback.
Camera Gestures
The VIN Scanner camera includes built-in support for intuitive camera controls:
Pinch to Zoom
Pinch-to-zoom is enabled by default. Simply pinch on the camera view to zoom in and out. The zoom gesture is natively implemented by react-native-vision-camera for optimal performance.
Tap to Focus
Tap anywhere on the camera view to focus at that point. This feature requires react-native-gesture-handler and react-native-reanimated:
Installation:
yarn add react-native-gesture-handler react-native-reanimated
# or
npm install react-native-gesture-handler react-native-reanimated
# iOS
cd ios && pod installNote: These dependencies are likely already installed if you're using React Navigation or other common React Native libraries.
The tap-to-focus functionality works automatically once these dependencies are installed. Simply tap on the camera view where you want to focus, and the camera will adjust both auto-focus (AF) and auto-exposure (AE) for that point.
How it works:
- Tap on a VIN to focus precisely on that area
- The camera adjusts focus and exposure automatically
- Works seamlessly with the pinch-to-zoom gesture
- No additional configuration required
Advanced Features
AR Overlay (deprecated)
The Skia-based overlay component has been removed while alignment issues are addressed. Default barcode formats are tuned for VIN labels (code-39, code-128, pdf-417) with an automatic fallback to all formats after sustained misses. No Skia install is required.
Confidence Scoring:
Each VinCandidate includes a confidence score (0.0-1.0) calculated from:
- Source reliability: Barcodes score higher than OCR text (+0.3)
- Text precision: Element-level text scores higher than block-level (+0.2)
- Context awareness: VIN prefixes like "VIN:" increase confidence (+0.2)
- Checksum validation: All candidates pass ISO 3779 validation (+0.2)
Overlay colors by confidence:
- 🟢 Green (
confidence > 0.8): High confidence - 🟡 Yellow (
confidence 0.5-0.8): Medium confidence - 🔴 Red (
confidence < 0.5): Low confidence
Smart Duplicate Filtering
By default, the scanner uses time-based debouncing to prevent duplicate callbacks for the same VIN:
const { frameProcessor } = useVinScanner({
duplicateDebounceMs: 1500, // Default: 1500ms
onResult: (candidates) => {
// Only called when a new VIN is detected or after debounce period
console.log('New VIN detected:', candidates[0]?.value);
},
});This prevents callback spam when holding the camera steady on a VIN, improving UX in fast-paced scanning scenarios.
Performance Telemetry
Every VinScannerEvent includes detailed performance metrics for data-driven optimization:
const { frameProcessor } = useVinScanner({
onResult: (candidates, event) => {
if (event.performance) {
console.log('Performance breakdown:');
console.log(` Barcode scan: ${event.performance.barcodeMs}ms`);
console.log(` Text recognition: ${event.performance.textMs}ms`);
console.log(` Validation: ${event.performance.validationMs}ms`);
console.log(` Total: ${event.performance.totalMs}ms`);
}
},
});Use these metrics to:
- Identify performance bottlenecks (barcode vs text recognition)
- Optimize
textScanIntervalbased on actual timing - Monitor performance across different devices
- Track improvements after configuration changes
Camera Settings Optimization
Configure camera parameters for device-specific optimization:
const { frameProcessor } = useVinScanner({
cameraSettings: {
fps: 60, // Higher FPS for smoother scanning
lowLightBoost: true, // Auto-boost in low light (default)
videoStabilizationMode: 'standard' // Reduce motion blur
},
onResult: (candidates) => {
console.log('Detected:', candidates[0]?.value);
},
});Available settings:
fps: Target frame rate (15-60). Higher = smoother but more CPU. Default: 30lowLightBoost: Auto-brighten in dark conditions. Default: truevideoStabilizationMode:'off'|'standard'|'cinematic'|'auto'. Default: 'off'
Tip: For auction lanes with good lighting, try fps: 60 and videoStabilizationMode: 'standard' for best results.
Callback payload
type VinScannerEvent = {
timestamp: number;
duration: number;
candidates: VinCandidate[];
firstCandidate?: VinCandidate | null;
raw: {
barcodes: BarcodeDetection[];
textBlocks: TextDetection[];
};
};VinCandidate contains { value, source: 'barcode' | 'text', confidence, boundingBox }.
The candidates array contains every potential VIN found in the frame. firstCandidate is a convenience reference to the best match.
Options
| Path | Type | Description | Default |
| --- | --- | --- | --- |
| options.barcode.enabled | boolean | Enable barcode scanning | true |
| options.barcode.formats | BarcodeFormat[] | Restrict ML Kit formats ('code-39', 'code-128', 'pdf-417', etc.) | ['all'] |
| options.text.enabled | boolean | Enable text recognition | true |
| options.text.language | 'latin' \| 'chinese' \| 'devanagari' \| 'japanese' \| 'korean' | ML Kit language pack | 'latin' |
| options.text.requireConfirmation | boolean | When true, text VINs are held until you confirm; barcodes still emit immediately | false |
| options.text.pendingTtlMs | number | Auto-dismiss pending text VINs after this many ms (when requireConfirmation is true) | 5000 |
| options.detection.textScanInterval | number | Run text recognition every Nth frame (1 = every frame) | 3 |
| options.detection.maxFrameRate | number | Max FPS budget for frame processing (drops surplus frames to avoid blocking) | 30 |
| options.detection.forceOrientation | 'portrait' \| 'portrait-upside-down' \| 'landscape-left' \| 'landscape-right' | Forces ML Kit to interpret every frame using the given orientation (useful when the UI is locked to portrait but the sensor reports landscape) | null |
| options.detection.scanRegion | ScanRegion | Restrict ML Kit processing to a specific region of the frame (normalized coordinates 0.0-1.0). Significantly improves performance by ignoring irrelevant areas. | { x: 0.15, y: 0.15, width: 0.7, height: 0.7 } |
| options.detection.enableFrameQualityCheck | boolean | Deprecated; when false it disables minLuma/minSharpness gates | true |
| options.detection.minLuma | number | Minimum mean luma (0–255) required to process a frame; skips too-dark frames | 30 |
| options.detection.minSharpness | number | Minimum sharpness metric required; skips blurry frames | 12 |
| options.detection.minConfidence | number | Minimum candidate confidence required before emitting | 0 |
| options.detection.barcodeFallbackAfter | number | Frames without barcode hits before scanning all formats | 45 |
| options.duplicateDebounceMs | number | Time in milliseconds to suppress duplicate VIN callbacks for the same value | 1500 |
| options.showOverlay | boolean | Deprecated; overlay component removed | false |
| options.overlayColors | OverlayColors | Deprecated; overlay component removed | { high: '#00FF00', medium: '#FFFF00', low: '#FF0000' } |
| options.cameraSettings | CameraSettings | Camera configuration: { fps (clamped 24–30), lowLightBoost, videoStabilizationMode } | { fps: 24, lowLightBoost: true, videoStabilizationMode: 'cinematic' } |
| options.onResult | (candidates, event) => void | Convenience callback when using useVinScanner; receives all candidates and the raw event | undefined |
| options.onTextPending | (pending) => void | Invoked when text.requireConfirmation is true and text VINs are detected | undefined |
| options.haptics | boolean | Enable built-in haptic cues (requires react-native-haptic-feedback installed) | true |
Behaviors & defaults
- Barcode-first: barcodes emit immediately; text VINs can require confirmation.
- Session dedupe: VINs are not re-emitted within a scan session (in addition to time-based debounce).
- Quality gate: frames below
minLumaorminSharpnessare skipped. - Confidence gate: candidates below
minConfidenceare dropped, but the default is0because checksum validity is the primary source of truth. - Barcode formats: defaults to
code-39,code-128,pdf-417with automatic fallback to all formats afterbarcodeFallbackAfterempty frames. - Camera hints: FPS clamped to 24–30 and
videoStabilizationModedefaults tocinematicto keep headroom and reduce jitter.
Text confirmation UI (barcode = instant, text = tap-to-confirm)
import { useState } from 'react';
import { Camera, useCameraDevice } from 'react-native-vision-camera';
import { useVinScanner, TextVinPrompt, VinCandidate } from '@mleonard9/vin-scanner';
export function ConfirmingScanner() {
const device = useCameraDevice('back');
const [pending, setPending] = useState<VinCandidate[]>([]);
const { frameProcessor, pendingTextCandidates, confirmTextCandidate } = useVinScanner({
text: { requireConfirmation: true },
onTextPending: setPending,
onResult: (candidates) => {
// barcode VINs (or confirmed text VINs) arrive here
console.log('confirmed VINs', candidates.map((c) => c.value));
},
});
return (
<>
{device && (
<Camera style={{ flex: 1 }} device={device} frameProcessor={frameProcessor} isActive />
)}
<TextVinPrompt
visible={pendingTextCandidates.length > 0}
candidates={pendingTextCandidates}
buttonLabel=\"Book It\"
buttonColor=\"#0A84FF\"
onConfirm={(candidate) => confirmTextCandidate(candidate.value)}
onDismiss={() => setPending([])}
/>
</>
);
}Manual VIN keypad with checksum guard
import { ManualVinInput } from '@mleonard9/vin-scanner';
export function ManualEntry({ onSubmit }: { onSubmit: (vin: string) => void }) {
return (
<ManualVinInput
buttonLabel=\"Book It\"
buttonColor=\"#0A84FF\"
onSubmit={onSubmit}
/>
);
}Pending banner (alternative to modal)
import { PendingVinBanner } from '@mleonard9/vin-scanner';
<PendingVinBanner
visible={pendingTextCandidates.length > 0}
candidates={pendingTextCandidates}
buttonLabel=\"Book It\"
buttonColor=\"#0A84FF\"
onConfirm={(candidate) => confirmTextCandidate(candidate.value)}
onDismiss={() => setPending([])}
/>;OCRScanner-style chrome overlay
import { ScannerChromeOverlay } from '@mleonard9/vin-scanner';
<ScannerChromeOverlay
onBackPress={() => navigation.goBack()}
onManualEntryPress={() => setManualVisible(true)}
onFlashPress={toggleTorch}
isTorchOn={isTorchOn}
manualEntryContent={<Ionicons name="keypad-outline" size={32} color="white" />}
flashOffContent={<Ionicons name="flash-outline" size={32} color="white" />}
flashOnContent={<Ionicons name="flash-off-outline" size={32} color="white" />}
backContent={<Ionicons name="chevron-back-outline" size={32} color="white" />}
/>;Performance
Phase 1 optimizations dramatically improve scanning performance through native ROI (Region of Interest) frame cropping:
| Configuration | Avg Duration | Improvement | | --- | --- | --- | | Full frame, every frame | ~180ms | baseline | | ROI scanning (70% center) | ~95ms | 47% faster | | ROI + text interval (3 frames) | ~45ms | 75% faster | | ROI + quality check + throttle | ~30ms | 83% faster |
Default configuration uses ROI scanning (scanRegion: { x: 0.15, y: 0.15, width: 0.7, height: 0.7 }) and a text scan interval of 3. This provides excellent accuracy while maintaining real-time performance on mid-range devices.
Tip: For challenging lighting or distance scenarios, set textScanInterval: 1 to scan every frame at the cost of higher CPU usage.
Custom scan regions:
const { frameProcessor } = useVinScanner({
detection: {
// Focus on center 50% of frame (set null to cover full frame / corner QR)
scanRegion: { x: 0.25, y: 0.25, width: 0.5, height: 0.5 },
textScanInterval: 2,
},
onResult: (candidates) => {
console.log('Detected VINs:', candidates);
},
});Advanced frame-processor controls
- Per-frame plugin overrides: both barcode and text frame processor plugins accept per-frame arguments, so you can dynamically change ML Kit barcode formats or text recognition language without reinitializing the plugin. Call
barcodeScanner.scanBarcodes(frame, { 'pdf-417': true })ortextScanner.scanText(frame, { language: 'japanese' })inside your worklet to override the resolved defaults for a single frame. - Orientation overrides: If your UI is locked to portrait (e.g., iPad kiosks) but VisionCamera streams landscape buffers, set
detection.forceOrientation: 'portrait'. The JS hook forwards that override to the native plugins so ML Kit always interprets frames with the requested rotation, eliminating the “upside-down unless I flip the paper” problem described in the VisionCamera orientation guide. - Shared bounding boxes: native plugins now stream bounding box coordinates via zero-copy shared arrays, minimizing JSI serialization. The hook translates these buffers into the familiar
BoundingBoxstructures before running VIN heuristics, so no API change is required. - Orientation-safe processing: the native plugins forward VisionCamera’s frame orientation metadata directly into ML Kit as recommended in the VisionCamera orientation guide, ensuring portrait VIN scans stay upright.
Hook-only usage
If you prefer to configure react-native-vision-camera yourself, grab the frame processor from the hook:
const { frameProcessor } = useVinScanner({
onResult: (candidates, event) => {
console.log('Current VINs', candidates, event.firstCandidate);
console.log(`Duration: ${event.duration}ms`);
},
});
return (
<Camera
ref={cameraRef}
device={device}
frameProcessor={frameProcessor}
pixelFormat="yuv"
style={StyleSheet.absoluteFill}
/>
);Publishing (internal use)
This package is scoped (@mleonard9/vin-scanner). To release a new build:
yarn prepare # builds /lib via bob
npm publish --access publicEnsure the authenticated npm user has access to the @mleonard9 scope.
