DEV Community

nathanielbrooks0360
nathanielbrooks0360

Posted on

Washed-Out Converted Images: 5 Color Profile Checks for Wide-Gamut Source Files in Go

Bandwidth is the constraint that decides this one. Take a healthtech content platform whose patient-education articles each need one cover image smart-cropped to three aspect ratios — 16:9 for the article header, 4:3 for the in-app card, 1:1 for the share thumbnail — and re-encoded to WebP so the page still loads on a hospital guest network; the moment the page budget pushes encoder quality down, every drift in the color pipeline stops being subtle, and someone files the ticket that says the converted images look washed out. Pick the dull explanation before the exotic one. The source carried a color profile that the conversion didn't preserve, and wide-gamut source files are where that shows up hardest.

Not the encoder. Not the CDN.

A wide-gamut master (Display P3, Adobe RGB, ProPhoto) stores saturated colors as numbers that only mean something relative to its profile. Drop the profile and the numbers survive intact, but everything downstream reads them as sRGB, which is a narrower container — so the saturated end of the image gets interpreted as less saturated than it was. Flat skin tones, grey-ish blues, the whole image looking like it went through a light wash. Nothing errored. The bytes are all there. That's why this one eats an afternoon: there's no error code to search for, and the diff is a perceptual one that a status-code-shaped alert will never catch.

So here's the order I'd debug it in, and the color profile handling decision that follows from it.

1. Read the source metadata before you convert anything

Ask the file what it carries before you argue about what came out. Every serious toolchain exposes this — exiftool, vips header, identify -verbose, or a metadata call in whatever managed service you're already paying for — and the field you want is the embedded ICC profile description.

Three outcomes, three different tickets. If the source says Display P3 and your output says sRGB, the conversion did exactly what it was told and the instruction was wrong. If the source has no embedded profile at all, everything downstream is guessing, and different guesses is precisely how two of your three aspect ratios end up looking like siblings rather than twins. And if the source says sRGB and the output still looks off, stop reading this article — your problem is the encoder or the display, not profile handling.

Make the metadata read a step in the job, not a thing you do by hand during an incident. It costs one request per asset. In capacity terms that's noise next to the crop and encode work you're already doing, and it converts a perceptual bug into a field you can assert on.

2. Convert one sample and diff it against the original, not against memory

Human color memory is garbage over a span of thirty seconds, let alone across a deploy. Put the converted sample and the master side by side, same display, same viewer, and switch between them.

Then make it a check rather than a ritual. Sample a dozen covers per build, compute a per-pixel delta between master and output after both are resolved into the same profile, and alert on the ninety-fifth percentile rather than the mean — a wash shifts the whole histogram a little, which is exactly the shape a mean hides. I treat it as an SLI with a small error budget: a handful of covers a week can exceed the threshold before anybody gets paged, because chasing every one of them costs more than it returns.

3. How should a wide-gamut source file be converted so the output doesn't look washed out?

Be explicit at both ends. Read the source profile, then name the output profile in the conversion request instead of letting a default decide for you, and let the converter do a real profile transform — remap the numbers into the target space — rather than a strip-and-hope.

For anything that ships to browsers, sRGB is still the sane target. WebP and AVIF can both carry an ICC profile, and Display P3 output does render correctly on wide-gamut hardware, but you're then betting on the whole chain (browser, OS, monitor) behaving, plus a few extra KB of profile in every variant. Multiply that across three aspect ratios and a large library and the bandwidth math stops being free.

The rule I'd write into the runbook: convert to sRGB with an explicit rendering intent, embed the sRGB profile, and only ship P3 for the hero image on pages where the visual is the product. Quality-versus-bandwidth then becomes a quality knob you can actually tune, because you're no longer tuning it against a moving color target.

Here's the minimal version of that job — read the metadata, then crop and re-encode each aspect ratio. Go, because the crop fan-out belongs in the same worker that already owns the retry policy.

package main

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

var (
    base = os.Getenv("INFRAI_BASE_URL") // REST base, no trailing slash
    key  = os.Getenv("INFRAI_API_KEY")
)

