kiwi/app/services/barcode_scanner.py
pyr0ball 8cbde774e5 chore: initial commit — kiwi Phase 2 complete
Pantry tracker app with:
- FastAPI backend + Vue 3 SPA frontend
- SQLite via circuitforge-core (migrations 001-005)
- Inventory CRUD, barcode scan, receipt OCR pipeline
- Expiry prediction (deterministic + LLM fallback)
- CF-core tier system integration
- Cloud session support (menagerie)
2026-03-30 22:20:48 -07:00

365 lines
12 KiB
Python

"""
Barcode scanning service using pyzbar.
This module provides functionality to detect and decode barcodes
from images (UPC, EAN, QR codes, etc.).
"""
import cv2
import numpy as np
from pyzbar import pyzbar
from pathlib import Path
from typing import List, Dict, Any, Optional
import logging
logger = logging.getLogger(__name__)
class BarcodeScanner:
"""
Service for scanning barcodes from images.
Supports various barcode formats:
- UPC-A, UPC-E
- EAN-8, EAN-13
- Code 39, Code 128
- QR codes
- And more via pyzbar/libzbar
"""
def scan_image(self, image_path: Path) -> List[Dict[str, Any]]:
"""
Scan an image for barcodes.
Args:
image_path: Path to the image file
Returns:
List of detected barcodes, each as a dictionary with:
- data: Barcode data (string)
- type: Barcode type (e.g., 'EAN13', 'QRCODE')
- quality: Quality score (0-100)
- rect: Bounding box (x, y, width, height)
"""
try:
# Read image
image = cv2.imread(str(image_path))
if image is None:
logger.error(f"Failed to load image: {image_path}")
return []
# Convert to grayscale for better detection
gray = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY)
# Try multiple preprocessing techniques and rotations for better detection
barcodes = []
# 1. Try on original grayscale
barcodes.extend(self._detect_barcodes(gray, image))
# 2. Try with adaptive thresholding (helps with poor lighting)
if not barcodes:
thresh = cv2.adaptiveThreshold(
gray, 255, cv2.ADAPTIVE_THRESH_GAUSSIAN_C,
cv2.THRESH_BINARY, 11, 2
)
barcodes.extend(self._detect_barcodes(thresh, image))
# 3. Try with sharpening (helps with blurry images)
if not barcodes:
kernel = np.array([[-1, -1, -1],
[-1, 9, -1],
[-1, -1, -1]])
sharpened = cv2.filter2D(gray, -1, kernel)
barcodes.extend(self._detect_barcodes(sharpened, image))
# 4. Try rotations if still no barcodes found (handles tilted/rotated barcodes)
if not barcodes:
logger.info("No barcodes found in standard orientation, trying rotations...")
# Try incremental angles: 30°, 60°, 90° (covers 0-90° range)
# 0° already tried, 180° is functionally same as 0°, 90°/270° are same axis
for angle in [30, 60, 90]:
rotated_gray = self._rotate_image(gray, angle)
rotated_color = self._rotate_image(image, angle)
detected = self._detect_barcodes(rotated_gray, rotated_color)
if detected:
logger.info(f"Found barcode(s) at {angle}° rotation")
barcodes.extend(detected)
break # Stop after first successful rotation
# Remove duplicates (same data)
unique_barcodes = self._deduplicate_barcodes(barcodes)
logger.info(f"Found {len(unique_barcodes)} barcode(s) in {image_path}")
return unique_barcodes
except Exception as e:
logger.error(f"Error scanning image {image_path}: {e}")
return []
def _detect_barcodes(
self,
image: np.ndarray,
original_image: np.ndarray
) -> List[Dict[str, Any]]:
"""
Detect barcodes in a preprocessed image.
Args:
image: Preprocessed image (grayscale)
original_image: Original color image (for quality assessment)
Returns:
List of detected barcodes
"""
detected = pyzbar.decode(image)
barcodes = []
for barcode in detected:
# Decode barcode data
barcode_data = barcode.data.decode("utf-8")
barcode_type = barcode.type
# Get bounding box
rect = barcode.rect
bbox = {
"x": rect.left,
"y": rect.top,
"width": rect.width,
"height": rect.height,
}
# Assess quality of barcode region
quality = self._assess_barcode_quality(original_image, bbox)
barcodes.append({
"data": barcode_data,
"type": barcode_type,
"quality": quality,
"rect": bbox,
})
return barcodes
def _assess_barcode_quality(
self,
image: np.ndarray,
bbox: Dict[str, int]
) -> int:
"""
Assess the quality of a detected barcode.
Args:
image: Original image
bbox: Bounding box of barcode
Returns:
Quality score (0-100)
"""
try:
# Extract barcode region
x, y, w, h = bbox["x"], bbox["y"], bbox["width"], bbox["height"]
# Add padding
pad = 10
y1 = max(0, y - pad)
y2 = min(image.shape[0], y + h + pad)
x1 = max(0, x - pad)
x2 = min(image.shape[1], x + w + pad)
region = image[y1:y2, x1:x2]
if region.size == 0:
return 50
# Convert to grayscale if needed
if len(region.shape) == 3:
region = cv2.cvtColor(region, cv2.COLOR_BGR2GRAY)
# Calculate sharpness (Laplacian variance)
laplacian_var = cv2.Laplacian(region, cv2.CV_64F).var()
sharpness_score = min(100, laplacian_var / 10) # Normalize
# Calculate contrast
min_val, max_val = region.min(), region.max()
contrast = (max_val - min_val) / 255.0 * 100
# Calculate size score (larger is better, up to a point)
area = w * h
size_score = min(100, area / 100) # Normalize
# Weighted average
quality = (sharpness_score * 0.4 + contrast * 0.4 + size_score * 0.2)
return int(quality)
except Exception as e:
logger.warning(f"Error assessing barcode quality: {e}")
return 50
def _rotate_image(self, image: np.ndarray, angle: float) -> np.ndarray:
"""
Rotate an image by a given angle.
Args:
image: Input image
angle: Rotation angle in degrees (any angle, but optimized for 90° increments)
Returns:
Rotated image
"""
# Use fast optimized rotation for common angles
if angle == 90:
return cv2.rotate(image, cv2.ROTATE_90_CLOCKWISE)
elif angle == 180:
return cv2.rotate(image, cv2.ROTATE_180)
elif angle == 270:
return cv2.rotate(image, cv2.ROTATE_90_COUNTERCLOCKWISE)
elif angle == 0:
return image
else:
# For arbitrary angles, use affine transformation
(h, w) = image.shape[:2]
center = (w // 2, h // 2)
# Get rotation matrix
M = cv2.getRotationMatrix2D(center, angle, 1.0)
# Calculate new bounding dimensions
cos = np.abs(M[0, 0])
sin = np.abs(M[0, 1])
new_w = int((h * sin) + (w * cos))
new_h = int((h * cos) + (w * sin))
# Adjust rotation matrix for new dimensions
M[0, 2] += (new_w / 2) - center[0]
M[1, 2] += (new_h / 2) - center[1]
# Perform rotation
return cv2.warpAffine(image, M, (new_w, new_h),
flags=cv2.INTER_CUBIC,
borderMode=cv2.BORDER_REPLICATE)
def _deduplicate_barcodes(
self,
barcodes: List[Dict[str, Any]]
) -> List[Dict[str, Any]]:
"""
Remove duplicate barcodes (same data).
If multiple detections of the same barcode, keep the one
with the highest quality score.
Args:
barcodes: List of detected barcodes
Returns:
Deduplicated list
"""
seen = {}
for barcode in barcodes:
data = barcode["data"]
if data not in seen or barcode["quality"] > seen[data]["quality"]:
seen[data] = barcode
return list(seen.values())
def scan_from_bytes(self, image_bytes: bytes) -> List[Dict[str, Any]]:
"""
Scan barcodes from image bytes (uploaded file).
Args:
image_bytes: Image data as bytes
Returns:
List of detected barcodes
"""
try:
# Convert bytes to numpy array
nparr = np.frombuffer(image_bytes, np.uint8)
image = cv2.imdecode(nparr, cv2.IMREAD_COLOR)
if image is None:
logger.error("Failed to decode image from bytes")
return []
# Convert to grayscale
gray = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY)
# Try multiple approaches for better detection
barcodes = []
# 1. Try original orientation
barcodes.extend(self._detect_barcodes(gray, image))
# 2. Try with adaptive thresholding
if not barcodes:
thresh = cv2.adaptiveThreshold(
gray, 255, cv2.ADAPTIVE_THRESH_GAUSSIAN_C,
cv2.THRESH_BINARY, 11, 2
)
barcodes.extend(self._detect_barcodes(thresh, image))
# 3. Try rotations if still no barcodes found
if not barcodes:
logger.info("No barcodes found in uploaded image, trying rotations...")
# Try incremental angles: 30°, 60°, 90° (covers 0-90° range)
for angle in [30, 60, 90]:
rotated_gray = self._rotate_image(gray, angle)
rotated_color = self._rotate_image(image, angle)
detected = self._detect_barcodes(rotated_gray, rotated_color)
if detected:
logger.info(f"Found barcode(s) in uploaded image at {angle}° rotation")
barcodes.extend(detected)
break
return self._deduplicate_barcodes(barcodes)
except Exception as e:
logger.error(f"Error scanning image from bytes: {e}")
return []
def validate_barcode(self, barcode: str, barcode_type: str) -> bool:
"""
Validate a barcode using check digits (for EAN/UPC).
Args:
barcode: Barcode string
barcode_type: Type of barcode (e.g., 'EAN13', 'UPCA')
Returns:
True if valid, False otherwise
"""
if barcode_type in ["EAN13", "UPCA"]:
return self._validate_ean13(barcode)
elif barcode_type == "EAN8":
return self._validate_ean8(barcode)
# For other types, assume valid if detected
return True
def _validate_ean13(self, barcode: str) -> bool:
"""Validate EAN-13 barcode using check digit."""
if len(barcode) != 13 or not barcode.isdigit():
return False
# Calculate check digit
odd_sum = sum(int(barcode[i]) for i in range(0, 12, 2))
even_sum = sum(int(barcode[i]) for i in range(1, 12, 2))
total = odd_sum + (even_sum * 3)
check_digit = (10 - (total % 10)) % 10
return int(barcode[12]) == check_digit
def _validate_ean8(self, barcode: str) -> bool:
"""Validate EAN-8 barcode using check digit."""
if len(barcode) != 8 or not barcode.isdigit():
return False
# Calculate check digit
odd_sum = sum(int(barcode[i]) for i in range(1, 7, 2))
even_sum = sum(int(barcode[i]) for i in range(0, 7, 2))
total = (odd_sum * 3) + even_sum
check_digit = (10 - (total % 10)) % 10
return int(barcode[7]) == check_digit