fix(phase1): add photo fields to schemas and set datetime default

This commit is contained in:
2026-04-20 21:49:51 +03:00
parent 6ed88fdb84
commit 92f6977cae
18 changed files with 250 additions and 6 deletions

View File

@@ -78,7 +78,10 @@
"Bash(/tmp/gitignore_audit.sh)", "Bash(/tmp/gitignore_audit.sh)",
"Bash(chmod +x /tmp/check_tracked.sh)", "Bash(chmod +x /tmp/check_tracked.sh)",
"Bash(/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 *)"
] ]
} }
} }

View File

@@ -54,7 +54,7 @@ class Item(Base):
# Photo fields for item onboarding # Photo fields for item onboarding
photo_path = Column(String, nullable=True) photo_path = Column(String, nullable=True)
photo_thumbnail_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 # Generic box/container association for multi-item OCR scanning
box_label = Column(String, index=True, nullable=True) box_label = Column(String, index=True, nullable=True)

View File

@@ -107,7 +107,7 @@ def create_item(
detail={ detail={
"message": f"Item with Part Number '{item.barcode}' already exists in inventory.", "message": f"Item with Part Number '{item.barcode}' already exists in inventory.",
"existing_id": existing.id, "existing_id": existing.id,
"existing_item": schemas.Item.model_validate(existing).model_dump() "existing_item": schemas.Item.model_validate(existing).model_dump(mode='json')
} }
) )

View File

@@ -1,5 +1,6 @@
from pydantic import BaseModel from pydantic import BaseModel, field_serializer
from typing import Optional from typing import Optional
from datetime import datetime
# --- Categories --- # --- Categories ---
@@ -54,6 +55,9 @@ class ItemBase(BaseModel):
image_url: Optional[str] = None image_url: Optional[str] = None
box_label: Optional[str] = None box_label: Optional[str] = None
labels_data: 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): class ItemCreate(ItemBase):
@@ -65,3 +69,8 @@ class Item(ItemBase):
class Config: class Config:
from_attributes = True 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

View File

@@ -34,7 +34,9 @@ class TestItemPhotoFields:
retrieved = test_db.query(Item).filter_by(id=item.id).first() retrieved = test_db.query(Item).filter_by(id=item.id).first()
assert retrieved.photo_path is None assert retrieved.photo_path is None
assert retrieved.photo_thumbnail_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): def test_photo_fields_can_store_values(self, test_db):
"""Test that photo fields can store string and datetime values.""" """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() retrieved = test_db.query(Item).filter_by(id=item.id).first()
assert retrieved.photo_path == "/images/items/photo-003.jpg" assert retrieved.photo_path == "/images/items/photo-003.jpg"
assert retrieved.photo_thumbnail_path is None 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)

View 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()

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 148 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 141 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB