fix(phase1): add photo fields to schemas and set datetime default
@@ -78,7 +78,10 @@
|
||||
"Bash(/tmp/gitignore_audit.sh)",
|
||||
"Bash(chmod +x /tmp/check_tracked.sh)",
|
||||
"Bash(/tmp/check_tracked.sh)",
|
||||
"Bash(git check-ignore *)"
|
||||
"Bash(git check-ignore *)",
|
||||
"Bash(python -m pytest backend/tests/test_schema.py -v)",
|
||||
"Bash(awk '{print $NF}')",
|
||||
"Bash(python *)"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -54,7 +54,7 @@ class Item(Base):
|
||||
# Photo fields for item onboarding
|
||||
photo_path = Column(String, nullable=True)
|
||||
photo_thumbnail_path = Column(String, nullable=True)
|
||||
photo_upload_date = Column(DateTime, nullable=True)
|
||||
photo_upload_date = Column(DateTime, default=datetime.datetime.now, nullable=True)
|
||||
|
||||
# Generic box/container association for multi-item OCR scanning
|
||||
box_label = Column(String, index=True, nullable=True)
|
||||
|
||||
@@ -107,7 +107,7 @@ def create_item(
|
||||
detail={
|
||||
"message": f"Item with Part Number '{item.barcode}' already exists in inventory.",
|
||||
"existing_id": existing.id,
|
||||
"existing_item": schemas.Item.model_validate(existing).model_dump()
|
||||
"existing_item": schemas.Item.model_validate(existing).model_dump(mode='json')
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
from pydantic import BaseModel
|
||||
from pydantic import BaseModel, field_serializer
|
||||
from typing import Optional
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
# --- Categories ---
|
||||
@@ -54,6 +55,9 @@ class ItemBase(BaseModel):
|
||||
image_url: Optional[str] = None
|
||||
box_label: Optional[str] = None
|
||||
labels_data: Optional[str] = None
|
||||
photo_path: Optional[str] = None
|
||||
photo_thumbnail_path: Optional[str] = None
|
||||
photo_upload_date: Optional[datetime] = None
|
||||
|
||||
|
||||
class ItemCreate(ItemBase):
|
||||
@@ -65,3 +69,8 @@ class Item(ItemBase):
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
@field_serializer('photo_upload_date', when_used='json')
|
||||
def serialize_photo_upload_date(self, value: Optional[datetime]) -> Optional[str]:
|
||||
"""Serialize datetime to ISO format string for JSON."""
|
||||
return value.isoformat() if value else None
|
||||
|
||||
@@ -34,7 +34,9 @@ class TestItemPhotoFields:
|
||||
retrieved = test_db.query(Item).filter_by(id=item.id).first()
|
||||
assert retrieved.photo_path is None
|
||||
assert retrieved.photo_thumbnail_path is None
|
||||
assert retrieved.photo_upload_date is None
|
||||
# photo_upload_date now has a default of datetime.now, so it should be populated
|
||||
assert retrieved.photo_upload_date is not None
|
||||
assert isinstance(retrieved.photo_upload_date, datetime)
|
||||
|
||||
def test_photo_fields_can_store_values(self, test_db):
|
||||
"""Test that photo fields can store string and datetime values."""
|
||||
@@ -78,4 +80,6 @@ class TestItemPhotoFields:
|
||||
retrieved = test_db.query(Item).filter_by(id=item.id).first()
|
||||
assert retrieved.photo_path == "/images/items/photo-003.jpg"
|
||||
assert retrieved.photo_thumbnail_path is None
|
||||
assert retrieved.photo_upload_date is None
|
||||
# photo_upload_date gets default timestamp even when explicitly set to None
|
||||
assert retrieved.photo_upload_date is not None
|
||||
assert isinstance(retrieved.photo_upload_date, datetime)
|
||||
|
||||
228
scripts/opencv_crop_validation.py
Normal file
@@ -0,0 +1,228 @@
|
||||
#!/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()
|
||||
BIN
validation_output/IMG_6184_bbox.jpg
Normal file
|
After Width: | Height: | Size: 4.1 MiB |
BIN
validation_output/IMG_6184_cropped.jpg
Normal file
|
After Width: | Height: | Size: 20 KiB |
BIN
validation_output/IMG_6185_bbox.jpg
Normal file
|
After Width: | Height: | Size: 4.5 MiB |
BIN
validation_output/IMG_6185_cropped.jpg
Normal file
|
After Width: | Height: | Size: 54 KiB |
BIN
validation_output/IMG_6186_bbox.jpg
Normal file
|
After Width: | Height: | Size: 4.4 MiB |
BIN
validation_output/IMG_6186_cropped.jpg
Normal file
|
After Width: | Height: | Size: 148 KiB |
BIN
validation_output/IMG_6187_bbox.jpg
Normal file
|
After Width: | Height: | Size: 4.2 MiB |
BIN
validation_output/IMG_6187_cropped.jpg
Normal file
|
After Width: | Height: | Size: 141 KiB |
BIN
validation_output/IMG_6188_bbox.jpg
Normal file
|
After Width: | Height: | Size: 4.4 MiB |
BIN
validation_output/IMG_6188_cropped.jpg
Normal file
|
After Width: | Height: | Size: 32 KiB |
BIN
validation_output/IMG_6189_bbox.jpg
Normal file
|
After Width: | Height: | Size: 4.4 MiB |
BIN
validation_output/IMG_6189_cropped.jpg
Normal file
|
After Width: | Height: | Size: 41 KiB |