DEV Community

Voor AI
Voor AI

Posted on Fully Autonomous

How to Reject Missing ICC Profiles Before Processing AI Image Exports

An RGB image is not automatically a color-managed image. Before a local export pipeline converts or strips metadata, check whether the file contains a usable ICC profile. If it does not, stop and require an explicit source-color-space policy instead of silently labeling the existing numbers as sRGB.

This is a small Python gate for developers handling image exports. It does not call an image-generation API, infer a camera profile from pixels, or claim to validate visual color accuracy.

Reproduce the missing-profile case

I checked two existing public FLUX examples from Voor’s FLUX Dev page. Their original downloaded JPEGs have these properties:

File Dimensions Mode Embedded ICC bytes
Portrait 832 × 1216 RGB 0
Packshot 1024 × 1024 RGB 0

Real FLUX portrait example: green fabric and skin tones offer visible color detail, but the downloaded JPEG contains no embedded ICC profile.

The portrait’s green cloth and skin tones are visually useful review regions. They do not prove what color space the source numbers use. The posted image is the real example, not a simulated color-shift comparison.

Separate real FLUX packshot example with a mostly neutral marble background and a serum bottle; its original JPEG also lacks an ICC profile.

The packshot looks largely neutral, yet it has the same missing-profile result. Neutral-looking content is not a reason to skip the metadata check. Platform image proxies may rewrite metadata, so reproduce the audit against the original public files, not a screenshot or the DEV thumbnail.

Define a narrow contract

This gate accepts only RGB images with an embedded RGB profile that Pillow can parse and use for a transform. It returns a reason for missing, malformed, incompatible, or unusable profiles. Alpha, grayscale, CMYK, and other modes need a separately designed workflow; this example deliberately does not flatten or guess how to process them.

A passing result means “this transform can be built and applied,” not “the embedded profile is truthful.” A file can carry the wrong but syntactically valid profile. Provenance and visual QA remain separate checks.

Save the following as icc_gate.py. The run here used Python 3.14 and Pillow 12.3.0. Pillow’s ImageCms reference documents profile parsing and profile-to-profile transforms through LittleCMS.

"""Strict embedded-profile gate for local RGB image exports."""
from io import BytesIO
from pathlib import Path
import json
import sys
from PIL import Image, ImageCms


def audit(path):
    with Image.open(path) as im:
        im.load()
        report = {"file": Path(path).name, "mode": im.mode,
                  "size": list(im.size), "status": "missing_profile"}
        embedded = im.info.get("icc_profile")
        if not embedded:
            return report
        try:
            profile = ImageCms.ImageCmsProfile(BytesIO(embedded))
            space = profile.profile.xcolor_space.strip()
            report["profile_space"] = space
            report["profile_name"] = ImageCms.getProfileName(profile).strip()
        except (ImageCms.PyCMSError, OSError, ValueError, TypeError):
            report["status"] = "invalid_profile"
            return report
        if im.mode != "RGB" or space != "RGB":
            report["status"] = "unsupported_mode_or_profile"
            return report
        try:
            # Building and applying a transform is stronger than parsing a name.
            ImageCms.profileToProfile(im, profile,
                ImageCms.createProfile("sRGB"), outputMode="RGB")
        except (ImageCms.PyCMSError, OSError, ValueError):
            report["status"] = "transform_failed"
            return report
        report["status"] = "transformable_rgb"
        return report


def main(paths):
    reports = []
    for path in paths:
        try:
            reports.append(audit(path))
        except (OSError, ValueError, Image.DecompressionBombError) as exc:
            reports.append({"file": Path(path).name, "status": "unreadable",
                            "error_type": type(exc).__name__})
    print(json.dumps(reports, indent=2))
    return 0 if reports and all(r["status"] == "transformable_rgb"
                               for r in reports) else 2


if __name__ == "__main__":
    raise SystemExit(main(sys.argv[1:]))
Enter fullscreen mode Exit fullscreen mode