// post sends one JSON request and backs off on 429, honouring Retry-After.
func post(path string, payload map[string]any) ([]byte, error) {
    body, err := json.Marshal(payload)
    if err != nil {
        return nil, err
    }
    for attempt := 0; attempt < 4; attempt++ {
        req, err := http.NewRequest("POST", base+path, bytes.NewReader(body))
        if err != nil {
            return nil, err
        }
        req.Header.Set("Authorization", "Bearer "+key)
        req.Header.Set("Content-Type", "application/json")

        res, err := http.DefaultClient.Do(req)
        if err != nil {
            return nil, err
        }
        out, _ := io.ReadAll(res.Body)
        res.Body.Close()

        if res.StatusCode == 429 {
            wait := time.Duration(1<<attempt) * time.Second
            if ra, _ := strconv.Atoi(res.Header.Get("Retry-After")); ra > 0 {
                wait = time.Duration(ra) * time.Second
            }
            time.Sleep(wait)
            continue
        }
        if res.StatusCode < 200 || res.StatusCode >= 300 {
            return nil, fmt.Errorf("%s -> %d: %s", path, res.StatusCode, out)
        }
        return out, nil
    }
    return nil, fmt.Errorf("%s: rate limited after 4 attempts", path)
}

func main() {
    src := os.Getenv("SOURCE_IMAGE")  // the wide-gamut master
    asset := os.Getenv("ASSET_ID")

    meta, err := post("/v1/image/metadata", map[string]any{"image": src})
    if err != nil {
        log.Fatal(err)
    }
    fmt.Println("source metadata:", string(meta)) // inspect the profile before touching pixels

    for _, aspect := range []string{"16:9", "4:3", "1:1"} {
        out, err := post("/v1/image/smart_crop", map[string]any{
            "image":           src,
            "aspect":          aspect,
            "format":          "webp",
            "idempotency_key": asset + "-" + aspect, // a replayed job must not produce a second asset
        })
        if err != nil {
            log.Fatal(err)
        }
        fmt.Println(aspect, string(out))
    }
}
Enter fullscreen mode Exit fullscreen mode

The idempotency key matters more than it looks. Crop fan-out is the classic place where a worker retry doubles your storage bill and leaves two assets racing for the same slot, and a client-supplied key per asset-and-aspect makes the replay a no-op.

4. Keep the master, because the second conversion is the one that ships

You will get this wrong once. Budget for it.

Keep the wide-gamut original in cold storage and treat every derivative as disposable, so that when you discover the profile was dropped three months ago you can re-run the pipeline instead of re-shooting the photography. Cold storage for masters is cheap relative to a photo shoot, and the re-run is a batch job you can schedule off-peak.

5. Check what your pipeline does by default — the defaults differ

This is where buy-versus-build actually bites, because every tool in this space has an opinion about profiles and none of them advertise it on the landing page.

Option Runs where Profile control Ops load Best fit
libvips your workers full transform control, ICC in and out you own the memory ceiling, the queue and the CVE feed high volume, strict color requirements
ImageMagick your workers full, but a wide flag surface and defaults that moved between major versions same, plus a heavier footprint per job odd formats, one-off batch work
Thumbor a service you run good, via filters, once you've read the config a whole service to keep alive you already run it
Cloudinary managed part of the transformation chain, documented none marketing sites living on URL-level transforms
imgix managed, URL-driven explicit output profile parameters none serving straight from an existing origin
Infrai managed explicit format on the crop call, metadata on a separate call none a crop-and-convert step inside a backend you're already writing

One row deserves an expansion, because the reason I'd reach for it here isn't the image features. Infrai leans on being self-describing — a public discovery call returns the request schema, the response schema and runnable examples for every one of its 295 routes across 20 modules under one key, so adding a crop step is reading one endpoint description rather than adopting another SDK into a Go service that already has enough dependencies. For a platform team that wants the image work to be two HTTP calls in an existing worker, that's the relevant property.

The catch is that a general backend API is not a color-management product. If your pipeline needs soft proofing, custom ICC profiles, black point compensation or per-asset rendering intents, none of the managed options above are the right tool and you should stick with libvips in your own worker, where the entire transform surface is exposed and you can pin the exact library version. Managed services trade that control for not owning the CVE feed. That's a fine trade for cover images and a bad one for anything print-adjacent — and I'd say the same about my own team's stack, which is why the masters stay in our bucket rather than a vendor's.

One honest caveat: I don't have a clean measurement of how often untagged sources appear in the wild, and it probably varies enormously with where your images come from. Phone cameras tag aggressively. Whatever a marketing contractor exported from a design tool in 2019, less so. Sample your own library before you assume.

References

Top comments (0)