Ultimate 3D LUT Light Calibration Guide

This post is a follow-up upgraded version on the Calibrating RGB-light

Most cheap LED lights (like my Airam or generic Zigbee/WiFi strips) are extremely non-linear. If you ask for white, you get green. If you dim the light, the color shifts to blue.

Why 3D LUT?

Because color is 3D!
For example In CIE1931xy space, x and y axis represent the point on the plot and then we need one more value to represent the brightness! so certain color becomes xyY for example!

Below is the visual gamut of my light against rec2020 color space!

A 1D LUT (the old method) only fixes the white balance. A 3D LUT (this new method) creates a “volumetric map” of your light. It corrects the color at 10% brightness, 50% brightness, and 100% brightness simultaneously, ensuring that the hue stays perfect regardless of the saturation or intensity.

  1. We will take measurements using DisplayCAL and use a little “hack” to profile a light with it rather than a display! DisplayCAL can output measurement patches to local web page and we will use that to output that color to our HA-light!

  2. Then we let DisplayCAL make the 3DLUT for us!

  3. we add AppDaemon script that uses that .cube-file we just created and calibrate our light!

Prerequisites & Requirements

  • Hardware: A colorimeter (e.g., Spyder, i1Display Pro).
  • Software (Local PC): (Im using Windows)
    • Python: (needed to run the “Bridge Script” that talks to DisplayCAL! Remember to ADD PATH during install).
options for python install

“After installing Python, open your Command Prompt (cmd) and run: pip install requests
This allows the bridge script to talk to Home Assistant!”

  • DisplayCAL: The industry standard for calibration.
    • You also might have to install ArgyllCMS manually
  • Software (Home Assistant):
    (You can find these in the -Settings → Apps → Install App)
    • AppDaemon: “Python Sandbox” that runs the calibration logic in the background.
    • File Editor/Samba: To upload the .cube files.
    • Virtual light entity ( To control the actual light.)
      • Choose your method; Im using HACS and the virtual light entity from previous guide!

Preparation: Create your Home Assistant Token

Since the “Bridge” script runs on your PC, it needs access to control your Home Assistant.

Steps
  1. In Home Assistant, click on your Profile Name (bottom left corner).
  2. Scroll all the way to the bottom to the Long-Lived Access Tokens section.
  3. Click Create Token.
  4. Give it a name like Calibration Bridge and click OK.
  5. CRITICAL: Copy the long string of characters immediately! Home Assistant will never show it to you again. Save it in a temporary notepad file. Create a folder named “Calibration” and save it there

Create your Python Bridge

Steps
  1. On the PC open notepad (Or notepad++ I would prefer)
  2. Paste the following code
  3. Copy the TOKEN from the temp-file and paste it in the “HA_TOKEN=”" (Inside the quotation marks!!)
  4. Check HA for the light entity that you want to calibrate and copy it to the ENTITY_ID=“”
  5. Check the “HA_URL=” and match with yours
  6. Save it as “Calibration_bridge.py”
Python Bridge code
import requests
import time
import traceback
import colorsys

# --- CONFIG ---
HA_URL = "http://homeassistant:8123/api/services/light/turn_on"
HA_TOKEN = "COPY THE TOKEN HERE"
ENTITY_ID = "light.ceiling_light"
DISPLAYCAL_URL = "http://127.0.0.1:8080/ajax/messages"

headers_ha = {
    "Authorization": f"Bearer {HA_TOKEN}",
    "content-type": "application/json",
}

def hex_to_rgb(hex_color):
    hex_color = hex_color.lstrip('#')
    return [int(hex_color[i:i+2], 16) for i in (0, 2, 4)]

def rgb_to_hs_payload(rgb):
    # Normalize RGB
    r = rgb[0] / 255.0
    g = rgb[1] / 255.0
    b = rgb[2] / 255.0

    # Convert to HSV
    h, s, v = colorsys.rgb_to_hsv(r, g, b)

    brightness = int(round(v * 255))

    # Handle OFF
    if brightness <= 0:
        return {
            "entity_id": ENTITY_ID,
            "brightness": 0,
            "effect": "none"
        }
    # === HACK for grayscale / near-white patches ===
    # Force a tiny saturation to stay in color (HS) mode
    # This prevents HA/driver from switching to white_mode
    if s < 0.001:          # 
        s = 0.001          # very small saturation (~0.1%)
   

    return {
        "entity_id": ENTITY_ID,
        "hs_color": [round(h * 360, 3), round(s * 100, 3)],
        "brightness": max(1, brightness),
        "effect": "none"
    }

