kiwi/app/services/quality/assessment.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

332 lines
No EOL
12 KiB
Python

#!/usr/bin/env python
# app/services/quality/assessment.py
import cv2
import numpy as np
import logging
from pathlib import Path
from typing import Dict, Any, Optional, Tuple
logger = logging.getLogger(__name__)
class QualityAssessor:
"""
Assesses the quality of receipt images for processing suitability.
"""
def __init__(self, min_quality_score: float = 50.0):
"""
Initialize the quality assessor.
Args:
min_quality_score: Minimum acceptable quality score (0-100)
"""
self.min_quality_score = min_quality_score
def assess_image(self, image_path: Path) -> Dict[str, Any]:
"""
Assess the quality of an image.
Args:
image_path: Path to the image
Returns:
Dictionary containing quality metrics
"""
try:
# Read image
img = cv2.imread(str(image_path))
if img is None:
return {
"success": False,
"error": f"Failed to read image: {image_path}",
"overall_score": 0.0,
}
# Convert to grayscale for some metrics
gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
# Calculate various quality metrics
blur_score = self._calculate_blur_score(gray)
lighting_score = self._calculate_lighting_score(gray)
contrast_score = self._calculate_contrast_score(gray)
size_score = self._calculate_size_score(img.shape)
# Check for potential fold lines
fold_detected, fold_severity = self._detect_folds(gray)
# Calculate overall quality score
overall_score = self._calculate_overall_score({
"blur": blur_score,
"lighting": lighting_score,
"contrast": contrast_score,
"size": size_score,
"fold": 100.0 if not fold_detected else (100.0 - fold_severity * 20.0)
})
# Create assessment result
result = {
"success": True,
"metrics": {
"blur_score": blur_score,
"lighting_score": lighting_score,
"contrast_score": contrast_score,
"size_score": size_score,
"fold_detected": fold_detected,
"fold_severity": fold_severity if fold_detected else 0.0,
},
"overall_score": overall_score,
"is_acceptable": overall_score >= self.min_quality_score,
"improvement_suggestions": self._generate_suggestions({
"blur": blur_score,
"lighting": lighting_score,
"contrast": contrast_score,
"size": size_score,
"fold": fold_detected,
"fold_severity": fold_severity if fold_detected else 0.0,
}),
}
return result
except Exception as e:
logger.exception(f"Error assessing image quality: {e}")
return {
"success": False,
"error": f"Error assessing image quality: {str(e)}",
"overall_score": 0.0,
}
def _calculate_blur_score(self, gray_img: np.ndarray) -> float:
"""
Calculate blur score using Laplacian variance.
Higher variance = less blurry (higher score)
Args:
gray_img: Grayscale image
Returns:
Blur score (0-100)
"""
# Use Laplacian for edge detection
laplacian = cv2.Laplacian(gray_img, cv2.CV_64F)
# Calculate variance of Laplacian
variance = laplacian.var()
# Map variance to a 0-100 score
# These thresholds might need adjustment based on your specific requirements
if variance < 10:
return 0.0 # Very blurry
elif variance < 100:
return (variance - 10) / 90 * 50 # Map 10-100 to 0-50
elif variance < 1000:
return 50 + (variance - 100) / 900 * 50 # Map 100-1000 to 50-100
else:
return 100.0 # Very sharp
def _calculate_lighting_score(self, gray_img: np.ndarray) -> float:
"""
Calculate lighting score based on average brightness and std dev.
Args:
gray_img: Grayscale image
Returns:
Lighting score (0-100)
"""
# Calculate mean brightness
mean = gray_img.mean()
# Calculate standard deviation of brightness
std = gray_img.std()
# Ideal mean would be around 127 (middle of 0-255)
# Penalize if too dark or too bright
mean_score = 100 - abs(mean - 127) / 127 * 100
# Higher std dev generally means better contrast
# But we'll cap at 60 for reasonable balance
std_score = min(std / 60 * 100, 100)
# Combine scores (weighted)
return 0.6 * mean_score + 0.4 * std_score
def _calculate_contrast_score(self, gray_img: np.ndarray) -> float:
"""
Calculate contrast score.
Args:
gray_img: Grayscale image
Returns:
Contrast score (0-100)
"""
# Calculate histogram
hist = cv2.calcHist([gray_img], [0], None, [256], [0, 256])
# Calculate percentage of pixels in each brightness range
total_pixels = gray_img.shape[0] * gray_img.shape[1]
dark_pixels = np.sum(hist[:50]) / total_pixels
mid_pixels = np.sum(hist[50:200]) / total_pixels
bright_pixels = np.sum(hist[200:]) / total_pixels
# Ideal: good distribution across ranges with emphasis on mid-range
# This is a simplified model - real receipts may need different distributions
score = (
(0.2 * min(dark_pixels * 500, 100)) + # Want some dark pixels (text)
(0.6 * min(mid_pixels * 200, 100)) + # Want many mid pixels
(0.2 * min(bright_pixels * 500, 100)) # Want some bright pixels (background)
)
return score
def _calculate_size_score(self, shape: Tuple[int, int, int]) -> float:
"""
Calculate score based on image dimensions.
Args:
shape: Image shape (height, width, channels)
Returns:
Size score (0-100)
"""
height, width = shape[0], shape[1]
# Minimum recommended dimensions for good OCR
min_height, min_width = 800, 600
# Calculate size score
if height < min_height or width < min_width:
# Penalize if below minimum dimensions
return min(height / min_height, width / min_width) * 100
else:
# Full score if dimensions are adequate
return 100.0
def _detect_folds(self, gray_img: np.ndarray) -> Tuple[bool, float]:
"""
Detect potential fold lines in the image.
Args:
gray_img: Grayscale image
Returns:
Tuple of (fold_detected, fold_severity)
fold_severity is a value between 0 and 5, with 5 being the most severe
"""
# Apply edge detection
edges = cv2.Canny(gray_img, 50, 150, apertureSize=3)
# Apply Hough Line Transform to detect straight lines
lines = cv2.HoughLinesP(
edges,
rho=1,
theta=np.pi/180,
threshold=100,
minLineLength=gray_img.shape[1] // 3, # Look for lines at least 1/3 of image width
maxLineGap=10
)
if lines is None:
return False, 0.0
# Check for horizontal or vertical lines that could be folds
potential_folds = []
height, width = gray_img.shape
for line in lines:
x1, y1, x2, y2 = line[0]
length = np.sqrt((x2 - x1)**2 + (y2 - y1)**2)
angle = np.abs(np.arctan2(y2 - y1, x2 - x1) * 180 / np.pi)
# Check if horizontal (0±10°) or vertical (90±10°)
is_horizontal = angle < 10 or angle > 170
is_vertical = abs(angle - 90) < 10
# Check if length is significant
is_significant = (is_horizontal and length > width * 0.5) or \
(is_vertical and length > height * 0.5)
if (is_horizontal or is_vertical) and is_significant:
# Calculate intensity variance along the line
# This helps determine if it's a fold (sharp brightness change)
# Simplified implementation for Phase 1
potential_folds.append({
"length": length,
"is_horizontal": is_horizontal,
})
# Determine if folds are detected and their severity
if not potential_folds:
return False, 0.0
# Severity based on number and length of potential folds
# This is a simplified metric for Phase 1
total_len = sum(fold["length"] for fold in potential_folds)
if is_horizontal:
severity = min(5.0, total_len / width * 2.5)
else:
severity = min(5.0, total_len / height * 2.5)
return True, severity
def _calculate_overall_score(self, scores: Dict[str, float]) -> float:
"""
Calculate overall quality score from individual metrics.
Args:
scores: Dictionary of individual quality scores
Returns:
Overall quality score (0-100)
"""
# Weights for different factors
weights = {
"blur": 0.30,
"lighting": 0.25,
"contrast": 0.25,
"size": 0.10,
"fold": 0.10,
}
# Calculate weighted average
overall = sum(weights[key] * scores[key] for key in weights)
return overall
def _generate_suggestions(self, metrics: Dict[str, Any]) -> list:
"""
Generate improvement suggestions based on metrics.
Args:
metrics: Dictionary of quality metrics
Returns:
List of improvement suggestions
"""
suggestions = []
# Blur suggestions
if metrics["blur"] < 60:
suggestions.append("Hold the camera steady and ensure the receipt is in focus.")
# Lighting suggestions
if metrics["lighting"] < 60:
suggestions.append("Improve lighting conditions and avoid shadows on the receipt.")
# Contrast suggestions
if metrics["contrast"] < 60:
suggestions.append("Ensure good contrast between text and background.")
# Size suggestions
if metrics["size"] < 60:
suggestions.append("Move the camera closer to the receipt for better resolution.")
# Fold suggestions
if metrics["fold"]:
if metrics["fold_severity"] > 3.0:
suggestions.append("The receipt has severe folds. Try to flatten it before capturing.")
else:
suggestions.append("Flatten the receipt to remove fold lines for better processing.")
return suggestions