feat: add _auto_save_photo_from_extraction helper with graceful fallbacks

This commit is contained in:
2026-04-21 18:56:17 +03:00
parent e368574fba
commit eca1ab7fd0
2 changed files with 736 additions and 0 deletions

View File

@@ -423,3 +423,216 @@ async def upload_photo(
status_code=500,
detail=f"Internal server error: {str(e)}"
)
def _auto_save_photo_from_extraction(
item_id: int,
image_bytes: bytes,
crop_bounds: Optional[Dict[str, int]],
rotation_degrees: Optional[float],
db: Session
) -> Dict[str, str]:
"""
Helper function to save extracted photos with AI-guided crop/rotation.
This function is called after item creation if image_processing metadata exists.
It gracefully handles missing/invalid data without throwing exceptions.
Args:
item_id: ID of the item to attach the photo to
image_bytes: Raw photo bytes
crop_bounds: Optional crop bounds dict {x, y, width, height} (in pixels)
rotation_degrees: Optional rotation in degrees (-360 to +360, clockwise)
db: SQLAlchemy session
Returns:
{status: "ok"} if photo saved successfully
{status: "skipped", reason: "..."} if data invalid or missing
Behavior:
- Validates crop_bounds (all keys present, all ints >= 0)
- Validates rotation_degrees (numeric, -360 to +360)
- Skips gracefully if crop_bounds is None (no exceptions)
- Skips gracefully on invalid data (logs warning, returns skipped)
- Updates item.photo_path, photo_thumbnail_path, photo_upload_date
- Never throws exceptions
"""
from ..logger import log
try:
# Validate item exists
db_item = db.query(models.Item).filter(models.Item.id == item_id).first()
if not db_item:
log.warning(f"Auto-save photo: Item {item_id} not found, skipping")
return {
"status": "skipped",
"reason": f"Item {item_id} not found"
}
# Validate image_bytes
if not image_bytes or len(image_bytes) == 0:
log.warning(f"Auto-save photo for item {item_id}: No image bytes provided")
return {
"status": "skipped",
"reason": "Empty image bytes"
}
# Graceful skip if crop_bounds is None
if crop_bounds is None:
log.info(f"Auto-save photo for item {item_id}: crop_bounds is None, skipping")
return {
"status": "skipped",
"reason": "crop_bounds is None"
}
# Validate crop_bounds
if not isinstance(crop_bounds, dict):
log.warning(f"Auto-save photo for item {item_id}: crop_bounds is not a dict, skipping")
return {
"status": "skipped",
"reason": "crop_bounds must be a dict"
}
# Check for required keys
required_keys = {'x', 'y', 'width', 'height'}
if not required_keys.issubset(crop_bounds.keys()):
missing = required_keys - set(crop_bounds.keys())
log.warning(f"Auto-save photo for item {item_id}: Missing crop_bounds keys: {missing}")
return {
"status": "skipped",
"reason": f"Missing crop_bounds keys: {missing}"
}
# Validate all values are integers >= 0
try:
crop_bounds_validated = {}
for key in required_keys:
val = crop_bounds[key]
# Convert to int if it's numeric
if isinstance(val, (int, float)):
int_val = int(val)
else:
raise ValueError(f"Non-numeric value for {key}: {val}")
if int_val < 0:
raise ValueError(f"Negative value for {key}: {int_val}")
crop_bounds_validated[key] = int_val
except (ValueError, TypeError) as e:
log.warning(f"Auto-save photo for item {item_id}: Invalid crop_bounds: {str(e)}")
return {
"status": "skipped",
"reason": f"Invalid crop_bounds: {str(e)}"
}
# Validate rotation_degrees (optional but if provided, must be valid)
if rotation_degrees is not None:
try:
rot = float(rotation_degrees)
if rot < -360 or rot > 360:
log.warning(f"Auto-save photo for item {item_id}: rotation_degrees {rot} out of range [-360, 360]")
return {
"status": "skipped",
"reason": f"rotation_degrees {rot} out of range [-360, 360]"
}
except (ValueError, TypeError) as e:
log.warning(f"Auto-save photo for item {item_id}: Invalid rotation_degrees: {str(e)}")
return {
"status": "skipped",
"reason": f"Invalid rotation_degrees: {str(e)}"
}
# All validation passed, proceed with processing
try:
# Process image (crop + rotation + compression + thumbnail)
processor = ImageProcessor()
process_result = processor.process_photo(image_bytes, crop_bounds_validated)
if process_result.get('status') != 'success':
error_msg = process_result.get('error', 'Unknown error')
log.warning(f"Auto-save photo for item {item_id}: Image processing failed: {error_msg}")
return {
"status": "skipped",
"reason": f"Image processing failed: {error_msg}"
}
# Get processed bytes
cropped_bytes = process_result.get('cropped_image_bytes')
thumbnail_bytes = process_result.get('thumbnail_bytes')
if not cropped_bytes or not thumbnail_bytes:
log.warning(f"Auto-save photo for item {item_id}: No image data from processing")
return {
"status": "skipped",
"reason": "No image data from processing"
}
# Get category for file storage
category = db_item.category or "items"
# Get unique filenames
existing_files = []
cat_dir = Path("images") / category.lower()
if cat_dir.exists():
existing_files = [f.name for f in cat_dir.iterdir() if f.is_file()]
filename_base = db_item.name or f"item_{item_id}"
original_filename = get_unique_filename(filename_base, category, existing_files, variant="original")
thumbnail_filename = get_unique_filename(filename_base, category, existing_files, variant="thumb")
# Save original image
try:
original_path = save_image(cropped_bytes, category, original_filename.replace("_original.jpg", ""), variant="original")
except (OSError, IOError) as e:
log.warning(f"Auto-save photo for item {item_id}: Failed to save image: {str(e)}")
return {
"status": "skipped",
"reason": f"Failed to save image: {str(e)}"
}
# Save thumbnail
try:
thumbnail_path = save_image(thumbnail_bytes, category, thumbnail_filename.replace("_thumb.jpg", ""), variant="thumb")
except (OSError, IOError) as e:
log.warning(f"Auto-save photo for item {item_id}: Failed to save thumbnail: {str(e)}")
# Clean up original if thumbnail save fails
try:
old_photo = Path(original_path.lstrip("/"))
if old_photo.exists():
old_photo.unlink()
except Exception:
pass
return {
"status": "skipped",
"reason": f"Failed to save thumbnail: {str(e)}"
}
# Update database
db_item.photo_path = original_path
db_item.photo_thumbnail_path = thumbnail_path
db_item.photo_upload_date = datetime.now(timezone.utc)
db.commit()
db.refresh(db_item)
log.info(f"Auto-save photo for item {item_id}: Success")
return {
"status": "ok"
}
except Exception as e:
db.rollback()
log.warning(f"Auto-save photo for item {item_id}: Unexpected error: {str(e)}")
return {
"status": "skipped",
"reason": f"Unexpected error: {str(e)}"
}
except Exception as e:
# Catch-all for any unexpected errors (never throw)
log.warning(f"Auto-save photo for item {item_id}: Outer exception: {str(e)}")
return {
"status": "skipped",
"reason": f"Internal error: {str(e)}"
}