diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 656f65d5..eece992a 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -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 *)" ] } } diff --git a/backend/models.py b/backend/models.py index 9bd60d1e..d3ac7df0 100644 --- a/backend/models.py +++ b/backend/models.py @@ -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) diff --git a/backend/routers/items.py b/backend/routers/items.py index 3e13f8f1..0fbb6907 100644 --- a/backend/routers/items.py +++ b/backend/routers/items.py @@ -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') } ) diff --git a/backend/schemas/items.py b/backend/schemas/items.py index 0401535a..00027257 100644 --- a/backend/schemas/items.py +++ b/backend/schemas/items.py @@ -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 diff --git a/backend/tests/test_schema.py b/backend/tests/test_schema.py index 43ee65d1..aee1d27d 100644 --- a/backend/tests/test_schema.py +++ b/backend/tests/test_schema.py @@ -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) diff --git a/scripts/opencv_crop_validation.py b/scripts/opencv_crop_validation.py new file mode 100644 index 00000000..7341c01e --- /dev/null +++ b/scripts/opencv_crop_validation.py @@ -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() diff --git a/validation_output/IMG_6184_bbox.jpg b/validation_output/IMG_6184_bbox.jpg new file mode 100644 index 00000000..5019087e Binary files /dev/null and b/validation_output/IMG_6184_bbox.jpg differ diff --git a/validation_output/IMG_6184_cropped.jpg b/validation_output/IMG_6184_cropped.jpg new file mode 100644 index 00000000..172db967 Binary files /dev/null and b/validation_output/IMG_6184_cropped.jpg differ diff --git a/validation_output/IMG_6185_bbox.jpg b/validation_output/IMG_6185_bbox.jpg new file mode 100644 index 00000000..ae147d08 Binary files /dev/null and b/validation_output/IMG_6185_bbox.jpg differ diff --git a/validation_output/IMG_6185_cropped.jpg b/validation_output/IMG_6185_cropped.jpg new file mode 100644 index 00000000..b07beb60 Binary files /dev/null and b/validation_output/IMG_6185_cropped.jpg differ diff --git a/validation_output/IMG_6186_bbox.jpg b/validation_output/IMG_6186_bbox.jpg new file mode 100644 index 00000000..656a24c7 Binary files /dev/null and b/validation_output/IMG_6186_bbox.jpg differ diff --git a/validation_output/IMG_6186_cropped.jpg b/validation_output/IMG_6186_cropped.jpg new file mode 100644 index 00000000..01c34e9b Binary files /dev/null and b/validation_output/IMG_6186_cropped.jpg differ diff --git a/validation_output/IMG_6187_bbox.jpg b/validation_output/IMG_6187_bbox.jpg new file mode 100644 index 00000000..93ed21d6 Binary files /dev/null and b/validation_output/IMG_6187_bbox.jpg differ diff --git a/validation_output/IMG_6187_cropped.jpg b/validation_output/IMG_6187_cropped.jpg new file mode 100644 index 00000000..e9015747 Binary files /dev/null and b/validation_output/IMG_6187_cropped.jpg differ diff --git a/validation_output/IMG_6188_bbox.jpg b/validation_output/IMG_6188_bbox.jpg new file mode 100644 index 00000000..e482a09f Binary files /dev/null and b/validation_output/IMG_6188_bbox.jpg differ diff --git a/validation_output/IMG_6188_cropped.jpg b/validation_output/IMG_6188_cropped.jpg new file mode 100644 index 00000000..adf2e089 Binary files /dev/null and b/validation_output/IMG_6188_cropped.jpg differ diff --git a/validation_output/IMG_6189_bbox.jpg b/validation_output/IMG_6189_bbox.jpg new file mode 100644 index 00000000..483e5aa1 Binary files /dev/null and b/validation_output/IMG_6189_bbox.jpg differ diff --git a/validation_output/IMG_6189_cropped.jpg b/validation_output/IMG_6189_cropped.jpg new file mode 100644 index 00000000..a992d4eb Binary files /dev/null and b/validation_output/IMG_6189_cropped.jpg differ