print("Starting Calibration Bridge (RGB → HSV mode)...")

current_bg = "#000000"

try:
    while True:
        try:
            query_url = f"{DISPLAYCAL_URL}?{current_bg} {time.time()}"
            response = requests.get(query_url, timeout=35)

            if response.status_code == 200:
                new_hex = response.text.strip()

                if new_hex and new_hex != current_bg:
                    rgb = hex_to_rgb(new_hex)

                    payload = rgb_to_hs_payload(rgb)

                    print(f"In RGB: {rgb} -> HS: {payload.get('hs_color')} | V: {payload.get('brightness')}")

                    requests.post(HA_URL, json=payload, headers=headers_ha, timeout=5)

                    current_bg = new_hex
                    time.sleep(1.5)

        except requests.exceptions.Timeout:
            continue
        except Exception as e:
            print(f"Loop Error: {e}")
            time.sleep(2)

except Exception:
    print("\n--- CRITICAL ERROR ---")
    traceback.print_exc()
    input("\nPress ENTER to close...")

On to the profiling!

  • Open DisplayCAL and use the following settings! (place the meter onto the light, If during measurements it errors with too bright light you need to place it farther away but make sure no other light hits the sensor. The darker room you can measure the better!)
DisplayCAL settings
Display and Instrument tab
  • Display and Instrument tab:
    • Display Device: Web @ localhost
    • Output levels: 0-255 Full range
    • Correction: None (or specific to your LED type if known)
Calibration tab
  • Calibration Tab:
    • Set Interactive Display Adjustment to ON.
    • Whitepoint: Color temperature, 6500K blackbody
    • White level: As measured
    • Black level: As measured
    • Tone curve: Custom, Gamma 1.0
    • Black output offset: 100%
Profiling tab
  • Profiling Tab:
    • Profile type: XYZ LUT + matrix
    • Profile quality: High
    • Testchart: Large testchart for LUT profiles (778 patches) or auto optimized and choose 500-2000 measurement points.
    • Target Gamma: Use 1.0 (Linear) or 1.5 if want low-level precision. (Ceiling lights don’t behave like OLED TVs!)
3D LUT tab
  • 3D LUT tab:
    • Set Create 3D LUT after profiling to ON.
    • Source colorspace: DCI-P3/SMPTE-431-2 D65
    • Tone curve: Custom, Gamma 1.0, Relative
    • Black output offset: 100%
    • Set Apply calibration (vcgt) to ON
    • Gamut mapping mode: Inverse device-to-PCS
    • Rendering intent: Absolute colorimetric
    • **3D LUT file format IRIDAS (.cube) CRITICAL!
    • input and output both to Full range
    • 3D LUT resolution: 65³ or 33³
Start Measurement
  • Start Measurement: Run your Calibration_bridge.py script, then hit “Calibrate & Profile” in DisplayCAL.
    • place the instrument and CLICK OK
    • click “start measurement”
    • It first measures the GAMUT and white point (RGB and WHITE)
    • It’ll then run measurements continuously asking to adjust the white point.
    • Click on *STOP" and continue on to calibration

Let it run and do its thing! I took 778 measurements and it took almost 2 hours!

You can of course use smaller amount of measurements (the profile quality setting in the 3rd picture) but the quality and precision of the resulting LUT will be lower.

DisplayCAL settings screenshots




video

https://www.youtube.com/shorts/2u5DucVXTLs

Step 3: Exporting the 3D LUT

Once the measurement is finished:

  1. Export the .cube-file and name it something simple like living_room.cube.

STEP 4: The AppDaemon script

