Repository navigation
Conversation
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.
Contributor
|
Caution The consumer version of Gemini Code Assist on GitHub has been sunset. All code review activity has officially ceased. |
jetd1
marked this pull request as ready for review
August 4, 2026 02:50
Contributor
|
Caution The consumer version of Gemini Code Assist on GitHub has been sunset. All code review activity has officially ceased. |
4 of 5 tasks
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: |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #33464
Motivation
JPEG images produced by phone cameras (iOS/Android) routinely carry an EXIF
Orientationtag — typically
6(rotate 90° CW). Browsers, image viewers and OS thumbnailers apply thistag 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 thebottom". 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.pydecode user images, and all three ignoredEXIF 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 notenough.
is_jpeg_with_cuda()routes every JPEG totorchvision.io.decode_jpegon a CUDAserver and returns before
Image.openis ever reached. nvJPEG decodes the bitstream and neverconsults EXIF. Since phone photos are JPEG, this is exactly the path real rotated images take.
Fix: read the orientation from the header (
Image.openis lazy, so this parses header + EXIFonly, no pixel decode) and, when it is non-trivial, fall through to the PIL path rather than
using nvJPEG. This keeps PIL's
exif_transposeas the single ground-truth implementationinstead of hand-rolling tensor transposes for all 8 orientation values. Images with
orientation
1or no EXIF — the overwhelming majority — still take the fast GPU path, sothere is no throughput regression for the common case.
2.
_load_image()CPU fallback.Image.opendoes not auto-apply EXIF;ImageOps.exif_transposeis the canonical normalization.
3.
load_image()direct-PIL branch.isinstance(image_file, Image.Image)returned theimage untouched, bypassing
_load_image()entirely.A small helper
_get_exif_orientation()reads the tag from raw bytes and returns1formissing, malformed, or out-of-range EXIF;
_safe_exif_transpose()wraps the transpose so thatbroken 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.pyalready callsPIL.ImageOps.exif_transpose. The diffusion side normalizes orientation; the VLM serving pathdid 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
tRNStransparency half of thevLLM 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:
PIL.ImageOps.exif_transpose(a parity assertion, so no transpose/flip sign error can survive)
decode_jpegmust not be called whenorientation is non-trivial, and must still be called when it is trivial
image_sizeorientation values, and non-JPEG input
_safe_exif_transposereturning the original image on failure, and short-circuitingwithout a copy when no rotation is required