chromatic-python 0.6.1__tar.gz → 0.7.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. chromatic_python-0.7.1/.github/workflows/release.yml +18 -0
  2. {chromatic_python-0.6.1 → chromatic_python-0.7.1}/PKG-INFO +5 -2
  3. {chromatic_python-0.6.1 → chromatic_python-0.7.1}/chromatic/__init__.pyi +0 -5
  4. {chromatic_python-0.6.1 → chromatic_python-0.7.1}/chromatic/__main__.py +54 -17
  5. {chromatic_python-0.6.1 → chromatic_python-0.7.1}/chromatic/_typing.py +7 -13
  6. {chromatic_python-0.6.1 → chromatic_python-0.7.1}/chromatic/_version.py +3 -3
  7. chromatic_python-0.7.1/chromatic/color/colorconv.py +358 -0
  8. chromatic_python-0.7.1/chromatic/color/colorconv.pyi +176 -0
  9. {chromatic_python-0.6.1 → chromatic_python-0.7.1}/chromatic/color/core.py +605 -395
  10. chromatic_python-0.7.1/chromatic/color/core.pyi +672 -0
  11. {chromatic_python-0.6.1 → chromatic_python-0.7.1}/chromatic/color/palette.py +162 -30
  12. chromatic_python-0.7.1/chromatic/color/palette.pyi +280 -0
  13. {chromatic_python-0.6.1 → chromatic_python-0.7.1}/chromatic/demo.py +62 -48
  14. {chromatic_python-0.6.1 → chromatic_python-0.7.1}/chromatic/image/__init__.pyi +2 -4
  15. {chromatic_python-0.6.1 → chromatic_python-0.7.1}/chromatic/image/_array.py +546 -548
  16. {chromatic_python-0.6.1 → chromatic_python-0.7.1}/chromatic/image/_curses.py +26 -36
  17. {chromatic_python-0.6.1 → chromatic_python-0.7.1}/chromatic_python.egg-info/PKG-INFO +5 -2
  18. {chromatic_python-0.6.1 → chromatic_python-0.7.1}/chromatic_python.egg-info/SOURCES.txt +2 -1
  19. {chromatic_python-0.6.1 → chromatic_python-0.7.1}/chromatic_python.egg-info/scm_file_list.json +23 -22
  20. chromatic_python-0.7.1/chromatic_python.egg-info/scm_version.json +8 -0
  21. {chromatic_python-0.6.1 → chromatic_python-0.7.1}/pyproject.toml +4 -1
  22. chromatic_python-0.6.1/.github/workflows/publish.yml +0 -29
  23. chromatic_python-0.6.1/chromatic/color/colorconv.py +0 -355
  24. chromatic_python-0.6.1/chromatic/color/core.pyi +0 -550
  25. chromatic_python-0.6.1/chromatic/color/palette.pyi +0 -258
  26. chromatic_python-0.6.1/chromatic_python.egg-info/scm_version.json +0 -8
  27. {chromatic_python-0.6.1 → chromatic_python-0.7.1}/.gitattributes +0 -0
  28. {chromatic_python-0.6.1 → chromatic_python-0.7.1}/.github/dependabot.yml +0 -0
  29. {chromatic_python-0.6.1 → chromatic_python-0.7.1}/.gitignore +0 -0
  30. {chromatic_python-0.6.1 → chromatic_python-0.7.1}/README.md +0 -0
  31. {chromatic_python-0.6.1 → chromatic_python-0.7.1}/banner.png +0 -0
  32. {chromatic_python-0.6.1 → chromatic_python-0.7.1}/chromatic/__init__.py +0 -0
  33. {chromatic_python-0.6.1 → chromatic_python-0.7.1}/chromatic/color/__init__.py +0 -0
  34. {chromatic_python-0.6.1 → chromatic_python-0.7.1}/chromatic/color/__init__.pyi +0 -0
  35. {chromatic_python-0.6.1 → chromatic_python-0.7.1}/chromatic/data/__init__.py +0 -0
  36. {chromatic_python-0.6.1 → chromatic_python-0.7.1}/chromatic/data/__init__.pyi +0 -0
  37. {chromatic_python-0.6.1 → chromatic_python-0.7.1}/chromatic/data/_fetchers.py +0 -0
  38. {chromatic_python-0.6.1 → chromatic_python-0.7.1}/chromatic/data/fonts/userfont.schema.json +0 -0
  39. {chromatic_python-0.6.1 → chromatic_python-0.7.1}/chromatic/data/fonts/vga437.ttf +0 -0
  40. {chromatic_python-0.6.1 → chromatic_python-0.7.1}/chromatic/data/images/butterfly.jpg +0 -0
  41. {chromatic_python-0.6.1 → chromatic_python-0.7.1}/chromatic/data/images/escher.png +0 -0
  42. {chromatic_python-0.6.1 → chromatic_python-0.7.1}/chromatic/data/images/goblin_virus.png +0 -0
  43. {chromatic_python-0.6.1 → chromatic_python-0.7.1}/chromatic/data/images/registry.json +0 -0
  44. {chromatic_python-0.6.1 → chromatic_python-0.7.1}/chromatic/data/userfont.py +0 -0
  45. {chromatic_python-0.6.1 → chromatic_python-0.7.1}/chromatic/data/userfont.pyi +0 -0
  46. {chromatic_python-0.6.1 → chromatic_python-0.7.1}/chromatic/image/__init__.py +0 -0
  47. {chromatic_python-0.6.1 → chromatic_python-0.7.1}/chromatic/image/_glyph.py +0 -0
  48. {chromatic_python-0.6.1 → chromatic_python-0.7.1}/chromatic_python.egg-info/dependency_links.txt +0 -0
  49. {chromatic_python-0.6.1 → chromatic_python-0.7.1}/chromatic_python.egg-info/entry_points.txt +0 -0
  50. {chromatic_python-0.6.1 → chromatic_python-0.7.1}/chromatic_python.egg-info/requires.txt +0 -0
  51. {chromatic_python-0.6.1 → chromatic_python-0.7.1}/chromatic_python.egg-info/top_level.txt +0 -0
  52. {chromatic_python-0.6.1 → chromatic_python-0.7.1}/setup.cfg +0 -0
  53. {chromatic_python-0.6.1 → chromatic_python-0.7.1}/tests/__init__.py +0 -0
  54. {chromatic_python-0.6.1 → chromatic_python-0.7.1}/tests/test_color_str.py +0 -0