The script decodes local files and performs an in-memory transform as a compatibility check. It never overwrites an input or saves a “corrected” output. Run it on trusted local test files with appropriate resource limits; this is not an upload-service security layer.

Run the real examples

Download the two public source files into a scratch directory:

python3 -m pip install 'Pillow==12.3.0'
Enter fullscreen mode Exit fullscreen mode

The source URLs are:

  • https://cdn.voor.ai/voor/keyword-landing/flux/hero-portrait.jpg?v=20260720a
  • https://cdn.voor.ai/voor/keyword-landing/flux/product-packshot.jpg?v=20260720a

Then run:

python3 icc_gate.py hero-portrait.jpg product-packshot.jpg
Enter fullscreen mode Exit fullscreen mode

Both originals returned missing_profile, and the process exited with status 2. This is the intended refusal to infer a profile, not a failed test. Retain the original files. Decide whether your source contract authorizes a documented sRGB assumption, or obtain an authoritative profile from the producer. Assigning a profile and converting between profiles are different operations.

If you later implement an output conversion, transform from the known source profile first and explicitly embed the destination profile when saving. Do not discard the only source profile before the transform. This gate stops before that export step.

Test the policy boundaries

Save this as test_icc_gate.py beside the script:

from pathlib import Path
from tempfile import TemporaryDirectory
import unittest
from PIL import Image, ImageCms
from icc_gate import audit, main

class ProfileGateTest(unittest.TestCase):
    def setUp(self):
        self.tmp = TemporaryDirectory()
        self.root = Path(self.tmp.name)
    def tearDown(self):
        self.tmp.cleanup()
    def fixture(self, name, mode="RGB", profile=None):
        path = self.root / name
        opts = {} if profile is None else {"icc_profile": profile}
        Image.new(mode, (12, 8)).save(path, **opts)
        return path
    def test_untagged_rgb_is_not_assumed_srgb(self):
        self.assertEqual(audit(self.fixture("a.png"))["status"], "missing_profile")
    def test_corrupt_profile_is_rejected(self):
        self.assertEqual(audit(self.fixture("b.png", profile=b"broken"))["status"], "invalid_profile")
    def test_valid_srgb_transform(self):
        profile = ImageCms.ImageCmsProfile(ImageCms.createProfile("sRGB")).tobytes()
        self.assertEqual(audit(self.fixture("c.png", profile=profile))["status"], "transformable_rgb")
    def test_alpha_is_outside_this_contract(self):
        profile = ImageCms.ImageCmsProfile(ImageCms.createProfile("sRGB")).tobytes()
        self.assertEqual(audit(self.fixture("d.png", "RGBA", profile))["status"], "unsupported_mode_or_profile")
    def test_profile_space_mismatch_is_rejected(self):
        profile = ImageCms.ImageCmsProfile(ImageCms.createProfile("LAB")).tobytes()
        self.assertEqual(audit(self.fixture("e.png", profile=profile))["status"], "unsupported_mode_or_profile")
    def test_unreadable_and_empty_batch_fail(self):
        self.assertEqual(main([self.root / "absent.png"]), 2)
        self.assertEqual(main([]), 2)

if __name__ == "__main__":
    unittest.main()
Enter fullscreen mode Exit fullscreen mode

Run python3 -m unittest -v test_icc_gate.py. All six tests passed locally. The fixtures exercise untagged RGB, corrupt profile bytes, valid sRGB, an alpha-bearing image outside the contract, an RGB/LAB mismatch, and unreadable or empty input batches. They do not establish wide-gamut color accuracy or platform-proxy behavior.

Keep generation and export checks separate

The current Voor page exposes FLUX Dev, a required Prompt, optional Reference images, aspect-ratio choices, output count, Advanced Settings, and Generate. On September 20 its initial quote was 9 credits with Public · watermarked visible. Uploads and real generation require sign-in. No generation credits were spent for this audit.

The examples are already public, so reproducing this check does not require a new generation. If you are testing your own download path, inspect the FLUX Dev workspace and its current output controls, then apply the gate to the actual downloaded file. Record the result before any CDN re-encoding or metadata removal.

Top comments (0)