AppDaemon needs a place to store your scripts and your .cube files. When you started the Add-on, it automatically created a folder structure inide your Home Assistant /config directory.
Go to /config/appdaemon/apps/ (Or homeassistant/appdaemon/apps/
edit the apps.yaml and remove the default hello world or just copypaste this in

Code
---
light_calibration_hybrid:
  module: light_calibrator_cube
  class: LightCalibrator
Pictures

Create a new file in the config/appdaemon/apps/ and name it “light_calibrator_cube.py”
(Same folder where apps.yaml lives)
Copypaste following code:

light_calibrator_cube.py

This text will be hidden

import hassapi as appdaemon_api
import os
import math
import colorsys

class LightCalibrator(appdaemon_api.Hass):
    """
    Applies a 3D LUT to map virtual light commands to calibrated physical hardware.
    Includes a transition point to switch from LUT-corrected color to native white.
    DONT FORGET TO MODIFY THE FILENAMES AND ENTITIES IF DIFFERENT
    """

    def initialize(self):
        # File and Entity setup
        self.lut_filepath = os.path.join(os.path.dirname(__file__), "living_room.cube") # Name of the .cube file
        self.real_light = "light.ceiling_light"
        self.virtual_light = "light.virtual_calibrated"

        # Logic threshold: Below this %, use LUT. Above this %, switch to native white LEDs.
        # Set to 101.0 to stay in LUT mode permanently.
        self.rgb_transition_ha_pct = 50.0

        # Hardware constraints for native white mode
        self.white_min_pct = 0.1
        self.white_max_pct = 100.0
        self.kelvin_2700k_maps_to = 2700

        # Pre-load LUT into memory for high-performance lookups
        self.lut_3d, self.lut_size = self.load_cube(self.lut_filepath)
        self.listen_state(self.on_light_change, self.virtual_light, attribute="all")

    def load_cube(self, filepath):
        """Parses standard IRIDAS .cube files into a coordinate-mapped dictionary."""
        if not os.path.exists(filepath):
            self.error(f"LUT file NOT FOUND: {filepath}")
            return {}, 0

        lut = {}
        size = 0
        data_points = []

        with open(filepath, 'r') as f:
            for line in f:
                if line.startswith("LUT_3D_SIZE"):
                    size = int(line.split()[1])
                elif not line.startswith(("#", "TITLE", "DOMAIN_")) and line.strip():
                    parts = line.split()
                    if len(parts) == 3:
                        data_points.append([float(p) for p in parts])

        if size > 0 and len(data_points) == size ** 3:
            idx = 0
            # Map the 1D list of RGB values into a 3D coordinate dictionary
            for b in range(size):
                for g in range(size):
                    for r in range(size):
                        lut[(r, g, b)] = data_points[idx]
                        idx += 1
            self.log(f"3D LUT loaded: {size}x{size}x{size}", level="INFO")
        else:
            self.error("LUT parsing failed: size mismatch.")

        return lut, size

    def interpolate_3d(self, r_norm, g_norm, b_norm):
        """Trilinear interpolation to find values between the fixed LUT grid points."""
        if self.lut_size == 0:
            return [r_norm, g_norm, b_norm]

        max_idx = self.lut_size - 1
        r_idx, g_idx, b_idx = r_norm * max_idx, g_norm * max_idx, b_norm * max_idx

        # Define the surrounding 8 points in the cube
        r0, g0, b0 = int(r_idx), int(g_idx), int(b_idx)
        r1, g1, b1 = min(r0 + 1, max_idx), min(g0 + 1, max_idx), min(b0 + 1, max_idx)
        rd, gd, bd = r_idx - r0, g_idx - g0, b_idx - b0

        def get_v(r, g, b): return self.lut_3d.get((r, g, b), [0.0, 0.0, 0.0])

        # Fetch values for the corners of the local 1x1x1 sub-cube
        c000, c100 = get_v(r0, g0, b0), get_v(r1, g0, b0)
        c010, c110 = get_v(r0, g1, b0), get_v(r1, g1, b0)
        c001, c101 = get_v(r0, g0, b1), get_v(r1, g0, b1)
        c011, c111 = get_v(r0, g1, b1), get_v(r1, g1, b1)

        # Mix the corner values based on the distance from the points (Interpolation)
        out = [0.0] * 3
        for i in range(3):
            c00 = c000[i]*(1-rd) + c100[i]*rd
            c01 = c001[i]*(1-rd) + c101[i]*rd
            c10 = c010[i]*(1-rd) + c110[i]*rd
            c11 = c011[i]*(1-rd) + c111[i]*rd
            c0 = c00*(1-gd) + c10*gd
            c1 = c01*(1-gd) + c11*gd
            out[i] = c0*(1-bd) + c1*bd
        return out

    def hs_to_rgb_norm(self, hue, saturation, brightness):
        """Converts HA color format to normalized 0.0-1.0 RGB for LUT processing."""
        h, s, v = hue / 360.0, saturation / 100.0, brightness / 255.0 
        return list(colorsys.hsv_to_rgb(h, s, v))

    def rgb_to_hs(self, r, g, b):
        """Converts corrected RGB back to HA-compatible HS and Brightness."""
        r, g, b = [max(0.0, min(1.0, x)) for x in [r, g, b]]
        h, s, v = colorsys.rgb_to_hsv(r, g, b)
        return round(h * 360, 3), round(s * 100, 3), int(v * 255)

    def get_kelvin(self, attrs):
        """Extracts Kelvin from HA attributes, handling both Kelvin and Mireds."""
        kelvin = attrs.get("color_temp_kelvin")
        if kelvin: return int(kelvin)
        mireds = attrs.get("color_temp")
        return int(1000000 / mireds) if mireds else 4000

    def kelvin_to_rgb_norm(self, kelvin, brightness):
        """Mathematical approximation of Kelvin to RGB for profiling virtual color temps."""
        effective_k = max(2700, kelvin) if kelvin <= 2700 else kelvin
        try:
            temp = effective_k / 100.0
            if temp <= 66:
                r, g = 255.0, 99.47 * math.log(temp) - 161.12
                b = 0.0 if temp <= 19 else 138.52 * math.log(temp - 10) - 305.04
            else:
                r, g = 329.70 * (temp - 60) ** -0.13, 288.12 * (temp - 60) ** -0.07
                b = 255.0
            
            v = brightness / 255.0
            return [max(0, min(1, x / 255.0)) * v for x in [r, g, b]]
        except:
            v = brightness / 255.0
            return [v, v, v]

    def on_light_change(self, entity, attribute, old, new, kwargs):
        """Main processing loop triggered by changes to the virtual light."""
        state = self.get_state(self.virtual_light, attribute="all")
        if not state or state.get("state") == "off":
            self.turn_off(self.real_light)
            return

        attrs = state.get("attributes", {})
        brightness_in = max(1, attrs.get("brightness", 255))
        ha_pct = (brightness_in / 255.0) * 100.0
        is_color_temp = attrs.get("color_mode") in ("color_temp", "color_temperature")

        # PATH A: Use LUT calibration (For colors or low-brightness whites)
        if not is_color_temp or ha_pct < self.rgb_transition_ha_pct:
            if is_color_temp:
                rgb_norm = self.kelvin_to_rgb_norm(self.get_kelvin(attrs), brightness_in)
            else:
                hue, sat = attrs.get("hs_color", [0, 0])
                rgb_norm = self.hs_to_rgb_norm(hue, sat, brightness_in)

            # Apply 3D LUT transformation
            corrected_norm = self.interpolate_3d(*rgb_norm)
            new_hue, new_sat, final_brightness = self.rgb_to_hs(*corrected_norm)

            service_params = {"hs_color": [new_hue, new_sat], "brightness": max(1, final_brightness)}

        # PATH B: Native White (Bypasses LUT to use dedicated White LEDs at high brightness)
        else:
            white_norm = (ha_pct - self.rgb_transition_ha_pct) / (100 - self.rgb_transition_ha_pct)
            white_pct = self.white_min_pct + white_norm * (self.white_max_pct - self.white_min_pct)
            final_brightness = int(max(1, (white_pct / 100.0) * 255))

            service_params = {"brightness": final_brightness, "color_temp_kelvin": self.get_kelvin(attrs)}

        self.call_service("light/turn_on", entity_id=self.real_light, **service_params)

next upload the .cube file to the same folder as the script!

picture

Thats it! Enjoy your perfectly calibrated light!

Add the virtual light entity to your dash and throw away the real one.
btw you can check the inputs and outputs comparing the values or colorpicker in the virtual light and the actual light!

I’ve made the Bridge-script as an executable, no need to install python anymore. Windows version for the Bridge-script can be found on Github