├── .github ├── dependabot.yml └── workflows │ └── test.yml ├── .gitignore ├── LICENSE.txt ├── MANIFEST.in ├── README.md ├── hsluv.py ├── setup.cfg ├── setup.py └── tests ├── __init__.py ├── snapshot-rev4.json └── test_hsluv.py /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2024 Sebastian Pipping 2 | # Licensed under the MIT license, see file LICENSE.txt 3 | 4 | version: 2 5 | updates: 6 | 7 | - package-ecosystem: "github-actions" 8 | commit-message: 9 | include: "scope" 10 | prefix: "Actions" 11 | directory: "/" 12 | labels: 13 | - "enhancement" 14 | schedule: 15 | interval: "weekly" 16 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2022 Sebastian Pipping 2 | # Licensed under the MIT license, see file LICENSE.txt 3 | 4 | name: Run the Test Suite 5 | 6 | on: 7 | pull_request: 8 | push: 9 | schedule: 10 | - cron: '0 16 * * 5' # Every Friday 4pm 11 | workflow_dispatch: 12 | 13 | jobs: 14 | run_test_suite: 15 | strategy: 16 | matrix: 17 | python-version: [3.9, 3.13] # no current need for in-between versions 18 | name: Run the Test Suite 19 | runs-on: ubuntu-24.04 20 | steps: 21 | - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 22 | 23 | - name: Set up Python ${{ matrix.python-version }} 24 | uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 25 | with: 26 | python-version: ${{ matrix.python-version }} 27 | 28 | - name: Run the Test Suite 29 | run: |- 30 | set -x 31 | pip install -U setuptools # for Python >=3.12 32 | pip install pytest 33 | python setup.py sdist 34 | cd dist/ 35 | tar xf hsluv-*.tar.gz 36 | cd hsluv-*/ 37 | pytest -v tests/ 38 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.pyc 2 | *.pyo 3 | *.egg-info 4 | /venv 5 | /build 6 | /dist 7 | # IntelliJ IDEA / PyCharm 8 | /.idea 9 | *.iml 10 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | Copyright (c) 2015 Alexei Boronine 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all 11 | copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 19 | SOFTWARE. 20 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include LICENSE.txt 2 | include tests/* 3 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Build Status](https://github.com/hsluv/hsluv-python/actions/workflows/test.yml/badge.svg)](https://github.com/hsluv/hsluv-python/actions/workflows/test.yml) 2 | [![Package Version](https://img.shields.io/pypi/v/hsluv.svg)](https://pypi.python.org/pypi/hsluv/) 3 | 4 | A Python implementation of [HSLuv](https://www.hsluv.org) (revision 4). 5 | 6 | ## Installation 7 | 8 | `pip install hsluv` 9 | 10 | Python 2 users: `pip install hsluv==5.0.0` 11 | 12 | ## Usage 13 | 14 | > This library does not hide (clamp) floating point error, e.g. you might receive a value outside 15 | > of the expected range. If you wish to display the outputs of this library, consider rounding them 16 | > for your purpose. The floating point error has not been quantified, but at least 10 decimal digits 17 | > should be free of it. 18 | 19 | ### `hsluv_to_hex([hue, saturation, lightness])` 20 | 21 | `hue` is a float between 0 and 360, `saturation` and `lightness` are floats between 0 and 100. This 22 | function returns the resulting color as a hex string. 23 | 24 | ### `hsluv_to_rgb([hue, saturation, lightness])` 25 | 26 | Like above, but returns a list of 3 floats between 0 and 1, for each RGB channel. 27 | 28 | ### `hex_to_hsluv(hex)` 29 | 30 | Takes a hex string and returns the HSLuv color as a list of floats as defined above. 31 | 32 | ### `rgb_to_hsluv([red, green, blue])` 33 | 34 | Like above, but `red`, `green` and `blue` are passed as floats between 0 and 1. 35 | 36 | For HPLuv (the pastel variant), use `hpluv_to_hex`, `hpluv_to_rgb`, `hex_to_hpluv` and `rgb_to_hpluv`. 37 | 38 | ## Testing 39 | 40 | Run `python setup.py test`. 41 | 42 | ## Authors 43 | 44 | * Robert McGinley ([mcginleyr1](https://github.com/mcginleyr1)) 45 | * Alexei Boronine ([boronine](https://github.com/boronine)) 46 | 47 | -------------------------------------------------------------------------------- /hsluv.py: -------------------------------------------------------------------------------- 1 | """ This module is generated by transpiling Haxe into Python and cleaning 2 | the resulting code by hand, e.g. removing unused Haxe classes. To try it 3 | yourself, clone https://github.com/hsluv/hsluv and run: 4 | 5 | haxe -cp haxe/src hsluv.Hsluv -python hsluv.py 6 | """ 7 | 8 | __version__ = '5.0.4' 9 | 10 | from functools import wraps as _wraps, partial as _partial # unexport, see #17 11 | import math as _math # unexport, see #17 12 | 13 | # XYZ-to-sRGB matrix 14 | _m = [[3.240969941904521, -1.537383177570093, -0.498610760293], 15 | [-0.96924363628087, 1.87596750150772, 0.041555057407175], 16 | [0.055630079696993, -0.20397695888897, 1.056971514242878]] 17 | # sRGB-to-XYZ matrix 18 | _m_inv = [[0.41239079926595, 0.35758433938387, 0.18048078840183], 19 | [0.21263900587151, 0.71516867876775, 0.072192315360733], 20 | [0.019330818715591, 0.11919477979462, 0.95053215224966]] 21 | _ref_y = 1.0 22 | _ref_u = 0.19783000664283 23 | _ref_v = 0.46831999493879 24 | _kappa = 903.2962962 # 24389/27 == (29/3)**3 25 | _epsilon = 0.0088564516 # 216/24389 == (6/29)**3 26 | 27 | 28 | def _normalize_output(conversion): 29 | # as in snapshot rev 4, the tolerance should be 1e-11 30 | normalize = _partial(round, ndigits=11-1) 31 | 32 | @_wraps(conversion) 33 | def normalized(*args, **kwargs): 34 | color = conversion(*args, **kwargs) 35 | return tuple(normalize(c) for c in color) 36 | return normalized 37 | 38 | 39 | def _distance_line_from_origin(line): 40 | v = line['slope'] ** 2 + 1 41 | return abs(line['intercept']) / _math.sqrt(v) 42 | 43 | 44 | def _length_of_ray_until_intersect(theta, line): 45 | return line['intercept']\ 46 | / (_math.sin(theta) - line['slope'] * _math.cos(theta)) 47 | 48 | 49 | def _get_bounds(l): 50 | result = [] 51 | sub1 = ((l + 16) ** 3) / 1560896 52 | if sub1 > _epsilon: 53 | sub2 = sub1 54 | else: 55 | sub2 = l / _kappa 56 | _g = 0 57 | while _g < 3: 58 | c = _g 59 | _g += 1 60 | m1 = _m[c][0] 61 | m2 = _m[c][1] 62 | m3 = _m[c][2] 63 | _g1 = 0 64 | while _g1 < 2: 65 | t = _g1 66 | _g1 += 1 67 | top1 = (284517 * m1 - 94839 * m3) * sub2 68 | top2 = (838422 * m3 + 769860 * m2 + 731718 * m1)\ 69 | * l * sub2 - (769860 * t) * l 70 | bottom = (632260 * m3 - 126452 * m2) * sub2 + 126452 * t 71 | result.append({'slope': top1 / bottom, 'intercept': top2 / bottom}) 72 | return result 73 | 74 | 75 | def _max_safe_chroma_for_l(l): 76 | return min(_distance_line_from_origin(bound) 77 | for bound in _get_bounds(l)) 78 | 79 | 80 | def _max_chroma_for_lh(l, h): 81 | hrad = _math.radians(h) 82 | lengths = [_length_of_ray_until_intersect(hrad, bound) for bound in _get_bounds(l)] 83 | return min(length for length in lengths if length >= 0) 84 | 85 | 86 | def _dot_product(a, b): 87 | return sum(i * j for i, j in zip(a, b)) 88 | 89 | 90 | def _from_linear(c): 91 | if c <= 0.0031308: 92 | return 12.92 * c 93 | 94 | return 1.055 * _math.pow(c, 5 / 12) - 0.055 95 | 96 | 97 | def _to_linear(c): 98 | if c > 0.04045: 99 | return _math.pow((c + 0.055) / 1.055, 2.4) 100 | 101 | return c / 12.92 102 | 103 | 104 | def _y_to_l(y): 105 | if y <= _epsilon: 106 | return y / _ref_y * _kappa 107 | 108 | return 116 * _math.pow(y / _ref_y, 1 / 3) - 16 109 | 110 | 111 | def _l_to_y(l): 112 | if l <= 8: 113 | return _ref_y * l / _kappa 114 | 115 | return _ref_y * (((l + 16) / 116) ** 3) 116 | 117 | 118 | def xyz_to_rgb(_hx_tuple): 119 | return ( 120 | _from_linear(_dot_product(_m[0], _hx_tuple)), 121 | _from_linear(_dot_product(_m[1], _hx_tuple)), 122 | _from_linear(_dot_product(_m[2], _hx_tuple))) 123 | 124 | 125 | def rgb_to_xyz(_hx_tuple): 126 | rgbl = (_to_linear(_hx_tuple[0]), 127 | _to_linear(_hx_tuple[1]), 128 | _to_linear(_hx_tuple[2])) 129 | return (_dot_product(_m_inv[0], rgbl), 130 | _dot_product(_m_inv[1], rgbl), 131 | _dot_product(_m_inv[2], rgbl)) 132 | 133 | 134 | def xyz_to_luv(_hx_tuple): 135 | x = float(_hx_tuple[0]) 136 | y = float(_hx_tuple[1]) 137 | z = float(_hx_tuple[2]) 138 | l = _y_to_l(y) 139 | if l == 0: 140 | return (0, 0, 0) 141 | divider = x + 15 * y + 3 * z 142 | if divider == 0: 143 | u = v = float("nan") 144 | return (l, u, v) 145 | var_u = 4 * x / divider 146 | var_v = 9 * y / divider 147 | u = 13 * l * (var_u - _ref_u) 148 | v = 13 * l * (var_v - _ref_v) 149 | return (l, u, v) 150 | 151 | 152 | def luv_to_xyz(_hx_tuple): 153 | l = float(_hx_tuple[0]) 154 | u = float(_hx_tuple[1]) 155 | v = float(_hx_tuple[2]) 156 | if l == 0: 157 | return (0, 0, 0) 158 | var_u = u / (13 * l) + _ref_u 159 | var_v = v / (13 * l) + _ref_v 160 | y = _l_to_y(l) 161 | x = y * 9 * var_u / (4 * var_v) 162 | z = y * (12 - 3 * var_u - 20 * var_v) / (4 * var_v) 163 | return (x, y, z) 164 | 165 | 166 | def luv_to_lch(_hx_tuple): 167 | l = float(_hx_tuple[0]) 168 | u = float(_hx_tuple[1]) 169 | v = float(_hx_tuple[2]) 170 | c = _math.hypot(u, v) 171 | if c < 1e-08: 172 | h = 0 173 | else: 174 | hrad = _math.atan2(v, u) 175 | h = _math.degrees(hrad) 176 | if h < 0: 177 | h += 360 178 | return (l, c, h) 179 | 180 | 181 | def lch_to_luv(_hx_tuple): 182 | l = float(_hx_tuple[0]) 183 | c = float(_hx_tuple[1]) 184 | h = float(_hx_tuple[2]) 185 | hrad = _math.radians(h) 186 | u = _math.cos(hrad) * c 187 | v = _math.sin(hrad) * c 188 | return (l, u, v) 189 | 190 | 191 | def hsluv_to_lch(_hx_tuple): 192 | h = float(_hx_tuple[0]) 193 | s = float(_hx_tuple[1]) 194 | l = float(_hx_tuple[2]) 195 | if l > 100-1e-7: 196 | return (100, 0, h) 197 | if l < 1e-08: 198 | return (0, 0, h) 199 | _hx_max = _max_chroma_for_lh(l, h) 200 | c = _hx_max / 100 * s 201 | return (l, c, h) 202 | 203 | 204 | def lch_to_hsluv(_hx_tuple): 205 | l = float(_hx_tuple[0]) 206 | c = float(_hx_tuple[1]) 207 | h = float(_hx_tuple[2]) 208 | if l > 100-1e-7: 209 | return (h, 0, 100) 210 | if l < 1e-08: 211 | return (h, 0, 0) 212 | _hx_max = _max_chroma_for_lh(l, h) 213 | s = c / _hx_max * 100 214 | return (h, s, l) 215 | 216 | 217 | def hpluv_to_lch(_hx_tuple): 218 | h = float(_hx_tuple[0]) 219 | s = float(_hx_tuple[1]) 220 | l = float(_hx_tuple[2]) 221 | if l > 100-1e-7: 222 | return (100, 0, h) 223 | if l < 1e-08: 224 | return (0, 0, h) 225 | _hx_max = _max_safe_chroma_for_l(l) 226 | c = _hx_max / 100 * s 227 | return (l, c, h) 228 | 229 | 230 | def lch_to_hpluv(_hx_tuple): 231 | l = float(_hx_tuple[0]) 232 | c = float(_hx_tuple[1]) 233 | h = float(_hx_tuple[2]) 234 | if l > 100-1e-7: 235 | return (h, 0, 100) 236 | if l < 1e-08: 237 | return (h, 0, 0) 238 | _hx_max = _max_safe_chroma_for_l(l) 239 | s = c / _hx_max * 100 240 | return (h, s, l) 241 | 242 | 243 | def rgb_to_hex(_hx_tuple): 244 | return '#{:02x}{:02x}{:02x}'.format( 245 | int(_math.floor(_hx_tuple[0] * 255 + 0.5)), 246 | int(_math.floor(_hx_tuple[1] * 255 + 0.5)), 247 | int(_math.floor(_hx_tuple[2] * 255 + 0.5))) 248 | 249 | 250 | def hex_to_rgb(_hex): 251 | # skip leading '#' 252 | r = int(_hex[1:3], base=16) / 255.0 253 | g = int(_hex[3:5], base=16) / 255.0 254 | b = int(_hex[5:7], base=16) / 255.0 255 | return (r, g, b) 256 | 257 | 258 | def lch_to_rgb(_hx_tuple): 259 | return xyz_to_rgb(luv_to_xyz(lch_to_luv(_hx_tuple))) 260 | 261 | 262 | def rgb_to_lch(_hx_tuple): 263 | return luv_to_lch(xyz_to_luv(rgb_to_xyz(_hx_tuple))) 264 | 265 | 266 | def _hsluv_to_rgb(_hx_tuple): 267 | return lch_to_rgb(hsluv_to_lch(_hx_tuple)) 268 | 269 | 270 | hsluv_to_rgb = _normalize_output(_hsluv_to_rgb) 271 | 272 | 273 | def rgb_to_hsluv(_hx_tuple): 274 | return lch_to_hsluv(rgb_to_lch(_hx_tuple)) 275 | 276 | 277 | def _hpluv_to_rgb(_hx_tuple): 278 | return lch_to_rgb(hpluv_to_lch(_hx_tuple)) 279 | 280 | 281 | hpluv_to_rgb = _normalize_output(_hpluv_to_rgb) 282 | 283 | 284 | def rgb_to_hpluv(_hx_tuple): 285 | return lch_to_hpluv(rgb_to_lch(_hx_tuple)) 286 | 287 | 288 | def hsluv_to_hex(_hx_tuple): 289 | return rgb_to_hex(hsluv_to_rgb(_hx_tuple)) 290 | 291 | 292 | def hpluv_to_hex(_hx_tuple): 293 | return rgb_to_hex(hpluv_to_rgb(_hx_tuple)) 294 | 295 | 296 | def hex_to_hsluv(s): 297 | return rgb_to_hsluv(hex_to_rgb(s)) 298 | 299 | 300 | def hex_to_hpluv(s): 301 | return rgb_to_hpluv(hex_to_rgb(s)) 302 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [bdist_wheel] 2 | universal = 1 3 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #! /usr/bin/env python3 2 | from setuptools import setup 3 | 4 | from hsluv import __version__ 5 | 6 | setup( 7 | name='hsluv', 8 | version=__version__, 9 | description='Human-friendly HSL', 10 | long_description=open('README.md').read(), 11 | long_description_content_type='text/markdown', 12 | license="MIT", 13 | author_email="alexei@boronine.com", 14 | url="https://www.hsluv.org", 15 | keywords="color hsl cie cieluv colorwheel hsluv hpluv", 16 | classifiers=[ 17 | "Development Status :: 5 - Production/Stable", 18 | "Intended Audience :: Developers", 19 | "License :: OSI Approved :: MIT License", 20 | "Operating System :: OS Independent", 21 | "Topic :: Software Development", 22 | "Programming Language :: Python :: 3", 23 | "Programming Language :: Python :: 3.9", 24 | "Programming Language :: Python :: 3.10", 25 | "Programming Language :: Python :: 3.11", 26 | "Programming Language :: Python :: 3.12", 27 | "Programming Language :: Python :: 3.13", 28 | "Programming Language :: Python :: 3 :: Only", 29 | ], 30 | python_requires='>=3.9', 31 | setup_requires=[ 32 | 'setuptools>=38.6.0', # for long_description_content_type 33 | ], 34 | py_modules=["hsluv"], 35 | test_suite="tests.test_hsluv", 36 | project_urls={ 37 | "Bug Tracker": "https://github.com/hsluv/hsluv-python/issues", 38 | "Source Code": "https://github.com/hsluv/hsluv-python", 39 | } 40 | ) 41 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hsluv/hsluv-python/28c2a01c52b86a4922c11fdfaf5afdd3ffbb79d7/tests/__init__.py -------------------------------------------------------------------------------- /tests/test_hsluv.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | import json 3 | import os.path 4 | 5 | 6 | from hsluv import (hex_to_hpluv, hex_to_hsluv, hex_to_rgb, hpluv_to_hex, 7 | hpluv_to_lch, hpluv_to_rgb, hsluv_to_hex, hsluv_to_lch, 8 | hsluv_to_rgb, lch_to_hpluv, lch_to_hsluv, lch_to_luv, 9 | luv_to_lch, luv_to_xyz, rgb_to_hex, rgb_to_xyz, 10 | xyz_to_luv, xyz_to_rgb) 11 | from hsluv import (_hsluv_to_rgb, _hpluv_to_rgb) # no normalized output 12 | 13 | rgb_range_tolerance = 1e-11 14 | # Note: we had 1e-11 precision before, but lowered it for FreeBSD: https://github.com/hsluv/hsluv/issues/87 15 | snapshot_tolerance = 1e-10 16 | 17 | 18 | class TestHsluv(unittest.TestCase): 19 | def setUp(self): 20 | # Load snapshot into memory 21 | name = os.path.join(os.path.dirname(__file__), 'snapshot-rev4.json') 22 | json_data = open(name) 23 | self.snapshot = json.load(json_data) 24 | json_data.close() 25 | 26 | def test_within_rgb_range(self): 27 | for h in range(0, 361, 5): 28 | for s in range(0, 101, 5): 29 | for l in range(0, 101, 5): 30 | for func in [_hsluv_to_rgb, _hpluv_to_rgb]: 31 | hsl = [h, s, l] 32 | rgb = func(hsl) 33 | for channel in rgb: 34 | in_range = -rgb_range_tolerance <= channel <= 1 + rgb_range_tolerance 35 | assert in_range, (hsl, rgb) 36 | for func in [hsluv_to_rgb, hpluv_to_rgb]: 37 | hsl = h, s, l 38 | rgb = func(hsl) 39 | for channel in rgb: 40 | self.assertLessEqual(channel, 1) 41 | self.assertLessEqual(0, channel) 42 | 43 | def test_snapshot(self): 44 | for hex_color, colors in self.snapshot.items(): 45 | # Test forward functions 46 | test_rgb = hex_to_rgb(hex_color) 47 | self.assert_tuples_close(test_rgb, colors['rgb']) 48 | test_xyz = rgb_to_xyz(test_rgb) 49 | self.assert_tuples_close(test_xyz, colors['xyz']) 50 | test_luv = xyz_to_luv(test_xyz) 51 | self.assert_tuples_close(test_luv, colors['luv']) 52 | test_lch = luv_to_lch(test_luv) 53 | self.assert_tuples_close(test_lch, colors['lch']) 54 | test_hsluv = lch_to_hsluv(test_lch) 55 | self.assert_tuples_close(test_hsluv, colors['hsluv']) 56 | test_hpluv = lch_to_hpluv(test_lch) 57 | self.assert_tuples_close(test_hpluv, colors['hpluv']) 58 | 59 | # Test backward functions 60 | test_lch = hsluv_to_lch(colors['hsluv']) 61 | self.assert_tuples_close(test_lch, colors['lch']) 62 | test_lch = hpluv_to_lch(colors['hpluv']) 63 | self.assert_tuples_close(test_lch, colors['lch']) 64 | test_luv = lch_to_luv(test_lch) 65 | self.assert_tuples_close(test_luv, colors['luv']) 66 | test_xyz = luv_to_xyz(test_luv) 67 | self.assert_tuples_close(test_xyz, colors['xyz']) 68 | test_rgb = xyz_to_rgb(test_xyz) 69 | self.assert_tuples_close(test_rgb, colors['rgb']) 70 | self.assertEqual(rgb_to_hex(test_rgb), hex_color) 71 | 72 | # Full test 73 | self.assertEqual(hsluv_to_hex(colors['hsluv']), hex_color) 74 | self.assert_tuples_close(hex_to_hsluv(hex_color), colors['hsluv']) 75 | self.assertEqual(hpluv_to_hex(colors['hpluv']), hex_color) 76 | self.assert_tuples_close(hex_to_hpluv(hex_color), colors['hpluv']) 77 | 78 | def assert_tuples_close(self, tup1, tup2): 79 | for a, b in zip(tup1, tup2): 80 | if abs(a - b) > snapshot_tolerance: 81 | raise Exception(f"Mismatch: {a} {b}") 82 | 83 | 84 | if __name__ == '__main__': 85 | unittest.main() 86 | --------------------------------------------------------------------------------