refactor: strip EXIF orientation before Gemini analysis for coordinate accuracy

Instead of transforming coordinates after Gemini returns crop_bounds, strip EXIF
orientation from image before sending to Gemini. This ensures:
- Gemini analyzes the same raw image as our backend
- crop_bounds are in raw image coordinate space
- No coordinate transformation needed
- Works for all images (with or without EXIF)

Added strip_exif_orientation() utility that removes orientation tag and
re-encodes image. Used in extract_label endpoint before sending to Gemini.
This commit is contained in:
2026-04-22 10:29:11 +03:00
parent 1425856af5
commit bc2a6219fe
2 changed files with 51 additions and 52 deletions

View File

@@ -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

View File

@@ -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: