Philips Hue White Ambiance is actually terrible...


Introduction

This is the eight post in the Reversing Philips Hue light driver series.

Problem statement

Not so long ago I presented my wonderful new e32wamb-driven light to a bunch of nerdy friends and colleagues of mine.

The feedback I’ve got?

But Philips has a terrible CRI!

How terrible is terrible? Let’s find out.

Test setup

In order to measure the light somewhat reproducibly, I took my fancy new TorchBearer Y21B7W10034CCPD spectrometer, Philips Hue White Ambiance light1, Philips Hue Bridge, and bolted them together with a bunch of “glue” scripts.

I guess picture’s worth a 100 words:

test setup Test setup: Hue bridge, Hue light, TorchBearer spectrometer, buncha scripts

Architecture diagram:

G sm Spectrometer hwa Hue Being (White Ambiance) sm→hwa reads spectrum of bridge Hue Bridge bridge→hwa speaks Zigbee to dump_script dump-all-spectra.py dump_script→sm controls, reads spectral data from control_hue control-light.rb dump_script→control_hue calls spectra spectra *.json files dump_script→spectra writes control_hue→bridge API calls report_script gen-report.py report_script→spectra reads image_files *.png report_script→image_files writes
test setup diagram

Scripts

In case you’re wondering how hard it was to pull off software-wise… not really:

dump-all-spectra.py (click to expand)

#!/usr/bin/env python3
"""Dump all Philips Hue White Ambiance spectra"""

# pylint: disable=invalid-name
# pylint: disable=broad-exception-caught
# pylint: disable=global-statement

import atexit
import os
import pprint
import signal
import sys
import subprocess
import time

# tobes_ui = gh:wejn/tobes-ui (the tobes_ui subdir)
from tobes_ui import protocol
from tobes_ui import spectrometer

if __name__ == "__main__":
    DEVICE = '/dev/ttyUSB0'
    try:
        SPECTROMETER = spectrometer.Spectrometer(DEVICE)
    except Exception as spec_ex:
        print(f"Couldn't init spectrometer: {spec_ex}")
        sys.exit(1)

    atexit.register(SPECTROMETER.cleanup)

    def signal_handler(_signum, _frame):
        """Signal handler to trigger cleanup"""
        print("\nReceived interrupt signal, shutting down gracefully...")
        SPECTROMETER.cleanup()
        sys.exit(0)

    signal.signal(signal.SIGINT, signal_handler)
    signal.signal(signal.SIGTERM, signal_handler)

    basic_info = SPECTROMETER.get_basic_info()
    if not basic_info['device_id'].startswith('Y'):
        print(f'Warning: only tested on Y21B*, this is {basic_info["device_id"]}')

    def is_ok(result):
        """Bool to string with extra nonsense on top, pylint"""
        return "success" if result else "failure"
    if basic_info['exposure_mode'] != protocol.ExposureMode.AUTOMATIC:
        print('Setting auto mode:',
              is_ok(SPECTROMETER.set_exposure_mode(protocol.ExposureMode.AUTOMATIC)))
    else:
        print('Spectrometer already in auto mode.')

    print("Exposure mode:", SPECTROMETER.get_exposure_mode())
    print("Exposure value:", SPECTROMETER.get_exposure_value(), 'μs')

    basic_info = SPECTROMETER.get_basic_info()
    print("Device basic info: ")
    pprint.pprint(basic_info)

    DATA = None

    def one_good(value):
        """Get one good reading from the spectrometer"""
        global DATA

        if value.status == protocol.ExposureStatus.NORMAL:
            DATA = value
            return False
        print(f"! got {value.status}, retry...")
        return True

    for temp in range(153, 455):
        output_filename = f'spectra/{temp}.json'

        if os.path.exists(output_filename):
            print(f"- {output_filename} already exists, skip...")
            continue

        set_result = subprocess.run(["./control-light.rb", str(temp)],
                                    capture_output=True, check=False)
        if set_result.returncode == 0:
            print("= set temp:", str(temp))
            time.sleep(1)
        else:
            print(f"! failed to set temp {temp}, reason:",
                  set_result.stdout.decode(), set_result.stderr.decode())
            break
        SPECTROMETER.stream_data(one_good)
        with open(output_filename, 'w', encoding='utf-8') as file:
            file.write(DATA.to_json())

        print(f"+ {output_filename} written.")

    print("Finishing up...")
    SPECTROMETER.cleanup()

