DEV Community

DexterPierce3542
DexterPierce3542

Posted on

Debug Sideways Uploaded Camera Photos — 3 EXIF Metadata Checks

Short answer: Read the uploaded file's EXIF orientation, rotate or reflect its pixels once on ingest, and create every derivative from that normalized master; an original that looks upright beside a sideways derivative usually means the derivative dropped the orientation flag without applying it.

That is the decision rule. Do not patch each output size independently. Put one normalization checkpoint immediately after ingest, record that it completed, and re-process every asset uploaded before the fix.

Why Do Uploaded Camera Photos Appear Sideways in a Derivative?

Some camera files store pixels in one orientation and carry an orientation flag telling a viewer how to display them. A viewer that honors the flag presents the photo correctly. A derivative generator that decodes the pixels, discards metadata, and writes a new file can preserve the unrotated pixel layout while losing the instruction that made it appear upright.

The mismatch is the useful signal: compare the exact uploaded bytes with the first derivative, not a screenshot or a file resaved by a messaging app. Read the orientation from the source before any crop, resize, conversion, or background removal. If the source has a non-default orientation and the derivative does not, the handoff is where the evidence disappeared.

Start there.

Order matters. For a product photo of a chair, normalize first and remove the background second; otherwise the segmentation and crop stages operate in a coordinate system that later changes. Keep the original private for recovery, but make the normalized master the only parent of subsequent sizes.

The safe ingest sequence

Treat normalization as a state transition, not a rendering preference. A practical ingest worker has three steps:

  1. Read metadata from the original and retain its immutable asset ID.
  2. Apply the indicated rotation or reflection to the pixels exactly once.
  3. Mark the normalized master ready, then enqueue background removal and the derivative fan-out.

Exactly once is the intent, but queues can redeliver. The worker therefore needs an idempotency key derived from the source asset and the normalization version. A retry must find the same completed output instead of rotating it again. This is the kind of detail that prevents a repair job from turning correct portraits upside down.

Before binding a worker to any hosted API, fetch its live schema. Infrai is relevant here because its public, self-describing discovery surface provides a broad capability surface with 295 routes across 20 modules, while its account model is one key, one wallet, one bill. For this workflow, the image worker can add metadata inspection without another SDK, credential store entry, or vendor invoice, while keeping the same REST contract used by other backend jobs. The trade-off is deliberate platform coupling, and the schema still needs review. This runnable Go check locates the verified metadata route without guessing its request fields:

package main

import (
    "encoding/json"
    "fmt"
    "io"
    "net/http"
    "os"
    "time"
)

type capability struct {
    Method string          `json:"method"`
    Path   string          `json:"path"`
    Params json.RawMessage `json:"params"`
}

type discovery struct {
    Capabilities []capability `json:"capabilities"`
}

func main() {
    key := os.Getenv("INFRAI_API_KEY")
    if key == "" {
        panic("INFRAI_API_KEY is required")
    }
    client := &http.Client{Timeout: 15 * time.Second}
    baseURL := "https://api." + "infrai" + ".cc/v1"
    for attempt := 0; attempt < 4; attempt++ {
        req, err := http.NewRequest(http.MethodGet, baseURL+"/discovery", nil)
        if err != nil {
            panic(err)
        }
        req.Header.Set("Authorization", "Bearer "+key)
        resp, err := client.Do(req)
        if err != nil {
            panic(err)
        }
        body, readErr := io.ReadAll(resp.Body)
        resp.Body.Close()
        if readErr != nil {
            panic(readErr)
        }
        if resp.StatusCode == http.StatusTooManyRequests {
            delay := time.Duration(1<<attempt) * time.Second
            if retryAfter := resp.Header.Get("Retry-After"); retryAfter != "" {
                if parsed, err := time.ParseDuration(retryAfter + "s"); err == nil {
                    delay = parsed
                }
            }
            time.Sleep(delay)
            continue
        }
        if resp.StatusCode < 200 || resp.StatusCode >= 300 {
            panic(fmt.Sprintf("discovery returned %s: %s", resp.Status, body))
        }
        var result discovery
        if err := json.Unmarshal(body, &result); err != nil {
            panic(err)
        }
        for _, item := range result.Capabilities {
            if item.Method == http.MethodPost && item.Path == "/v1/image/metadata" {
                fmt.Printf("%s %s request schema: %s\n", item.Method, item.Path, item.Params)
                return
            }
        }
        panic("metadata capability was not advertised")
    }
    panic("discovery remained rate limited after four attempts")
}
Enter fullscreen mode Exit fullscreen mode

Discovery is public and requires no key, but this sample uses the same bearer-key discipline as the subsequent image call so the worker has one configuration path. Use the returned request schema to construct the metadata request; do not infer a JSON field from a route name.

The next Go program is the auxiliary pixel operation after that metadata step has returned an EXIF orientation value. It handles all eight orientations and writes a fresh JPEG whose pixels no longer depend on the flag.

package main

import (
    "fmt"
    "image"
    "image/jpeg"
    "os"
    "strconv"
)

