Skip to content

[Fix] multimodal: apply EXIF orientation when decoding images - #33449

Closed
jetd1 wants to merge 1 commit into
sgl-project:mainfrom
jetd1:fix/anthropic-exif-orientation
Closed

jetd1 wants to merge 1 commit into
sgl-project:mainfrom
jetd1:fix/anthropic-exif-orientation

Conversation

@jetd1

@jetd1 jetd1 commented Aug 4, 2026 •

Copy link
Copy Markdown
Contributor

Fixes #33464

Motivation

JPEG images produced by phone cameras (iOS/Android) routinely carry an EXIF Orientation
tag — typically 6 (rotate 90° CW). Browsers, image viewers and OS thumbnailers apply this
tag automatically, but SGLang's image ingestion never does. The model therefore receives raw
sensor pixels while the human who uploaded the photo sees a rotated image.

The failure is silent: no error, no warning, just degraded OCR / spatial / grounding quality,
plus a human-vs-model visual mismatch.

Reproduction: a 640×480 JPEG with a red bar on the left edge and a green bar on the bottom
edge, tagged Orientation=6, is described by a served VLM as "red on the left, green on the
bottom"
. A human viewing the same file sees red on top and green on the left. Baking
the rotation into the pixels makes the model answer correctly.

What this changes

Three paths in python/sglang/srt/utils/common.py decode user images, and all three ignored
EXIF orientation. Fixing only one leaves a live bypass, so this PR covers all three:

1. _load_image() nvJPEG branch — the primary path, and the reason a CPU-only fix is not
enough.
is_jpeg_with_cuda() routes every JPEG to torchvision.io.decode_jpeg on a CUDA
server and returns before Image.open is ever reached. nvJPEG decodes the bitstream and never
consults EXIF. Since phone photos are JPEG, this is exactly the path real rotated images take.

Fix: read the orientation from the header (Image.open is lazy, so this parses header + EXIF
only, no pixel decode) and, when it is non-trivial, fall through to the PIL path rather than
using nvJPEG. This keeps PIL's exif_transpose as the single ground-truth implementation
instead of hand-rolling tensor transposes for all 8 orientation values. Images with
orientation 1 or no EXIF — the overwhelming majority — still take the fast GPU path, so
there is no throughput regression for the common case.

2. _load_image() CPU fallback. Image.open does not auto-apply EXIF; ImageOps.exif_transpose
is the canonical normalization.

3. load_image() direct-PIL branch. isinstance(image_file, Image.Image) returned the
image untouched, bypassing _load_image() entirely.

A small helper _get_exif_orientation() reads the tag from raw bytes and returns 1 for
missing, malformed, or out-of-range EXIF; _safe_exif_transpose() wraps the transpose so that
broken metadata can never turn a decodable image into an exception, and short-circuits when no
rotation is needed so unrotated (and multi-frame) images are not eagerly copied.

Consistency with the rest of the repo

python/sglang/multimodal_gen/runtime/utils/vision.py already calls
PIL.ImageOps.exif_transpose. The diffusion side normalizes orientation; the VLM serving path
did not. This PR removes that inconsistency.

Prior art

vLLM shipped the equivalent fix in #44974 ("Fix image EXIF orientation and tRNS transparency
handling") and then had to follow up a month later with #47566 ("Normalize direct PIL image
inputs") because the direct-PIL branch was missed the first time. This PR deliberately covers
that branch in the same pass.

Scope is intentionally limited to EXIF orientation; the PNG tRNS transparency half of the
vLLM change is a separate concern and is not included here.

Tests

