color-management
Manage color workflows with ICC profiles, working spaces, gamut mapping, and color science. Use when inspecting ICC profiles, converting between color spaces, checking gamut clipping, validating well-behaved working spaces, or troubleshooting color workflow issues with ImageMagic
Install
npx skills add https://github.com/magnus919/agent-skills/tree/main/color-management
claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install magnus919-agent-skills@llmmart
git clone https://github.com/magnus919/agent-skills.git
The skills CLI installs just this skill, for any of its supported agents. Claude Code installs the whole magnus919/agent-skills collection as a plugin from our marketplace. Git is the plain clone.
README
Color Management — ICC Profiles, Color Spaces & Gamut Analysis
Expert-level color management for open-source workflows. Covers ICC profiles, working spaces, gamut mapping, and color science fundamentals.
Why Install This Skill
When your agent loads this skill, it becomes a color management specialist who can:
- Inspect ICC profiles — check metadata, primaries, TRC curves, and well-behaved status
- Convert between color spaces — sRGB, ProPhotoRGB, ACEScg, Rec.2020, and more
- Analyze gamut — check which image colors fall outside a target color space
- Compare sRGB variants — understand differences between sRGB profiles from different vendors
- Calculate color difference — compute dE between images or color values
- Generate comprehensive color reports with visualizations
What You Get
| Directory | Purpose |
|---|---|
SKILL.md |
Quick reference table mapping tasks to scripts and references |
scripts/ |
6 Python scripts: profile inspect, well-behaved check, color space convert, gamut check, sRGB compare, color difference, color report |
references/ |
5 reference files: overview, ICC operations, working spaces, soft-proofing workflow, tool commands, monitor calibration, dcraw pipeline |
Triggers
Load this when inspecting ICC profiles, converting between color spaces, checking gamut clipping, validating working spaces, or troubleshooting color workflows.
Requirements
ImageMagick, Exiftool, ArgyllCMS, LittleCMS (all platform-independent). Python scripts require Python 3.8+.
Quick Start
Start with the setup and first workflow in SKILL.md, then use the linked resources for the specific task you need to complete.
Skill manifest
Color Management Skill
Expert-level guidance for ICC profile color management in open-source workflows. Covers color science fundamentals, working space selection, ICC profile operations, gamut analysis, and practical tool usage.
Quick Reference
| If you need to... | Load this reference | Run this script |
|---|---|---|
| Understand CIELAB, xyY, or color science basics | references/color-management-overview.md |
— |
| Inspect an ICC profile's metadata/primaries/TRC | — | scripts/icc-profile-inspect.py |
| Check if a profile is well-behaved (neutral gray axis) | references/working-spaces-reference.md |
scripts/well-behaved-check.py |
| Convert images between color spaces | references/icc-profile-operations.md |
scripts/color-space-convert.py |
| Check which image colors exceed a color space gamut | references/soft-proofing-workflow.md |
scripts/gamut-check.py |
| Compare sRGB profile variants | references/working-spaces-reference.md |
scripts/srgb-compare.py |
| Calculate color difference (dE) between two images | — | scripts/color-difference.py |
| Soft proof an image before conversion | references/soft-proofing-workflow.md |
scripts/gamut-check.py |
| Calibrate and profile a monitor | references/monitor-calibration-workflow.md |
— |
| Process raw files with dcraw | references/dcraw-pipeline.md |
— |
| Understand hex quantization and create well-behaved profiles | references/hex-quantization-and-profile-creation.md |
scripts/well-behaved-check.py |
| Generate a comprehensive color analysis report | — | scripts/color-report.py |
| Set up a GIMP LCH layer stack for separate tonality/color editing | assets/templates/lch-layer-stack.md |
— |
| Extract embedded ICC profile from an image | references/icc-profile-operations.md |
scripts/icc-profile-inspect.py |
| Understand conversion intents (relative/absolute/perceptual) | references/color-management-overview.md |
— |
| Set up Firefox for color-managed browsing | references/tool-reference.md |
— |
Required Tools
The scripts in this skill check for these tools and report if missing. Install what you need:
- ImageMagick (
convert,identify,compare,composite) — primary image processing - Exiftool — metadata and ICC profile extraction
- ArgyllCMS (
xicclu,iccgamut,colprof,cctiff) — professional color management - LittleCMS (
tificc,transicc) — ICC profile conversions
# macOS
brew install imagemagick exiftool argyllcms littlecms
# Debian/Ubuntu
sudo apt install imagemagick libimage-exiftool-perl argyll littlecms2
# Fedora
sudo dnf install ImageMagick perl-Image-Exiftool ArgyllCMS littlecms2
Python Scripts
All scripts are in scripts/. They require Python 3.8+ with these optional dependencies:
pip install numpy colour-science Pillow # optional but recommended
Each script has a --help flag:
python3 scripts/icc-profile-inspect.py --help
python3 scripts/well-behaved-check.py --help
Gotchas
sRGB profile variants
There is no single "sRGB" ICC profile. Different vendors produce profiles that differ in D50 adaptation, hexadecimal quantization, and TRC encoding. The ArgyllCMS sRGB.icm and the colord Shared sRGB.icm are both well-behaved; the Adobe/color.org/Windows 2000 variants are not well-behaved (they produce a false magenta cast at high bit depths). See references/working-spaces-reference.md and scripts/srgb-compare.py.
Matrix profiles cannot use perceptual intent
sRGB, AdobeRGB, ProPhotoRGB, and all other matrix working space profiles do NOT support perceptual or saturation intents. When you select perceptual intent for a matrix destination profile, you actually get relative colorimetric (which clips out-of-gamut colors). The "perceptual keeps all colors" mantra only applies when converting to LUT profiles (printer profiles, some monitor profiles).
Perceptual intent for sRGB is a lie
The oft-repeated statement "perceptual intent preserves colors when converting to sRGB" is false. sRGB is a matrix profile; it has no perceptual intent table. What actually happens is relative colorimetric intent, which clips. The only way to preserve out-of-gamut colors at floating point is to use LCMS2 unbounded mode.
Display-referred vs scene-referred
- Display-referred: RGB values bounded by 0.0-1.0. White (1,1,1) = maximum display brightness. ~9 stops dynamic range. Operations clamp.
- Scene-referred: No upper bound on RGB values. White has no special significance. 20+ stops possible (OpenEXR). Requires linear gamma. Operations do NOT clamp. Always check which model your editing pipeline uses.
Unbounded editing has limits
LCMS2 unbounded mode prevents clipping during conversions by allowing negative and >1.0 RGB values. This is useful for storage and transport, but many editing operations (Multiply, Divide, Screen, Levels gamma slider, Curves, color correction) produce meaningless results on out-of-gamut colors. Use integer precision if you don't want to manage out-of-gamut values.
Luminance vs Luma is not pedantry
- Luminance: calculated on linearized RGB (radiometrically correct). Uses sRGB-specific multipliers (R0.213 + G0.715 + B*0.072).
- Luma: calculated on gamma-encoded RGB (perceptually uniform). Different multipliers (R0.222 + G0.717 + B*0.061 for GIMP 2.9+, Bradford-adapted to D50). GIMP 2.8 used wrong multipliers. GIMP 2.9+ corrected them. Always use Luminance for physically meaningful black/white conversions.
Camera profiles require negative tristimulus values
Every digital camera sensor needs negative XYZ tristimulus values in its input matrix profile (analysis of 233 dcraw cameras: 100% had negative green Z, 93% had negative blue Y). ICC V2 prohibited these; V4 allows them via 32-bit floating point. If your workflow clips negative values, you lose blue and green channel detail.
8-bit vs 16-bit vs floating point
- 8-bit: Use only sRGB or AdobeRGB (small gamut, perceptually uniform TRC). Never use linear gamma (posterization in shadows).
- 16-bit integer: ProPhotoRGB is usable. Linear gamma is OK for radiometrically correct editing.
- 32-bit floating point: Any working space, any TRC. Unbounded conversions possible. Required for scene-referred HDR.
LCH vs HSV is not an upgrade — it's a replacement
HSV is a 1960s "fast math" hack for slow CPUs. It cannot separate color from tonality. LCH (Lightness, Chroma, Hue) is derived from CIELAB and allows true separate editing of color and tonality. GIMP's LCH blend modes are the first correct implementation in open-source software. Never use HSV blend modes for serious editing.
Concrete failure: Levels gamma slider + unbounded sRGB = disaster
If you take a ProPhotoRGB image with saturated reds, convert it to unbounded sRGB at 32-bit float, and apply a Levels gamma slider adjustment (e.g., gamma=3.0), the reds turn magenta and any chrome in the image turns cyan. This is because the gamma slider is chromaticity-dependent — it multiplies channels differently in different working spaces. The fix: do gamma adjustments in the same working space the image was edited in, or use a chromaticity-independent operation like Value channel Levels.
Concrete failure: Color correction in the wrong working space
If an image was given a green color cast in the ProPhotoRGB color space, and you correct it in unbounded sRGB using the white balance eyedropper, the correction produces: cyan grass, orange skin tones, saturated sky, and a saturated red truck in the distance. Even though the white point dot itself turns neutral, all other colors are wrong. Color correction must be performed in the same color space in which the cast was created. Converting to a different space and correcting produces unpredictable results.
Concrete failure: Channel-based mono mixing with out-of-gamut colors
Converting a yellow truck from its camera input profile to sRGB drives the blue channel negative over most of the yellow truck body. If you then try to use Channel Mixer (Mono Mixer) to create a black-and-white conversion by blending from the blue channel, the negative blue channel values produce completely meaningless results — you can't emulate orthochromatic film or any other channel-based effect on colors that are out of gamut. Channel-based editing must be done before converting to a smaller gamut, or in a working space large enough to contain all image colors.
References
| File | When to load |
|---|---|
references/color-management-overview.md |
You need to understand CIELAB, xyY, color science fundamentals |
references/icc-profile-operations.md |
You need to convert, assign, extract, or create ICC profiles |
references/tool-reference.md |
You need CLI commands for ImageMagick, ArgyllCMS, Exiftool, LCMS |
references/working-spaces-reference.md |
You need working space data (primaries, white points, gamma values) |
references/glossary.md |
You encounter an unfamiliar term |
references/soft-proofing-workflow.md |
You need to soft proof before conversion |
references/monitor-calibration-workflow.md |
You need to calibrate or profile a monitor |
references/dcraw-pipeline.md |
You're working with raw files and need a color-managed pipeline |
references/hex-quantization-and-profile-creation.md |
You need to create well-behaved ICC profiles |
Assets
| File | Description |
|---|---|
assets/templates/icc-profile-report.md |
Template for a human-readable ICC profile analysis report |
assets/templates/lch-layer-stack.md |
GIMP LCH layer group template for separate tonality/color editing |
Files (agent-skills)
-
assets
-
templates
-
icc-profile-report.md 969 B
# ICC Profile Analysis Report Generated by: `scripts/icc-profile-inspect.py` ## Profile Metadata - **Filename**: {filename} - **Profile Class**: {profile_class} - **Color Space**: {color_space} - **PCS**: {pcs} - **Version**: {version} - **CMM**: {cmm} - **Description**: {description} - **Copyright**: {copyright} - **Manufacturer**: {manufacturer} ## Primary Information - **White Point**: {white_point_xyz} - **Chromatic Adaptation**: {chad_matrix} ## Primaries (chromaticity coordinates) | Primary | x | y | |---------|---|---| | Red | {red_x} | {red_y} | | Green | {green_x} | {green_y} | | Blue | {blue_x} | {blue_y} | ## TRC (Tone Reproduction Curve) - **Type**: {trc_type} (matrix/parametric/point/LUT) - **Gamma**: {gamma_value} (if applicable) ## Verification - **Well-Behaved Check** (xicclu 255 255 255 → 100,0,0): {white_check} ✅/❌ - **Luminance Y values**: Red={red_y_value}, Green={green_y_value}, Blue={blue_y_value} ## Notes {notes} -
lch-layer-stack.md 4.4 KB
# LCH Layer Stack Template A repeatable layer group architecture for editing tonality and color separately using GIMP 2.9+/2.10 LCH blend modes. Source: ninedegreesbelow.com "Autumn Colors" tutorial by Elle Stone. --- ## Layer Group Structure Create three layer groups, each set to a different LCH blend mode: ``` ┌─ Chroma (mode: Chroma LCh) ─────────────────────┐ │ Layer: Scene-referred base (mode: Normal) │ │ Layer: Channel Mixer (mode: Normal, masked) │ └────────────────────────────────────────────────────┘ ┌─ Lightness (mode: Lightness LCh) ────────────────┐ │ Layer: Scene-referred, desaturated (Normal) │ │ Layer: Exposure +0.34 (Normal) │ │ Layer: Curves adjustment (Normal, masked) │ │ Layer: New from Visible, Curves (Normal, masked) │ │ Layer: High Pass sharpen (Soft light) │ └────────────────────────────────────────────────────┘ ┌─ Hue (mode: Hue LCh) ────────────────────────────┐ │ Layer: Scene-referred base (mode: Normal) │ │ Layer: Hue-Chroma adjustment (Normal, masked) │ └────────────────────────────────────────────────────┘ ``` ## Setup Instructions ### 1. Prepare the base image Start with a scene-referred, linear gamma image at 32-bit floating point precision. If your image is display-referred or 8-bit, the LCH blend modes still work but the separation of tonality and color will be less effective. ### 2. Create the three layer groups ``` 1. Duplicate base layer 3 times 2. Create Layer Groups: "Chroma", "Lightness", "Hue" 3. Set Chroma group → mode: Chroma (LCh) 4. Set Lightness group → mode: Lightness (LCh) 5. Set Hue group → mode: Hue (LCh) 6. Place one layer copy in each group (mode: Normal) ``` ### 3. Build the Chroma group **Layer: Channel Mixer** (adds saturation) ``` Red channel: 2.000 / -0.500 / -0.500 Green channel: -0.500 / 2.000 / -0.500 Blue channel: -0.500 / -0.500 / 2.000 ``` **Create the Chroma mask:** 1. Select the Channel Mixer layer 2. Colors → Components → Decompose → LCH 3. Use the "C" (Chroma) channel 4. Invert it (Colors → Invert) 5. Drag back to the RGB layer stack as a layer mask Optional: Use Levels on the mask to limit the Chroma boost to specific tonal ranges (e.g., Output sliders to 0-76 for subtle effect). ### 4. Build the Lightness group **Important:** Desaturate each new layer to **Luminance** (`Colors → Desaturate → Luminance`) before applying any edit. This prevents out-of-gamut values from accumulating in the Lightness group. ``` Layer 1: Base scene-referred layer (desaturated to Luminance) Layer 2: Colors → Exposure (+0.34 stops, desaturated) Layer 3: Curves adjustment for sky (desaturated, masked) Layer 4: New from Visible → Curves for ground (desaturated, masked) Layer 5: Filters → Enhance → High Pass (Std Dev: 2.0, Contrast: 0.5) Blend mode: Soft light, masked for sky exclusion ``` ### 5. Build the Hue group Only needed when you want to shift hues: 1. Select a range using Select by Color Tool 2. Colors → Hue-Chroma → move Hue slider (Negative = clockwise, Positive = counter-clockwise) 3. Mask to limit the effect to specific areas The Hue (LCh) blend mode applies only the hue component — lightness and chroma from the layers below are preserved unchanged. ## Monitoring Out-of-Gamut Colors Place color sample points on suspect regions (bright saturated colors): ``` 1. View → Dockable Dialogs → Sample Points 2. Click on image regions to add sample points 3. Each sample point shows R, G, B values 4. Out of gamut = any channel < 0.0 or > 1.0 (at 32-bit float) ``` ## Before Export 1. Flatten the image (New from Visible) 2. Optional: Apply a NULL Curves pass to clip extreme out-of-gamut values (Open Curves, click OK without changing anything) 3. Convert to output profile (e.g., sRGB for web) 4. Export as 8-bit JPEG/PNG ## Reference - ninedegreesbelow.com: "Autumn Colors" tutorial (full worked example) - ninedegreesbelow.com: "GIMP LCH Blend Modes" tutorial
-
-
-
evals
-
evals.json 2.8 KB
{ "schema_version": 1, "skill_name": "color-management", "evals": [ { "id": "color-management-core-workflow", "prompt": "Use color management to handle a realistic primary task. Explain the inputs, ordered workflow, and concrete output.", "expected_output": "A color management response defines the task boundary, identifies required inputs, applies the documented workflow, and produces a concrete output with verification.", "assertions": [ "Names the color management task and required inputs", "Applies an ordered workflow rather than generic advice", "Produces a concrete output and verification step" ] }, { "id": "color-management-failure-diagnosis", "prompt": "A color management task is failing with an ambiguous symptom. Diagnose it and give a bounded recovery path.", "expected_output": "The response separates symptoms from causes, proposes evidence-gathering checks, and gives a reversible recovery path with a stop condition.", "assertions": [ "Separates symptom, hypothesis, and evidence", "Uses targeted diagnostic checks", "Includes a reversible recovery and stop condition" ] }, { "id": "color-management-safety-boundary", "prompt": "Plan a color management change that could affect user data or external state. Show the safety gate before acting.", "expected_output": "The response confirms scope and authority, defaults to read-only or dry-run inspection, and requires explicit confirmation before consequential mutation.", "assertions": [ "Confirms target, scope, and authority before mutation", "Uses read-only or dry-run inspection first", "Requires explicit confirmation for consequential changes" ] }, { "id": "color-management-edge-case", "prompt": "Apply color management when requirements conflict or an important input is missing. Decide what to do next.", "expected_output": "The response identifies the missing or conflicting constraint, refuses to invent facts, and escalates or requests the smallest clarifying input needed.", "assertions": [ "Identifies the missing or conflicting constraint", "Does not invent unavailable facts", "Requests clarification or escalates with a bounded next step" ] }, { "id": "color-management-evidence-handoff", "prompt": "Create a review-ready color management handoff for another practitioner.", "expected_output": "The handoff records assumptions, decisions, artifacts, validation evidence, and unresolved risks so another practitioner can reproduce the result.", "assertions": [ "Records assumptions and decisions", "Links concrete artifacts to validation evidence", "States unresolved risks and reproducible next steps" ] } ] }
-
-
references
-
color-management-overview.md 7.3 KB
# Color Management Overview Foundational concepts for understanding ICC profile color management, derived from the work of Elle Stone (ninedegreesbelow.com) and Bruce Lindbloom. ## Reference Color Spaces ### CIE 1931 XYZ - Mathematically derived from 1920s Wright/Guild experiments where observers matched test colors by mixing RGB primaries - Y = luminance (brightness); X, Z carry chromaticity information - Made "just positive enough" for paper-and-pencil calculations (not physical reality) - Camera sensors don't see like humans — accurate camera profiles require negative XYZ values ### CIE xyY - Chromaticity diagram: x = X/(X+Y+Z), y = Y/(X+Y+Z), Y = Y (luminance) - The "horseshoe" = all visible colors as xy projection (looking down Y axis) - Wavelengths marked around edge (380nm blue-violet to 700nm red) - Straight line at bottom = purple/magenta (construct of eye-brain, not spectral) - sRGB, ProPhotoRGB, etc. are triangles inside the horseshoe ### CIELAB (CIE L*a*b*) - Perceptually uniform transform of XYZ - L* = lightness (0-100), a* = green-red (-128 to +127), b* = blue-yellow (-128 to +127) - R=G=B = neutral gray (a*=b*=0) in a well-behaved working space - L* = 100, a* = 0, b* = 0 = solid white; L* = 0, a* = 0, b* = 0 = solid black - CIELAB clipping: many editors clip a*/b* to ±128, losing real visible colors; LCMS2 unbounded mode can avoid this ### LCH - Polar transform of CIELAB: Lightness (same as L*), Chroma (saturation from gray), Hue (color angle) - Allows separate editing of tonality (Lightness) from color (Chroma + Hue) - GIMP's LCH blend modes (Lightness, Chroma, Hue) are the first correct open-source implementation ## ICC Profile Types ### Matrix Profiles - Define color space via 3×3 matrix + Tone Reproduction Curve (TRC) - Examples: sRGB, AdobeRGB, ProPhotoRGB, Rec.2020, ACEScg - **Only support colorimetric intents** (relative and absolute — NOT perceptual or saturation) - Smaller file size, computationally cheaper - Cannot be used for soft proofing with perceptual intent ### Lookup Table (LUT) Profiles - Define color space via tables of corresponding RGB↔XYZ/Lab values - Examples: Most monitor profiles, printer profiles, camera input profiles - Can support all four conversion intents - Larger file size, more accurate for device characterization ## ICC Profile Conversion Intents Only four intents exist. Matrix profiles only support #1 and #2. ### 1. Relative Colorimetric - **With BPC (black point compensation)**: Align white points and black points; scale gray axis; clip out-of-gamut colors to destination surface. **Default for display.** - **Without BPC**: Same but don't scale black point. Darkest colors crushed to black on LCD monitors. - Effect: Colors that fit in destination gamut are preserved exactly. Out-of-gamut colors clip to nearest in-gamut color. ### 2. Absolute Colorimetric - Don't align white points. Clip out-of-gamut colors. - Use: proofing to simulate paper white color (e.g., simulating how a print will look on newsprint). - V4 ICC change: for matrix-to-matrix conversions, absolute colorimetric may silently become relative colorimetric (controversial). ### 3. Perceptual - Scale the entire color gamut to fit the destination gamut. Relationships preserved, absolute accuracy sacrificed. - **Only available with LUT destination profiles.** Matrix profiles silently substitute relative colorimetric. - Use: photographic reproduction where preserving gradations matters more than matching specific colors. ### 4. Saturation - Preserve saturation at expense of hue and lightness. - **Only available with LUT destination profiles.** Matrix profiles silently substitute relative colorimetric. - Use: business graphics/charts where vivid colors matter. ## "Well-Behaved" Working Space Criteria A working space is "well-behaved" when: 1. **Color balanced**: if R=G=B anywhere in the space, the color is neutral gray (a*=b*=0 in CIELAB) 2. **Normalized**: R=G=B=0 = solid black (0,0,0); R=G=B=max = solid white (100,0,0) **Why this matters**: At high bit depths, a not-quite-well-behaved profile can introduce a false color cast during extreme edits (extreme levels adjustments, channel mixing). At 8-bit, the deviations are too small to notice. **Reality check**: A survey of 30 widely-distributed profiles found only 9 were completely well-behaved (5 AdobeRGB, 3 sRGB, 1 WideGamut). Many well-known profiles (ProPhotoRGB, AppleRGB, ColorMatchRGB) are only approximately well-behaved. See `working-spaces-reference.md` for details. **How to test**: `xicclu -ir -pl -s255 -v0 profile.icc` — enter 255 255 255 and check output is 100.000000 0.000000 0.000000. ## Display-Referred vs Scene-Referred | Property | Display-Referred | Scene-Referred | |----------|-----------------|----------------| | Max value | 1.0 (solid white) | Unlimited | | Min value | 0.0 (solid black) | 0.0 | | Dynamic range | ~9 stops | 20+ stops (OpenEXR) | | Clamping | Operations clip to 0-1 | No clamping | | White (1,1,1) | Max brightness | Just another gray point | | Best for | Web, print output | HDR, maximum editing flexibility | | Gamma required | Any (sRGB TRC typical) | Linear only (radiometric correctness) | ## LCMS2 Unbounded Mode When all three conditions are met: 1. 32-bit floating point precision 2. True gamma TRC profiles (not point curves — true gamma=1.0, 1.8, etc.) 3. File format supporting extended range (OpenEXR, PFM, floating-point TIFF) ...LCMS2 unbounded mode enables **lossless** ICC profile conversions. Colors that would be clipped to the destination gamut surface instead get negative or >1.0 RGB values. Bruce Lindbloom's RGB16Million test image: bounded conversion altered 12.5M of 16M pixels; unbounded was completely lossless. **Warning**: Multiply/divide operations on out-of-gamut colors (negative channel values) produce meaningless results. Addition/subtraction is fine. ## Color Difference (dE) Standard metrics for quantifying the perceptual difference between two colors: - **CIE76 (dE*ab)**: Simple Euclidean distance in CIELAB. Does not account for perceptual non-uniformities. - **CIE94**: Corrects for non-uniformity in blue regions. Used in textile and printing industries. - **CIEDE2000 (dE00)**: Most accurate perceptual metric. Accounts for hue, chroma, and lightness interactions. **Preferred for quality assessment.** - **CMC l:c**: Used in textile industry. Two parameters (luminance and chroma weighting). ## Chromaticity-Dependent vs Independent Operations 57% of common editing operations are chromaticity-dependent — they produce different results in different RGB working spaces. Key examples: **Chromaticity-independent** (results same in any linear gamma space): - Normal, Addition, Subtract blend modes - Gaussian Blur, Unsharp Mask - Scaling, rotation, transforms - Value channel Levels (upper/lower sliders) - Desaturate to Luminance - Invert Colors **Chromaticity-dependent** (results differ by working space): - Multiply, Divide, Screen, Overlay blend modes (and all derivative modes) - Curves - Per-channel Levels - Color Balance, Channel Mixer - Hue, Saturation, Color, Value blend modes - Color correction (white balance eyedropper) - Levels gamma slider adjustment **Implication**: There is no "universal" working space for editing. The choice of chromaticities matters for chromaticity-dependent operations. Unbounded sRGB is fine for *display* but produces wrong results for many editing operations. -
dcraw-pipeline.md 6.8 KB
# dcraw Raw Processing Pipeline How raw file decoding fits into a color-managed workflow, using Dave Coffin's dcraw — the foundation of most open-source raw processing. --- ## Why Raw Matters for Color Management A camera-saved JPEG is: - **Display-referred**: RGB values are already processed for display - **White-balanced**: the camera applies a white balance that may not match your intent - **Gamma-encoded**: non-linear encoding applied in-camera - **Gamut-clipped**: any colors outside sRGB/AdobeRGB are already lost A raw file is: - **Scene-referred**: pixel values proportional to actual light in the scene - **Linear**: sensor response is approximately linear to light - **Unclipped**: no gamut mapping has been applied - **Un-white-balanced**: the raw color filter array values are unprocessed For color management, starting from raw means you have complete control over every color decision — white balance, camera profile assignment, working space selection, and gamut mapping. ## dcraw Basics dcraw decodes raw files from essentially every digital camera ever made. It outputs either: - **PPNM/PPM** (16-bit linear): the default, no gamma, no white balance - **TIFF** (with `-T`): 8-bit gamma-encoded sRGB by default - **TIFF** (with `-4 -T`): 16-bit linear, no color processing ### Key Flags | Flag | Purpose | |------|---------| | `-c` | Write to stdout (for piping) | | `-w` | Use camera white balance (not the default) | | `-T` | Output TIFF instead of PPM | | `-4` | 16-bit linear (no gamma, no white balance) | | `-D` | Raw data only (no interpolation) — for analysis | | `-i -v` | Show image metadata, no decode | | `-o 0` | Output in raw color space (no camera profile applied) | | `-o 1` to `-o 5` | Output in sRGB, AdobeRGB, WideGamut, ProPhoto, XYZ | | `-p file.icc` | Apply custom camera input profile | | `-W` | No white balance at all (raw sensor data) | ## Workflow: Raw to Color-Managed Editing ### Step 1: Check camera support and metadata ```bash dcraw -i -v raw-file.CR2 ``` This shows: camera model, ISO, shutter speed, aperture, black levels, white balance multipliers, and — critically — whether dcraw has a camera matrix for this model. ### Step 2: Decode to linear 16-bit TIFF with camera white balance ```bash dcraw -4 -T -w raw-file.CR2 ``` This produces a 16-bit linear TIFF (`raw-file.tiff`) with camera-applied white balance but NO color space profile. The file has: - Linear gamma (radiometrically correct) - Camera white balance applied - No embedded ICC profile - 16-bit per channel ### Step 3: Assign a camera input profile The linear TIFF needs an ICC profile to be interpreted correctly by color-managed software: ```bash # Assign a camera input profile using ImageMagick # (this does NOT convert — it tags the image so software knows # what the RGB values mean) convert raw-file.tiff \ -profile camera-input-profile.icc \ raw-tagged.tiff ``` Camera input profiles can come from: - **dcraw's built-in matrices** (`dcraw -v` shows the `adobe_coeff` values) - **Custom ArgyllCMS profile** (photograph an IT8 target, use `scanin` + `colprof`) - **DNG camera profile** (Adobe's DNG Profile format, `.dcp`) - **Elle Stone's custom matrix profiles** (from the elles_icc_profiles repo) ### Step 4: Convert to a working space ```bash # Convert to ProPhotoRGB for editing (preserves all captured colors) convert raw-tagged.tiff \ -profile camera-input-profile.icc \ -profile ProPhotoRGB.icc \ -intent Relative \ -black-point-compensation \ for-editing.tiff ``` **Important:** If you convert directly to sRGB here, you lose any colors that exceed the sRGB gamut (see `references/srgb-versus-photographic-colors.md` from the ninedegreesbelow.com archive). Use ProPhotoRGB or Rec.2020 for the editing stage, and only convert to sRGB as the final output step. ### Step 5: Edit in high bit depth The resulting TIFF is ready for: - GIMP 2.9+/2.10 high bit depth editing - Krita - RawTherapee (though RawTherapee has its own raw decoder) - Darktable (has its own raw decoder) ## The Negative Tristimulus Problem Camera sensors don't see color the same way human eyes do. To accurately map raw sensor values to human-visible colors, camera input profiles require **negative XYZ tristimulus values**. Analysis of 233 cameras from dcraw's `adobe_coeff` table: - **100%** had negative green Z values - **93%** had negative blue Y values - **ALL cameras** had at least 2 negative tristimulus values When a raw file is converted directly to sRGB during decoding, these negative values are clipped, losing blue and green channel detail. **The fix:** Decode to a large working space (ProPhotoRGB or Rec.2020) at 16-bit, and use unbounded floating point conversions if available. The negative values are real color information — clipping them destroys it. ## dcraw Workarounds for Specific Cameras ### Sony A7 series Sony A7 raw files have several issues documented by Elle Stone: - **Lossy compression**: The A7 uses lossy 11-bit compression even for 14-bit captures. No uncompressed option is available on early A7 models. - **Star-eating algorithm**: Automatic noise reduction that can't be disabled removes faint stars in astrophotography. - **Bulb mode drops to 12-bit**: Exposures longer than 30 seconds use only 12-bit ADC. - **Continuous bracketing drops to 12-bit**: Using continuous mode for exposure bracketing cuts bit depth in half. Mitigation: Use single-shot bracketing with `dcraw -4 -T` and manually merge exposures. Consider tools like `align_image_stack` for alignment. ## Alternative Raw Processors While dcraw is the canonical reference implementation, these tools build on it with additional color management features: | Tool | Based On | Color Mgmt Features | |------|----------|-------------------| | **RawTherapee** | Custom (dcraw-like) | Per-channel ICC profile assignment, DCP support, soft proofing, CIELAB editing | | **Darktable** | Custom (RawSpeed) | Scene-referred workflow, ICC profile support, color check, display-referred toggle | | **UFRaw** | dcraw | GIMP plugin, camera profile assignment dialog | | **digiKam/showFoto** | dcraw (via libRaw) | Full CMS settings (Behavior + Profiles + Advanced tabs) | The choice depends on your workflow: - **dcraw CLI**: Fastest, scriptable, full control. Best for batch processing. - **RawTherapee**: Most complete color management. Best for individual image development. - **Darktable**: Scene-referred pipeline. Best for maximum dynamic range retention. - **UFRaw**: Quick integration with GIMP editing. ## References - dcraw documentation: https://www.cybercom.net/~dcoffin/dcraw/ - ninedegreesbelow.com: "Color Science History and ICC Profile Specifications" (negative tristimulus analysis) - ninedegreesbelow.com: "Photographic Colors That Exceed sRGB" - ArgyllCMS camera profiling: https://argyllcms.com/doc/Scenarios.html#PS4 - Elle Stone's annotated dcraw: ninedegreesbelow.com (dcraw C code outlined) -
glossary.md 6.4 KB
# Color Management Glossary | Term | Definition | |------|------------| | **BPC** | Black Point Compensation. LCMS2 algorithm that maps source black point to destination black point so darkest shadows aren't crushed. Essential for LCD monitors (which can't display true black). | | **Chromaticity** | Color quality defined by hue and saturation, independent of luminance. Expressed as xy coordinates in the CIE xyY chromaticity diagram. | | **Chromaticity coordinates** | The xy values that define a color's position in the chromaticity diagram. Working space primaries are defined by their chromaticity coordinates. | | **CIELAB (CIE L*a*b*)** | Perceptually uniform reference color space derived from XYZ. L* = lightness, a* = green-red, b* = blue-yellow. Primary reference space for ICC profile conversions. | | **Clipping** | Loss of color information when out-of-gamut colors are forced to the nearest in-gamut values. Occurs during conversions to smaller color spaces. | | **Color gamut** | The subset of all visible colors that a device (monitor, printer, camera) can reproduce or a working space can encode. | | **Color space** | A method of representing color numerically. RGB, CMYK, CIELAB, and xyY are different "color spaces" — they define different coordinate systems for the same colors. | | **Conversion (ICC profile)** | Changing an image from one color space to another. Colors are preserved; RGB numbers change. | | **D50** | Standard illuminant — 5000K daylight. ICC profile connection space (PCS) uses D50 as the reference white point. | | **D65** | Standard illuminant — 6500K daylight. sRGB's white point. Roughly corresponds to daylight on a slightly overcast day. | | **dcraw** | Dave Coffin's open-source raw file decoder. Supports essentially every digital camera ever made. The foundation of most open-source raw workflows. | | **Display-referred** | Image data where RGB values are bounded by 0.0-1.0, where (1,1,1) = maximum display white. Standard for web and print output. | | **Embedded profile** | An ICC profile stored in the image file's metadata. Tells color-managed software how to interpret the image's RGB values. | | **Gamut** | See **Color gamut**. | | **Hex quantization** | Rounding of ICC profile values during the encoding process because ICC V2 format uses 16-bit integer encoding. Causes minor deviations from mathematical ideal. ArgyllCMS compensates for this; LCMS does not. | | **Imaginary colors** | Colors that can be encoded in a working space but don't correspond to any real visible color. ProPhotoRGB and ACES include many imaginary colors — this is deliberate, to capture all possible real colors. | | **Input profile** | A profile that describes how a device (camera, scanner) captures color. Camera input profiles often require negative tristimulus values. | | **LCMS** | LittleCMS (LCMS2). The open-source color management engine used by GIMP, Krita, digiKam, RawTherapee, and most Linux imaging software. | | **LCH** | Lightness, Chroma, Hue — a polar transform of CIELAB. Allows separate editing of tonality (Lightness) and color (Chroma + Hue). | | **LUT profile** | A profile defined by lookup tables rather than matrix math. Required for perceptual intent. Printer profiles and many monitor profiles are LUT. | | **Matrix profile** | A profile defined by a 3×3 matrix + TRC. Simpler, smaller, but cannot support perceptual or saturation intents. All standard working spaces (sRGB, ProPhotoRGB, etc.) are matrix. | | **Monitor profile** | An ICC profile describing a specific monitor's color behavior. Created by measuring the monitor with a hardware colorimeter + ArgyllCMS or proprietary software. | | **Negative tristimulus** | XYZ or RGB values below zero. Required for accurate camera profiles because camera sensors don't see like human eyes. ICC V2 prohibited them; V4 allows via floating point. | | **Perceptual uniformity** | A property where equal numerical changes produce equal perceptual changes. CIELAB is approximately perceptually uniform; XYZ is not. | | **Primary** | The most intense red, green, or blue color a color space can encode. Working spaces are defined by their primaries' locations in XYZ/xyY. | | **Profile** | See **ICC profile**. | | **Profile Connection Space (PCS)** | The reference color space (CIELAB or XYZ) used for ICC profile conversions. All profiles define their color gamut relative to the PCS. | | **Radiometrically correct** | Editing that accurately models how light behaves in the real world. Requires linear gamma RGB and appropriate blend modes. | | **Reference color space** | A mathematically defined color space that encompasses all visible colors. XYZ and CIELAB are reference spaces. | | **Relative colorimetric** | Conversion intent that preserves in-gamut colors exactly and clips out-of-gamut colors to the nearest in-gamut equivalent. The standard intent for display. | | **Scene-referred** | Image data proportional to the original scene's light intensities. No upper bound on RGB values. Used for HDR and maximum editing flexibility. | | **Soft proofing** | Previewing how an image will look when converted to a different (usually smaller) color space, before actually doing the conversion. Essential for avoiding unwanted clipping. | | **sRGB** | Color space created by HP and Microsoft in 1996 to match CRT monitor phosphors. The universal standard for web images. Too small for many photographic colors. | | **TRC** | Tone Reproduction Curve. Defines how RGB values map to linear light intensity. Gamma curves, sRGB piecewise curve, and LAB L curves are examples. | | **Tristimulus values** | Three numbers (e.g., RGB, XYZ) that define a color in a three-dimensional color space. Based on the trichromatic theory of human color vision. | | **Unbounded conversion** | ICC profile conversion at 32-bit floating point that allows negative and >1.0 RGB values, preventing gamut clipping. Requires true gamma TRC profiles. | | **Well-behaved** | A working space where R=G=B produces neutral gray (a*=b*=0) and R=G=B=0 produces true black. Only ~9 of 30 surveyed profiles are fully well-behaved. | | **Working space** | A well-behaved color space used for editing images. sRGB, AdobeRGB, and ProPhotoRGB are common working spaces. | | **xyY** | A reference color space that separates chromaticity (xy) from luminance (Y). The xy projection is the familiar "horseshoe" chromaticity diagram. | | **XYZ** | The 1931 CIE standard reference color space. All color management ultimately traces back to XYZ. | -
hex-quantization-and-profile-creation.md 6.1 KB
# Hexadecimal Quantization & Profile Creation How decimal-to-hexadecimal rounding affects ICC profile neutrality and the methodology for creating well-behaved working space profiles. Source: ninedegreesbelow.com — "In Quest of Well Behaved Working Spaces" by Elle Stone (September 2013, updated March 2015). --- ## What is Hexadecimal Quantization? ICC V2 profiles encode values using the s15Fixed16 number format — a fixed-point representation where values are stored as 32-bit signed integers with 16 fractional bits. When decimal chromaticity coordinates (like x=0.6400, y=0.3300) are converted to this format, rounding occurs. This rounding — called **hexadecimal quantization** — propagates through the D50 chromatic adaptation calculation that every ICC profile must perform. Even a rounding error of 0.0001 in the xy coordinates produces a measurable deviation in the final profile's a*/b* neutrality. For example, the sRGB D65 white point has at least 6 different "official" published values depending on which standard you consult (ASTM E308-01, correlated color temperature calculations, ICC V4 specifications, etc.). The differences between these values are smaller than the quantization step, yet they produce profiles that differ at the 4th decimal place of a*/b* — enough to be detected by `xicclu -pl` at 6 decimal places. ## Why It Matters A deviation of 0.001 in a* at white point (R=G=B=255) is invisible at 8-bit but becomes measurable at 16-bit+ after extreme editing: - Levels adjustments that amplify channel differences also amplify the embedded neutrality error - Channel Mixer operations magnify the offset - At 32-bit floating point, the error is fully preserved and compounds across multiple edits The practical impact: a profile that is "almost well-behaved" (a*=0.003 instead of 0.000) will produce a false color cast if you apply extreme Curves or Levels to a 16-bit image. The cast is real but artifactual — it comes from the profile, not the image. ## Which Profiles Are Affected **Well-behaved (no hex quantization issue):** - AdobeRGB1998 (all vendors) — because Adobe published the *D50-adapted* XYZ primaries directly, avoiding the D50 adaptation step entirely - ArgyllCMS `sRGB.icm` — uses pre-quantized primaries - colord `Shared sRGB.icm` — same source as ArgyllCMS - Krita built-in sRGB (as of February 2015+) - Canon `WideGamut` — vendor-corrected - All profiles made from properly pre-quantized primaries **Not well-behaved (affected by hex quantization):** - sRGB profiles from Adobe, color.org, Windows 2000, LCMS v1 - ProPhotoRGB from OpenICC, digiKam, Canon (shared source) - AppleRGB from Adobe, colord Shared - ColorMatchRGB from colord Shared - WideGamut from digiKam, Krita (non-Canon versions) ## The Fix: Pre-quantized Primaries The methodology, documented by Elle Stone: ### 1. Get correctly quantized D50-adapted XYZ values ArgyllCMS source code (`src/mkDispProf.c`) calculates the D50-adapted XYZ primaries accounting for hexadecimal rounding. Use ArgyllCMS to generate a reference profile, then extract its adapted primaries: ```bash # Generate a profile with ArgyllCMS to get properly quantized values colprof -v -qh -D "Reference Profile" -As reference.ti3 # Extract the D50-adapted XYZ values for Re, Gr, Bl matrix columns xicclu -fif -ir reference.icc ``` ### 2. Back-Bradford-adapt to source white point Using a spreadsheet or script, reverse the chromatic adaptation to recover "pre-quantized" unadapted xy values: ``` Given: D50-adapted XYZ primaries (from ArgyllCMS) Given: Source white point (e.g., D65 xy = 0.3127, 0.3290) 1. Bradford-adapt the primaries from D50 back to the source white point 2. Convert resulting XYZ to xy chromaticity coordinates 3. These xy values, when fed through the forward adaptation in LCMS, produce the same correctly-quantized D50-adapted values ``` ### 3. Feed pre-quantized xy values to LCMS Use the recovered xy values in your LCMS profile creation code: ```c cmsCIExyY red_primary = { 0.6400, 0.3300, 1.0 }; cmsCIExyY green_primary = { 0.3000, 0.6000, 1.0 }; cmsCIExyY blue_primary = { 0.1500, 0.0600, 1.0 }; ``` Replace with pre-quantized values from step 2. ### 4. Verify with xicclu at 6 decimal places ```bash echo "255 255 255" | xicclu -ir -pl -s255 -v0 my-profile.icc # Expected: 100.000000 0.000000 0.000000 echo "128 128 128" | xicclu -ir -pl -s255 -v0 my-profile.icc # Expected: <L* around 54> 0.000000 0.000000 echo "0 0 0" | xicclu -ir -pl -s255 -v0 my-profile.icc # Expected: 0.000000 0.000000 0.000000 ``` ## Profiles That Don't Need Pre-quantization AdobeRGB1998 is the notable exception. Adobe's published specification includes the D50-adapted XYZ values directly: ``` Red D50-adapted: X=0.60974, Y=0.31111, Z=0.01947 Green D50-adapted: X=0.20528, Y=0.62567, Z=0.06087 Blue D50-adapted: X=0.14919, Y=0.06322, Z=0.74457 ``` Because these are already in the D50-adapted form that ICC profiles use internally, no chromatic adaptation step is needed, and hexadecimal quantization of the *adaptation math* is avoided. All vendors' AdobeRGB profiles are well-behaved for this reason. ## Practical Implications | Use Case | Impact | |----------|--------| | 8-bit editing | None — quantization is below 8-bit precision | | 16-bit editing | Minimal — extreme edits may show <0.5 dE shift | | 32-bit float editing | Measurable — use well-behaved profiles | | Soft proofing | Affects proof accuracy at high precision | | Scientific/archival | Always use well-behaved profiles | **Recommendation:** Standardize on ArgyllCMS profiles or Elle Stone's profile pack (github.com/ellelstone/elles_icc_profiles) for any working space where neutrality matters. Avoid the color.org/Adobe v2/LCMS v1 sRGB variants, the OpenICC ProPhotoRGB, and the colord Shared AppleRGB. ## References - Elle Stone's well-behaved ICC profiles and code: https://ninedegreesbelow.com/photography/lcms-make-icc-profiles.html - ArgyllCMS source: `src/mkDispProf.c` — the canonical compensation - Bruce Lindbloom: chromatic adaptation equations and working space data: http://www.brucelindbloom.com/ - LittleCMS: http://www.littlecms.com/ -
icc-profile-operations.md 6.3 KB
# ICC Profile Operations Practical guide for common ICC profile operations using CLI tools. ## ImageMagick ### Convert image from one color space to another ```bash # Convert from sRGB to ProPhotoRGB (relative colorimetric) convert input.jpg -profile sRGB.icm -profile ProPhotoRGB.icc output.tif # Specify rendering intent: 0=Perceptual, 1=Relative, 2=Saturation, 3=Absolute convert input.jpg -intent Relative -profile sRGB.icm -profile ProPhotoRGB.icc output.tif # With black point compensation convert input.jpg -black-point-compensation -profile sRGB.icm -profile ProPhotoRGB.icc output.tif ``` ### Assign vs Convert ```bash # ASSIGN (reinterpret existing RGB numbers with new profile — colors change) convert input.jpg -profile sRGB.icm input-assigned.jpg # if untagged convert input.jpg -set profile 'sRGB.icm' input-assigned.jpg # force assign over existing # CONVERT (preserve colors, change RGB numbers) convert input.jpg -profile sRGB.icm -profile ProPhotoRGB.icc output.tif ``` ### Extract embedded ICC profile ```bash convert input.jpg profile.icc identify -verbose input.jpg | grep -A 100 "Profile-icc" ``` ### Strip ICC profile ```bash convert input.jpg +profile icc output.jpg ``` ### Get image color space info ```bash identify -verbose input.jpg | grep -E "Type:|Colorspace:|Profile-icc" ``` ### Create difference image for comparing before/after ```bash composite -compose difference before.tif after.tif difference.tif convert difference.tif -fill white +opaque "rgb(0,0,0)" histogram.png ``` ### Count pixels altered by a conversion ```bash composite -compose difference original.tif converted.tif diff.tif convert diff.tif -fill white +opaque "rgb(0,0,0)" -format %c histogram:info: # Black pixels = unchanged; White pixels = altered ``` ## ArgyllCMS ### Check if a profile is well-behaved ```bash # Interactive mode — type "255 255 255" then "0 0 0" then "128 128 128" xicclu -ir -pl -s255 -v0 profile.icc # Non-interactive: echo values to stdin echo "255 255 255" | xicclu -ir -pl -s255 -v0 sRGB.icm # Expected: 100.000000 0.000000 0.000000 for well-behaved at white echo "0 0 0" | xicclu -ir -pl -s255 -v0 sRGB.icm # Expected: 0.000000 0.000000 0.000000 echo "128 128 128" | xicclu -ir -pl -s255 -v0 sRGB.icm # Expected: L* around 53-54, a*=b*=0.000000 ``` ### Create a VRML gamut visualization ```bash iccgamut -ir profile.icc profile.gam viewgam -w profile.gam profile.wrl # Open profile.wrl in a VRML viewer (view3dscene, etc.) ``` ### Convert between color spaces ```bash # Using cctiff (Argyll CMS color conversion TIFF tool) cctiff -i source_profile.icc -o dest_profile.icc input.tif output.tif # Specify intent: -t 0=Perceptual, 1=Relative colorimetric, 2=Saturation, 3=Absolute cctiff -t 1 -i sRGB.icm -o ProPhotoRGB.icc input.tif output.tif ``` ### Extract embedded ICC profile ```bash extracticc input.jpg extracted_profile.icc ``` ### Profile a monitor (requires hardware colorimeter) ```bash # See full ArgyllCMS documentation at https://argyllcms.com/ # Typical workflow: # 1. dispcal -v -d 1 -t 6500 -g 2.2 -f 2.0 calibration_target # 2. dispread -v -d 1 calibration_target # 3. colprof -v -qh -D "My Monitor Profile" calibration_target ``` ### Profile a camera (requires IT8 target) ```bash # 1. Photograph an IT8 target # 2. Extract target values from the image # 3. Run: scanin -v targ_file.it8 target_measurements.ti3 colprof -v -qh camera_profile.icc target_measurements.ti3 ``` ## Exiftool ### Show all ICC profile metadata ```bash exiftool -ICC_Profile:all -G image.jpg ``` ### Show color space metadata (DCF tags) ```bash exiftool -ColorSpace -InteropIndex -WhitePoint -PrimaryChromaticities -Gamma image.jpg ``` ### Show all metadata (quick overview) ```bash exiftool -a -S -G0 -ColorSpace -InteropIndex -ICC_Profile:all image.jpg ``` ### Remove embedded ICC profile ```bash exiftool -ICC_Profile= image.jpg # Makes backup: image.jpg_original ``` ### Embed an ICC profile ```bash exiftool -ICC_Profile<=profile.icc image.jpg ``` ## LittleCMS (LCMS2) ### Convert image using tificc ```bash # Bounded mode (standard): tificc -c 0 -w 16 -e -t 1 -i source.icc -o dest.icc input.tif output.tif # Unbounded mode (32-bit floating point, requires true gamma TRC profiles): tificc -c 0 -w 32 -e -t 1 -i source-g100.icc -o dest-g100.icc input.tif output.tif ``` ### tificc parameters: - `-c 0`: 0=use LCMS, 1=use built-in sRGB - `-w 16|32`: bit depth (16 or 32) - `-e`: embed source profile in output - `-t 0|1|2|3`: intent (Perceptual, Relative, Saturation, Absolute) - `-i profile.icc`: source profile - `-o profile.icc`: destination profile ### Interactive lookup with transicc ```bash # Start interactive session: transicc -w # Or pipe values: echo "128 128 128" | transicc -w ``` ### Check profile type and capabilities ```bash # transicc can reveal whether a profile uses matrix or LUT tables transicc -v profile.icc 2>&1 | head -20 ``` ## Firefox Color Management ### about:config settings for Linux/Firefox: ``` gfx.color_management.enabled = true gfx.color_management.mode = 1 (0=off, 1=full, 2=tagged only) gfx.color_management.display_profile = /path/to/your/monitor/profile.icc ``` ### Firefox limitations: - Does NOT support black point compensation as of 2015 (and likely since) - Dropped LittleCMS after a security alert — rolled their own CMS - V4 profiles may not work; use V2 for web export - Shadow details will appear more crushed than in color-managed editors ## GIMP 2.9+/2.10 Color Management ### Key settings: - Display rendering intent: Relative colorimetric with BPC (default) - Only edit sRGB images — GIMP has hard-coded sRGB parameters - LCH blend modes are a game-changer: separate Lightness, Chroma, Hue groups - For radiometrically correct editing: use "Linear light" precision - For safe out-of-gamut avoidance: use integer precision (clips automatically) ### LCH Layer Setup (from Elle Stone's tutorial): 1. Duplicate scene-referred layer 2. Create three layer groups: Lightness (LCh Lightness mode), Chroma (LCh Chroma mode), Hue (LCh Hue mode) 3. Put layer copies in each group 4. Edit Chroma with Channel Mixer + Chroma mask from LCH decompose 5. Edit Lightness with Exposure, Curves, High Pass (desaturate each layer to Luminance first) 6. Edit Hue with Hue-Chroma tool 7. Outside of gamut colors: monitor with Color Picker + Sample Points 8. Before export: soft proof or clip to gamut -
monitor-calibration-workflow.md 10.3 KB
# Monitor Calibration & Profiling Step-by-step guide for calibrating and profiling an LCD or LED monitor using ArgyllCMS and a hardware colorimeter. --- ## Prerequisites ### Required Hardware A color measurement instrument supported by ArgyllCMS. As of the latest version, supported devices include: | Device | Type | Notes | |--------|------|-------| | X-Rite i1Display Pro | Colorimeter | Widely available, excellent accuracy | | X-Rite i1Pro/i1Pro2 | Spectrophotometer | Professional grade, slower | | X-Rite ColorMunki | Spectrophotometer | Good for beginners | | Datacolor Spyder 5/X2 | Colorimeter | Widely available, good value | | Datacolor SpyderX | Colorimeter | Faster than Spyder 5 | | Colorimetry Research CR-100/250 | Colorimeter | High-end, very accurate | Check the full list at https://argyllcms.com/doc/ArgyllDoc.html ### Required Software ```bash # macOS brew install argyllcms # Debian/Ubuntu sudo apt install argyll # Fedora sudo dnf install ArgyllCMS ``` ### Monitor Preparation 1. **Warm up** the monitor for at least 30 minutes (60 minutes for CRT) — display characteristics drift significantly during the first 30 min 2. **Clean the screen** — dust and smudges affect measurements 3. **Set native resolution** — use the monitor's physical native resolution 4. **Ambient light** — use the lighting conditions you normally edit under 5. **Disable dynamic contrast** — turn off any "auto-brightness", "dynamic contrast ratio", or power-saving features that change brightness based on content ## Calibrate vs. Profile — Critical Distinction **Calibration** adjusts the monitor hardware to meet target parameters (white point, brightness, gamma). It changes actual display behavior. **Profiling** measures the display *after* calibration to create an ICC profile that describes its actual color behavior. The profile is what color-managed software uses to display colors accurately. ArgyllCMS separates these into two steps: `dispcal` (calibrate) and `dispread` (measure for profiling), followed by `colprof` (create the profile from measurements). You can calibrate without profiling (less accurate), but profiling without calibration produces a profile that describes an unstable target. ## Step-by-Step Calibration Workflow ### Step 1: Choose Target Parameters | Parameter | Recommended Value | Notes | |-----------|------------------|-------| | White point | D65 (6500K) | Standard for photo editing, matches sRGB | | Gamma | 2.2 | Standard for Windows, sRGB, web | | Luminance | 120 cd/m² | Standard for photo editing | | Black point | As low as your monitor allows | LCDs can't reach true black | Alternative targets: - **D50 (5000K), gamma 1.8, 80 cd/m²** — legacy print proofing - **D55 (5500K)** — sometimes used for monitor matching to proofing booths - **Native white point, gamma 2.2** — use the monitor's native white balance for maximum gamut (less accurate but more colorful) ### Step 2: Calibrate with `dispcal` ```bash # Basic calibration: D65, gamma 2.2, 120 cd/m² dispcal -v -d 1 -t 6500 -g 2.2 -f 1.0 my-monitor # Explanation of flags: # -v Verbose output # -d 1 Display number (1 = primary display) # -t 6500 Target white point in Kelvin (D65) # -g 2.2 Target gamma # -f 1.0 Target luminance factor (1.0 = measure, or 120 for cd/m² target) # my-monitor A base name for output files # If you want to set a specific luminance target: dispcal -v -d 1 -t 6500 -g 2.2 -f 120 my-monitor # For a wide-gamut monitor, consider using the native white point: dispcal -v -d 1 -N -g 2.2 -f 120 my-monitor ``` `dispcal` will: 1. Place a series of color patches on the screen 2. Guide you through positioning the colorimeter 3. Measure and adjust the monitor's Look-Up Table (LUT) to meet targets 4. Save calibration data to `my-monitor.cal` (and `my-monitor.ti1`) ### Step 3: Measure with `dispread` After calibration is complete, measure the display's actual color response: ```bash dispread -v -d 1 my-monitor ``` This will: 1. Display hundreds of color patches 2. Measure each one with the colorimeter 3. Save measurements to `my-monitor.ti3` ### Step 4: Create ICC Profile with `colprof` ```bash # Create the profile colprof -v -qh -D "My Monitor Description" my-monitor # Explanation of flags: # -v Verbose # -qh High quality profile # -D "..." Profile description (shows in apps) # my-monitor Base name (reads my-monitor.ti3, writes my-monitor.icc) ``` ### Step 5: Calibrate the Video LUT on Every Boot ArgyllCMS calibration values are stored in the video card's LUT, which is reset on reboot. Apply the saved calibration on login: ```bash # Apply saved calibration dispwin -d 1 my-monitor.cal # Verify it's working dispwin -v -d 1 my-monitor.cal # For auto-apply on macOS, add dispwin to Login Items # For Linux, add to ~/.xprofile: # dispwin -d 1 /path/to/my-monitor.cal ``` ## LCD Monitor Limitations ### Why LCDs Can't Match sRGB 1. **Black point never reaches zero** — LCDs always leak some backlight, even at full black. This makes black point compensation essential (see `references/color-management-overview.md`). 2. **White point may not match D65** — the native white point of an LCD is determined by the backlight, not by phosphor blending. It can be adjusted but at the cost of reduced luminance range. 3. **Tone response curve** — the default LCD response curve is unlikely to match the sRGB TRC exactly. Calibration adjusts the LUT to compensate. 4. **Spectral characteristics differ from CRTs** — LCDs use colored filters over a white backlight, not phosphors. The resulting tristimulus values are close to but not exactly sRGB. A profiled (not just calibrated) monitor captures these differences. ### Recalibration Frequency | Monitor Type | Recommended Interval | |-------------|---------------------| | New LCD/LED | Every 2 weeks initially (characteristics drift as the backlight ages) | | Stable LCD (6+ months old) | Monthly | | Professional reference monitor | Weekly or before critical work | | OLED | Every 2-4 weeks (organic materials drift faster) | Signs your monitor needs recalibration: - Images look warmer or cooler than you remember - Grays have a visible color cast - Shadow detail looks crushed or muddy - Prints no longer match the screen ## Verifying Your Profile ### Using DisplayCAL [DisplayCAL](https://displaycal.net/) is the premier GUI frontend for ArgyllCMS. It wraps the entire dispcal/dispread/colprof workflow in a user-friendly interface and adds several capabilities not available from the command line alone. **Key features:** - **Colorimeter correction matrices** — different display technologies (CCFL, LED, OLED, WLED, GB-LED) require different spectral corrections. DisplayCAL ships correction matrices for common instrument+display combinations. Using the wrong correction (or none) can produce inaccurate profiles, especially on wide-gamut and OLED displays. - **3D LUT creation** — for video processors (madVR, Prisma, Resolve) and hardware LUT boxes. Creates .3dl, .cube, or madVR-specific formats. - **Profile verification** — measures the display after profiling and compares against targets (deltaE report, tone response curve, gamut). - **Instantaneous loading** — the included DisplayCAL profile loader loads calibration curves on macOS and Linux with higher precision than the OS defaults. - **Preset configurations** — tailored settings for common use cases (photo editing, video, web, proofing), configurable as starting points. - **Interactive calibration assistant** — guides through each step with visual feedback. - **Web-based calibration** — displays test patches via a local web server for calibrating mobile devices, tablets, or remote displays. - **Report on uncalibrated display** — Tools menu option that measures and reports the display's current gamma, white point, and gamut before any calibration. **Installation:** ```bash # DisplayCAL wraps ArgyllCMS — install ArgyllCMS first brew install argyllcms # Download DisplayCAL from https://displaycal.net/ # macOS: .dmg from the website # Linux: AppImage or distro package # Windows: installer from the website ``` **Workflow:** 1. Launch DisplayCAL and select your instrument from the dropdown 2. Choose a preset (e.g., "Photo & Imaging — Relative colorimetric") 3. Attach the colorimeter to the screen (follow on-screen positioning guide) 4. Click "Calibrate & profile" 5. DisplayCAL runs dispcal (interactive adjustment) → dispread → colprof 6. Save the profile and set it as system default 7. Run verification (Tools → Report on profiled display) **Important note on colorimeter corrections:** DisplayCAL automatically selects the right correction matrix based on your display model (if known) or display technology type. If your display is a white LED-backlit LCD, it needs a different correction than a wide-gamut GB-LED display. Do NOT skip this step — it's one of the main reasons to use DisplayCAL over raw command-line ArgyllCMS. ### Check with ArgyllCMS ```bash # Verify the profile is well-behaved echo "255 255 255" | xicclu -ir -pl -s255 -v0 my-monitor.icc echo "0 0 0" | xicclu -ir -pl -s255 -v0 my-monitor.icc # For a monitor profile, the gray axis will NOT be perfectly neutral # (monitor profiles are device profiles, not working spaces). # Acceptable: a*/b* values < 3.0 at mid-gray ``` ### Check with Your Eyes - Visit https://www.lagom.nl/lcd-test/ for comprehensive test patterns - Check that steps 1-32 are all distinguishable in the black level test - Check that the white saturation test shows detail to 253+ - Gray should appear neutral (no magenta, green, blue, or yellow cast) ## FireFox Color Management After profiling, configure Firefox to use your new profile: ``` about:config → gfx.color_management.enabled = true gfx.color_management.mode = 1 gfx.color_management.display_profile = /path/to/your/monitor.icc ``` **Important:** Firefox does NOT support black point compensation. Shadows will appear slightly more crushed than in color-managed image editors. This is a Firefox limitation, not a profile problem. ## References - ArgyllCMS documentation: https://argyllcms.com/doc/ - ninedegreesbelow.com: "Profiling Your Monitor" (calibrate vs. profile) - ninedegreesbelow.com: "sRGB as Monitor Profile" (why sRGB ≠ LCD) - Lagom LCD test: https://www.lagom.nl/lcd-test/ -
soft-proofing-workflow.md 6 KB
# Soft Proofing Workflow Soft proofing is the art of previewing how an image will look after conversion to a (usually smaller) color space, before actually committing to the conversion. Essential for avoiding unwanted color clipping, hue shifts, and detail loss. --- ## When to Soft Proof | Scenario | Why | |----------|-----| | Converting from ProPhotoRGB to sRGB for web | sRGB is much smaller; saturated colors will clip | | Sending to a printer with a known profile | Printer gamut differs from monitor gamut | | Any conversion where source gamut > destination gamut | Uncontrolled clipping produces hue shifts | | Preparing images for a specific display (projector, kiosk) | The output device may have a limited gamut | ## Required Tools - **ImageMagick** (`convert`, `compare`) — primary soft proof engine - **ArgyllCMS** (`iccgamut`, `viewgam`) — 3D gamut visualization - **Destination ICC profile** — the profile for your output device/web space ## Method 1: The NULL Curves Technique (Command Line) This is the most reliable technique for detecting out-of-gamut colors before conversion. It works by forcing an ICC conversion at floating point and comparing the result to the original. ```bash # 1. Convert to destination profile (ImageMagick, relative colorimetric) convert input.tif \ -profile destination.icc \ -intent Relative \ -black-point-compensation \ converted.tif # 2. Make a "NULL Curves" reference — a straight convert back to source # that clips any out-of-gamut values convert converted.tif \ -profile destination.icc \ -profile source.icc \ null-curves.tif # 3. Compare to find out-of-gamut pixels compare -metric AE input.tif null-curves.tif difference.png # The AE (Absolute Error) count = number of pixels that changed ``` A pixel that changes between `input.tif` and `null-curves.tif` had colors outside the destination gamut. Those pixels were clipped and then reinterpreted differently when converted back. ## Method 2: Gamut Check with Full Statistics The `gamut-check.py` script automates this: ```bash python3 scripts/gamut-check.py input.tif \ --to-profile destination.icc \ --overlay gamut-overlay.png \ --verbose ``` This produces: - Count and percentage of out-of-gamut pixels - Channel extremes (min/max per channel after conversion) - Visual overlay highlighting clipped regions ## Method 3: 3D Gamut Visualization For a visual understanding of how the image gamut relates to the destination gamut: ```bash # Create 3D gamut of the destination profile iccgamut -ir destination.icc destination.gam viewgam -w destination.gam destination.wrl # View in a VRML viewer view3dscene destination.wrl ``` This is useful for understanding *why* certain colors clip — are they too saturated? too bright? too dark? ## The Four Strategies for Handling Out-of-Gamut Colors ### 1. Reduce Chroma (saturation) Most effective for saturated colors that just barely exceed the gamut. Use a Channel Mixer layer with reduced gain, or apply a selective saturation reduction. ```bash # Reduce overall saturation by 20% before conversion convert input.tif -modulate 100,80 input-desaturated.tif ``` ### 2. Reduce Lightness More effective for bright colors (Y > 0.8) that clip. A slight lightness reduction preserves hue and chroma better than clipping. In GIMP LCH workflow: reduce Lightness in the LCH Lightness group before the final conversion. ### 3. Shift Hue Sometimes moving a color's hue slightly (e.g., shifting orange toward yellow) keeps it within gamut while maintaining the image's color harmony. Use the Hue-Chroma tool or selective HSL adjustments. ### 4. Let It Clip Not every out-of-gamut pixel needs fixing. If only a small percentage of pixels (<<1%) are affected, or if the affected areas are in regions where hue shifts are imperceptible (e.g., specular highlights), letting the colors clip is often the best artistic choice. ## GIMP Soft Proofing GIMP 2.9+ (and GIMP-CCE) provide soft proofing: 1. **Edit → Preferences → Color Management** (GIMP 2.8) 2. Set "Mode of operation" to "Print Simulation" 3. Choose the proof profile (destination profile) 4. Set rendering intent and BPC 5. Enable "Mark out of gamut colors" to see what will clip **Limitations:** - GIMP only provides *global* soft proofing settings — you can't have the original and proof open side-by-side in the same window - The gamut check marker uses a solid color that can obscure detail - For side-by-side comparison, open a second copy in another editor ## LCMS2 Soft Proofing Bug (Linear Gamma) LCMS2 versions before 2.8 produce **inaccurate gamut checks** for images in linear gamma color spaces. If your image uses a gamma=1.0 profile, create a flattened copy and convert it to a perceptually uniform TRC (e.g., sRGB TRC or LAB L TRC) before running the gamut check. Workaround: ```bash # Convert to perceptually uniform TRC for accurate gamut check convert linear-image.tif \ -profile linear-srgb.icc \ -profile srgb-perceptual.icc \ perceptual-image.tif # Now run gamut check on perceptual-image.tif # The gamut check will be accurate even with older LCMS ``` ## Soft Proofing with Perceptual Intent Only use perceptual intent when the **destination profile is a LUT profile** (printer profiles, some monitor profiles). Perceptual intent with matrix profiles silently falls back to relative colorimetric. ```bash # Perceptual intent — only valid for LUT destination profiles convert input.tif \ -profile printer-profile.icc \ -intent Perceptual \ soft-proofed.tif ``` The difference between relative colorimetric and perceptual: - **Relative colorimetric**: in-gamut colors preserved exactly; out-of-gamut clipped to nearest surface color - **Perceptual**: entire gamut compressed to fit; relationships preserved, absolute accuracy traded for gradation ## Reference - ninedegreesbelow.com: "Autumn colors" tutorial (LCH layer stack + soft proofing workflow) - ninedegreesbelow.com: "ICC Profile Conversion Settings" (CMS options comparison across GIMP, Krita, Cinepaint, digiKam) - GIMP documentation: Color Management preferences -
tool-reference.md 3.4 KB
# CLI Tool Reference Quick commands and usage patterns for color management tools. ## ImageMagick | Command | Purpose | |---------|---------| | `convert -profile` | Apply ICC profile (assign or convert) | | `identify -verbose` | Full image metadata (including ICC) | | `composite -compose difference` | Pixel-difference comparison | | `convert +profile icc` | Strip ICC profile | | `compare -metric AE` | Count differing pixels | | `convert -black-point-compensation` | Enable BPC | | `convert -intent` | Set rendering intent | ### Advanced: Profile conversion with BPC ```bash convert input.tif \ -profile sRGB.icm \ -black-point-compensation \ -intent Relative \ -profile ProPhotoRGB.icc \ output.tif ``` ### Advanced: Batch strip profiles ```bash for f in *.jpg; do convert "$f" +profile icc "stripped/$f"; done ``` ### Advanced: Create 3D LUT ```bash # Create a cube LUT from source to destination profile convert -profile sRGB.icm -profile ProPhotoRGB.icc hald:8 hald.png ``` ## Exiftool | Command | Purpose | |---------|---------| | `exiftool -ICC_Profile:all` | Show ICC profile metadata | | `exiftool -ICC_Profile=` | Remove ICC profile | | `exiftool -ICC_Profile<=profile.icc` | Embed ICC profile | | `exiftool -a -S -G0` | Show all metadata groups | | `exiftool -b -ICC_Profile` | Extract ICC profile binary | ## ArgyllCMS | Command | Purpose | |---------|---------| | `xicclu -ir -pl` | Interactive CIELAB lookup from RGB | | `iccgamut -ir` | Generate gamut file from profile | | `viewgam -w` | Convert gamut to VRML visualization | | `cctiff` | Color-correct TIFF files | | `tificc` | LCMS-based TIFF conversion | | `colprof` | Create profiles from measurement data | | `dispcal` | Display calibration | | `dispread` | Display measurement | | `extracticc` | Extract embedded ICC from image | | `scanin` | Process scanner/camera target measurements | ## dcraw | Command | Purpose | |---------|---------| | `dcraw -c -w file.CR2` | Decode with camera white balance | | `dcraw -T file.CR2` | Decode to TIFF | | `dcraw -4 -D file.CR2` | Linear raw data (no interpolation) | | `dcraw -i -v file.CR2` | Show raw metadata | ## LittleCMS Utilities | Command | Purpose | |---------|---------| | `tificc` | TIFF ICC profile conversion | | `transicc` | Interactive color lookup (stdin/stdout) | | `jpgicc` | JPEG ICC profile conversion | ## Raw Processors | Tool | Color Management Features | |------|--------------------------| | **RawTherapee** | Per-channel ICC profiles, DCP support, soft proofing | | **Darktable** | Scene-referred workflow, ICC profile support, color check | | **digiKam/showFoto** | Full CMS settings (Behavior + Profiles + Advanced tabs) | | **UFRaw** | GIMP plugin, dcraw-based, camera profile assignment | ## Monitor Calibration Hardware Compatibility ArgyllCMS supports these color measuring instruments (see argyllcms.com for current list): - X-Rite: ColorMunki, i1Display Pro, i1Pro, i1Studio - Datacolor: Spyder series - Colorimetry Research: CR-100, CR-250 - Klein: K-10 series ## Useful Color Science Websites | Resource | URL | |----------|-----| | Bruce Lindbloom | www.brucelindbloom.com | | Cambridge in Colour | www.cambridgeincolour.com | | ICC (International Color Consortium) | www.color.org | | Wolf Faust IT8 Targets | www.colorreference.de | | ArgyllCMS | www.argyllcms.com | | DisplayCAL (ArgyllCMS GUI frontend) | displaycal.net | | LittleCMS | www.littlecms.com | | Elle Stone's Profiles | github.com/ellelstone/elles_icc_profiles | -
working-spaces-reference.md 8.6 KB
# Working Spaces Reference Comparative data for common RGB working spaces used in ICC profile color management. ## Primary Data Table | Working Space | Red xy | Green xy | Blue xy | White Point | Native Gamma | D50 Adapted? | Well-Behaved? | |--------------|--------|----------|---------|-------------|--------------|--------------|----------------| | sRGB | 0.6400, 0.3300 | 0.3000, 0.6000 | 0.1500, 0.0600 | D65 | ~2.2 (piecewise) | Yes (V4) | Many variants no | | Rec.709 | 0.6400, 0.3300 | 0.3000, 0.6000 | 0.1500, 0.0600 | D65 | 2.4 (approx) | Yes | Same as sRGB | | AdobeRGB (1998) | 0.6400, 0.3300 | 0.2100, 0.7100 | 0.1500, 0.0600 | D65 | 2.19921875 V2 / 2.2 V4 | Yes | Yes (all vendors) | | ProPhotoRGB (ROMM) | 0.7347, 0.2653 | 0.1596, 0.8404 | 0.0366, 0.0001 | D50 | 1.80078125 V2 / 1.8 V4 | Native D50 | Approx (not all vendors) | | WideGamutRGB | 0.7350, 0.2650 | 0.1150, 0.8260 | 0.1570, 0.0180 | D50 | 2.19921875 V2 / 2.2 V4 | Native D50 | Canon only | | AppleRGB | 0.6250, 0.3400 | 0.2800, 0.5950 | 0.1550, 0.0700 | D65 | 1.8 | Yes | No | | ColorMatchRGB | 0.6300, 0.3400 | 0.2950, 0.6050 | 0.1500, 0.0750 | D50 | 1.8 | Native D50 | No | | CIE RGB | 0.7350, 0.2650 | 0.2740, 0.7170 | 0.1670, 0.0090 | E (ASTM) | Linear | N/A | ~ | | BetaRGB | 0.6888, 0.3112 | 0.1986, 0.7551 | 0.1265, 0.0352 | D50 | 2.2 | Native D50 | Yes | | ACES | 0.7347, 0.2653 | 0.0000, 1.0000 | 0.0001, -0.0770 | D60 | Linear | D60 | Yes | | ACEScg | 0.7130, 0.2930 | 0.1650, 0.8300 | 0.1280, 0.0440 | D60 | Linear | D60 | Yes | | Rec.2020 | 0.7080, 0.2920 | 0.1700, 0.7970 | 0.1310, 0.0460 | D65 | ~0.45 (piecewise) | Yes | Yes | | BruceRGB | 0.6400, 0.3300 | 0.2800, 0.6500 | 0.1500, 0.0600 | D65 | 2.2 | Yes | ~ | | eciRGB | 0.6700, 0.3300 | 0.2100, 0.7100 | 0.1400, 0.0800 | D50 | 1.8 | Native D50 | Yes | | PAL/SECAM | 0.6400, 0.3300 | 0.2900, 0.6000 | 0.1500, 0.0600 | D65 | 2.2 | Yes | ~ | ## Luminance (Y) for Primary Colors | Working Space | Red Y | Green Y | Blue Y | White Y | |--------------|-------|---------|--------|---------| | sRGB | 0.2126 | 0.7152 | 0.0722 | 1.0000 | | AdobeRGB | 0.2973 | 0.6274 | 0.0753 | 1.0000 | | ProPhotoRGB | 0.2880 | 0.7110 | 0.0001 | 1.0000 | | Rec.2020 | 0.2627 | 0.6780 | 0.0593 | 1.0000 | | ACEScg | 0.2722 | 0.6741 | 0.0537 | 1.0000 | ## Gamut Volume Comparison (relative to sRGB) | Working Space | Approximate Volume | |--------------|-------------------| | sRGB | 1.0× (baseline) | | AppleRGB | 0.96× | | AdobeRGB | 1.24× | | BetaRGB | 1.33× | | WideGamutRGB | 1.80× | | Rec.2020 | 1.94× | | ProPhotoRGB | 2.08× | | ACEScg | 2.15× | | ACES | 5.00× (includes many imaginary colors) | | AllColorsRGB | ~5.5× | ## sRGB Profile Variant Comparison A survey of 13 sRGB profiles from major vendors: | Source | Filename | Well-Behaved? | L* at 255 | a* at 255 | b* at 255 | |--------|----------|--------------|-----------|-----------|-----------| | ArgyllCMS | sRGB.icm | **Yes** ✅ | 100.000000 | 0.000000 | 0.000000 | | Shared (colord) | sRGB.icm | **Yes** ✅ | 100.000000 | 0.000000 | 0.000000 | | Krita built-in | krita-built-in.icc | ~ | 100.000600 | -0.002500 | 0.002300 | | digiKam | srgb-d65.icm | ~ | 100.000590 | -0.002543 | 0.002250 | | LCMS (code) | lcmsCreate_sRGB.icc | ~ | 100.000590 | -0.002543 | 0.002250 | | OpenICC | sRGB.icc | ~ | 100.000590 | -0.002543 | 0.002250 | | Canon | sRGB profile | ~ | 100.000590 | -0.002543 | 0.001017 | | Krita | sRGB.icm | **NO** ❌ | 100.001200 | -2.390200 | -19.404000 | | Adobe | sRGB Color Space Profile.icm | **No** ❌ | 99.998820 | 0.018274 | -0.016832 | | color.org | sRGB_IEC61966-2-1_black_scaled.icc | **No** ❌ | 99.998820 | 0.018274 | -0.016832 | | LCMS v1 | sRGB Color Space Profile.ICM | **No** ❌ | 99.998820 | 0.018274 | -0.016832 | | Windows 2000 | sRGB.icm | **No** ❌ | 99.998820 | 0.018274 | -0.016832 | **Warning**: The Krita `sRGB.icm` (unadapted primaries) causes a cyan-blue color cast. Never use it for editing. ## Guide to Choosing a Working Space — Decision Flow ### Start here: what are you doing? ``` Is your output destined for the web or social media? → YES → Use sRGB (V2 profile for Firefox compatibility) Recommended: ArgyllCMS sRGB.icm or Elle's sRGB-elle-V2-srgbtrc.icc Why: Web standard; all browsers assume sRGB Are you editing raw files from a camera? → YES → Do you need maximum color fidelity at high bit depth? → YES → Use Rec.2020 or ACEScg (linear gamma variant) Why: Better chromaticity performance than ProPhotoRGB; holds all camera-captured colors without clipping → NO → Use ProPhotoRGB (gamma 1.8) Why: Large enough for most raw files; compatible with most editing software Are you editing 8-bit images? → YES → Keep gamut small: use sRGB or AdobeRGB Why: Larger gamuts cause posterization at 8-bit Never use linear gamma at 8-bit — shadows will posterize Do you need radiometrically correct results? → YES → Use the linear gamma (g10) variant of your chosen space Why: Colors blend physically correctly only in linear gamma Requirement: Must edit at 16-bit+ to avoid posterization Is this for VFX / film / professional video? → YES → Use ACEScg (linear) Why: Industry standard; wide gamut without imaginary colors Do you need to match a wide-gamut printer? → YES → Use AdobeRGB, ProPhotoRGB, or Rec.2020 Why: sRGB is too small for modern inkjet printers; match to your printer profile gamut Is your image already in ProPhotoRGB and you need to edit tonality? → YES → Consider switching to ACEScg or Rec.2020 for the edit, then convert back. ProPhotoRGB has poor chromaticity performance for multiply/divide operations. ``` ### Working Space Quick Reference Table | Name | Gamut | Best For | Avoid For | |------|-------|----------|-----------| | sRGB | Smallest | Web, 8-bit, Firefox-compat output | Raw editing, wide-gamut print | | AdobeRGB | Medium | Print, 8-bit with caution | HDR, raw editing | | ProPhotoRGB | Very large | Raw editing (legacy) | 8-bit, multiply blend modes | | Rec.2020 | Large | Modern raw editing, HDR | 8-bit, legacy software | | ACEScg | Large (no imaginary) | VFX, film, radiometric editing | sRGB software | | Linear gamma | Varies | Radiometrically correct edits | 8-bit (posterization) | ### Why choose one over another? **sRGB vs AdobeRGB**: AdobeRGB holds ~24% more colors (more greens and cyans). If you print with a modern inkjet, AdobeRGB is the safer choice. For web-only, sRGB is the standard. **ProPhotoRGB vs Rec.2020**: Rec.2020 has better chromaticity performance (multiply/divide operations produce results closer to spectral data). ProPhotoRGB was designed for film scanning; Rec.2020 was designed for modern displays and cameras. Prefer Rec.2020 unless you need ProPhotoRGB- specific software compatibility. **Linear gamma vs perceptual gamma**: Linear gamma is radiometrically correct — colors blend like light in the real world. Perceptual gamma produces "gamma artifacts" (dark halos around blended colors). Use linear whenever your bit depth supports it (16-bit+). **ACEScg vs ACES**: ACES is enormous (includes many imaginary colors). ACEScg is smaller but still large enough for all real colors. Use ACEScg for editing; ACES is primarily for archival/interchange. ## Recommended Profiles | If you... | Use this working space | Notes | |-----------|----------------------|-------| | Export images to web | sRGB (V2 for Firefox compat) | Web standard; use ArgyllCMS or Elle's profiles | | Print on wide-gamut printer | AdobeRGB, ProPhotoRGB, or Rec.2020 | Match the printer gamut | | Edit raw files (high bit depth) | Rec.2020 or ACEScg | Better chromaticity performance than ProPhotoRGB | | Work in VFX/film pipeline | ACEScg | Industry standard; linear gamma | | Edit 8-bit images | sRGB (small gamut, perceptually uniform) | Avoid posterization | | Need radiometrically correct results | Linear gamma (g10) variant | Use only at 16-bit+ to avoid posterization | | Want to edit color and tonality separately | Any + use LCH blend modes | GIMP 2.9+/2.10 required | ## TRC (Tone Reproduction Curve) Reference | TRC Name | Formula/Type | Use Case | |----------|-------------|----------| | Linear (gamma 1.0) | value = code / max | Radiometrically correct editing | | sRGB | Piecewise: linear slope near 0, ~2.4 gamma above | Web standard, display | | Gamma 1.8 | value = (code/max)^(1/1.8) | ProPhotoRGB native, Apple displays | | Gamma 2.2 | value = (code/max)^(1/2.2) | AdobeRGB, Windows displays | | Rec.709 | ~gamma 2.4 (piecewise) | HDTV broadcast standard | | LAB L | Perceptually uniform | CIELAB L* encoding, LCH editing |
-
-
scripts
-
color-difference.py 16.5 KB
#!/usr/bin/env python3 """ Color Difference Calculator Computes perceptual color difference (dE) between two images. Supports CIE76 (dE*ab), CIE94 (dE*94), CIEDE2000 (dE00), and CMC l:c metrics. If colour-science is installed, uses it for accurate CIEDE2000. All metrics have pure-Python fallbacks. Usage: python3 scripts/color-difference.py reference.png modified.png python3 scripts/color-difference.py before.jpg after.jpg --metric de00 python3 scripts/color-difference.py a.tif b.tif --histogram """ import subprocess import sys import os import argparse import json import math # ── Engine detection ────────────────────────────────────────────────────── _has_colour_science = False try: import colour _has_colour_science = True # Verify we can actually compute delta E try: colour.delta_E((50, 0, 0), (50, 1, 0)) except AttributeError: # colour-science is installed but may not have delta_E (old version) _has_colour_science = False except ImportError: pass # ── Tool check ──────────────────────────────────────────────────────────── def check_tool(name, cmd): try: subprocess.run(cmd, capture_output=True, timeout=5) return True except (FileNotFoundError, subprocess.TimeoutExpired): return False # ── Pixel extraction ────────────────────────────────────────────────────── def get_pixel_data(path): """Extract RGB pixel data using ImageMagick text format.""" try: result = subprocess.run( ['convert', path, '-depth', '8', 'txt:-'], capture_output=True, text=True, timeout=30 ) if result.returncode != 0: return None, 0, 0 pixels = [] lines = result.stdout.strip().split('\n')[1:] for line in lines: if ':' in line: parts = line.split(':', 1)[1].strip() rgb_part = parts.split(')')[0].strip('(') r, g, b = [int(x.strip()) for x in rgb_part.split(',')[:3]] pixels.append((r, g, b)) dim_result = subprocess.run( ['identify', '-format', '%w %h', path], capture_output=True, text=True, timeout=10 ) if dim_result.returncode == 0: parts = dim_result.stdout.strip().split() return pixels, int(parts[0]), int(parts[1]) return pixels, 0, 0 except (FileNotFoundError, subprocess.TimeoutExpired, ValueError): return None, 0, 0 # ── Color space conversion (sRGB 8-bit → CIELAB D50) ───────────────────── def srgb_to_linear(rgb): """Convert sRGB 8-bit to linear RGB.""" result = [] for c in rgb: c_norm = c / 255.0 if c_norm <= 0.04045: result.append(c_norm / 12.92) else: result.append(((c_norm + 0.055) / 1.055) ** 2.4) return result def linear_to_xyz(rgb): """Convert linear RGB (sRGB primaries) to XYZ D50.""" r, g, b = rgb x = r * 0.4360747 + g * 0.3850649 + b * 0.1430804 y = r * 0.2225045 + g * 0.7168786 + b * 0.0606169 z = r * 0.0139322 + g * 0.0971045 + b * 0.7141733 return (x, y, z) def xyz_to_lab(xyz): """Convert XYZ to CIELAB (D50 white point).""" xn, yn, zn = 0.9642, 1.0, 0.8249 x, y, z = xyz delta = 6.0 / 29.0 def f(t): if t > delta ** 3: return t ** (1.0 / 3.0) else: return t / (3.0 * delta ** 2) + 4.0 / 29.0 fx, fy, fz = f(x / xn), f(y / yn), f(z / zn) L = 116.0 * fy - 16.0 a = 500.0 * (fx - fy) b = 200.0 * (fy - fz) return (L, a, b) def srgb_to_lab(rgb): """Convert sRGB 8-bit values to CIELAB D50.""" linear = srgb_to_linear(rgb) xyz = linear_to_xyz(linear) return xyz_to_lab(xyz) # ── CIE76: dE*ab ────────────────────────────────────────────────────────── def cie76_dE(lab1, lab2): """CIE76 color difference (simple Euclidean in CIELAB).""" return math.sqrt( (lab1[0] - lab2[0]) ** 2 + (lab1[1] - lab2[1]) ** 2 + (lab1[2] - lab2[2]) ** 2 ) # ── CIE94: dE*94 ────────────────────────────────────────────────────────── def cie94_dE(lab1, lab2, application='graphic_arts'): """ CIE94 color difference. application: 'graphic_arts' (kL=1, K1=0.045, K2=0.015) or 'textiles' (kL=2, K1=0.048, K2=0.014) """ L1, a1, b1 = lab1 L2, a2, b2 = lab2 if application == 'graphic_arts': kL, K1, K2 = 1.0, 0.045, 0.015 else: kL, K1, K2 = 2.0, 0.048, 0.014 dL = L1 - L2 C1 = math.sqrt(a1 ** 2 + b1 ** 2) C2 = math.sqrt(a2 ** 2 + b2 ** 2) dC = C1 - C2 dH_sq = (a1 - a2) ** 2 + (b1 - b2) ** 2 - dC ** 2 # Guard against tiny negative from floating point if dH_sq < 0: dH_sq = 0.0 dH = math.sqrt(dH_sq) SL = 1.0 SC = 1.0 + K1 * C1 SH = 1.0 + K2 * C1 return math.sqrt( (dL / (kL * SL)) ** 2 + (dC / SC) ** 2 + (dH / SH) ** 2 ) # ── CMC l:c ─────────────────────────────────────────────────────────────── def cmc_dE(lab1, lab2, l=2.0, c=1.0): """ CMC l:c color difference. Standard parameters: - Perceptibility: l=1, c=1 - Acceptability: l=2, c=1 (textile industry default) """ L1, a1, b1 = lab1 L2, a2, b2 = lab2 dL = L1 - L2 C1 = math.sqrt(a1 ** 2 + b1 ** 2) C2 = math.sqrt(a2 ** 2 + b2 ** 2) dC = C1 - C2 dH_sq = (a1 - a2) ** 2 + (b1 - b2) ** 2 - dC ** 2 if dH_sq < 0: dH_sq = 0.0 dH = math.sqrt(dH_sq) # Arithmetic mean chroma Cab = (C1 + C2) / 2.0 # Hue angle in degrees def hue_angle(a, b): h = math.degrees(math.atan2(b, a)) if h < 0: h += 360 return h h1 = hue_angle(a1, b1) # SL if L1 < 16: SL = 0.511 else: SL = 0.040975 * L1 / (1 + 0.01765 * L1) # SC SC = 0.0638 * Cab / (1 + 0.0131 * Cab) + 0.638 # SH f = math.sqrt(Cab ** 4 / (Cab ** 4 + 1900)) T = (0.36 + abs(0.4 * math.cos(math.radians(h1 + 35)))) if h1 >= 164 and h1 <= 345: T = 0.56 + abs(0.2 * math.cos(math.radians(h1 + 168))) SH = SC * (f * T + 1 - f) return math.sqrt( (dL / (l * SL)) ** 2 + (dC / (c * SC)) ** 2 + (dH / SH) ** 2 ) # ── CIEDE2000 (dE00) — pure Python fallback ────────────────────────────── def _deg(rad): return rad * 180.0 / math.pi def _rad(deg): return deg * math.pi / 180.0 def ciede2000_dE(lab1, lab2): """ CIEDE2000 color difference. Based on the corrected formula from: Sharma, Wu, Dalal (2005) """ L1, a1, b1 = lab1 L2, a2, b2 = lab2 dLp = L2 - L1 Lbar = (L1 + L2) / 2.0 C1 = math.sqrt(a1 ** 2 + b1 ** 2) C2 = math.sqrt(a2 ** 2 + b2 ** 2) Cbar = (C1 + C2) / 2.0 # G factor for CIEDE2000 G = 0.5 * (1 - math.sqrt(Cbar ** 7 / (Cbar ** 7 + 25 ** 7))) ap1 = a1 * (1 + G) ap2 = a2 * (1 + G) Cp1 = math.sqrt(ap1 ** 2 + b1 ** 2) Cp2 = math.sqrt(ap2 ** 2 + b2 ** 2) Cpbar = (Cp1 + Cp2) / 2.0 dCp = Cp2 - Cp1 # Hue angles def hp(a, b): if a == 0 and b == 0: return 0.0 h = _deg(math.atan2(b, a)) if h < 0: h += 360 return h hp1 = hp(ap1, b1) hp2 = hp(ap2, b2) # dHp dhp = hp2 - hp1 if Cp1 == 0.0 or Cp2 == 0.0: dhp = 0.0 elif abs(dhp) > 180: if dhp <= 180: dhp += 360 else: dhp -= 360 dHp = 2 * math.sqrt(Cp1 * Cp2) * math.sin(_rad(dhp) / 2.0) # Hpbar if Cp1 == 0.0 or Cp2 == 0.0: Hpbar = hp1 + hp2 elif abs(hp1 - hp2) <= 180: Hpbar = (hp1 + hp2) / 2.0 elif abs(hp1 - hp2) > 180 and (hp1 + hp2) < 360: Hpbar = (hp1 + hp2 + 360) / 2.0 else: Hpbar = (hp1 + hp2 - 360) / 2.0 # Lightness weight T_ = ( 1 - 0.17 * math.cos(_rad(Hpbar - 30)) + 0.24 * math.cos(_rad(2 * Hpbar)) + 0.32 * math.cos(_rad(3 * Hpbar + 6)) - 0.20 * math.cos(_rad(4 * Hpbar - 63)) ) # Rotation term dtheta = 30 * math.exp(-((Hpbar - 275) / 25) ** 2) RC = 2 * math.sqrt(Cpbar ** 7 / (Cpbar ** 7 + 25 ** 7)) RT = -math.sin(_rad(2 * dtheta)) * RC # Weights SL = 1 + (0.015 * (Lbar - 50) ** 2) / math.sqrt(20 + (Lbar - 50) ** 2) SC = 1 + 0.045 * Cpbar SH = 1 + 0.015 * Cpbar * T_ dE = math.sqrt( (dLp / SL) ** 2 + (dCp / SC) ** 2 + (dHp / SH) ** 2 + RT * (dCp / SC) * (dHp / SH) ) return dE # ── Dispatcher ──────────────────────────────────────────────────────────── def compute_dE(lab1, lab2, metric, **kwargs): """Dispatch to the correct delta-E implementation.""" if metric == 'cie76': return cie76_dE(lab1, lab2) elif metric == 'cie94': return cie94_dE(lab1, lab2, **kwargs) elif metric == 'de00': if _has_colour_science: try: import colour return float(colour.delta_E(lab1, lab2)) except Exception: pass return ciede2000_dE(lab1, lab2) elif metric == 'cmc': return cmc_dE(lab1, lab2, **kwargs) return cie76_dE(lab1, lab2) def engine_label(metric): """Report which engine computed the result.""" if metric == 'de00' and _has_colour_science: return 'colour-science library' elif metric in ('cie76', 'cie94', 'cmc'): return 'pure Python (closed-form)' elif metric == 'de00': return 'pure Python (Sharma/Wu/Dalal 2005)' return 'pure Python' # ── Image-level computation ─────────────────────────────────────────────── def compute_de_image(pixels_a, pixels_b, width, height, metric): """Compute dE for all pixels with the given metric. Returns (stats_dict, raw_de_values).""" de_values = [] n = len(pixels_a) print(f" Computing {metric.upper()} for {n:,} pixels...") for i, (rgb_a, rgb_b) in enumerate(zip(pixels_a, pixels_b)): lab_a = srgb_to_lab(rgb_a) lab_b = srgb_to_lab(rgb_b) de = compute_dE(lab_a, lab_b, metric) de_values.append(de) if (i + 1) % max(n // 10, 1) == 0: pct = (i + 1) / n * 100 sys.stdout.write(f"\r Progress: {pct:.0f}%") sys.stdout.flush() print() # Statistics de_sum = sum(de_values) de_sq_sum = sum(d * d for d in de_values) mean_de = de_sum / n max_de = max(de_values) min_de = min(de_values) std_de = math.sqrt(de_sq_sum / n - mean_de ** 2) # Percentiles sorted_de = sorted(de_values) p50 = sorted_de[n // 2] p95 = sorted_de[int(n * 0.95)] p99 = sorted_de[int(n * 0.99)] # Categories imperceptible = sum(1 for d in de_values if d < 1.0) perceptible = sum(1 for d in de_values if 1.0 <= d < 3.0) noticeable = sum(1 for d in de_values if 3.0 <= d < 6.0) significant = sum(1 for d in de_values if 6.0 <= d < 10.0) extreme = sum(1 for d in de_values if d >= 10.0) return { 'metric': metric, 'engine': engine_label(metric), 'count': n, 'width': width, 'height': height, 'mean': mean_de, 'std': std_de, 'min': min_de, 'max': max_de, 'p50': p50, 'p95': p95, 'p99': p99, 'categories': { 'imperceptible (dE<1)': (imperceptible, imperceptible / n * 100), 'perceptible (1-3)': (perceptible, perceptible / n * 100), 'noticeable (3-6)': (noticeable, noticeable / n * 100), 'significant (6-10)': (significant, significant / n * 100), 'extreme (10+)': (extreme, extreme / n * 100), } } return stats, de_values # ── Main ────────────────────────────────────────────────────────────────── def main(): parser = argparse.ArgumentParser( description='Calculate perceptual color difference (dE) between two images' ) parser.add_argument('reference', help='Reference image') parser.add_argument('modified', help='Modified image') parser.add_argument('--metric', choices=['cie76', 'cie94', 'de00', 'cmc'], default='de00', help='Color difference metric (default: de00)') parser.add_argument('--histogram', action='store_true', help='Show dE distribution as ASCII histogram') parser.add_argument('--json', action='store_true', help='Output as JSON') args = parser.parse_args() if not check_tool('convert', ['convert', '-version']): print("Error: ImageMagick (convert) required.") print("Install: brew install imagemagick") sys.exit(1) for f in [args.reference, args.modified]: if not os.path.exists(f): print(f"Error: file not found: {f}") sys.exit(1) print(f"Color Difference Analysis") print(f" Reference: {args.reference}") print(f" Modified: {args.modified}") print(f" Metric: {args.metric.upper()}") if _has_colour_science: print(f" Engine: colour-science library available") else: print(f" Engine: pure Python fallback (install colour-science for better accuracy)") print() pixels_a, w_a, h_a = get_pixel_data(args.reference) pixels_b, w_b, h_b = get_pixel_data(args.modified) if pixels_a is None or pixels_b is None: print("Error: could not read image pixels.") print("Install ImageMagick with: brew install imagemagick") sys.exit(1) if len(pixels_a) != len(pixels_b): print(f"Error: image dimensions differ.") print(f" Reference: {w_a}×{h_a} = {len(pixels_a)} pixels") print(f" Modified: {w_b}×{h_b} = {len(pixels_b)} pixels") sys.exit(1) stats, de_values = compute_de_image(pixels_a, pixels_b, w_a, h_a, args.metric) if args.json: print(json.dumps(stats, indent=2)) return # Print report print(f"\n === {stats['metric'].upper()} Color Difference Report ===") print(f" Image: {w_a}×{h_a} ({stats['count']:,} pixels)") print(f" Metric: {stats['metric'].upper()}") print(f" Engine: {stats['engine']}") print() print(f" Statistics:") print(f" Mean dE: {stats['mean']:.3f}") print(f" Std dev: {stats['std']:.3f}") print(f" Min dE: {stats['min']:.3f}") print(f" Max dE: {stats['max']:.3f}") print(f" Median: {stats['p50']:.3f}") print(f" 95th pctl: {stats['p95']:.3f}") print(f" 99th pctl: {stats['p99']:.3f}") print() print(f" Breakdown:") for label, (count, pct) in stats['categories'].items(): bar = '█' * int(pct / 2) print(f" {label:<25}: {count:>8,} ({pct:5.1f}%) {bar}") if args.histogram: print(f"\n Distribution:") for bucket in range(0, int(stats['max']) + 2, 2): count = sum(1 for d in de_values if bucket <= d < bucket + 2) pct = count / stats['count'] * 100 bar = '█' * max(int(pct * 2), 1) if pct > 0 else '' print(f" {bucket:>3}-{bucket+2:<3}: {bar} ({pct:.1f}%)") print() if stats['mean'] < 1.0 and stats['max'] < 5.0: print(f" ✅ Colors are visually nearly identical") elif stats['mean'] < 3.0: print(f" ⚠️ Small visible differences — within typical reproduction tolerance") elif stats['mean'] < 6.0: print(f" ⚠️ Noticeable differences — check the working space and intent settings") else: print(f" ❌ Large differences — likely a different color space or significant gamut clipping") if __name__ == '__main__': main() -
color-report.py 6.5 KB
#!/usr/bin/env python3 """ Color Report Generator Unified analysis of an image and its ICC profile. Combines profile inspection, well-behaved check, and gamut check into a single comprehensive report. Outputs to stdout or a markdown file. Usage: python3 scripts/color-report.py image.jpg python3 scripts/color-report.py image.tif --output report.md python3 scripts/color-report.py image.jpg --profile sRGB.icc """ import subprocess import sys import os import argparse from datetime import datetime def check_tool(name, cmd): try: subprocess.run(cmd, capture_output=True, timeout=5) return True except (FileNotFoundError, subprocess.TimeoutExpired): return False def run_script(script, *args): """Run one of our analysis scripts and capture output.""" script_path = os.path.join(os.path.dirname(__file__), script) if not os.path.exists(script_path): return f"[Script not found: {script}]" cmd = [sys.executable or 'python3', script_path] + list(args) try: result = subprocess.run(cmd, capture_output=True, text=True, timeout=120) if result.returncode == 0: return result.stdout else: return f"[Script error ({script}): {result.stderr[:500]}]" except subprocess.TimeoutExpired: return f"[Script timed out: {script}]" except FileNotFoundError: return f"[Python not found]" def get_identify_info(path): """Get basic image info from ImageMagick.""" try: result = subprocess.run( ['identify', '-verbose', path], capture_output=True, text=True, timeout=15 ) if result.returncode != 0: return "" info = {} for line in result.stdout.split('\n'): s = line.strip() if 'Type:' in s: info['type'] = s.split(':', 1)[1].strip() if 'Colorspace:' in s and 'colorspace' not in info: info['colorspace'] = s.split(':', 1)[1].strip() if 'Depth:' in s: info['depth'] = s.split(':', 1)[1].strip() if 'Profile-icc' in s: info['icc'] = s.split(':', 1)[1].strip() if 'Geometry:' in s: info['geometry'] = s.split(':', 1)[1].strip() if 'Channel statistics' in s.split(':')[0]: break parts = [] for k in ['type', 'colorspace', 'depth', 'geometry', 'icc']: if k in info: parts.append(f"- **{k}**: {info[k]}") return '\n'.join(parts) except FileNotFoundError: return "[ImageMagick not found]" def main(): parser = argparse.ArgumentParser( description='Generate a comprehensive color analysis report for an image' ) parser.add_argument('input', help='Input image file') parser.add_argument('--profile', '-p', help='Target profile for gamut check (default: detect from image)') parser.add_argument('--output', '-o', help='Write report to file instead of stdout') args = parser.parse_args() if not os.path.exists(args.input): print(f"Error: input not found: {args.input}") sys.exit(1) image_path = os.path.abspath(args.input) image_name = os.path.basename(image_path) report = [] # Header report.append(f"# Color Analysis Report: {image_name}") report.append(f"Generated: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}") report.append("") report.append("## Required Tools Status") report.append("") report.append(f"| Tool | Status |") report.append(f"|------|--------|") for tool_name, check_cmd in [ ('ImageMagick', ['convert', '-version']), ('Exiftool', ['exiftool', '-ver']), ('ArgyllCMS (xicclu)', ['xicclu', '-v']), ('LittleCMS (transicc)', ['transicc', '-v']), ]: status = '✅' if check_tool(tool_name, [check_cmd[0], '--version' if check_cmd[0] != 'xicclu' else '-v']) or check_tool(tool_name, check_cmd) else '❌' if tool_name == 'ImageMagick' and status == '❌': status = '❌ (install: brew install imagemagick)' report.append(f"| {tool_name} | {status} |") # Image info report.append("") report.append("## Image Information") report.append("") info = get_identify_info(image_path) if info: report.append(info) else: report.append("*ImageMagick identify not available*") # ICC profile inspection report.append("") report.append("## ICC Profile Inspection") report.append("") report.append("```") insp_output = run_script('icc-profile-inspect.py', image_path) report.append(insp_output.strip()) report.append("```") # Well-behaved check report.append("") report.append("## Well-Behaved Check") report.append("") report.append("```") wb_output = run_script('well-behaved-check.py', image_path) report.append(wb_output.strip()) report.append("```") # Gamut check (if profile specified or we can find one) target_profile = args.profile if not target_profile: # Try to detect sRGB profile on the system for d in ['/usr/share/color/icc/', '/usr/local/share/color/icc/', os.path.expanduser('~/.local/share/color/icc/')]: p = os.path.join(d, 'sRGB.icm') if os.path.exists(p): target_profile = p break p2 = os.path.join(d, 'sRGB.icc') if os.path.exists(p2): target_profile = p2 break if target_profile: report.append("") report.append(f"## Gamut Check (target: {os.path.basename(target_profile)})") report.append("") report.append("```") gamut_output = run_script('gamut-check.py', image_path, '--to-profile', target_profile) report.append(gamut_output.strip()) report.append("```") else: report.append("") report.append("## Gamut Check") report.append("") report.append("*No target profile specified or found on system.*") report.append(" Re-run with --profile <path> to check gamut clipping.") # Summary report.append("") report.append("---") report.append("*Report generated by color-management/scripts/color-report.py*") report_text = '\n'.join(report) if args.output: with open(args.output, 'w') as f: f.write(report_text) print(f"Report saved to: {args.output}") else: print(report_text) if __name__ == '__main__': main() -
color-space-convert.py 9.1 KB
#!/usr/bin/env python3 """ Color Space Converter Convert images between ICC color spaces with configurable intent and BPC. Uses ImageMagick (convert) for ICC profile conversions. Optionally uses LittleCMS (tificc) for 32-bit floating point / unbounded conversions. Usage: python3 scripts/color-space-convert.py input.jpg --to-srgb python3 scripts/color-space-convert.py input.tif --from ProPhotoRGB.icc --to sRGB.icc --intent perceptual python3 scripts/color-space-convert.py input.tif --info # just show current color space """ import subprocess import sys import os import argparse def check_tool(name: str, cmd: list) -> bool: """Check if CLI tool is available.""" try: subprocess.run(cmd, capture_output=True, timeout=5) return True except (FileNotFoundError, subprocess.TimeoutExpired): return False def detect_colorspace(path: str) -> dict: """Use ImageMagick to detect image color space and profile info.""" try: result = subprocess.run( ['identify', '-verbose', path], capture_output=True, text=True, timeout=15 ) if result.returncode != 0: return {} info = {} for line in result.stdout.split('\n'): stripped = line.strip() if 'Type:' in stripped and 'Type:' not in info: info['type'] = stripped.split(':', 1)[1].strip() if 'Colorspace:' in stripped: info['colorspace'] = stripped.split(':', 1)[1].strip() if 'Profile-icc' in stripped: info['icc'] = stripped.split(':', 1)[1].strip() if 'Depth:' in stripped: info['depth'] = stripped.split(':', 1)[1].strip() return info except FileNotFoundError: return {} def get_profile_path(identifier: str) -> str: """Resolve a profile name to a known path.""" known_profiles = { 'srgb': 'sRGB.icm', 'adobergb': 'AdobeRGB1998.icc', 'prophoto': 'ProPhotoRGB.icc', 'widegamut': 'WideGamutRGB.icc', 'rec2020': 'Rec2020.icc', 'linear': 'sRGB-elle-V4-g10.icc', 'linear-srgb': 'sRGB-elle-V4-g10.icc', 'linear-prophoto': 'ProPhoto-elle-V4-g10.icc', 'aces': 'ACES-elle-V4-g10.icc', 'acescg': 'ACEScg-elle-V4-g10.icc', 'gray': 'Gray-D50-elle-V4-srgbtrc.icc', 'lab': 'Lab-D50-Identity-elle-V4.icc', 'xyz': 'XYZ-D50-Identity-elle-V4.icc', } if identifier.lower() in known_profiles: return known_profiles[identifier.lower()] # Assume it's a file path if os.path.exists(identifier): return identifier # Check common ICC directories common_dirs = [ '/usr/share/color/icc/', '/usr/local/share/color/icc/', os.path.expanduser('~/.local/share/color/icc/'), os.path.expanduser('~/.color/icc/'), ] for d in common_dirs: full_path = os.path.join(d, identifier) if os.path.exists(full_path): return full_path # Also check if it's just the filename for root, dirs, files in os.walk(d): if identifier in files: return os.path.join(root, identifier) return identifier # return original, let ImageMagick fail with helpful message def main(): parser = argparse.ArgumentParser( description='Convert images between ICC color spaces' ) parser.add_argument('input', help='Input image file') parser.add_argument('--to-srgb', action='store_true', help='Convert to sRGB (most common operation)') parser.add_argument('--to-prophoto', action='store_true', help='Convert to ProPhotoRGB') parser.add_argument('--to-adobe', action='store_true', help='Convert to AdobeRGB') parser.add_argument('--profile', '-p', help='Source profile (default: embedded or auto-detect)') parser.add_argument('--to', '-t', help='Destination profile (path or name)') parser.add_argument('--output', '-o', help='Output file (default: input-converted.ext)') parser.add_argument('--intent', '-i', choices=['perceptual', 'relative', 'saturation', 'absolute'], default='relative', help='Rendering intent (default: relative)') parser.add_argument('--bpc', action='store_true', default=True, help='Use black point compensation (default: on)') parser.add_argument('--no-bpc', action='store_true', help='Disable black point compensation') parser.add_argument('--unbounded', action='store_true', help='Use LCMS2 unbounded mode (32-bit float, requires true gamma TRC)') parser.add_argument('--info', action='store_true', help='Just show color space info, no conversion') parser.add_argument('--preview', action='store_true', help='Preview: create gamut check warning overlay') args = parser.parse_args() if not os.path.exists(args.input): print(f"Error: input file not found: {args.input}") sys.exit(1) # Check tools has_magick = check_tool('convert', ['convert', '-version']) has_tificc = check_tool('tificc', ['tificc', '-?']) if not has_magick: print("Error: ImageMagick (convert) not found.") print("Install: brew install imagemagick") sys.exit(1) # Show info mode if args.info: info = detect_colorspace(args.input) print(f"Color Space Info: {args.input}") print(f" {'=' * 40}") for key, val in info.items(): print(f" {key}: {val}") return # Determine destination profile dest_profile = None if args.to_srgb: dest_profile = get_profile_path('srgb') elif args.to_prophoto: dest_profile = get_profile_path('prophoto') elif args.to_adobe: dest_profile = get_profile_path('adobergb') elif args.to: dest_profile = get_profile_path(args.to) if not dest_profile: print("Error: no destination profile specified.") print("Use --to-srgb, --to-prophoto, --to-adobe, or --to <profile>") sys.exit(1) # Determine output path if args.output: output = args.output else: base, ext = os.path.splitext(args.input) dest_name = os.path.splitext(os.path.basename(dest_profile))[0] output = f"{base}-to-{dest_name}{ext}" # Build ImageMagick command use_bpc = not args.no_bpc and args.bpc intent_map = { 'perceptual': 'Perceptual', 'relative': 'Relative', 'saturation': 'Saturation', 'absolute': 'Absolute', } if args.unbounded and has_tificc: # Use LCMS2 unbounded mode print("Using LCMS2 unbounded mode (32-bit float)...") cmd = [ 'tificc', '-c', '0', '-w', '32', '-e', '-t', str(['Perceptual', 'Relative', 'Saturation', 'Absolute'].index(intent_map[args.intent])), ] if args.profile: cmd.extend(['-i', get_profile_path(args.profile)]) else: # Need source profile; extract from image first src_info = detect_colorspace(args.input) if 'icc' in src_info and src_info['icc'] != '0 bytes': cmd.extend(['-i', dest_profile]) # tificc will extract from image else: cmd.extend(['-i', dest_profile]) cmd.extend(['-o', dest_profile, args.input, output]) else: # Use ImageMagick cmd = ['convert', args.input] # Source profile (optional — if not specified, use embedded) if args.profile: cmd.extend(['-profile', get_profile_path(args.profile)]) # Black point compensation if use_bpc: cmd.append('-black-point-compensation') # Intent cmd.extend(['-intent', intent_map[args.intent]]) # Destination profile cmd.extend(['-profile', dest_profile]) # Output cmd.append(output) # Execute print(f"Converting: {args.input}") print(f" Intent: {args.intent}") print(f" BPC: {'on' if use_bpc else 'off'}") print(f" Destination: {dest_profile}") print(f" Output: {output}") print(f" Command: {' '.join(cmd)}") try: result = subprocess.run(cmd, capture_output=True, text=True, timeout=300) if result.returncode == 0: size = os.path.getsize(output) print(f"\n✅ Conversion complete: {output} ({size:,} bytes)") # Show result info info = detect_colorspace(output) if info.get('icc'): print(f" Embedded profile: {info['icc']}") if info.get('colorspace'): print(f" Color space: {info['colorspace']}") else: print(f"\n❌ Conversion failed:") if result.stderr: print(f" {result.stderr[:500]}") sys.exit(1) except subprocess.TimeoutExpired: print("\n❌ Conversion timed out after 300 seconds.") print(" Try a smaller image or check that profiles are valid.") sys.exit(1) if __name__ == '__main__': main() -
gamut-check.py 7.2 KB
#!/usr/bin/env python3 """ Gamut Checker Analyzes which pixels in an image are out of gamut with respect to a target ICC color space. Produces a visual overlay highlighting out-of-gamut regions and counts affected pixels. Uses ImageMagick for pixel-level analysis. Requires the target ICC profile. How it works: 1. Convert image to target color space using LCMS2 bounded mode 2. Compare pixel values before and after to find out-of-gamut clipping 3. Visualize clipped regions as a colored overlay Usage: python3 scripts/gamut-check.py input.jpg --to-profile sRGB.icc python3 scripts/gamut-check.py input.tif --to-profile ProPhotoRGB.icc --overlay gamut-overlay.png """ import subprocess import sys import os import argparse def check_tool(name, cmd): try: subprocess.run(cmd, capture_output=True, timeout=5) return True except (FileNotFoundError, subprocess.TimeoutExpired): return False def get_profile_path(identifier): """Resolve a profile name.""" known = { 'srgb': 'sRGB.icm', 'adobergb': 'AdobeRGB1998.icc', 'prophoto': 'ProPhotoRGB.icc', 'widegamut': 'WideGamutRGB.icc', 'rec2020': 'Rec2020.icc', } if identifier.lower() in known: return known[identifier.lower()] if os.path.exists(identifier): return identifier # Check common paths for d in ['/usr/share/color/icc/', '/usr/local/share/color/icc/', os.path.expanduser('~/.local/share/color/icc/')]: p = os.path.join(d, identifier) if os.path.exists(p): return p return identifier def main(): parser = argparse.ArgumentParser( description='Check which image pixels are out of gamut for a target color space' ) parser.add_argument('input', help='Input image') parser.add_argument('--to-profile', '-p', required=True, help='Target ICC profile (path or name)') parser.add_argument('--overlay', '-o', help='Output overlay image showing out-of-gamut regions in red') parser.add_argument('--verbose', '-v', action='store_true', help='Show detailed channel statistics') args = parser.parse_args() if not os.path.exists(args.input): print(f"Error: input not found: {args.input}") sys.exit(1) if not check_tool('convert', ['convert', '-version']): print("Error: ImageMagick (convert) required.") sys.exit(1) target_profile = get_profile_path(args.to_profile) print(f"Gamut Check: {args.input}") print(f" Target profile: {target_profile}") base, ext = os.path.splitext(args.input) converted = f"{base}-gamut-converted{ext}" # Convert to target profile print(f" Converting to target...") conv_result = subprocess.run( ['convert', args.input, '-profile', target_profile, '-intent', 'Relative', converted], capture_output=True, text=True, timeout=120 ) if conv_result.returncode != 0: print(f" ❌ Conversion failed: {conv_result.stderr[:200]}") sys.exit(1) # Compare using ImageMagick print(f" Analyzing gamut clipping...") # Use compare to find differing pixels diff_file = f"{base}-gamut-diff{ext}" subprocess.run( ['compare', '-metric', 'AE', args.input, converted, diff_file], capture_output=True, text=True, timeout=60 ) # Count total differing pixels metric_result = subprocess.run( ['compare', '-metric', 'AE', '-verbose', args.input, converted, '/dev/null'], capture_output=True, text=True, timeout=60 ) # Also get image dimensions dim_result = subprocess.run( ['identify', '-format', '%w %h %[channels]', args.input], capture_output=True, text=True, timeout=10 ) # Parse metrics total_pixels = 0 clipped_pixels = 0 channel_stats = {} if dim_result.returncode == 0: parts = dim_result.stdout.strip().split() if len(parts) >= 2: try: w, h = int(parts[0]), int(parts[1]) total_pixels = w * h except ValueError: pass # Extract AE metric from stderr for line in metric_result.stderr.split('\n'): line = line.strip() if line.isdigit(): clipped_pixels = int(line) break # Get per-channel statistics if args.verbose: for channel in ['red', 'green', 'blue']: ch_result = subprocess.run( ['identify', '-verbose', converted], capture_output=True, text=True, timeout=10 ) in_ch = False for line in ch_result.stdout.split('\n'): if f'Channel {channel}:' in line.lower() or f'{channel}:' in line.lower(): in_ch = True if in_ch and 'min:' in line.lower(): channel_stats[channel] = line.strip() in_ch = False # Report pct = (clipped_pixels / total_pixels * 100) if total_pixels > 0 else 0 print(f"\n === Gamut Analysis Results ===") print(f" Image dimensions: {w}×{h} = {total_pixels:,} total pixels") print(f" Out of gamut: {clipped_pixels:,} pixels ({pct:.2f}%)") if args.verbose and channel_stats: print(f"\n Channel extremes after conversion:") for ch, stat in channel_stats.items(): print(f" {ch}: {stat}") if pct == 0: print(f"\n ✅ All colors fit within the target color space gamut.") elif pct < 1: print(f"\n ⚠️ Fewer than 1% of pixels are out of gamut.") print(f" Likely negligible for most purposes.") elif pct < 10: print(f"\n ⚠️ {pct:.1f}% of pixels are out of gamut.") print(f" Soft proof before final conversion. Consider:") print(f" • Using perceptual intent if target profile supports it") print(f" • Reducing chroma/saturation in affected regions") print(f" • Using a larger intermediate working space") else: print(f"\n ❌ {pct:.1f}% of pixels are out of gamut — significant clipping.") print(f" This will cause visible loss of detail and hue shifts.") print(f" Recommended actions:") print(f" 1. Soft proof the image before final output") print(f" 2. Consider a wider gamut output profile") print(f" 3. Reduce saturation in affected regions") print(f" 4. Try perceptual intent (if target is LUT profile)") # Create visual overlay if requested if args.overlay: print(f"\n Creating gamut overlay: {args.overlay}") overlay_result = subprocess.run( ['convert', converted, '-alpha', 'set', '-channel', 'RGBA', '-negate', '-fill', 'red', '-opaque', 'black', args.overlay], capture_output=True, text=True, timeout=30 ) if overlay_result.returncode == 0: print(f" ✅ Overlay saved to: {args.overlay}") else: print(f" ❌ Overlay failed: {overlay_result.stderr[:200]}") # Cleanup temp files for f in [converted, diff_file]: if os.path.exists(f): os.remove(f) if __name__ == '__main__': main() -
icc-profile-inspect.py 11.5 KB
#!/usr/bin/env python3 """ ICC Profile Inspector Extracts metadata, primaries, TRC info, and well-behaved verification from ICC color profiles. Requires: Python 3.8+, Pillow (optional, for ICC chunk extraction) Tests for: exiftool (for detailed ICC metadata), identify (ImageMagick) Usage: python3 scripts/icc-profile-inspect.py path/to/profile.icc python3 scripts/icc-profile-inspect.py path/to/image.jpg # extract embedded """ import struct import sys import os import subprocess import json from dataclasses import dataclass, field from typing import Optional, List, Tuple @dataclass class ICCProfile: """Parsed ICC profile data.""" filename: str = "" size: int = 0 profile_class: str = "" color_space: str = "" pcs: str = "" version: str = "" cmm: str = "" description: str = "" copyright_text: str = "" manufacturer: str = "" model: str = "" white_point: Tuple[float, float, float] = (0.0, 0.0, 0.0) red_primary: Tuple[float, float] = (0.0, 0.0) green_primary: Tuple[float, float] = (0.0, 0.0) blue_primary: Tuple[float, float] = (0.0, 0.0) red_y: float = 0.0 green_y: float = 0.0 blue_y: float = 0.0 trc_type: str = "unknown" is_matrix: bool = False is_lut: bool = False tags: dict = field(default_factory=dict) # Profile class IDs PROFILE_CLASSES = { b'scnr': 'Input Device (scanner/camera)', b'mntr': 'Display Device (monitor)', b'prtr': 'Output Device (printer)', b'link': 'DeviceLink', b'spac': 'ColorSpace (working space)', b'abst': 'Abstract', b'nmed': 'Named Color', } # Color space IDs COLOR_SPACES = { b'RGB ': 'RGB', b'CMYK': 'CMYK', b'Lab ': 'CIELAB', b'XYZ ': 'XYZ', b'GRAY': 'Gray', b'YCCK': 'YCCK', b"Y'Cb": 'YCbCr', } # CMM IDs CMM_IDS = { b'none': 'None', b'lcms': 'LittleCMS', b'ADBE': 'Adobe', b'ACMS': 'Agfa', b'appl': 'Apple', b'KCMS': 'Kodak', b'MSFT': 'Microsoft', b'SGIO': 'SGI', b'SUNW': 'Sun', b'argl': 'ArgyllCMS', b'CCMS': 'ColorGear', } def check_tool(name: str, cmd: List[str]) -> bool: """Check if a CLI tool is available.""" try: subprocess.run(cmd, capture_output=True, timeout=5) return True except (FileNotFoundError, subprocess.TimeoutExpired): return False def s15fixed16_to_float(raw: bytes) -> float: """Convert ICC s15Fixed16Number to float.""" val = struct.unpack('>i', raw)[0] return val / 65536.0 def parse_icc_binary(data: bytes) -> ICCProfile: """Parse ICC profile binary data.""" prof = ICCProfile() prof.size = struct.unpack('>I', data[0:4])[0] # Version (4.3.0.0 format) major = data[8] minor_bugfix = data[9] minor = (minor_bugfix >> 4) & 0x0F bugfix = minor_bugfix & 0x0F prof.version = f"{major}.{minor}.{bugfix}" # Profile class prof.profile_class = PROFILE_CLASSES.get(data[12:16], data[12:16].decode('ascii', errors='replace')) # Color space prof.color_space = COLOR_SPACES.get(data[16:20], data[16:20].decode('ascii', errors='replace')) # PCS prof.pcs = COLOR_SPACES.get(data[20:24], data[20:24].decode('ascii', errors='replace')) # CMM cmm_tag = data[4:8] prof.cmm = CMM_IDS.get(cmm_tag, cmm_tag.decode('ascii', errors='replace')) # White point tag # Find 'wtpt' tag for i in range(128, prof.size - 12): if data[i:i+4] == b'wtpt': wtpt_offset = struct.unpack('>I', data[i+4:i+8])[0] prof.white_point = ( s15fixed16_to_float(data[wtpt_offset+8:wtpt_offset+12]), s15fixed16_to_float(data[wtpt_offset+12:wtpt_offset+16]), s15fixed16_to_float(data[wtpt_offset+16:wtpt_offset+20]), ) break return prof def inspect_with_exiftool(path: str) -> dict: """Use exiftool to get ICC profile metadata.""" try: result = subprocess.run( ['exiftool', '-ICC_Profile:all', '-G', '-j', path], capture_output=True, text=True, timeout=15 ) if result.returncode == 0 and result.stdout.strip(): data = json.loads(result.stdout) if data: return data[0] except (FileNotFoundError, subprocess.TimeoutExpired, json.JSONDecodeError): pass return {} def inspect_with_imagemagick(path: str) -> dict: """Use ImageMagick identify to get ICC info.""" try: result = subprocess.run( ['identify', '-verbose', path], capture_output=True, text=True, timeout=15 ) if result.returncode == 0: info = {} in_icc = False for line in result.stdout.split('\n'): if 'Profile-icc' in line: in_icc = True parts = line.split(':', 1) if len(parts) == 2: info['icc_size'] = parts[1].strip() elif in_icc and ':' in line: key, val = line.split(':', 1) info[key.strip()] = val.strip() elif in_icc and not line.strip(): in_icc = False # Capture general info if 'Type:' in line: info['type'] = line.split(':', 1)[1].strip() if 'Colorspace:' in line: info['colorspace'] = line.split(':', 1)[1].strip() return info except FileNotFoundError: pass return {} def find_icc_profile(image_path: str) -> Optional[str]: """Extract embedded ICC profile from an image to a temp file.""" # Try exiftool first try: result = subprocess.run( ['exiftool', '-b', '-ICC_Profile', image_path], capture_output=True, timeout=15 ) if result.returncode == 0 and len(result.stdout) > 100: temp_path = image_path + '.extracted.icc' with open(temp_path, 'wb') as f: f.write(result.stdout) return temp_path except (FileNotFoundError, subprocess.TimeoutExpired): pass # Try ImageMagick try: result = subprocess.run( ['convert', image_path, 'profile.icc'], capture_output=True, timeout=15 ) if result.returncode == 0 and os.path.exists('profile.icc'): return 'profile.icc' except FileNotFoundError: pass return None def check_well_behaved(path: str) -> Optional[dict]: """Use xicclu to check if profile is well-behaved.""" try: results = {} for rgb_str, expected in [ ("255 255 255", (100.0, 0.0, 0.0)), ("0 0 0", (0.0, 0.0, 0.0)), ("128 128 128", (None, 0.0, 0.0)), ]: proc = subprocess.run( ['xicclu', '-ir', '-pl', '-s255', '-v0', path], input=rgb_str + '\n', capture_output=True, text=True, timeout=10 ) if proc.returncode == 0: line = proc.stdout.strip().split('\n')[0] parts = line.split() if len(parts) >= 3: results[rgb_str] = { 'L': float(parts[0]), 'a': float(parts[1]), 'b': float(parts[2]), } return results if results else None except (FileNotFoundError, subprocess.TimeoutExpired, ValueError): return None def main(): if len(sys.argv) < 2: print("Usage: python3 icc-profile-inspect.py <profile.icc>") print(" or: python3 icc-profile-inspect.py <image.jpg>") print("\nExtracts ICC profile metadata, primaries, and TRC info.") print("Requires: exiftool or ImageMagick (one must be installed)") sys.exit(1) path = sys.argv[1] if not os.path.exists(path): print(f"Error: file not found: {path}") sys.exit(1) # Check if it's an image with embedded profile ext = os.path.splitext(path)[1].lower() image_exts = {'.jpg', '.jpeg', '.tif', '.tiff', '.png', '.psd', '.webp', '.avif'} icc_path = path if ext in image_exts: print(f"Detected image file: {path}") print("Attempting to extract embedded ICC profile...\n") extracted = find_icc_profile(path) if extracted: print(f"Extracted profile to: {extracted}") icc_path = extracted else: print("No embedded ICC profile found or extraction tools missing.") sys.exit(1) # Read binary ICC data with open(icc_path, 'rb') as f: data = f.read() if len(data) < 128: print(f"Error: file too small to be a valid ICC profile ({len(data)} bytes)") sys.exit(1) # Signature check if data[36:40] != b'acsp': print("Warning: File does not have standard ICC signature ('acsp')") print("Attempting to parse anyway...\n") # Parse binary header prof = parse_icc_binary(data) # Get exiftool data if available exif_data = inspect_with_exiftool(icc_path) im_data = inspect_with_imagemagick(icc_path) # Also check with ArgyllCMS xicclu if available wb_results = check_well_behaved(icc_path) # Print report print("=" * 60) print(f" ICC Profile Analysis: {os.path.basename(icc_path)}") print("=" * 60) print(f"\n File Size: {prof.size} bytes") print(f" Profile Class: {prof.profile_class}") print(f" Color Space: {prof.color_space}") print(f" PCS: {prof.pcs}") print(f" Version: {prof.version}") print(f" CMM: {prof.cmm}") if exif_data: desc = exif_data.get('ICC_Profile:ProfileDescription', '') if desc: print(f" Description: {desc}") copyright_str = exif_data.get('ICC_Profile:ProfileCopyright', '') if copyright_str: print(f" Copyright: {copyright_str}") device = exif_data.get('ICC_Profile:DeviceMfgDesc', '') if device: print(f" Device: {device}") print(f"\n White Point (XYZ): ({prof.white_point[0]:.5f}, {prof.white_point[1]:.5f}, {prof.white_point[2]:.5f})") if wb_results: print(f"\n --- Well-Behaved Check (xicclu) ---") for rgb, vals in wb_results.items(): status = "✅" if (abs(vals['a']) < 0.001 and abs(vals['b']) < 0.001) else "❌" print(f" RGB({rgb}) → Lab({vals['L']:.4f}, {vals['a']:.4f}, {vals['b']:.4f}) {status}") is_wb = all( abs(v['a']) < 0.001 and abs(v['b']) < 0.001 for v in wb_results.values() ) print(f"\n Overall: {'✅ WELL-BEHAVED' if is_wb else '❌ NOT well-behaved'}") else: print("\n (Install ArgyllCMS (xicclu) for well-behaved verification)") # Tool availability print(f"\n --- Available Tools ---") print(f" ImageMagick: {'✅' if check_tool('identify', ['identify', '-version']) else '❌'} (install: brew install imagemagick)") print(f" Exiftool: {'✅' if check_tool('exiftool', ['exiftool', '-ver']) else '❌'} (install: brew install exiftool)") print(f" ArgyllCMS: {'✅' if check_tool('xicclu', ['xicclu', '-v']) else '❌'} (install: brew install argyllcms)") print(f" LCMS: {'✅' if check_tool('transicc', ['transicc', '-v']) else '❌'} (install: brew install littlecms)") # Cleanup if icc_path != path and icc_path.endswith('.extracted.icc'): os.remove(icc_path) if os.path.exists('profile.icc') and icc_path != path: os.remove('profile.icc') if __name__ == '__main__': main() -
srgb-compare.py 6.6 KB
#!/usr/bin/env python3 """ sRGB Profile Comparator Compares multiple sRGB ICC profile variants against a reference (e.g., ArgyllCMS sRGB.icm) to identify differences in white point, neutral axis, and primary coordinates. Helps answer: "Which sRGB profile should I use?" Usage: python3 scripts/srgb-compare.py [--dir /path/to/icc/profiles/] python3 scripts/srgb-compare.py --compare profile1.icc profile2.icc """ import subprocess import sys import os import argparse import glob def check_tool(name, cmd): try: subprocess.run(cmd, capture_output=True, timeout=5) return True except (FileNotFoundError, subprocess.TimeoutExpired): return False def get_xicclu_rgb(path, r, g, b, scale=255): """Get CIELAB values for RGB input using xicclu.""" try: proc = subprocess.run( ['xicclu', '-ir', '-pl', f'-s{scale}', '-v0', path], input=f"{r} {g} {b}\n", capture_output=True, text=True, timeout=10 ) if proc.returncode == 0: line = proc.stdout.strip().split('\n')[0] parts = line.split() if len(parts) >= 3: return {'L': float(parts[0]), 'a': float(parts[1]), 'b': float(parts[2])} except (FileNotFoundError, subprocess.TimeoutExpired, ValueError): pass return None def get_exiftool_icc(path): """Get ICC metadata from exiftool.""" try: result = subprocess.run( ['exiftool', '-ICC_Profile:all', '-G', path], capture_output=True, text=True, timeout=15 ) if result.returncode == 0: data = {} for line in result.stdout.split('\n'): if ':' in line: parts = line.split(':', 1) data[parts[0].strip()] = parts[1].strip() return data except (FileNotFoundError, subprocess.TimeoutExpired): pass return {} def find_icc_files(directory): """Find all ICC/ICM profiles in a directory.""" patterns = ['*.icc', '*.icm', '*.ICM'] files = [] for p in patterns: files.extend(glob.glob(os.path.join(directory, p))) return sorted(files) def classify_profile(name, lab_white, lab_mid): """Classify a profile based on well-behaved check.""" if lab_white is None or lab_mid is None: return "unknown" white_ok = abs(lab_white['L'] - 100.0) < 0.01 and abs(lab_white['a']) < 0.001 and abs(lab_white['b']) < 0.001 gray_ok = abs(lab_mid['a']) < 0.001 and abs(lab_mid['b']) < 0.001 # Check for known poorly-behaved variants if abs(lab_white['L'] - 99.9988) < 0.001 and abs(lab_white['a']) > 0.01: return "POOR (Adobe/color.org/Windows variant)" if abs(lab_white['a']) > 2.0: return "BAD (unadapted primaries — DO NOT USE)" if white_ok and gray_ok: return "WELL-BEHAVED" return "approx-well-behaved" if (white_ok or abs(lab_white['L'] - 100.0) < 0.001) else "not-well-behaved" def main(): parser = argparse.ArgumentParser( description='Compare sRGB ICC profile variants' ) parser.add_argument('--dir', '-d', default='/usr/share/color/icc/', help='Directory to scan for ICC profiles') parser.add_argument('--compare', nargs=2, metavar=('PROFILE1', 'PROFILE2'), help='Compare two specific profiles') parser.add_argument('--reference', default=None, help='Reference sRGB profile (default: use ArgyllCMS sRGB.icm)') args = parser.parse_args() has_xicclu = check_tool('xicclu', ['xicclu', '-v']) has_exiftool = check_tool('exiftool', ['exiftool', '-ver']) if not has_xicclu: print("Warning: xicclu (ArgyllCMS) not found. Install with: brew install argyllcms") print("Without xicclu, only basic file metadata can be compared.\n") profiles_to_test = [] if args.compare: profiles_to_test = [os.path.abspath(p) for p in args.compare] else: # Scan directory for sRGB profiles for f in find_icc_files(args.dir): if 'srgb' in os.path.basename(f).lower(): profiles_to_test.append(f) if not profiles_to_test: print(f"No sRGB profiles found in {args.dir}") print("Try: python3 srgb-compare.py --compare profile1.icc profile2.icc") sys.exit(1) print(f"Found {len(profiles_to_test)} sRGB profile(s)") print("=" * 80) # Test each profile results = [] for p in profiles_to_test: name = os.path.basename(p) size = os.path.getsize(p) lab_white = get_xicclu_rgb(p, 255, 255, 255) if has_xicclu else None lab_mid = get_xicclu_rgb(p, 128, 128, 128) if has_xicclu else None lab_black = get_xicclu_rgb(p, 0, 0, 0) if has_xicclu else None classification = classify_profile(name, lab_white, lab_mid) meta = get_exiftool_icc(p) if has_exiftool else {} results.append({ 'name': name, 'path': p, 'size': size, 'lab_white': lab_white, 'lab_mid': lab_mid, 'lab_black': lab_black, 'classification': classification, 'description': meta.get('ICC_Profile:ProfileDescription', ''), }) # Print table print(f"\n {'Profile':<35} {'Size':>8} {'L*':>8} {'a*':>8} {'b*':>8} {'Status'}") print(f" {'-'*80}") for r in results: lw = r['lab_white'] if lw: l_str = f"{lw['L']:.4f}" a_str = f"{lw['a']:.4f}" b_str = f"{lw['b']:.4f}" else: l_str = a_str = b_str = "N/A" status_symbol = { 'WELL-BEHAVED': '✅', 'POOR (Adobe/color.org/Windows variant)': '⚠️', 'BAD (unadapted primaries — DO NOT USE)': '❌', 'approx-well-behaved': '~', 'not-well-behaved': '⚠️', }.get(r['classification'], '?') print(f" {r['name']:<35} {r['size']:>8} {l_str:>8} {a_str:>8} {b_str:>8} {status_symbol}") print(f"\n Summary:") print(f" ✅ = Well-behaved (ArgyllCMS, colord Shared, Krita built-in, scRGB)") print(f" ⚠️ = Not well-behaved (Adobe, color.org, Windows 2000, LCMS v1)") print(f" ⚠️ = Not well-behaved (OpenICC, digiKam, Canon variants)") print(f" ~ = Approximately well-behaved (small deviation, OK for 8-bit)") print(f" ❌ = BAD — unadapted primaries (Krita sRGB.icm, old digiKam srgb.icm)") print(f"\n Recommendation: Use ArgyllCMS sRGB.icm or colord Shared sRGB.icm.") print(f" See references/working-spaces-reference.md for full survey data.") if __name__ == '__main__': main() -
well-behaved-check.py 8 KB
#!/usr/bin/env python3 """ Well-Behaved Profile Checker Verifies whether an ICC profile is "well-behaved" (color-balanced, normalized) by testing R=G=B values against CIELAB neutrality. Uses xicclu (ArgyllCMS) when available, with a mathematical fallback for known working space primaries. A well-behaved profile must have: 1. R=G=B → neutral gray (a*=b*=0 in CIELAB) 2. R=G=B=0 → Lab(0, 0, 0) 3. R=G=B=255 → Lab(100, 0, 0) 4. All R=G=B values on the neutral axis produce a*=b*=0 Usage: python3 scripts/well-behaved-check.py path/to/profile.icc python3 scripts/well-behaved-check.py path/to/image.jpg # extract embedded """ import subprocess import sys import os import struct import json KNOWN_WELL_BEHAVED = { 'ArgyllCMS sRGB.icm': '100.000000 0.000000 0.000000', 'ClayRGB (ArgyllCMS AdobeRGB)': '100.000000 0.000000 0.000000', 'colord Shared sRGB.icm': '100.000000 0.000000 0.000000', 'Canon WideGamut': '100.000000 0.000000 0.000000', 'Krita scRGB.icm': '100.000000 0.000000 0.000000', 'Adobe AdobeRGB1998': '100.000000 0.000000 0.000000', } KNOWN_NOT_WELL_BEHAVED = { 'Krita sRGB.icm (unadapted)': '100.001200, a*=-2.390, b*=-19.404 (DO NOT USE)', 'Adobe sRGB.icm': '99.998820, a*=0.018, b*=-0.017', 'color.org sRGB_v4': '99.998820, a*=0.018, b*=-0.017', 'Windows 2000 sRGB.icm': '99.998820, a*=0.018, b*=-0.017', 'LCMS v1 sRGB': '99.998820, a*=0.018, b*=-0.017', 'ProPhotoRGB (OpenICC)': '100.000590, a*=-0.003, b*=-0.008', 'AppleRGB (Shared)': '100.001180, a*=-0.002, b*=-0.000', 'ColorMatchRGB (Shared)': '100.001180, a*=-0.002, b*=-0.000', 'WideGamut (digiKam/Krita)': '100.001180, a*=0.016, b*=-0.015', } def get_file_hash(path: str) -> str: """Get MD5 hash of file (for identification).""" try: import hashlib with open(path, 'rb') as f: return hashlib.md5(f.read()).hexdigest() except ImportError: return '' def extract_icc_from_image(path: str) -> Optional[str]: """Extract embedded ICC profile from image file.""" # Try exiftool first try: result = subprocess.run( ['exiftool', '-b', '-ICC_Profile', path], capture_output=True, timeout=15 ) if result.returncode == 0 and len(result.stdout) > 100: temp_path = path + '.wb-check.icc' with open(temp_path, 'wb') as f: f.write(result.stdout) return temp_path except (FileNotFoundError, subprocess.TimeoutExpired): pass # Try ImageMagick try: result = subprocess.run( ['convert', path, 'wb-temp-profile.icc'], capture_output=True, timeout=15 ) if result.returncode == 0 and os.path.exists('wb-temp-profile.icc'): return 'wb-temp-profile.icc' except FileNotFoundError: pass return None def check_with_xicclu(path: str) -> dict: """Use ArgyllCMS xicclu to check well-behaved status.""" test_values = [ ("255 255 255", "white"), ("0 0 0", "black"), ("128 128 128", "mid-gray"), ("64 64 64", "dark-gray"), ("192 192 192", "light-gray"), ] results = {} for rgb_str, label in test_values: try: proc = subprocess.run( ['xicclu', '-ir', '-pl', '-s255', '-v0', path], input=rgb_str + '\n', capture_output=True, text=True, timeout=10 ) if proc.returncode == 0: line = proc.stdout.strip().split('\n')[0] parts = line.split() if len(parts) >= 3: results[label] = { 'L': float(parts[0]), 'a': float(parts[1]), 'b': float(parts[2]), 'input': rgb_str, } except (FileNotFoundError, subprocess.TimeoutExpired, ValueError): pass return results def verdict(results: dict) -> dict: """Determine if profile is well-behaved from xicclu results.""" if not results: return {'well_behaved': None, 'reason': 'No xicclu data available'} issues = [] for label, vals in results.items(): ab_max = max(abs(vals['a']), abs(vals['b'])) if ab_max > 0.001: issues.append(f"{label}: a*={vals['a']:.4f}, b*={vals['b']:.4f} (should be 0.0000)") passes_white = 'white' in results and abs(results['white']['L'] - 100.0) < 0.01 and abs(results['white']['a']) < 0.001 and abs(results['white']['b']) < 0.001 passes_black = 'black' in results and abs(results['black']['L']) < 0.01 and abs(results['black']['a']) < 0.001 and abs(results['black']['b']) < 0.001 is_wb = len(issues) == 0 and passes_white and passes_black return { 'well_behaved': is_wb, 'issues': issues, 'passes_white': passes_white, 'passes_black': passes_black, 'reason': 'All checks passed' if is_wb else ('; '.join(issues) if issues else 'Failed basic RGB→Lab mapping'), } def main(): if len(sys.argv) < 2: print("Usage: python3 well-behaved-check.py <profile.icc>") print(" or: python3 well-behaved-check.py <image.jpg>") print("\nChecks if an ICC profile is 'well-behaved' (neutral gray axis).") print("Uses xicclu (ArgyllCMS) — install with: brew install argyllcms") sys.exit(1) path = sys.argv[1] if not os.path.exists(path): print(f"Error: file not found: {path}") sys.exit(1) # Handle image files ext = os.path.splitext(path)[1].lower() image_exts = {'.jpg', '.jpeg', '.tif', '.tiff', '.png', '.psd'} icc_path = path if ext in image_exts: print(f"Detected image: {path}") extracted = extract_icc_from_image(path) if extracted: print(f"Extracted profile to: {extracted}") icc_path = extracted else: print("No embedded ICC profile found. Install exiftool or ImageMagick.") sys.exit(1) print(f"\nChecking: {icc_path}") print("=" * 50) # Quick identification hash_val = get_file_hash(icc_path) # Run xicclu check result = check_with_xicclu(icc_path) if not result: print("\n❌ xicclu (ArgyllCMS) not found or failed.") print(" Install: brew install argyllcms") print(" Or: apt install argyll / dnf install ArgyllCMS") print("\n Without xicclu, cannot verify well-behaved status programmatically.") print(" See references/working-spaces-reference.md for known-good profiles.") sys.exit(1) # Print results table print(f"\n {'Test Point':<20} {'L*':>10} {'a*':>10} {'b*':>10} {'Status'}") print(f" {'-'*60}") for label, vals in sorted(result.items()): is_neutral = abs(vals['a']) < 0.001 and abs(vals['b']) < 0.001 status = "✅" if is_neutral else "❌" print(f" {label:<20} {vals['L']:>10.4f} {vals['a']:>10.4f} {vals['b']:>10.4f} {status}") # Verdict v = verdict(result) print(f"\n Verdict:") if v['well_behaved'] is None: print(" ⚠️ Unable to determine (install ArgyllCMS)") elif v['well_behaved']: print(" ✅ This profile is WELL-BEHAVED") print(" R=G=B produces neutral gray throughout the tone range.") print(" Suitable for use as a working space at any bit depth.") else: print(" ❌ This profile is NOT well-behaved") for issue in v['issues']: print(f" • {issue}") print("\n At 8-bit, the deviation is usually unnoticeable.") print(" At 16-bit+ floating point, extreme edits may introduce a false color cast.") print(" Consider switching to a well-behaved equivalent.") print(" See references/working-spaces-reference.md for recommended profiles.") # Cleanup temp files if icc_path != path and icc_path.endswith('.wb-check.icc'): os.remove(icc_path) if os.path.exists('wb-temp-profile.icc') and icc_path != path: os.remove('wb-temp-profile.icc') if __name__ == '__main__': main()
-
-
.gitignore 29 B · in bundle
-
README.md 1.7 KB
# Color Management — ICC Profiles, Color Spaces & Gamut Analysis Expert-level color management for open-source workflows. Covers ICC profiles, working spaces, gamut mapping, and color science fundamentals. ## Why Install This Skill When your agent loads this skill, it becomes a **color management specialist** who can: - **Inspect ICC profiles** — check metadata, primaries, TRC curves, and well-behaved status - **Convert between color spaces** — sRGB, ProPhotoRGB, ACEScg, Rec.2020, and more - **Analyze gamut** — check which image colors fall outside a target color space - **Compare sRGB variants** — understand differences between sRGB profiles from different vendors - **Calculate color difference** — compute dE between images or color values - **Generate comprehensive color reports** with visualizations ## What You Get | Directory | Purpose | |-----------|---------| | `SKILL.md` | Quick reference table mapping tasks to scripts and references | | `scripts/` | 6 Python scripts: profile inspect, well-behaved check, color space convert, gamut check, sRGB compare, color difference, color report | | `references/` | 5 reference files: overview, ICC operations, working spaces, soft-proofing workflow, tool commands, monitor calibration, dcraw pipeline | ## Triggers Load this when inspecting ICC profiles, converting between color spaces, checking gamut clipping, validating working spaces, or troubleshooting color workflows. ## Requirements ImageMagick, Exiftool, ArgyllCMS, LittleCMS (all platform-independent). Python scripts require Python 3.8+. ## Quick Start Start with the setup and first workflow in SKILL.md, then use the linked resources for the specific task you need to complete. -
SKILL.md 10.3 KB
--- name: color-management description: >- Manage color workflows with ICC profiles, working spaces, gamut mapping, and color science. Use when inspecting ICC profiles, converting between color spaces, checking gamut clipping, validating well-behaved working spaces, or troubleshooting color workflow issues with ImageMagick, ArgyllCMS, Exiftool, or LittleCMS. Do not use this skill for unrelated requests; route to the nearest named specialist. license: MIT compatibility: CLI tools used (ImageMagick, Exiftool, ArgyllCMS, LittleCMS) are platform-independent; Python scripts require Python 3.8+ metadata: spec-version: "1.0" source: Distilled from ninedegreesbelow.com (Elle Stone) and Bruce Lindbloom's color science resources version: "1.0.0" --- # Color Management Skill Expert-level guidance for ICC profile color management in open-source workflows. Covers color science fundamentals, working space selection, ICC profile operations, gamut analysis, and practical tool usage. ## Quick Reference | If you need to... | Load this reference | Run this script | |------------------|-------------------|-----------------| | Understand CIELAB, xyY, or color science basics | `references/color-management-overview.md` | — | | Inspect an ICC profile's metadata/primaries/TRC | — | `scripts/icc-profile-inspect.py` | | Check if a profile is well-behaved (neutral gray axis) | `references/working-spaces-reference.md` | `scripts/well-behaved-check.py` | | Convert images between color spaces | `references/icc-profile-operations.md` | `scripts/color-space-convert.py` | | Check which image colors exceed a color space gamut | `references/soft-proofing-workflow.md` | `scripts/gamut-check.py` | | Compare sRGB profile variants | `references/working-spaces-reference.md` | `scripts/srgb-compare.py` | | Calculate color difference (dE) between two images | — | `scripts/color-difference.py` | | Soft proof an image before conversion | `references/soft-proofing-workflow.md` | `scripts/gamut-check.py` | | Calibrate and profile a monitor | `references/monitor-calibration-workflow.md` | — | | Process raw files with dcraw | `references/dcraw-pipeline.md` | — | | Understand hex quantization and create well-behaved profiles | `references/hex-quantization-and-profile-creation.md` | `scripts/well-behaved-check.py` | | Generate a comprehensive color analysis report | — | `scripts/color-report.py` | | Set up a GIMP LCH layer stack for separate tonality/color editing | `assets/templates/lch-layer-stack.md` | — | | Extract embedded ICC profile from an image | `references/icc-profile-operations.md` | `scripts/icc-profile-inspect.py` | | Understand conversion intents (relative/absolute/perceptual) | `references/color-management-overview.md` | — | | Set up Firefox for color-managed browsing | `references/tool-reference.md` | — | ## Required Tools The scripts in this skill check for these tools and report if missing. Install what you need: - **ImageMagick** (`convert`, `identify`, `compare`, `composite`) — primary image processing - **Exiftool** — metadata and ICC profile extraction - **ArgyllCMS** (`xicclu`, `iccgamut`, `colprof`, `cctiff`) — professional color management - **LittleCMS** (`tificc`, `transicc`) — ICC profile conversions ```bash # macOS brew install imagemagick exiftool argyllcms littlecms # Debian/Ubuntu sudo apt install imagemagick libimage-exiftool-perl argyll littlecms2 # Fedora sudo dnf install ImageMagick perl-Image-Exiftool ArgyllCMS littlecms2 ``` ## Python Scripts All scripts are in `scripts/`. They require Python 3.8+ with these optional dependencies: ```bash pip install numpy colour-science Pillow # optional but recommended ``` Each script has a `--help` flag: ```bash python3 scripts/icc-profile-inspect.py --help python3 scripts/well-behaved-check.py --help ``` ## Gotchas ### sRGB profile variants There is no single "sRGB" ICC profile. Different vendors produce profiles that differ in D50 adaptation, hexadecimal quantization, and TRC encoding. The ArgyllCMS `sRGB.icm` and the colord `Shared sRGB.icm` are both well-behaved; the Adobe/color.org/Windows 2000 variants are *not* well-behaved (they produce a false magenta cast at high bit depths). See `references/working-spaces-reference.md` and `scripts/srgb-compare.py`. ### Matrix profiles cannot use perceptual intent sRGB, AdobeRGB, ProPhotoRGB, and all other matrix working space profiles do NOT support perceptual or saturation intents. When you select perceptual intent for a matrix destination profile, you actually get relative colorimetric (which clips out-of-gamut colors). The "perceptual keeps all colors" mantra only applies when converting *to* LUT profiles (printer profiles, some monitor profiles). ### Perceptual intent for sRGB is a lie The oft-repeated statement "perceptual intent preserves colors when converting to sRGB" is false. sRGB is a matrix profile; it has no perceptual intent table. What actually happens is relative colorimetric intent, which clips. The only way to preserve out-of-gamut colors at floating point is to use LCMS2 unbounded mode. ### Display-referred vs scene-referred - **Display-referred**: RGB values bounded by 0.0-1.0. White (1,1,1) = maximum display brightness. ~9 stops dynamic range. Operations clamp. - **Scene-referred**: No upper bound on RGB values. White has no special significance. 20+ stops possible (OpenEXR). Requires linear gamma. Operations do NOT clamp. Always check which model your editing pipeline uses. ### Unbounded editing has limits LCMS2 unbounded mode prevents clipping during conversions by allowing negative and >1.0 RGB values. This is useful for storage and transport, but many editing operations (Multiply, Divide, Screen, Levels gamma slider, Curves, color correction) produce *meaningless results* on out-of-gamut colors. Use integer precision if you don't want to manage out-of-gamut values. ### Luminance vs Luma is not pedantry - **Luminance**: calculated on linearized RGB (radiometrically correct). Uses sRGB-specific multipliers (R*0.213 + G*0.715 + B*0.072). - **Luma**: calculated on gamma-encoded RGB (perceptually uniform). Different multipliers (R*0.222 + G*0.717 + B*0.061 for GIMP 2.9+, Bradford-adapted to D50). GIMP 2.8 used wrong multipliers. GIMP 2.9+ corrected them. Always use Luminance for physically meaningful black/white conversions. ### Camera profiles require negative tristimulus values Every digital camera sensor needs negative XYZ tristimulus values in its input matrix profile (analysis of 233 dcraw cameras: 100% had negative green Z, 93% had negative blue Y). ICC V2 prohibited these; V4 allows them via 32-bit floating point. If your workflow clips negative values, you lose blue and green channel detail. ### 8-bit vs 16-bit vs floating point - **8-bit**: Use only sRGB or AdobeRGB (small gamut, perceptually uniform TRC). Never use linear gamma (posterization in shadows). - **16-bit integer**: ProPhotoRGB is usable. Linear gamma is OK for radiometrically correct editing. - **32-bit floating point**: Any working space, any TRC. Unbounded conversions possible. Required for scene-referred HDR. ### LCH vs HSV is not an upgrade — it's a replacement HSV is a 1960s "fast math" hack for slow CPUs. It cannot separate color from tonality. LCH (Lightness, Chroma, Hue) is derived from CIELAB and allows true separate editing of color and tonality. GIMP's LCH blend modes are the first correct implementation in open-source software. Never use HSV blend modes for serious editing. ### Concrete failure: Levels gamma slider + unbounded sRGB = disaster If you take a ProPhotoRGB image with saturated reds, convert it to unbounded sRGB at 32-bit float, and apply a Levels gamma slider adjustment (e.g., gamma=3.0), the reds turn *magenta* and any chrome in the image turns *cyan*. This is because the gamma slider is chromaticity-dependent — it multiplies channels differently in different working spaces. The fix: do gamma adjustments in the same working space the image was edited in, or use a chromaticity-independent operation like Value channel Levels. ### Concrete failure: Color correction in the wrong working space If an image was given a green color cast in the ProPhotoRGB color space, and you correct it in unbounded sRGB using the white balance eyedropper, the correction produces: cyan grass, orange skin tones, saturated sky, and a saturated red truck in the distance. Even though the white point dot itself turns neutral, all other colors are wrong. Color correction must be performed in the same color space in which the cast was created. Converting to a different space and correcting produces unpredictable results. ### Concrete failure: Channel-based mono mixing with out-of-gamut colors Converting a yellow truck from its camera input profile to sRGB drives the blue channel negative over most of the yellow truck body. If you then try to use Channel Mixer (Mono Mixer) to create a black-and-white conversion by blending from the blue channel, the negative blue channel values produce completely meaningless results — you can't emulate orthochromatic film or any other channel-based effect on colors that are out of gamut. Channel-based editing must be done before converting to a smaller gamut, or in a working space large enough to contain all image colors. ## References | File | When to load | |------|-------------| | `references/color-management-overview.md` | You need to understand CIELAB, xyY, color science fundamentals | | `references/icc-profile-operations.md` | You need to convert, assign, extract, or create ICC profiles | | `references/tool-reference.md` | You need CLI commands for ImageMagick, ArgyllCMS, Exiftool, LCMS | | `references/working-spaces-reference.md` | You need working space data (primaries, white points, gamma values) | | `references/glossary.md` | You encounter an unfamiliar term | | `references/soft-proofing-workflow.md` | You need to soft proof before conversion | | `references/monitor-calibration-workflow.md` | You need to calibrate or profile a monitor | | `references/dcraw-pipeline.md` | You're working with raw files and need a color-managed pipeline | | `references/hex-quantization-and-profile-creation.md` | You need to create well-behaved ICC profiles | ## Assets | File | Description | |------|-------------| | `assets/templates/icc-profile-report.md` | Template for a human-readable ICC profile analysis report | | `assets/templates/lch-layer-stack.md` | GIMP LCH layer group template for separate tonality/color editing |
Comments (0)
Sign in to join the conversation.
Reviews (0)
No reviews yet.
No comments yet.