229 lines
6.9 KiB
Python
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()
|