test/registered/unit/multimodal/test_base_processor_image_decode.py — CPU-only, no server,
no model load:

  • all 8 orientation values, asserting pixel-identical output to PIL.ImageOps.exif_transpose
    (a parity assertion, so no transpose/flip sign error can survive)
  • nvJPEG routing, mocked so it needs no CUDA: decode_jpeg must not be called when
    orientation is non-trivial, and must still be called when it is trivial
  • the direct-PIL branch across all 8 orientations, including the returned image_size
  • no-EXIF, malformed bytes, corrupt EXIF inside an otherwise valid JPEG, out-of-range
    orientation values, and non-JPEG input
  • _safe_exif_transpose returning the original image on failure, and short-circuiting
    without a copy when no rotation is required

JPEG images from phone cameras (iOS/Android) routinely carry an EXIF
Orientation tag (typically 6 = 90° CW). Browsers and image viewers
auto-apply this tag, but SGLang's image decoding pipeline did not — the
model received raw sensor pixels while the human saw a rotated image,
causing silent degradation of OCR, spatial, and grounding tasks.

Three code paths in python/sglang/srt/utils/common.py are affected:

1. _load_image() GPU branch: is_jpeg_with_cuda() routes every CUDA-served
   JPEG to torchvision.io.decode_jpeg, which returns raw CHW pixels
   without EXIF correction. This is the primary path for phone photos on
   CUDA servers. Fix: read the EXIF orientation from the header (cheap —
   PIL.Image.open is lazy) and when orientation is not 1/absent, fall
   through to the PIL path instead of using nvJPEG. This avoids hand-coded
   tensor transpose/flip for all 8 orientation values and keeps PIL's
   exif_transpose as the single ground-truth implementation.

2. _load_image() CPU fallback: Image.open was returned without applying
   exif_transpose. Fix: call PIL.ImageOps.exif_transpose before returning.

3. load_image() direct-PIL branch: isinstance(image_file, Image.Image)
   returned the image as-is, bypassing _load_image() entirely. Fix: apply
   ImageOps.exif_transpose to the incoming PIL Image.

A new helper _get_exif_orientation() safely reads the orientation tag
from raw bytes, returning 1 (no rotation) for missing/malformed/out-of-
range EXIF. It never raises — EXIF parsing failure degrades to "no
rotation".

The repo's own diffusion pipeline (python/sglang/multimodal_gen/runtime/
utils/vision.py) already calls PIL.ImageOps.exif_transpose, but the VLM
serving path did not — this fixes that inconsistency.

Prior art: vLLM shipped the equivalent fix in PR sgl-project#44974 (merged
2026-06-09) and a follow-up PR sgl-project#47566 (merged 2026-07-03) for the
direct-PIL branch that was missed initially. This change covers all
three touch points in one pass.

Tests: added TestExifOrientation to
test/registered/unit/multimodal/test_base_processor_image_decode.py
covering all 8 EXIF orientation values (parity with
PIL.ImageOps.exif_transpose), no-EXIF, malformed-EXIF, out-of-range
orientation, non-JPEG inputs, and the direct-PIL branch. CPU-only, no
server, no model load.
@gemini-code-assist

Copy link
Copy Markdown
Contributor

Caution

The consumer version of Gemini Code Assist on GitHub has been sunset. All code review activity has officially ceased.

@jetd1
jetd1 marked this pull request as ready for review August 4, 2026 02:50
@gemini-code-assist

Copy link
Copy Markdown
Contributor

Caution

The consumer version of Gemini Code Assist on GitHub has been sunset. All code review activity has officially ceased.

@github-actions

github-actions Bot commented Nov 4, 2026

Copy link
Copy Markdown
Contributor

Thanks @jetd1. Closing this because it has had no updates in 91 days.

Reopen it if the work is still relevant.

Some directories moved recently, so an older branch may need retargeting:
sgl-kernel/ -> python/sglang/kernels/aot/, python/sglang/jit_kernel/
-> python/sglang/kernels/jit/, docs/ -> docs/docs/ (.mdx),
bench_serving.py -> benchmark/serving.py, test/srt/ -> test/registered/.

@github-actions github-actions Bot closed this Nov 4, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug] EXIF orientation is never applied when decoding images for VLM serving

1 participant