Files
tfm_ainventory/scripts/opencv_crop_validation.py

229 lines
6.9 KiB
Python

#!/usr/bin/env python3
"""
OpenCV Smart Crop Validation Script
Run this on sample photos of your small components (RAM, SFP, HDD, etc) to validate:
1. Auto-crop accuracy (does it detect the object correctly?)
2. Text orientation detection (is text upright?)
3. Performance (how fast on CPU?)
Usage:
python scripts/opencv_crop_validation.py path/to/photo.jpg
python scripts/opencv_crop_validation.py path/to/photo.jpg --show-preview
Output:
- Prints timing and crop dimensions
- Saves debug images to ./validation_output/
"""
import cv2
import numpy as np
import sys
import argparse
import time
from pathlib import Path
def detect_text_orientation(image, bbox):
"""Detect if text in bounding box is upright or rotated."""
x, y, w, h = bbox
roi = image[y:y+h, x:x+w]
if roi.size == 0:
return 0, "N/A"
# Hough line detection to find text angle
gray = cv2.cvtColor(roi, cv2.COLOR_BGR2GRAY) if len(roi.shape) == 3 else roi
edges = cv2.Canny(gray, 100, 200)
lines = cv2.HoughLines(edges, 1, np.pi/180, 50)
if lines is None:
return 0, "no_text_detected"
angles = []
for rho, theta in lines[:, 0]:
angle = np.degrees(theta)
# Normalize to -45 to 45 range (text is typically horizontal)
if angle > 90:
angle -= 180
angles.append(angle)
if not angles:
return 0, "no_text_detected"
dominant_angle = np.median(angles)
# Classify orientation
if abs(dominant_angle) < 15:
status = "UPRIGHT"
elif abs(dominant_angle - 90) < 15 or abs(dominant_angle + 90) < 15:
status = "SIDEWAYS"
elif abs(dominant_angle - 180) < 15 or abs(dominant_angle + 180) < 15:
status = "UPSIDE_DOWN"
else:
status = "ROTATED"
return dominant_angle, status
def smart_crop(image_path, output_dir="validation_output"):
"""
Run the smart crop pipeline.
Returns:
dict with timing, dimensions, status
"""
output_path = Path(output_dir)
output_path.mkdir(exist_ok=True)
# Load image
image = cv2.imread(str(image_path))
if image is None:
return {"error": f"Could not load image: {image_path}"}
original_h, original_w = image.shape[:2]
# Time the pipeline
t0 = time.time()
# Step 1: Convert to grayscale
gray = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY)
# Step 2: Edge detection (Canny)
edges = cv2.Canny(gray, 100, 200)
# Step 3: Find contours
contours, _ = cv2.findContours(edges, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
if not contours:
return {
"error": "No contours detected (image may be blank or very simple)",
"original_size": f"{original_w}x{original_h}",
"time_ms": round((time.time() - t0) * 1000, 2)
}
# Step 4: Filter contours by size (ignore tiny labels, keep component)
# Require contour area >= 5% of image area (filters out text/labels)
min_area = (original_w * original_h) * 0.05
large_contours = [c for c in contours if cv2.contourArea(c) >= min_area]
if not large_contours:
# Fallback: use largest contour anyway
largest_contour = max(contours, key=cv2.contourArea)
fallback_note = " (used largest contour; no contours met 5% size filter)"
else:
largest_contour = max(large_contours, key=cv2.contourArea)
fallback_note = ""
x, y, w, h = cv2.boundingRect(largest_contour)
# Step 5: Add 10% padding
pad_x = int(w * 0.1)
pad_y = int(h * 0.1)
x = max(0, x - pad_x)
y = max(0, y - pad_y)
w = min(original_w - x, w + pad_x * 2)
h = min(original_h - y, h + pad_y * 2)
# Step 6: Crop
cropped = image[y:y+h, x:x+w]
# Step 7: Detect text orientation
angle, orientation = detect_text_orientation(image, (x, y, w, h))
t_total = time.time() - t0
# Save debug outputs
base_name = Path(image_path).stem
# Save cropped image
cropped_path = output_path / f"{base_name}_cropped.jpg"
cv2.imwrite(str(cropped_path), cropped)
# Save original with bounding box overlay
debug_image = image.copy()
cv2.rectangle(debug_image, (x, y), (x+w, y+h), (0, 255, 0), 3)
cv2.putText(debug_image, f"Object: {w}x{h}px", (x, y-10),
cv2.FONT_HERSHEY_SIMPLEX, 0.7, (0, 255, 0), 2)
cv2.putText(debug_image, f"Angle: {angle:.1f}° ({orientation})", (x, y-40),
cv2.FONT_HERSHEY_SIMPLEX, 0.7, (0, 255, 0), 2)
bbox_path = output_path / f"{base_name}_bbox.jpg"
cv2.imwrite(str(bbox_path), debug_image)
result = {
"status": "OK",
"original_size": f"{original_w}x{original_h}",
"crop_size": f"{w}x{h}",
"crop_position": f"({x}, {y})",
"text_angle_degrees": round(angle, 1),
"text_orientation": orientation,
"time_ms": round(t_total * 1000, 2),
"cropped_image": str(cropped_path),
"debug_image": str(bbox_path)
}
if fallback_note:
result["note"] = fallback_note
return result
def main():
parser = argparse.ArgumentParser(
description="Validate OpenCV smart crop on a photo"
)
parser.add_argument("image", help="Path to image file")
parser.add_argument("--show-preview", action="store_true",
help="Display images (requires display)")
parser.add_argument("--output-dir", default="validation_output",
help="Output directory for debug images")
args = parser.parse_args()
result = smart_crop(args.image, args.output_dir)
# Print results
print("\n" + "="*60)
print(f"VALIDATION: {args.image}")
print("="*60)
if "error" in result:
print(f"❌ ERROR: {result['error']}")
else:
print(f"✅ CROP SUCCESS")
for key, value in result.items():
if key not in ["error"]:
print(f" {key:25} {value}")
print("="*60 + "\n")
# Show preview if requested
if args.show_preview and "cropped_image" in result:
try:
original = cv2.imread(args.image)
cropped = cv2.imread(result["cropped_image"])
debug = cv2.imread(result["debug_image"])
# Resize for display if very large
h, w = original.shape[:2]
if w > 1920 or h > 1080:
scale = min(1920/w, 1080/h)
original = cv2.resize(original, (int(w*scale), int(h*scale)))
cropped = cv2.resize(cropped, (int(cropped.shape[1]*scale), int(cropped.shape[0]*scale)))
debug = cv2.resize(debug, (int(debug.shape[1]*scale), int(debug.shape[0]*scale)))
cv2.imshow("Original", original)
cv2.imshow("Cropped", cropped)
cv2.imshow("Debug (with bounds)", debug)
print("Displaying images... Press any key to close")
cv2.waitKey(0)
cv2.destroyAllWindows()
except Exception as e:
print(f"Could not display preview: {e}")
if __name__ == "__main__":
main()