control-light.rb (click to expand)

#!/usr/bin/env ruby

require 'net/http'
require 'uri'
require 'json'
require 'pp'

class HueBridge
  def initialize(ip, apikey)
    uri = URI("https://#{ip}/clip/v2/resource/device")
    Net::HTTP.start(ip, :use_ssl => true, :verify_mode => OpenSSL::SSL::VERIFY_NONE) do |http|
      req = Net::HTTP::Get.new(uri, {'hue-application-key': apikey})
      res = http.request(req)
      raise "can't init list of lights" unless res.code.to_i == 200
      json = JSON.parse(res.body)
      raise "errors: #{json["errors"].inspect}" unless json["errors"].empty?
      @devices = json["data"]
      @ip = ip
      @apikey = apikey
    end
    self
  end

  def light_info(id)
    out = nil
    uri = URI("https://#{@ip}/clip/v2/resource/light/#{id}")
    Net::HTTP.start(@ip, :use_ssl => true, :verify_mode => OpenSSL::SSL::VERIFY_NONE) do |http|
      req = Net::HTTP::Get.new(uri, {'hue-application-key': @apikey})
      res = http.request(req)
      raise "can't poll light #{id.inspect}" unless res.code.to_i == 200
      json = JSON.parse(res.body)
      raise "errors: #{json["errors"].inspect}" unless json["errors"].empty?
      out = json['data'].first
    end
    out
  end

  def get_temp_and_brightness(id)
    info = light_info(id)
    [
      info["color_temperature"]["mirek"],
      info["dimming"]["brightness"],
    ]
  end

  def set_temp_and_brightness(id, temp = :keep, brightness = :keep)
    return :noop if temp == :keep && brightness == :keep

    params = {}
    params['dimming'] = {'brightness': brightness} unless brightness == :keep
    params['color_temperature'] = {'mirek': temp} unless temp == :keep

    uri = URI("https://#{@ip}/clip/v2/resource/light/#{id}")
    Net::HTTP.start(@ip, :use_ssl => true, :verify_mode => OpenSSL::SSL::VERIFY_NONE) do |http|
      req = Net::HTTP::Put.new(uri, {'hue-application-key': @apikey})
      req.body = params.to_json
      res = http.request(req)
      json = nil
      begin
	json = JSON.parse(res.body)
      rescue
	raise "can't set light #{id.inspect} to #{params.inspect}: #{res}"
      end
      if res.code.to_i == 200
	return :ok
      else
	raise "can't set light #{id.inspect} to #{params.inspect}: #{json["errors"].inspect}"
      end
    end

  end
end

if __FILE__ == $0
  # bridge 2
  # $ curl -s -k https://10.0.0.251/api -d '{"devicetype":"app_name#instance_name", "generateclientkey":true}'
  bridge_ip = '10.x.y.z'
  apikey = '___i_am_not_leaking_the_apikey_sorry_;)_'
  light_id = '76ba5654-631a-11f0-ba26-0010182e9fa6' # light -> services[rtype="light"]

  unless (1..2).include?(ARGV.size)
    STDERR.puts "Usage: #{File.basename($0)} <temp> [brightness=100]"
    exit 1
  end
  temp= ARGV[0].to_i
  brightness = (ARGV[1] || 100).to_i

  unless (153..454).include?(temp)
    STDERR.puts "Wrong temp: #{temp} (153..454)"
    exit 1
  end
  unless (0..100).include?(brightness)
    STDERR.puts "Wrong brightness: #{brightness} (0..100)"
    exit 1
  end

  hue = HueBridge.new(bridge_ip, apikey)

  success = false

  3.times do
    res = hue.set_temp_and_brightness(light_id, temp, brightness)
    unless res == :ok
      puts "Setting failed: #{res}"
      next
    end
    res = hue.get_temp_and_brightness(light_id)
    want = [temp, brightness]
    unless res.first == want.first && (res.last - want.last).abs <= 0.4
      puts "Setting differs: want=#{want.inspect}, got=#{res.inspect}"
      next
    end
    success = true
    break
  end

  exit success ? 0 : 2
