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: Hue bridge, Hue light, TorchBearer spectrometer, buncha scripts
Architecture 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
cold 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 (spectrum)
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 (spectrum)
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 (spectrum)
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 (spectrum)
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 (spectrum)
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.
-
With the original driver board, on the off chance my programming skills are total bovine waste. ↩
-
Omitting those, but happy to share via email. ↩
-
But that’s another huge post waiting to be written. ↩
-
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. ↩
-
Sauce: chatgpt, who else? ;) No, seriously, let’s trust Mr. LLM expert at that; it’s actually not wrong. ↩
-
I’m getting major Ship of Theseus vibes here. ↩