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 pathlib import Path
|
||||||
from .. import models, schemas, auth
|
from .. import models, schemas, auth
|
||||||
from ..database import get_db
|
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 ..services.image_storage import save_image, get_unique_filename
|
||||||
from ..logger import log
|
from ..logger import log
|
||||||
|
|
||||||
@@ -104,8 +104,12 @@ async def extract_label(
|
|||||||
detail="File exceeds 10MB limit."
|
detail="File exceeds 10MB limit."
|
||||||
)
|
)
|
||||||
|
|
||||||
log.info(f"[EXTRACT] Sending {len(contents)} bytes to Gemini for analysis")
|
# Strip EXIF orientation so Gemini analyzes raw image (not rotated)
|
||||||
result = extract_label_info(contents, mode=mode)
|
# 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__}")
|
log.info(f"[EXTRACT] Gemini returned: {type(result).__name__}")
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
|||||||
@@ -22,6 +22,46 @@ import numpy as np
|
|||||||
logger = logging.getLogger(__name__)
|
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:
|
class ImageProcessor:
|
||||||
"""Service for processing uploaded images with smart features."""
|
"""Service for processing uploaded images with smart features."""
|
||||||
|
|
||||||
@@ -85,19 +125,12 @@ class ImageProcessor:
|
|||||||
self.logger.info(msg)
|
self.logger.info(msg)
|
||||||
print(f">>> {msg}") # Explicit print for visibility
|
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)
|
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
|
# Smart cropping (on raw image - crop_bounds come from Gemini analyzing same raw image)
|
||||||
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)
|
|
||||||
cropped_image = image
|
cropped_image = image
|
||||||
crop_size = None
|
crop_size = None
|
||||||
text_angle = None
|
text_angle = None
|
||||||
@@ -213,44 +246,6 @@ class ImageProcessor:
|
|||||||
self.logger.debug(f"Could not extract EXIF orientation: {e}")
|
self.logger.debug(f"Could not extract EXIF orientation: {e}")
|
||||||
return None
|
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(
|
def _rotate_by_orientation(
|
||||||
self, image: Image.Image, orientation: int
|
self, image: Image.Image, orientation: int
|
||||||
) -> Image.Image:
|
) -> Image.Image:
|
||||||
|
|||||||
Reference in New Issue
Block a user