end

gen-report.py (click to expand)

#!/usr/bin/env python3
"""Generate various reports for given spectral data."""

# pylint: disable=invalid-name

import argparse
from enum import Enum
from pathlib import Path
import re
import warnings

import colour
from colour.colorimetry import sd_to_XYZ
from colour.plotting.tm3018 import plot_single_sd_colour_rendition_report
from colour.plotting.tm3018.components import plot_colour_vector_graphic
from colour.plotting import (
    plot_planckian_locus_in_chromaticity_diagram_CIE1931,
    plot_planckian_locus_in_chromaticity_diagram_CIE1960UCS,
    plot_planckian_locus_in_chromaticity_diagram_CIE1976UCS,
)
from colour.quality import (
    colour_fidelity_index_CIE2017,
    colour_fidelity_index_ANSIIESTM3018,
)
from colour.models import XYZ_to_xy
from matplotlib import pyplot as plt

from tobes_ui.spectrometer import Spectrum

class ReportType(Enum):
    """Enum of available report types"""
    TM30 = 1
    TM30_SIMPLE = 2
    CIE1931 = 3
    CIE1960UCS = 4
    CIE1976UCS = 5
    SPECTRUM = 6

    def __str__(self):
        """Convert to readable string"""
        return str(self.name).lower()

