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:
@@ -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
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user