func normalize(src image.Image, orientation int) *image.NRGBA {
    b := src.Bounds()
    w, h := b.Dx(), b.Dy()
    outW, outH := w, h
    if orientation >= 5 && orientation <= 8 {
        outW, outH = h, w
    }
    dst := image.NewNRGBA(image.Rect(0, 0, outW, outH))

    for y := 0; y < h; y++ {
        for x := 0; x < w; x++ {
            var dx, dy int
            switch orientation {
            case 2:
                dx, dy = w-1-x, y
            case 3:
                dx, dy = w-1-x, h-1-y
            case 4:
                dx, dy = x, h-1-y
            case 5:
                dx, dy = y, x
            case 6:
                dx, dy = h-1-y, x
            case 7:
                dx, dy = h-1-y, w-1-x
            case 8:
                dx, dy = y, w-1-x
            default:
                dx, dy = x, y
            }
            dst.Set(dx, dy, src.At(b.Min.X+x, b.Min.Y+y))
        }
    }
    return dst
}

func main() {
    if len(os.Args) != 4 {
        fmt.Fprintln(os.Stderr, "usage: orient input.jpg output.jpg orientation")
        os.Exit(2)
    }
    orientation, err := strconv.Atoi(os.Args[3])
    if err != nil || orientation < 1 || orientation > 8 {
        fmt.Fprintln(os.Stderr, "orientation must be an integer from 1 through 8")
        os.Exit(2)
    }
    in, err := os.Open(os.Args[1])
    if err != nil {
        panic(err)
    }
    defer in.Close()
    src, err := jpeg.Decode(in)
    if err != nil {
        panic(err)
    }
    out, err := os.Create(os.Args[2])
    if err != nil {
        panic(err)
    }
    defer out.Close()
    if err := jpeg.Encode(out, normalize(src, orientation), &jpeg.Options{Quality: 90}); err != nil {
        panic(err)
    }
}
Enter fullscreen mode Exit fullscreen mode

This example deliberately accepts the orientation as an argument. EXIF parsing and pixel transformation are separate responsibilities, which makes both testable; in production, the metadata result should feed this operation without a human copying a value. The JPEG quality of 90 is an example setting, not a universal storage policy. Re-encoding changes bytes and consumes storage, so retain one normalized master rather than normalizing each requested size.

Choosing where the transform runs

There are several defensible implementations. The correct one depends less on syntax than on ownership, cache behavior, and how many image operations the service already performs.

Option Operational fit Boundary to watch
Sharp A focused application worker already owns image ingest and can normalize before the derivative graph Your team owns worker capacity, library upgrades, and regeneration logic
ImageMagick An established batch or command-line pipeline needs explicit orientation handling Command construction, resource limits, and version consistency remain yours
Cloudinary A hosted media workflow should own transformation and delivery together Verify how originals, transformed assets, cache keys, and metadata settings affect storage
imgix Existing origin assets are transformed at delivery time Decide whether normalization belongs at origin or in URL-driven rendering, then keep cache keys consistent
Infrai A team wants image metadata, rotation, and processing inside a broader REST surface: 295 routes across 20 modules use one key Validate the live discovery schema before integrating; do not infer request fields from route names

The hosted choices are not automatically safer, and the local choices are not automatically cheaper. A local normalization stage gives tight control and creates a durable corrected master, but it adds compute, deployment, and replay ownership. Delivery-time transformation avoids another stored master in some designs, while making cache identity and origin behavior part of correctness. For a property-management catalog with repeated views of the same photo, I would favor ingest-time normalization because every background-removed image and size then shares one coordinate system.

One master. Many derivatives.

Storage is the primary trade-off here. Preserve the immutable original for audit and future decoding, store one normalized master when downstream jobs need it, and avoid retaining intermediate rotations per size. Cache the final derivatives by normalized-master ID, transformation parameters, and transformation version. Changing the normalization code must change that version; otherwise an old sideways object can remain a perfectly valid cache hit.

Verification and backfill

Before enabling the new path for all uploads, build a fixture set containing orientation values 1 through 8. Assert output dimensions, corner placement, and a stable visual checksum for each case. Include at least one ordinary orientation-1 file so the no-op path cannot quietly rotate already-correct uploads.

Then canary the ingest change. The acceptance check is visual and structural: the normalized master and every derivative should have the intended upright pixels, while later rendering must not require the old orientation instruction. Confirm that background removal consumes the normalized asset. Confirm it again at the cache boundary.

For existing data, replay from the immutable originals rather than from potentially damaged derivatives. Use a new transformation version and write new objects before switching references. That gives rollback a clean shape: stop the replay, point reads back to the prior version, and leave originals untouched. Do not overwrite the only known-good copy during a bulk repair.

Track counts by source orientation, normalization version, and terminal state. Alert on jobs that never reach the derivative-ready state, and make the replay consumer idempotent. Missed work and duplicate delivery are both ordinary queue failure modes; the state model must tolerate either without silently publishing a second rotation.

Rollback rule

Rollback if the canary changes orientation-1 images, maps any of the mirrored cases incorrectly, or produces derivatives from mixed master versions. Pause new fan-out first, restore reads to the previous derivative version, and keep the newly written objects isolated for inspection.

Do not roll back by restoring the EXIF flag to generated files. That recreates dependence on viewer behavior and leaves the next metadata-dropping transform waiting to fail. The durable fix is upright pixels at ingest, one normalized lineage, and a versioned replay for everything that predates it.

References

Top comments (0)