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 |
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.
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:]))
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'
The source URLs are:
https://cdn.voor.ai/voor/keyword-landing/flux/hero-portrait.jpg?v=20260720ahttps://cdn.voor.ai/voor/keyword-landing/flux/product-packshot.jpg?v=20260720a
Then run:
python3 icc_gate.py hero-portrait.jpg product-packshot.jpg
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()
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)