@@ -0,0 +1,18 @@
1
+ name: release
2
+
3
+ on:
4
+ release:
5
+ types: [released]
6
+
7
+ permissions:
8
+ id-token: write
9
+
10
+ jobs:
11
+ publish:
12
+ runs-on: ubuntu-latest
13
+ environment: pypi
14
+ steps:
15
+ - uses: actions/checkout@v7
16
+ - uses: astral-sh/setup-uv@v7
17
+ - run: uv build
18
+ - uses: pypa/gh-action-pypi-publish@release/v1
@@ -1,16 +1,19 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: chromatic-python
3
- Version: 0.6.1
3
+ Version: 0.7.1
4
4
  Summary: ANSI art image processing and colored terminal text
5
5
  Author: crypt0lith
6
6
  License-Expression: MIT
7
7
  Project-URL: repository, https://github.com/crypt0lith/chromatic.git
8
8
  Keywords: ansi,ascii,art,font,image,terminal,parser
9
+ Classifier: Programming Language :: Python :: 3
10
+ Classifier: Programming Language :: Python :: 3.12
9
11
  Classifier: Programming Language :: Python :: 3.13
12
+ Classifier: Programming Language :: Python :: 3.14
10
13
  Classifier: Operating System :: OS Independent
11
14
  Classifier: Topic :: Software Development :: Libraries :: Python Modules
12
15
  Classifier: Typing :: Typed
13
- Requires-Python: <4.0,>=3.13
16
+ Requires-Python: <4.0,>=3.12
14
17
  Description-Content-Type: text/markdown
15
18
  Requires-Dist: numpy<2.6,>=1.25.2
16
19
  Requires-Dist: pillow>=12.2.0
@@ -1,5 +1,4 @@
1
1
  __all__ = [
2
- 'AnsiImage',
3
2
  'Back',
4
3
  'Color',
5
4
  'ColorNamespace',
@@ -8,7 +7,6 @@ __all__ = [
8
7
  'SgrParameter',
9
8
  'Style',
10
9
  'UserFont',
11
- '__version__',
12
10
  'ansi2img',
13
11
  'ansi_quantize',
14
12
  'ansicolor24Bit',
@@ -40,7 +38,6 @@ __all__ = [
40
38
  'rgb2hexstr',
41
39
  'rgb2int',
42
40
  'sort_glyphs',
43
- 'to_sgr_array',
44
41
  'ttf_extract_codepoints',
45
42
  'userfonts',
46
43
  'set_default_ansi',
@@ -69,7 +66,6 @@ from .color import (
69
66
  )
70
67
  from .data import UserFont, register_userfont, userfonts
71
68
  from .image import (
72
- AnsiImage,
73
69
  ansi2img,
74
70
  ansi_quantize,
75
71
  ansify,
@@ -85,6 +81,5 @@ from .image import (
85
81
  read_ans,
86
82
  render_ans,
87
83
  reshape_ansi,
88
- to_sgr_array,
89
84
  )
90
85
  from .image._glyph import get_glyph_masks, sort_glyphs, ttf_extract_codepoints
@@ -197,19 +197,48 @@ def parse_args():
197
197
  def color_parser(allow_rgba=False):
198
198
  def parsed_color(
199
199
  s: str, /
200
- ) -> tuple[int, int, int] | tuple[int, int, int, int]:
200
+ ) -> tuple[int, int, int, int] | tuple[int, int, int]:
201
201
  try:
202
202
  return color_ns[s.strip().replace(" ", "_").upper()]
203
203
  except KeyError:
204
- pass
205
- values = tuple(bytes.fromhex(s))
206
- if len(values) == 3 or (allow_rgba and len(values) == 4):
207
- return values
208
- raise ValueError
204
+ if any(c not in "0123456789abcdef" for c in s.casefold()):
205
+ raise
206
+ n = len(s)
207
+ if allow_rgba and n % 4 == 0:
208
+ if n == 4:
209
+ s = "".join(c * 2 for c in s)
210
+ x = int(s, 16)
211
+ if not 0 <= x < (1 << 32):
212
+ raise ValueError(f"{x:#x} is not u32")
213
+ return (
214
+ (x >> 24) & 0xFF,
215
+ (x >> 16) & 0xFF,
216
+ (x >> 8) & 0xFF,
217
+ x & 0xFF,
218
+ ) # fmt: skip
219
+ elif n % 3 == 0:
220
+ if n == 3:
221
+ s = "".join(c * 2 for c in s)
222
+ x = int(s, 16)
223
+ if not 0 <= x < (1 << 24):
224
+ raise ValueError(f"{x:#x} is not u24")
225
+ return (x >> 16) & 0xFF, (x >> 8) & 0xFF, x & 0xFF
226
+ raise ValueError(
227
+ "expected hex digit with nbytes in {}, got {}".format(
228
+ (3, 4, 6, 8), int(s, 16).bit_length() // 8
229
+ )
230
+ )
209
231
 
210
232
  return parsed_color
211
233
 
212
234
  color_opts = ansify_base.add_argument_group("color defaults")
235
+ color_opts.add_argument(
236
+ "--bg",
237
+ dest="bg_default",
238
+ metavar="COLOR",
239
+ type=color_parser(allow_rgba=True),
240
+ help="default background color. can be hex rgb(a) value or color name",
241
+ )
213
242
  color_opts.add_argument(
214
243
  "--fg",
215
244
  dest="fg_default",
@@ -218,11 +247,10 @@ def parse_args():
218
247
  help="default foreground color. can be hex rgb(a) value or color name",
219
248
  )
220
249
  color_opts.add_argument(
221
- "--bg",
222
- dest="bg_default",
223
- metavar="COLOR",
224
- type=color_parser(allow_rgba=True),
225
- help="default background color. can be hex rgb(a) value or color name",
250
+ "--alpha",
251
+ dest="alpha",
252
+ action="store_true",
253
+ help="use alpha channel with defaults: bg=0, fg=0xFF",
226
254
  )
227
255
 
228
256
  from_img_base = new_base_parser()
@@ -444,9 +472,7 @@ def parse_args():
444
472
  formatter_class=EnvHelpFormatter,
445
473
  )
446
474
  subcmd_p_ansify.add_argument(
447
- dest="img",
448
- metavar="IMAGEFILE",
449
- help="input image",
475
+ dest="img", metavar="IMAGEFILE", help="input image"
450
476
  )
451
477
 
452
478
  font_cmd_subparser = cmd_subparsers.add_parser("font")
@@ -471,16 +497,27 @@ def _call_from_ns[R](f: abc.Callable[..., R], /, ns, **kwargs) -> R:
471
497
  f_kwargs[k] = kwargs[k]
472
498
  return f(*f_args, **f_kwargs)
473
499
 
500
+
474
501
  def handle_image(ns):
475
502
  vars(ns).setdefault("outfile_callback", ns._outfile_callback)
503
+ if getattr(ns, "alpha", False):
504
+ for k, x in [("bg_default", 0), ("fg_default", 0xFF)]:
505
+ v = getattr(ns, k, None) or (x,) * 4
506
+ if len(v) == 3:
507
+ v = *v, x
508
+ setattr(ns, k, v)
509
+ delattr(ns, "alpha")
476
510
  match ns.subcmd:
477
511
  case "ansify":
478
- from .image import img2ansi, ansi2img
512
+ from .image import ansi2img, img2ansi
479
513
 
480
514
  ansi_array = _call_from_ns(img2ansi, ns)
481
515
  if hasattr(ns, "dumpfile"):
482
- ns.dumpfile.writelines(f"{s}\n".encode() for s in map("".join, ansi_array))
483
- img = _call_from_ns(ansi2img, ns, ansi_array=ansi_array)
516
+ ns.dumpfile.writelines(
517
+ f"{s}\n".encode() for s in ansi_array.splitlines()
518
+ )
519
+ ns.dumpfile.write(b"\x1b[0m")
520
+ img = _call_from_ns(ansi2img, ns, arr=ansi_array)
484
521
  try:
485
522
  outpath = ns.outfile_callback(ns, img)
486
523
  except Exception as e:
@@ -1,5 +1,4 @@
1
1
  import collections.abc as abc
2
- import sys
3
2
  import types
4
3
  import typing as tp
5
4
  from functools import reduce
@@ -16,14 +15,9 @@ if tp.TYPE_CHECKING:
16
15
  type ArrayReducerFunc[_SCT: np.generic, **_P] = abc.Callable[
17
16
  tp.Concatenate[_ArrayLike[_SCT], _P], NDArray[_SCT]
18
17
  ]
19
- if sys.version_info >= (3, 13):
20
- type ShapedNDArray[_Shape: tuple[int, ...], _SCT = np.generic] = np.ndarray[
21
- _Shape, np.dtype[_SCT]
22
- ]
23
- else:
24
- type ShapedNDArray[_Shape: tuple[int, ...], _SCT: np.generic] = np.ndarray[
25
- _Shape, np.dtype[_SCT]
26
- ]
18
+ type ShapedNDArray[_Shape: tuple[int, ...], _SCT: np.generic] = np.ndarray[
19
+ _Shape, np.dtype[_SCT]
20
+ ]
27
21
  type MatrixLike[_SCT: np.generic] = ShapedNDArray[TupleOf2[int], _SCT]
28
22
  type SquareMatrix[_I: int, _SCT: np.generic] = ShapedNDArray[TupleOf2[_I], _SCT]
29
23
  type GlyphArray[_SCT: np.generic] = SquareMatrix[L[24], _SCT]
@@ -45,11 +39,11 @@ RGBPixel: tp.TypeAlias = ShapedNDArray[tuple[L[3]], np.uint8]
45
39
  RGBImageLike: tp.TypeAlias = Image | RGBArray
46
40
  RGBVectorLike: tp.TypeAlias = IntSequence | RGBPixel
47
41
  ColorDictKeys = L['fg', 'bg']
48
- Ansi4BitAlias = L['4b']
49
- Ansi8BitAlias = L['8b']
50
- Ansi24BitAlias = L['24b']
42
+ Ansi4BitAlias = L['4b', 1]
43
+ Ansi8BitAlias = L['8b', 2]
44
+ Ansi24BitAlias = L['24b', 3]
51
45
  AnsiColorAlias = Ansi4BitAlias | Ansi8BitAlias | Ansi24BitAlias
52
- FontArgType: tp.TypeAlias = 'FreeTypeFont | UserFont | str'
46
+ FontArgType: tp.TypeAlias = tp.Union[FreeTypeFont, "UserFont", str]
53
47
 
54
48
 
55
49
  def type_error_msg(err_obj, *expected, context: str = '', obj_repr=False):
@@ -18,7 +18,7 @@ version_tuple: tuple[int | str, ...]
18
18
  commit_id: str | None
19
19
  __commit_id__: str | None
20
20
 
21
- __version__ = version = '0.6.1'
22
- __version_tuple__ = version_tuple = (0, 6, 1)
21
+ __version__ = version = '0.7.1'
22
+ __version_tuple__ = version_tuple = (0, 7, 1)
23
23
 
24
- __commit_id__ = commit_id = 'g453609020'
24
+ __commit_id__ = commit_id = 'g22c31929d'
@@ -0,0 +1,358 @@
1
+ __all__ = [
2
+ 'ANSI_4BIT_RGB',
3
+ 'ansi_4bit_to_rgb',
4
+ 'ansi_8bit_to_rgb',
5
+ 'int2rgb',
6
+ 'hexstr2rgb',
7
+ 'hsl2rgb',
8
+ 'hsv2rgb',
9
+ 'is_u24',
10
+ 'lab2rgb',
11
+ 'lab2xyz',
12
+ 'nearest_ansi_4bit_rgb',
13
+ 'nearest_ansi_8bit_rgb',
14
+ 'rgb2int',
15
+ 'rgb2hexstr',
16
+ 'rgb2hsl',
17
+ 'rgb2hsv',
18
+ 'rgb2lab',
19
+ 'rgb2xyz',
20
+ 'rgb_diff',
21
+ 'rgb_to_ansi_8bit',
22
+ 'xyz2lab',
23
+ 'xyz2rgb',
24
+ ]
25
+
26
+ import typing as tp
27
+ from functools import lru_cache
28
+ from types import MappingProxyType as mappingproxy
29
+ from typing import Final, Literal as L, SupportsInt, TypeGuard
30
+
31
+ import numpy as np
32
+
33
+ from .._typing import Float3Tuple, Int3Tuple, RGBPixel, RGBVectorLike, ShapedNDArray
34
+
35
+
36
+ @lru_cache
37
+ def _supports_int(typ: type, /) -> TypeGuard[type[SupportsInt]]:
38
+ return issubclass(typ, SupportsInt)
39
+
40
+
41
+ def is_u24(value, *, strict: bool = False) -> bool:
42
+ """Check if value is an unsigned 24-bit integer.
43
+
44
+ Parameters
45
+ ---------
46
+ value
47
+ Input number
48
+ strict : bool
49
+ Whether to return False or raise ValueError on failure
50
+
51
+ Raises
52
+ ------
53
+ ValueError
54
+ Raised when `strict=True` and value is not u24
55
+ """
56
+ if _supports_int(value.__class__):
57
+ if 0 <= int(value) < (1 << 24):
58
+ return True
59
+ elif not strict:
60
+ return False
61
+ raise ValueError(f"{value!r} is not u24")
62
+
63
+
64
+ def hexstr2rgb(s: str, /) -> Int3Tuple:
65
+ n = len(s)
66
+ if n % 4 == 0: # trunc alpha
67
+ n *= 3
68
+ n //= 4
69
+ s = s[:n]
70
+ if n == 3: # rgb -> rrggbb
71
+ s = ''.join(c * 2 for c in s)
72
+ x = int(s, 16)
73
+ if not 0 <= x < (1 << 24):
74
+ raise ValueError(f"{x:#x} is not u24")
75
+ return int2rgb(x)
76
+
77
+
78
+ def rgb2hexstr(rgb: RGBVectorLike, /) -> str:
79
+ return "%02x%02x%02x" % tuple(rgb)
80
+
81
+
82
+ def rgb2int(rgb: RGBVectorLike, /) -> int:
83
+ r, g, b = map(int, rgb)
84
+ return r << 16 | g << 8 | b
85
+
86
+
87
+ def int2rgb(x: int, /) -> Int3Tuple:
88
+ x = int(x) & 0xFFFFFF
89
+ return (x >> 16) & 0xFF, (x >> 8) & 0xFF, x & 0xFF
90
+
91
+
92
+ # fmt: off
93
+ # sRGB RGB to XYZ [M]
94
+ M_RGB2XYZ = np.array(
95
+ [[0.4124564, 0.3575761, 0.1804375],
96
+ [0.2126729, 0.7151522, 0.0721750],
97
+ [0.0193339, 0.1191920, 0.9503041]],
98
+ dtype=np.float64
99
+ )
100
+ M_XYZ2RGB = np.linalg.inv(M_RGB2XYZ)
101
+
102
+ # D65 reference white
103
+ REFWT = M_RGB2XYZ.sum(axis=1)
104
+
105
+ M_RGB2XYZ.flags.writeable = \
106
+ M_XYZ2RGB.flags.writeable = \
107
+ REFWT.flags.writeable = False
108
+ # fmt: on
109
+
110
+ EPS = 216 / 24389
111
+ LIN = (1 / 3) * (6 / 29) ** -2
112
+
113
+
114
+ def xyz2rgb(xyz, /):
115
+ out = np.clip(np.asarray(xyz, dtype=np.float64) @ M_XYZ2RGB.T, 0.0, 1.0)
116
+ return np.rint(out * 255).astype(np.uint8)
117
+
118
+
119
+ def rgb2xyz(rgb, /):
120
+ return (np.asarray(rgb, dtype=np.float64) / 255.0) @ M_RGB2XYZ.T
121
+
122
+
123
+ def xyz2lab(xyz, /):
124
+ arr = np.asarray(xyz, dtype=np.float64)
125
+ shape = arr.shape
126
+ arr = np.atleast_2d(arr)
127
+ n = arr / REFWT
128
+ f = np.where(n > EPS, np.cbrt(n), LIN * n + (16 / 116))
129
+ fx, fy, fz = f[..., 0], f[..., 1], f[..., 2]
130
+ L = 116.0 * fy - 16.0
131
+ a = 500.0 * (fx - fy)
132
+ b = 200.0 * (fy - fz)
133
+ return np.stack((L, a, b), axis=-1).reshape(shape)
134
+
135
+
136
+ def lab2xyz(lab, /):
137
+ arr = np.asarray(lab, dtype=np.float64)
138
+ shape = arr.shape
139
+ arr = np.atleast_2d(arr)
140
+ L, a, b = arr[..., 0], arr[..., 1], arr[..., 2]
141
+ fy = (L + 16.0) / 116.0
142
+ fx = a / 500.0 + fy
143
+ fz = fy - b / 200.0
144
+ f = np.stack((fx, fy, fz), axis=-1)
145
+ f3 = f**3
146
+ n = np.where(f3 > EPS, f3, (f - (16 / 116)) / LIN)
147
+ return (n * REFWT).reshape(shape)
148
+
149
+
150
+ def hsl2rgb(hsl, /):
151
+ arr = np.asarray(hsl, dtype=np.float32)
152
+ shape = arr.shape
153
+ arr = np.atleast_2d(arr)
154
+ h, s, L = arr[..., 0], arr[..., 1], arr[..., 2]
155
+ C = (1.0 - np.abs(2.0 * L - 1.0)) * s
156
+ h6 = h * 6.0
157
+ i = np.floor(h6).astype(int)
158
+ X = C * (1.0 - np.abs(h6 % 2.0 - 1.0))
159
+ m = L - C / 2.0
160
+ xs = np.stack([C + m, X + m, m], axis=-1)
161
+ P = np.array(
162
+ [[0, 1, 2],
163
+ [1, 0, 2],
164
+ [2, 0, 1],
165
+ [2, 1, 0],
166
+ [1, 2, 0],
167
+ [0, 2, 1]]
168
+ ) # fmt: skip
169
+ out = np.take_along_axis(xs, P[i % 6], axis=-1)
170
+ return np.rint(out * 255).astype(np.uint8).reshape(shape)
171
+
172
+
173
+ def rgb2hsl(rgb, /):
174
+ arr = np.asarray(rgb, dtype=np.float32) / 255.0
175
+ shape = arr.shape
176
+ arr = np.atleast_2d(arr)
177
+ r, g, b = arr[..., 0], arr[..., 1], arr[..., 2]
178
+ m = np.min(arr, axis=-1)
179
+ v = np.max(arr, axis=-1)
180
+ C = v - m
181
+ L = (v + m) / 2.0
182
+ s = np.zeros_like(v)
183
+ denom = 1.0 - np.abs(2.0 * L - 1.0)
184
+ ok = denom != 0
185
+ s[ok] = C[ok] / denom[ok]
186
+ nz = C != 0
187
+ rmax = (v == r) & nz
188
+ gmax = (v == g) & nz
189
+ bmax = (v == b) & nz
190
+ h = np.zeros_like(v)
191
+ h[rmax] = ((g[rmax] - b[rmax]) / C[rmax]) % 6
192
+ h[gmax] = ((b[gmax] - r[gmax]) / C[gmax]) + 2
193
+ h[bmax] = ((r[bmax] - g[bmax]) / C[bmax]) + 4
194
+ h = (h / 6.0) % 1.0
195
+ return np.stack((h, s, L), axis=-1).reshape(shape)
196
+
197
+
198
+ def hsv2rgb(hsv, /):
199
+ arr = np.asarray(hsv, dtype=np.float32)
200
+ shape = arr.shape
201
+ arr = np.atleast_2d(arr)
202
+ h, s, v = arr[..., 0], arr[..., 1], arr[..., 2]
203
+ h6 = h * 6.0
204
+ i = np.floor(h6).astype(int)
205
+ f = h6 - i
206
+ xs = np.stack([v, v * (1 - s), v * (1 - f * s), v * (1 - (1 - f) * s)], axis=-1)
207
+ P = np.array(
208
+ [[0, 3, 1],
209
+ [2, 0, 1],
210
+ [1, 0, 3],
211
+ [1, 2, 0],
212
+ [3, 1, 0],
213
+ [0, 1, 2]]
214
+ ) # fmt: skip
215
+ out = np.take_along_axis(xs, P[i % 6], axis=-1)
216
+ return np.rint(out * 255).astype(np.uint8).reshape(shape)
217
+
218
+
219
+ def rgb2hsv(rgb, /):
220
+ arr = np.asarray(rgb, dtype=np.float32) / 255.0
221
+ shape = arr.shape
222
+ arr = np.atleast_2d(arr)
223
+ r, g, b = arr[..., 0], arr[..., 1], arr[..., 2]
224
+ m = np.min(arr, axis=-1)
225
+ v = np.max(arr, axis=-1)
226
+ C = v - m
227
+ ok = v != 0
228
+ s = np.zeros_like(v)
229
+ s[ok] = C[ok] / v[ok]
230
+ nz = C != 0
231
+ rmax = (v == r) & nz
232
+ gmax = (v == g) & nz
233
+ bmax = (v == b) & nz
234
+ h = np.zeros_like(v)
235
+ h[rmax] = ((g[rmax] - b[rmax]) / C[rmax]) % 6
236
+ h[gmax] = ((b[gmax] - r[gmax]) / C[gmax]) + 2
237
+ h[bmax] = ((r[bmax] - g[bmax]) / C[bmax]) + 4
238
+ h = (h / 6.0) % 1.0
239
+ return np.stack((h, s, v), axis=-1).reshape(shape)
240
+
241
+
242
+ def lab2rgb(lab, /):
243
+ return xyz2rgb(lab2xyz(lab))
244
+
245
+
246
+ def rgb2lab(rgb, /):
247
+ return xyz2lab(rgb2xyz(rgb))
248
+
249
+
250
+ def rgb_diff(rgb1, rgb2, /):
251
+ return lab2rgb((rgb2lab(rgb1) + rgb2lab(rgb2)) / 2)
252
+
253
+
254
+ ANSI_4BIT_RGB = (
255
+ (0, 0, 0), # black
256
+ (170, 0, 0), # red
257
+ (0, 170, 0), # green
258
+ (170, 85, 0), # yellow
259
+ (0, 0, 170), # blue
260
+ (170, 0, 170), # magenta
261
+ (0, 170, 170), # cyan
262
+ (170, 170, 170), # white
263
+ (85, 85, 85), # bright black (grey)
264
+ (255, 85, 85), # bright red
265
+ (85, 255, 85), # bright green
266
+ (255, 255, 85), # bright yellow
267
+ (85, 85, 255), # bright blue
268
+ (255, 85, 255), # bright magenta
269
+ (85, 255, 255), # bright cyan
270
+ (255, 255, 255), # bright white
271
+ ) # fmt: skip
272
+
273
+
274
+ def ansi_4bit_to_rgb(value: int, /):
275
+ offset = 0
276
+ if value > 37:
277
+ if value <= 47:
278
+ offset -= 10
279
+ elif value <= 97:
280
+ offset += 8
281
+ else:
282
+ offset -= 2
283
+ value %= 30
284
+ value += offset
285
+ return ANSI_4BIT_RGB[value]
286
+
287
+
288
+ def _4b_lookup() -> ShapedNDArray[tuple[L[32], L[32], L[32], L[3]], np.uint8]:
289
+ def rgb_dist(rgb, ansi):
290
+ r_mean = (rgb[:, 0:1] + ansi[:, 0]) / 2
291
+ r_diff = (rgb[:, 0:1] - ansi[:, 0]) * (2 + r_mean / 256)
292
+ g_diff = (rgb[:, 1:2] - ansi[:, 1]) * 4
293
+ b_diff = (rgb[:, 2:3] - ansi[:, 2]) * (2 + (255 - r_mean) / 256)
294
+ return r_diff**2 + g_diff**2 + b_diff**2
295
+
296
+ rgb_4b_arr = np.asarray(ANSI_4BIT_RGB)
297
+ shape = (32,) * 3
298
+ quants = np.indices(shape).reshape(3, -1).T
299
+ nearest_colors = rgb_4b_arr[np.argmin(rgb_dist(quants * 8, rgb_4b_arr), axis=1)]
300
+ lut = nearest_colors.reshape(*shape, 3).astype(np.uint8)
301
+ lut.flags.writeable = False
302
+ return lut # type: ignore
303
+
304
+
305
+ ANSI_4BIT_RGB_LUT = _4b_lookup()
306
+
307
+
308
+ def nearest_ansi_4bit_rgb(rgb, /):
309
+ if is_3tuple := (rgb.__class__ is tuple and len(rgb) == 3):
310
+ r, g, b = (min(x >> 3, 32) for x in rgb)
311
+ else:
312
+ arr = np.asarray(rgb, dtype=np.uint8) >> 3
313
+ if not arr.shape or arr.shape[-1] != 3:
314
+ raise ValueError
315
+ r, g, b = arr[..., 0], arr[..., 1], arr[..., 2]
316
+ out = ANSI_4BIT_RGB_LUT[r, g, b]
317
+ return tuple(out.tolist()) if is_3tuple else out
318
+
319
+
320
+ def nearest_ansi_8bit_rgb(value, /):
321
+ return ansi_8bit_to_rgb(rgb_to_ansi_8bit(value))
322
+
323
+
324
+ def ansi_8bit_to_rgb(value, /):
325
+ arr = np.atleast_1d(value).astype(np.uint8)
326
+ out = np.empty((*arr.shape, 3), dtype=np.uint8)
327
+ mask = np.ones(arr.shape, dtype=np.bool_)
328
+ if np.any(ansi_16c := arr < 16):
329
+ out[ansi_16c] = np.take(ANSI_4BIT_RGB, arr[ansi_16c], axis=0)
330
+ mask &= ~ansi_16c
331
+ if np.any(colorcube := (arr >= 16) & (arr < 232)):
332
+ c = arr[colorcube] - 16
333
+ out[colorcube, 0] = (c // 36) * 51
334
+ out[colorcube, 1] = (c % 36 // 6) * 51
335
+ out[colorcube, 2] = (c % 6) * 51
336
+ mask &= ~colorcube
337
+ out[mask] = (8 + (arr[mask] - 232) * 10)[:, None]
338
+ return tuple(out[0].tolist()) if np.isscalar(value) else out
339
+
340
+
341
+ def rgb_to_ansi_8bit(rgb, /) -> int | ShapedNDArray[tuple[int, ...], np.uint8]:
342
+ is_3tuple = rgb.__class__ is tuple and len(rgb) == 3
343
+ arr = np.asarray(rgb, dtype=np.uint8)
344
+ out = np.zeros(arr.shape[:-1], dtype=np.uint8)
345
+ mask = np.ones(arr.shape[:-1], dtype=np.bool_)
346
+ if np.any(grey := (arr[..., 1:] == arr[..., 0:1]).all(axis=-1)):
347
+ c = arr[..., 0]
348
+ r_lo = grey & (c < 8)
349
+ r_hi = grey & (c > 248)
350
+ mid = grey & ~(r_lo | r_hi)
351
+ out[r_lo] = 16
352
+ out[r_hi] = 231
353
+ out[mid] = np.rint((c[mid] - 8) / 247 * 24) + 232
354
+ mask &= ~grey
355
+ rest = np.rint(arr[mask] / 255 * 5)
356
+ r, g, b = rest[..., 0], rest[..., 1], rest[..., 2]
357
+ out[mask] = 16 + (36 * r) + (6 * g) + b
358
+ return out.item() if is_3tuple else out