if __name__ == "__main__":
    parser = argparse.ArgumentParser(description="Colour Science TM-30 report")

    parser.add_argument('input_json', help="Input json file (saved from gh:wejn/tobes-ui)")

    parser.add_argument('--src', default=None, help='Emission source name')
    parser.add_argument('--mfg', default=None, help='Emission source manufacturer')
    parser.add_argument('--mdl', default=None, help='Emission source model')
    parser.add_argument('--notes', default=None, help='Notes for the report')

    parser.add_argument('-d', '--dpi', type=int, default=100,
                        help='DPI for the report (default 100)')

    def parse_resolution(value):
        """Parse image resolution"""
        try:
            width_str, height_str = re.split(r'[xX*,]', value)
            width = int(width_str)
            height = int(height_str)
            return (width, height)
        except ValueError as exc:
            raise argparse.ArgumentTypeError(
                    f"Invalid dimensions format: '{value}'. Use WxH, e.g., 1920x1080.") from exc

    parser.add_argument('-s', '--size', type=parse_resolution, default=None,
                        help='Report max dimensions in WxH format (e.g., 1920x1080)')

    def report_type(value):
        """parser for --type"""
        try:
            return ReportType[value.upper()]
        except KeyError as exc:
            raise argparse.ArgumentTypeError(f"Invalid report type {value}") from exc

    parser.add_argument(
        '-t', '--type',
        type=report_type,
        default=ReportType.TM30,
        help=f"Report type ({', '.join([e.name for e in ReportType])}) (default TM30)"
    )

    def out_file(value):
        """parser for output_file"""
        val_path = Path(value)
        if val_path.suffix in ('.png', '.pdf'):
            return value
        raise argparse.ArgumentTypeError(f"Unsupported filetype: {val_path.suffix}")

    parser.add_argument('output_file', type=out_file,
                        help="Output file to save report as (.png, .pdf)")

    loci = [1800, 2500, 3500, 4500, 6500]
    parser.add_argument('--loci', type=int, nargs='+', default=loci,
                        help=f'List of planckian locus labels (default: {loci})')

    parser.add_argument('--ymax', type=float, default=None,
                        help='Max on Y axis for the spectral report (default auto)')

    argv = parser.parse_args()

    print("Reading:", argv.input_json)
    with open(argv.input_json, 'r', encoding='utf-8') as file:
        data = Spectrum.from_json(file.read())

    sd = colour.SpectralDistribution(data.spd, name=(argv.notes if argv.notes else 'X'))

    print("Rf (CIE2017):", colour_fidelity_index_CIE2017(sd.copy()))
    print("Rf (TM‑30‑18):", colour_fidelity_index_ANSIIESTM3018(sd.copy()))
    warnings.filterwarnings("ignore",
                            '"OpenImageIO" related API features are not available,' +
                            ' switching to "Imageio"!')

    path = Path(argv.output_file)

    XYZ = sd_to_XYZ(sd)
    xy = XYZ_to_xy(XYZ)

    chroma_kwargs = {
        'annotate_kwargs': {'annotate':False},
        'show_spectral_locus': False,
        'planckian_locus_labels': argv.loci
    }

    # set size
    if argv.dpi:
        plt.rcParams['savefig.dpi'] = argv.dpi
    if argv.size:
        dpi = plt.rcParams['savefig.dpi']
        plt.rcParams['figure.figsize'] = [argv.size[0] / dpi, argv.size[1] / dpi ]

    match argv.type:
        case ReportType.CIE1931:
            fig, axes = plot_planckian_locus_in_chromaticity_diagram_CIE1931(
                    xy,
                    title=argv.notes, show=False, transparent_background=False,
                    **chroma_kwargs)
        case ReportType.CIE1960UCS:
            fig, axes = plot_planckian_locus_in_chromaticity_diagram_CIE1960UCS(
                    xy,
                    title=argv.notes, show=False, transparent_background=False,
                    **chroma_kwargs)
        case ReportType.CIE1976UCS:
            fig, axes = plot_planckian_locus_in_chromaticity_diagram_CIE1976UCS(
                    xy,
                    title=argv.notes, show=False, transparent_background=False,
                    **chroma_kwargs)
        case ReportType.TM30_SIMPLE:
            spec_full = colour_fidelity_index_ANSIIESTM3018(sd, True)
            fig, axes = plot_colour_vector_graphic(
                    spec_full, show=False, transparent_background=False,
                    title=argv.notes)
        case ReportType.SPECTRUM:
            fig, axes = colour.plotting.plot_single_sd(
                    sd, show=False, transparent_background=False)
            plt.ylabel("Spectral Distribution ($W/m^2$)")
            if argv.ymax:
                plt.ylim([0, argv.ymax])
        case _: # TM30 & al
            if argv.size:
                kwargs = {'report_size': plt.rcParams['figure.figsize']}
            else:
                kwargs = {}
            fig, axes = plot_single_sd_colour_rendition_report(
                    sd.copy(), show=False,
                    date=str(data.ts),
                    source=argv.src,
                    manufacturer=argv.mfg,
                    model=argv.mdl,
                    notes=argv.notes,
                    transparent_background=False, **kwargs)
    fig.savefig(argv.output_file, format=path.suffix[1:], bbox_inches='tight')
    print(f"Wrote {argv.type} report as:", path)

Coupled with a few more glue scripts (and a Makefile) for the final report (and video) generation2.

Anyway, let’s have a tour through the results…

Results

Single-channels

In the previous posts3 I complained that I don’t know the exact color characteristic of the individual channels.

Well, I do now:

normal channel normal channel

cold channel cold channel

warm channel warm channel

If you happen to not speak color-wonk, the “normal” channel is plenty abnormal, IMO “just” used to boost the total luminous flux (amount of visible light emitted).

The cold channel is your average terrible “lemme burn your retina with all the blue” LED.

