diff --git a/backend/routers/items.py b/backend/routers/items.py index ce21819b..3d269230 100644 --- a/backend/routers/items.py +++ b/backend/routers/items.py @@ -10,7 +10,7 @@ from slowapi.util import get_remote_address from pathlib import Path from .. import models, schemas, auth from ..database import get_db -from ..services.image_processing import ImageProcessor +from ..services.image_processing import ImageProcessor, strip_exif_orientation from ..services.image_storage import save_image, get_unique_filename from ..logger import log @@ -104,8 +104,12 @@ async def extract_label( detail="File exceeds 10MB limit." ) - log.info(f"[EXTRACT] Sending {len(contents)} bytes to Gemini for analysis") - result = extract_label_info(contents, mode=mode) + # Strip EXIF orientation so Gemini analyzes raw image (not rotated) + # Backend will process the same raw image + contents_no_exif = strip_exif_orientation(contents) + log.info(f"[EXTRACT] Sending {len(contents_no_exif)} bytes to Gemini (EXIF orientation stripped)") + + result = extract_label_info(contents_no_exif, mode=mode) log.info(f"[EXTRACT] Gemini returned: {type(result).__name__}") return result diff --git a/backend/services/image_processing.py b/backend/services/image_processing.py index 05d8c688..5159f728 100644 --- a/backend/services/image_processing.py +++ b/backend/services/image_processing.py @@ -22,6 +22,46 @@ import numpy as np logger = logging.getLogger(__name__) +def strip_exif_orientation(file_bytes: bytes) -> bytes: + """ + Remove EXIF orientation metadata from image bytes. + + Returns image bytes with orientation tag removed (or set to 1 = normal). + This ensures both Gemini and our backend analyze the same raw image. + + Args: + file_bytes: Raw image file bytes + + Returns: + Image bytes with EXIF orientation stripped + """ + try: + image = Image.open(io.BytesIO(file_bytes)) + + # Try to get and remove EXIF orientation + try: + exif_dict = piexif.load(image.info.get('exif', b'')) + if piexif.ImageIFD.Orientation in exif_dict['0th']: + del exif_dict['0th'][piexif.ImageIFD.Orientation] + exif_bytes = piexif.dump(exif_dict) + else: + exif_bytes = None + except: + exif_bytes = None + + # Save image without orientation + output = io.BytesIO() + if exif_bytes: + image.save(output, format='JPEG', quality=85, exif=exif_bytes) + else: + image.save(output, format='JPEG', quality=85) + + return output.getvalue() + except Exception as e: + logger.warning(f"Failed to strip EXIF orientation: {e}, returning original bytes") + return file_bytes + + class ImageProcessor: """Service for processing uploaded images with smart features.""" @@ -85,19 +125,12 @@ class ImageProcessor: self.logger.info(msg) print(f">>> {msg}") # Explicit print for visibility - # Extract EXIF orientation (but don't apply yet) + # Extract EXIF orientation (but don't apply it) exif_orientation = self._extract_exif_orientation(image) + if exif_orientation and exif_orientation > 1: + self.logger.info(f"[PROCESS] Note: Image has EXIF orientation {exif_orientation}, will be applied after crop") - # Transform crop_bounds from EXIF-applied space to raw space - if crop_bounds and exif_orientation and exif_orientation > 1: - crop_bounds = self._transform_crop_bounds_by_exif( - crop_bounds, exif_orientation, original_size[0], original_size[1] - ) - msg = f"[CROP] EXIF {exif_orientation} transform applied: {crop_bounds}" - self.logger.info(msg) - print(f">>> {msg}") - - # Smart cropping (on raw, un-rotated image so crop_bounds match AI analysis) + # Smart cropping (on raw image - crop_bounds come from Gemini analyzing same raw image) cropped_image = image crop_size = None text_angle = None @@ -213,44 +246,6 @@ class ImageProcessor: self.logger.debug(f"Could not extract EXIF orientation: {e}") return None - def _transform_crop_bounds_by_exif( - self, crop_bounds: Dict, orientation: int, raw_width: int, raw_height: int - ) -> Dict: - """ - Transform crop_bounds from EXIF-applied space to raw image space. - - Gemini analyzes images with EXIF applied, returning crop_bounds in that space. - We need to transform them back to raw image coordinates before cropping. - - Args: - crop_bounds: {x, y, width, height} in EXIF-applied space - orientation: EXIF orientation value (1-8) - raw_width: raw image width (before EXIF) - raw_height: raw image height (before EXIF) - - Returns: - Transformed crop_bounds in raw image space - """ - if orientation == 1: - # No rotation, use as-is - return crop_bounds - elif orientation == 6: - # Rotate 90 CW: raw (w×h) appears as displayed (h×w) - # Transform from displayed (h×w) back to raw (w×h) - x, y, w, h = crop_bounds['x'], crop_bounds['y'], crop_bounds['width'], crop_bounds['height'] - # After 90° CW: (x,y) in raw → (raw_height - y, x) in displayed - # Reverse: (x,y) in displayed → (y, raw_width - x) in raw - return { - 'x': y, - 'y': raw_width - x - w, - 'width': h, - 'height': w - } - else: - # Other orientations: return as-is (TODO: implement if needed) - self.logger.warning(f"EXIF orientation {orientation} not supported for crop_bounds transform, using as-is") - return crop_bounds - def _rotate_by_orientation( self, image: Image.Image, orientation: int ) -> Image.Image: