Skip to content

Commit

Permalink
Update docker file and docs
Browse files Browse the repository at this point in the history
Signed-off-by: mbw <matthew@mh-white.com>
  • Loading branch information
mbwhite committed Mar 2, 2024
1 parent 0ea0755 commit b31c5c0
Show file tree
Hide file tree
Showing 9 changed files with 601 additions and 110 deletions.
31 changes: 25 additions & 6 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -1,10 +1,29 @@
FROM python:3.9
WORKDIR /code
FROM python:3.11-buster as builder

RUN pip install poetry==1.8.1

ENV POETRY_NO_INTERACTION=1 \
POETRY_VIRTUALENVS_IN_PROJECT=1 \
POETRY_VIRTUALENVS_CREATE=1 \
POETRY_CACHE_DIR=/tmp/poetry_cache

WORKDIR /app

COPY pyproject.toml poetry.lock ./
RUN touch README.md

COPY ./requirements.txt /code/requirements.txt
RUN pip install --no-cache-dir --upgrade -r /code/requirements.txt
COPY ./aprox.txt /code
RUN --mount=type=cache,target=$POETRY_CACHE_DIR poetry install --no-root

FROM python:3.11-slim-buster as runtime

ENV VIRTUAL_ENV=/app/.venv \
PATH="/app/.venv/bin:$PATH"

COPY --from=builder ${VIRTUAL_ENV} ${VIRTUAL_ENV}
WORKDIR /code
COPY ./abridged-cie-cmf.txt /code
COPY ./cie-cmf.txt /code
COPY ./colour_system.py /code
COPY ./main.py /code

CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "3000"]
CMD ["hypercorn", "main:app", "--bind", "0.0.0.0:3000"]
40 changes: 27 additions & 13 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,20 +1,23 @@
# Spectrum RGB

**_Essential REST API service to convert spectrum to RGB_**
**_Essential REST API service to convert Spectrum to RGB & Colour Temperature to RGB_**

- Python REST API using [FastAPI](https://fastapi.tiangolo.com/)
- Docker image via github actions (arm and x86)
- Tailored for frequencies from [AdaFruti AS7341](https://learn.adafruit.com/adafruit-as7341-10-channel-light-color-sensor-breakout)
- Tailoured for frequences from [AdaFruti AS7341](https://learn.adafruit.com/adafruit-as7341-10-channel-light-color-sensor-breakout)
- Generate RGB values for specific colour temperature

## Theory

Firstly, colo[u]r theory is a large topic, very very large - you have been warned!
Firstly, colo[u]r(1) theory is a large and complex topic. Add in how your brain perceives colours. You have been warned!

The idea was to take the spectrum values from the Adafruit AS7341 and convert that to a RGB value that can be sent to a tri-colour LED or strip.
Overacall context for this was to take the the sprectrum values from the [Adafruit AS7341](https://www.adafruit.com/product/4698) and convert that to a RGB value that can be sent to a tri-colour LED or strip. There is a tri-colour LED strip in the office that I wanted to control.

This is link in with an outdoor light sensor and the WS8212B led strip in the office.
Idea being that the colour of the LED would reflect the outside colour; by adding in the ability to convert colour temperature to RGB is a bonus.

### AS7341
Specifically the I'm aiming for 'emperically perceptually equivalent'. (_i.e._ it looked ok-ish). This will only ever be an aproximation; but this is not intended for serious scientific use.

## AS7341

This outputs counts for a number of frequencies: 415nm, 445nm, 480nm, 515nm, 555nm, 590nm, 630nm, 680nm
In addition there near-ir counts, but I've stuck with the visible counts.
Expand All @@ -23,22 +26,24 @@ As an example here's the distribution of these frequencies currently getting. ![

The sensor gets these values to a MQTT topic, so the Node-Red flow is processing these already; but it need to get the RGB value to send to the LEDs.

### Spectrum to RGB Conversion

Being a photographer I knew that the theory here was immense, so knew to be careful looking for the conversion algorithm. Specifically the aim was to get a 'empirically perceptually equivalent'. (_i.e._ it looked ok-ish).
## Spectrum to RGB Conversion

A [superb post on the SciPython](https://scipython.com/blog/converting-a-spectrum-to-a-colour/) site provided the theory and also some python code that was very close to what was needed. This specifically calculates the RGB for given colour temperature; colour temperature was converted to a spectrum of wave lengths, and then to RGB.

The code here is to a very large extent taken from the code on that page; with two changes:

- I didn't need the colour temperature to spectrum function
- The spectrum it uses is frequencies from 380nm increase in 5nm increments. With only 8 entires in the AS7341 spectrum this need some changes. The `cie-cmf.txt` file was reduced to just the frequencies I had (the `aprox.txt` file).
- The sprectrum it uses is frequencies from 380nm increase in 5nm increments. With only 8 entires in the AS7341 spectrum this need some changes. The `cie-cmf.txt` file was reduced to just the frequencies I had (the `abrdiged-cie-cmf.txt` file). Suspect that this is a source of loss of accuracy. How wide the frequency response of the sensor is I don't know.

- Some refectoring to make it easier to handle

### REST API

## REST API

Using the [FastAPI](https://fastapi.tiangolo.com/) it was straightforward to create a simple Python REST api that would accept a simple JSON structure with the frequency counts. Process this via the algorithm above, and return RGB values (both in hex and int styles) in a JSON structure.

That's it really, built into a docker image via github actions - ready to be deployed into my [Portainer](https://www.portainer.io/) configuration.
Similarly the API can is also converting a colour temperature to a spectrum for conversion to RGB. This is using the full range as per the original code.

That's it really, built into a docker image via github actions - ready to be deployed into my Portainer configuration.

## Deployment

Expand All @@ -48,3 +53,12 @@ Briefly, the system is configured as follows:
- RaspberryPi I've called the 'EdgeController' receives the 433Mhz signal, and forwards on via MQTT
- Node-RED flow triggered by MQTT. This takes the spectrum count array, passes to this SpectrumRGB service, and then publishes on MQTT
- PicoW is subscribed to MQTT and does the LED strip control

## Development Notes

- Using [Poetry](https://python-poetry.org/) for handling the depdencies of the python project; currently seems to be leading tool and handles most situations well.
- As above, this is using FastAPI. Previously used [uvicorn](https://www.uvicorn.org/), but recently I've [had issues](https://stackoverflow.com/questions/76371195/how-to-make-a-json-post-request-from-java-client-to-python-fastapi-server/78076530#78076530) with that and HTTP/2, so using [hypercord](https://pgjones.gitlab.io/hypercorn/). Purely a pragmatic decision.

## Notes

[^1]: Yes I'm British, so it will be spelt with a u from now on :-)
File renamed without changes.
140 changes: 74 additions & 66 deletions colour_system.py
Original file line number Diff line number Diff line change
@@ -1,116 +1,124 @@
#
#
# colour_system.py
# https://scipython.com/blog/converting-a-spectrum-to-a-colour/

import numpy as np

def xyz_from_xy(x, y):
"""Return the vector (x, y, 1-x-y)."""
return np.array((x, y, 1-x-y))
from enum import Enum

class ColourSystem:
"""A class representing a colour system.

A colour system defined by the CIE x, y and z=1-x-y coordinates of
its three primary illuminants and its "white point".
class MatchSpace(Enum):
FULL = 1
ABRIDGED = 2

TODO: Implement gamma correction

"""
# Return the vector (x, y, 1-x-y).
def xyz_from_xy(x, y):

# The CIE colour matching function for 380 - 780 nm in 5 nm intervals
cmf = np.loadtxt('aprox.txt', usecols=(1,2,3))
return np.array((x, y, 1 - x - y))

def __init__(self, red, green, blue, white):
"""Initialise the ColourSystem object.

Pass vectors (ie NumPy arrays of shape (3,)) for each of the
red, green, blue chromaticities and the white illuminant
defining the colour system.
# A colour system defined by the CIE x, y and z=1-x-y coordinates of
# its three primary illuminants and its "white point".
class ColourSystem:

"""
# Pass vectors (ie NumPy arrays of shape (3,)) for each of the
# red, green, blue chromaticities and the white illuminant
# defining the colour system.
def __init__(self, red, green, blue, white, matchSpace: MatchSpace):
# The CIE colour matching function for 380 - 780 nm in 5 nm intervals
if matchSpace == MatchSpace.FULL:
self.cmf = np.loadtxt("cie-cmf.txt", usecols=(1, 2, 3))
else:
self.cmf = np.loadtxt("abridged-cie-cmf.txt", usecols=(1, 2, 3))

# Chromaticities
self.red, self.green, self.blue = red, green, blue
self.white = white

# The chromaticity matrix (rgb -> xyz) and its inverse
self.M = np.vstack((self.red, self.green, self.blue)).T
self.M = np.vstack((self.red, self.green, self.blue)).T
self.MI = np.linalg.inv(self.M)

# White scaling array
self.wscale = self.MI.dot(self.white)

# xyz -> rgb transformation matrix
self.T = self.MI / self.wscale[:, np.newaxis]

# Transform from xyz to rgb representation of colour.
#
# The output rgb components are normalized on their maximum
# value. If xyz is out the rgb gamut, it is desaturated until it
# comes into gamut.
#
# By default, fractional rgb components are returned; if
# out_fmt='html', the HTML hex string '#rrggbb' is returned.
def xyz_to_rgb(self, xyz, out_fmt=None):
"""Transform from xyz to rgb representation of colour.
The output rgb components are normalized on their maximum
value. If xyz is out the rgb gamut, it is desaturated until it
comes into gamut.
By default, fractional rgb components are returned; if
out_fmt='html', the HTML hex string '#rrggbb' is returned.
"""

rgb = self.T.dot(xyz)
if np.any(rgb < 0):
# We're not in the RGB gamut: approximate by desaturating
w = - np.min(rgb)
w = -np.min(rgb)
rgb += w
if not np.all(rgb==0):
if not np.all(rgb == 0):
# Normalize the rgb vector
rgb /= np.max(rgb)

if out_fmt == 'html':
if out_fmt == "html":
return self.rgb_to_hex(rgb)
return rgb

# Convert from fractional rgb values to HTML-style hex string.
def rgb_to_hex(self, rgb):
"""Convert from fractional rgb values to HTML-style hex string."""

hex_rgb = (255 * rgb).astype(int)
return '#{:02x}{:02x}{:02x}'.format(*hex_rgb)
return "#{:02x}{:02x}{:02x}".format(*hex_rgb)

# Convert a spectrum to an xyz point.
# The spectrum must be on the same grid of points as the colour-matching
# function, self.cmf: 380-780 nm in 5 nm steps.
def spec_to_xyz(self, spec):
"""Convert a spectrum to an xyz point.
The spectrum must be on the same grid of points as the colour-matching
function, self.cmf: 380-780 nm in 5 nm steps.
"""
XYZ = np.sum(spec[:, np.newaxis] * self.cmf, axis=0)
den = np.sum(XYZ)
if den == 0.:
if den == 0.0:
return XYZ
return XYZ / den

# Convert a spectrum to an rgb value.
def spec_to_rgb(self, spec, out_fmt=None):
"""Convert a spectrum to an rgb value."""

xyz = self.spec_to_xyz(spec)
return self.xyz_to_rgb(xyz, out_fmt)


illuminant_D65 = xyz_from_xy(0.3127, 0.3291)
cs_hdtv = ColourSystem(red=xyz_from_xy(0.67, 0.33),
green=xyz_from_xy(0.21, 0.71),
blue=xyz_from_xy(0.15, 0.06),
white=illuminant_D65)

cs_smpte = ColourSystem(red=xyz_from_xy(0.63, 0.34),
green=xyz_from_xy(0.31, 0.595),
blue=xyz_from_xy(0.155, 0.070),
white=illuminant_D65)

cs_srgb = ColourSystem(red=xyz_from_xy(0.64, 0.33),
green=xyz_from_xy(0.30, 0.60),
blue=xyz_from_xy(0.15, 0.06),
white=illuminant_D65)

spec = np.array([ 60. ,37. ,33. ,56. ,4. ,4. ,4., 3. ])


print(cs_hdtv.spec_to_rgb(spec, out_fmt='html'))
print(cs_srgb.spec_to_rgb(spec, out_fmt='html'))
print(cs_smpte.spec_to_rgb(spec, out_fmt='html'))
@classmethod
def HDTV(cls, matchSpace: MatchSpace):
illuminant_D65 = xyz_from_xy(0.3127, 0.3291)
return cls(
red=xyz_from_xy(0.67, 0.33),
green=xyz_from_xy(0.21, 0.71),
blue=xyz_from_xy(0.15, 0.06),
white=illuminant_D65,
matchSpace=matchSpace,
)

@classmethod
def SMPTE(cls, matchSpace: MatchSpace):
illuminant_D65 = xyz_from_xy(0.3127, 0.3291)
return cls(
red=xyz_from_xy(0.67, 0.33),
green=xyz_from_xy(0.21, 0.71),
blue=xyz_from_xy(0.15, 0.06),
white=illuminant_D65,
matchSpace=matchSpace,
)

@classmethod
def SRGB(cls, matchSpace: MatchSpace):
illuminant_D65 = xyz_from_xy(0.3127, 0.3291)
return cls(
red=xyz_from_xy(0.64, 0.33),
green=xyz_from_xy(0.30, 0.60),
blue=xyz_from_xy(0.15, 0.06),
white=illuminant_D65,
matchSpace=matchSpace,
)
64 changes: 56 additions & 8 deletions main.py
Original file line number Diff line number Diff line change
@@ -1,34 +1,82 @@
# SPDX Apache:2.0

from pydantic import BaseModel
from fastapi import FastAPI
from colour_system import *
import numpy as np
from scipy.constants import h, c, k

app = FastAPI()


# Spretrum and RGB are the two objects pased as JSON into and
# out of the REST API
# Use pydantic to handle these by extending from BaseModel
class Spectrum(BaseModel):
freq: list = []


class RGB(BaseModel):
hdtv_hex: str
srgb_hex: str
hdtv_rgb: list = []
srgb_rgb: list = []


## Utility to convert text HEX values to RGB int values
def hex_to_rgb(hexa):
hexa = hexa.lstrip('#')
hexa = hexa.lstrip("#")
rgb = []
for i in (0,2,4):
rgb.append(int(hexa[i:i+2], 16))
for i in (0, 2, 4):
rgb.append(int(hexa[i : i + 2], 16))
return rgb


# Returns the spectral radiance, B(lam, T), in W.sr-1.m-2 of a black body
# at temperature T (in K) at a wavelength lam (in nm), using Planck's law.
#
# Algorthm here is clear by inspection so no need for comments :-)
def planck(lam, T):
lam_m = lam / 1.0e9
fac = h * c / lam_m / k / T
B = 2 * h * c**2 / lam_m**5 / (np.exp(fac) - 1)
return B


@app.post("/spectrum/")
def read_item(spec: Spectrum) -> RGB:
spec = np.array(spec.freq)
print(spec)
hdtv = cs_hdtv.spec_to_rgb(spec, out_fmt='html')
srgb = cs_srgb.spec_to_rgb(spec, out_fmt='html')

r = RGB(hdtv_hex=hdtv, srgb_hex=srgb, hdtv_rgb=hex_to_rgb(hdtv),srgb_rgb=hex_to_rgb(srgb))

# use the abridge set of colour frequencies here
hdtv = ColourSystem.HDTV(MatchSpace.ABRIDGED).spec_to_rgb(spec, out_fmt="html")
srgb = ColourSystem.SRGB(MatchSpace.ABRIDGED).spec_to_rgb(spec, out_fmt="html")

r = RGB(
hdtv_hex=hdtv,
srgb_hex=srgb,
hdtv_rgb=hex_to_rgb(hdtv),
srgb_rgb=hex_to_rgb(srgb),
)
print(r)
return r


@app.post("/temperature/")
def colour_temp(temp: int) -> RGB:

# full range of frequencies here
lam = np.arange(380.0, 781.0, 5)
spec = planck(lam, temp)

hdtv = ColourSystem.HDTV(MatchSpace.FULL).spec_to_rgb(spec, out_fmt="html")
srgb = ColourSystem.SRGB(MatchSpace.FULL).spec_to_rgb(spec, out_fmt="html")

r = RGB(
hdtv_hex=hdtv,
srgb_hex=srgb,
hdtv_rgb=hex_to_rgb(hdtv),
srgb_rgb=hex_to_rgb(srgb),
)
print(r)
return r
return r
Loading

0 comments on commit b31c5c0

Please sign in to comment.