The warm channel is a relatively nice warm-ish orangey-red color, but still very much deadly for your circadian circuitry4 due to all the fun light emitted below ~600nm.

But you don’t actually use just the individual channels. Ain’t nobody interested in that, I’m guessing. So let’s move to the CCT mixes.

Reference spectra

But before we go further, let’s have a squiz at what would pass for a nice lighting (easy on the eyes, natural):

A halogen bulb

random halogen bulb random halogen bulb (spectrum)

random halogen bulb tm30 random halogen bulb (TM30 report)

You can also have the full TM30 report of this random halogen bulb.

With CCT of ~2600K, rendering index (Rf) of 99, that’s nice.

A random snap of the sun

How some random snap of the sun spectra5?

sun sun (spectrum)

sun tm30 sun (TM30 report)

You can also have the full TM30 report of this random sun snap.

With CCT of ~6100K, rendering index (Rf) of 99, that’s also nice, ain’t it?

A few points on the CCT scale

So let’s see how the Philips White Ambiance fares on the ~6500K (coldest white it can do), ~2700K (default on startup), and ~2200K (warmest white it can do).

6535K (153 mired)

Philips Hue White Ambiance 6535K Philips Hue White Ambiance 6535K (spectrum)

Philips Hue White Ambiance 6535K (TM30) Philips Hue White Ambiance 6535K (TM30)

You can also have the full TM30 report of this Philips Hue White Ambiance 6535K.

Now, colour-science here says that the CCT is actually 6259K, which I don’t care much about. But I’m worried about the Rf of 82. That’s bad color fidelity6:

Metric Rf = 82 Rf = 99
Color Fidelity Moderate – noticeable shifts in some colors Very high – almost no visible shift
Application Suitability May be fine for basic lighting or casual environments Ideal for art, retail, healthcare, where color accuracy is critical
Perception Some colors may look slightly off or dull Colors appear vibrant and true-to-life

But maybe the others are better?

2732K (366 mired)

Philips Hue White Ambiance 2732K Philips Hue White Ambiance 2732K (spectrum)

Philips Hue White Ambiance 2732K (TM30) Philips Hue White Ambiance 2732K (TM30)

You can also have the full TM30 report of this Philips Hue White Ambiance 2732K.

The CCT this time ~matches (2722K), but the Rf is still relatively abysmal 85.

Let’s see the warmest white…

2202K (454 mired)

And don’t hold your breath. By definition this can’t be better than the 2722K. ;)

Philips Hue White Ambiance 2202K Philips Hue White Ambiance 2202K (spectrum)

Philips Hue White Ambiance 2202K (TM30) Philips Hue White Ambiance 2202K (TM30)

You can also have the full TM30 report of this Philips Hue White Ambiance 2202K.

So, yes, also not great Rf=83.

It’s a relatively consistent (poor) CRI, just like I’ve been told.

But maybe there’s a magical mired value where it’s all better?

Nope. Below are three videos that go through every single mired value the Philips Hue White Ambiance can be set to (at 100% brightness), and you can judge for yourself – on spectrum, TM30, and even on CIE1976UCS locus, in case you were into that thing.

Conclusion

Indeedy, my nerdy friends were right, Philips White Ambiance has a pretty poor color fidelity of 82-85, depending on the color temperature chosen.

Looks like I’ll have to change the LED strips7 for something better. I heard the YUJILEDS name uttered more than once. Hmm. Stay tuned.

  1. With the original driver board, on the off chance my programming skills are total bovine waste.

  2. Omitting those, but happy to share via email.

  3. E.g. Project intro: Reversing Philips Hue light driver

  4. But that’s another huge post waiting to be written.

  5. Taken just willy-nilly without much regard for the setup; through a glass window that has metallic shades in front of them, too, so some of it might be slightly off.

  6. Sauce: chatgpt, who else? ;) No, seriously, let’s trust Mr. LLM expert at that; it’s actually not wrong.

  7. I’m getting major Ship of Theseus vibes here.