chromatic-python 0.7.4__tar.gz → 0.8.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 (57) hide show
  1. {chromatic_python-0.7.4 → chromatic_python-0.8.1}/PKG-INFO +1 -1
  2. {chromatic_python-0.7.4 → chromatic_python-0.8.1}/chromatic/_typing.py +3 -2
  3. {chromatic_python-0.7.4 → chromatic_python-0.8.1}/chromatic/_version.py +3 -3
  4. {chromatic_python-0.7.4 → chromatic_python-0.8.1}/chromatic/color/palette.py +1 -1
  5. {chromatic_python-0.7.4 → chromatic_python-0.8.1}/chromatic/image/__init__.pyi +1 -6
  6. {chromatic_python-0.7.4 → chromatic_python-0.8.1}/chromatic/image/_array.py +305 -200
  7. {chromatic_python-0.7.4 → chromatic_python-0.8.1}/chromatic_python.egg-info/PKG-INFO +1 -1
  8. chromatic_python-0.8.1/chromatic_python.egg-info/scm_version.json +8 -0
  9. chromatic_python-0.7.4/chromatic_python.egg-info/scm_version.json +0 -8
  10. {chromatic_python-0.7.4 → chromatic_python-0.8.1}/.gitattributes +0 -0
  11. {chromatic_python-0.7.4 → chromatic_python-0.8.1}/.github/dependabot.yml +0 -0
  12. {chromatic_python-0.7.4 → chromatic_python-0.8.1}/.github/workflows/docs.yml +0 -0
  13. {chromatic_python-0.7.4 → chromatic_python-0.8.1}/.github/workflows/release.yml +0 -0
  14. {chromatic_python-0.7.4 → chromatic_python-0.8.1}/.gitignore +0 -0
  15. {chromatic_python-0.7.4 → chromatic_python-0.8.1}/README.md +0 -0
  16. {chromatic_python-0.7.4 → chromatic_python-0.8.1}/banner.png +0 -0
  17. {chromatic_python-0.7.4 → chromatic_python-0.8.1}/chromatic/__init__.py +0 -0
  18. {chromatic_python-0.7.4 → chromatic_python-0.8.1}/chromatic/__init__.pyi +0 -0
  19. {chromatic_python-0.7.4 → chromatic_python-0.8.1}/chromatic/__main__.py +0 -0
  20. {chromatic_python-0.7.4 → chromatic_python-0.8.1}/chromatic/color/__init__.py +0 -0
  21. {chromatic_python-0.7.4 → chromatic_python-0.8.1}/chromatic/color/__init__.pyi +0 -0
  22. {chromatic_python-0.7.4 → chromatic_python-0.8.1}/chromatic/color/colorconv.py +0 -0
  23. {chromatic_python-0.7.4 → chromatic_python-0.8.1}/chromatic/color/colorconv.pyi +0 -0
  24. {chromatic_python-0.7.4 → chromatic_python-0.8.1}/chromatic/color/core.py +0 -0
  25. {chromatic_python-0.7.4 → chromatic_python-0.8.1}/chromatic/color/core.pyi +0 -0
  26. {chromatic_python-0.7.4 → chromatic_python-0.8.1}/chromatic/color/palette.pyi +0 -0
  27. {chromatic_python-0.7.4 → chromatic_python-0.8.1}/chromatic/data/__init__.py +0 -0
  28. {chromatic_python-0.7.4 → chromatic_python-0.8.1}/chromatic/data/__init__.pyi +0 -0
  29. {chromatic_python-0.7.4 → chromatic_python-0.8.1}/chromatic/data/_fetchers.py +0 -0
  30. {chromatic_python-0.7.4 → chromatic_python-0.8.1}/chromatic/data/fonts/userfont.schema.json +0 -0
  31. {chromatic_python-0.7.4 → chromatic_python-0.8.1}/chromatic/data/fonts/vga437.ttf +0 -0
  32. {chromatic_python-0.7.4 → chromatic_python-0.8.1}/chromatic/data/images/butterfly.jpg +0 -0
  33. {chromatic_python-0.7.4 → chromatic_python-0.8.1}/chromatic/data/images/escher.png +0 -0
  34. {chromatic_python-0.7.4 → chromatic_python-0.8.1}/chromatic/data/images/goblin_virus.png +0 -0
  35. {chromatic_python-0.7.4 → chromatic_python-0.8.1}/chromatic/data/images/registry.json +0 -0
  36. {chromatic_python-0.7.4 → chromatic_python-0.8.1}/chromatic/data/userfont.py +0 -0
  37. {chromatic_python-0.7.4 → chromatic_python-0.8.1}/chromatic/data/userfont.pyi +0 -0
  38. {chromatic_python-0.7.4 → chromatic_python-0.8.1}/chromatic/demo.py +0 -0
  39. {chromatic_python-0.7.4 → chromatic_python-0.8.1}/chromatic/image/__init__.py +0 -0
  40. {chromatic_python-0.7.4 → chromatic_python-0.8.1}/chromatic/image/_curses.py +0 -0
  41. {chromatic_python-0.7.4 → chromatic_python-0.8.1}/chromatic/image/_glyph.py +0 -0
  42. {chromatic_python-0.7.4 → chromatic_python-0.8.1}/chromatic_python.egg-info/SOURCES.txt +0 -0
  43. {chromatic_python-0.7.4 → chromatic_python-0.8.1}/chromatic_python.egg-info/dependency_links.txt +0 -0
  44. {chromatic_python-0.7.4 → chromatic_python-0.8.1}/chromatic_python.egg-info/entry_points.txt +0 -0
  45. {chromatic_python-0.7.4 → chromatic_python-0.8.1}/chromatic_python.egg-info/requires.txt +0 -0
  46. {chromatic_python-0.7.4 → chromatic_python-0.8.1}/chromatic_python.egg-info/scm_file_list.json +0 -0
  47. {chromatic_python-0.7.4 → chromatic_python-0.8.1}/chromatic_python.egg-info/top_level.txt +0 -0
  48. {chromatic_python-0.7.4 → chromatic_python-0.8.1}/docs/api/chromatic.md +0 -0
  49. {chromatic_python-0.7.4 → chromatic_python-0.8.1}/docs/api/color.md +0 -0
  50. {chromatic_python-0.7.4 → chromatic_python-0.8.1}/docs/api/data.md +0 -0
  51. {chromatic_python-0.7.4 → chromatic_python-0.8.1}/docs/api/image.md +0 -0
  52. {chromatic_python-0.7.4 → chromatic_python-0.8.1}/docs/index.md +0 -0
  53. {chromatic_python-0.7.4 → chromatic_python-0.8.1}/mkdocs.yml +0 -0
  54. {chromatic_python-0.7.4 → chromatic_python-0.8.1}/pyproject.toml +0 -0
  55. {chromatic_python-0.7.4 → chromatic_python-0.8.1}/setup.cfg +0 -0
  56. {chromatic_python-0.7.4 → chromatic_python-0.8.1}/tests/__init__.py +0 -0
  57. {chromatic_python-0.7.4 → chromatic_python-0.8.1}/tests/test_color_str.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: chromatic-python
3
- Version: 0.7.4
3
+ Version: 0.8.1
4
4
  Summary: ANSI art image processing and colored terminal text
5
5
  Author: crypt0lith
6
6
  License-Expression: MIT
@@ -33,10 +33,11 @@ GlyphBitmask: tp.TypeAlias = GlyphArray[np.bool_]
33
33
  Bitmask: tp.TypeAlias = MatrixLike[np.bool_]
34
34
  GreyscaleGlyphArray: tp.TypeAlias = GlyphArray[np.float64]
35
35
  GreyscaleArray: tp.TypeAlias = MatrixLike[np.float64]
36
- RGBArray: tp.TypeAlias = ShapedNDArray[tuple[int, int, L[3]], np.uint8]
37
36
  RGBPixel: tp.TypeAlias = ShapedNDArray[tuple[L[3]], np.uint8]
37
+ RGBArray: tp.TypeAlias = ShapedNDArray[tuple[int, int, L[3]], np.uint8]
38
+ RGBArray3d: tp.TypeAlias = ShapedNDArray[tuple[int, int, int, L[3]], np.uint8]
38
39
 
39
- RGBImageLike: tp.TypeAlias = Image | RGBArray
40
+ RGBImageLike: tp.TypeAlias = Image | RGBArray | RGBArray3d
40
41
  RGBVectorLike: tp.TypeAlias = IntSequence | RGBPixel
41
42
  ColorDictKeys = L["fg", "bg"]
42
43
  Ansi4BitAlias = L["4b", 1]
@@ -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.7.4'
22
- __version_tuple__ = version_tuple = (0, 7, 4)
21
+ __version__ = version = '0.8.1'
22
+ __version_tuple__ = version_tuple = (0, 8, 1)
23
23
 
24
- __commit_id__ = commit_id = 'gbbb16e443'
24
+ __commit_id__ = commit_id = 'ga30deae42'
@@ -519,7 +519,7 @@ def rgb_dispatch(*names, replace_defaults=True):
519
519
  ... def func(fruit_or_color="orange", /):
520
520
  ... res = "fruit" if isinstance(fruit_or_color, str) else "color"
521
521
  ... return f"{fruit_or_color} is a {res}"
522
- ...
522
+ ...
523
523
  >>> func()
524
524
  'orange is a fruit'
525
525
  >>> func("orange")
@@ -45,10 +45,5 @@ from ._array import (
45
45
  scale_saturation,
46
46
  shuffle_char_set,
47
47
  )
48
- from ._curses import (
49
- ascii_printable,
50
- backtrans_cp437,
51
- cp437_printable,
52
- translate_cp437,
53
- )
48
+ from ._curses import ascii_printable, backtrans_cp437, cp437_printable, translate_cp437
54
49
  from ._glyph import get_glyph_masks, sort_glyphs, ttf_extract_codepoints
@@ -32,9 +32,7 @@ from shutil import get_terminal_size
32
32
 
33
33
  import cv2 as cv
34
34
  import numpy as np
35
- import PIL.Image
36
- import PIL.ImageDraw
37
- import PIL.ImageFont
35
+ from PIL import Image, ImageDraw, ImageFont, ImageSequence
38
36
 
39
37
  from .. import _typing as _tp
40
38
  from ..color import core
@@ -43,7 +41,7 @@ from ..color.palette import rgb_dispatch
43
41
  from ..data import userfont as uf
44
42
 
45
43
 
46
- def get_font_key(font: PIL.ImageFont.FreeTypeFont):
44
+ def get_font_key(font: ImageFont.FreeTypeFont):
47
45
  """Obtain a unique tuple pair from a FreeTypeFont object.
48
46
 
49
47
  Parameters
@@ -80,7 +78,7 @@ def get_font_key(font: PIL.ImageFont.FreeTypeFont):
80
78
  @tp.overload
81
79
  def get_font_object(
82
80
  font: _tp.FontArgType, *, retpath: tp.Literal[False] = False
83
- ) -> PIL.ImageFont.FreeTypeFont: ...
81
+ ) -> ImageFont.FreeTypeFont: ...
84
82
 
85
83
 
86
84
  @tp.overload
@@ -90,13 +88,13 @@ def get_font_object(font: _tp.FontArgType, *, retpath: tp.Literal[True]) -> str:
90
88
  @tp.overload
91
89
  def get_font_object(
92
90
  font: _tp.FontArgType, *, retpath: bool
93
- ) -> PIL.ImageFont.FreeTypeFont | str: ...
91
+ ) -> ImageFont.FreeTypeFont | str: ...
94
92
 
95
93
 
96
94
  @lru_cache
97
95
  def get_font_object(
98
96
  font: _tp.FontArgType, *, retpath: bool = False
99
- ) -> PIL.ImageFont.FreeTypeFont | str:
97
+ ) -> ImageFont.FreeTypeFont | str:
100
98
  """Return a FreeTypeFont object or its filepath.
101
99
 
102
100
  The result is cached to prevent FreeType from consuming excessive resources.
@@ -123,21 +121,21 @@ def get_font_object(
123
121
  if retpath:
124
122
  return (
125
123
  getattr(font.path, "name", os.fspath(font.path))
126
- if isinstance(font, PIL.ImageFont.FreeTypeFont)
124
+ if isinstance(font, ImageFont.FreeTypeFont)
127
125
  else get_font_object(get_font_object(font), retpath=True)
128
126
  )
129
127
  else:
130
128
  match font:
131
- case PIL.ImageFont.FreeTypeFont():
129
+ case ImageFont.FreeTypeFont():
132
130
  return font
133
131
  case uf.UserFont():
134
132
  return font.to_truetype()
135
133
  case str() if font in uf.userfonts:
136
134
  return get_font_object(uf.userfonts[font])
137
135
  case str() | os.PathLike():
138
- return PIL.ImageFont.truetype(font, 24)
136
+ return ImageFont.truetype(font, 24)
139
137
  raise TypeError(
140
- f"Expected {PIL.ImageFont.FreeTypeFont.__name__!r} or pathlike object, "
138
+ f"Expected {ImageFont.FreeTypeFont.__name__!r} or pathlike object, "
141
139
  f"got {type(font).__name__!r} object instead"
142
140
  )
143
141
 
@@ -201,7 +199,7 @@ def render_font_str(s: str, /, font: _tp.FontArgType):
201
199
  for line in map(lambda x: f"{x:<{maxlen}}", lines)
202
200
  ]
203
201
  )
204
- return PIL.Image.fromarray(stacked)
202
+ return Image.fromarray(stacked)
205
203
  return render_font_char(s, font)
206
204
 
207
205
 
@@ -239,8 +237,8 @@ def render_font_char(
239
237
  """
240
238
  if len(c) > 1:
241
239
  raise ValueError(f"expected a character, but string of length {len(c)} found")
242
- img = PIL.Image.new("RGB", size=size)
243
- draw = PIL.ImageDraw.Draw(img)
240
+ img = Image.new("RGB", size=size)
241
+ draw = ImageDraw.Draw(img)
244
242
  font_obj = get_font_object(font)
245
243
  bbox = draw.textbbox((0, 0), c, font=font_obj)
246
244
  x_offset, y_offset = (
@@ -250,50 +248,6 @@ def render_font_char(
250
248
  return img
251
249
 
252
250
 
253
- def get_rgb_array(img: str | os.PathLike[str] | _tp.RGBImageLike, /):
254
- """Convert an input image into an RGB array.
255
-
256
- Parameters
257
- ----------
258
- img : str | PathLike[str] | RGBImageLike
259
- Input image or path to the image.
260
-
261
- Returns
262
- -------
263
- RGBArray
264
-
265
- Raises
266
- ------
267
- ValueError
268
- If the image format is invalid.
269
-
270
- TypeError
271
- If the input is not a valid image or path.
272
- """
273
- if isinstance(img, (str, os.PathLike)):
274
- x = cv.imread(fp := os.fspath(img))
275
- if x is None:
276
- raise FileNotFoundError(fp)
277
- img = cv.cvtColor(x, cv.COLOR_BGR2RGB)
278
- if not _is_rgb_array(img):
279
- if _is_image(img):
280
- img = img.convert("RGB")
281
- elif _is_array(img):
282
- if img.ndim == 2:
283
- img = cv.cvtColor(img[:, :, 0], cv.COLOR_GRAY2RGB)
284
- elif img.ndim == 4:
285
- img = cv.cvtColor(img, cv.COLOR_RGBA2RGB)
286
- else:
287
- raise ValueError(f"unexpected array shape: {img.shape!r}")
288
- else:
289
- err = TypeError(
290
- _tp.type_error_msg(img, os.PathLike, PIL.Image.Image, np.ndarray)
291
- )
292
- raise err
293
- img = np.asarray(img, dtype=np.uint8)
294
- return img
295
-
296
-
297
251
  def ansi_quantize(img: _tp.RGBArray, ansi_type: core.AnsiColorParam):
298
252
  """Color-quantize an RGB array into ANSI 4-bit or 8-bit color space.
299
253
 
@@ -397,14 +351,151 @@ def scale_saturation(
397
351
  return img
398
352
 
399
353
 
400
- def _get_asciidraw_vars(
401
- img: str | os.PathLike[str] | _tp.RGBImageLike, font: _tp.FontArgType, /
402
- ):
403
- return get_rgb_array(img), get_font_object(font)
354
+ def _get_bbox_shape(font: ImageFont.FreeTypeFont, /):
355
+ return font.getbbox(" ")[2:]
404
356
 
405
357
 
406
- def _get_bbox_shape(font: PIL.ImageFont.FreeTypeFont, /):
407
- return font.getbbox(" ")[2:]
358
+ class _ConversionHandler:
359
+ def _close_owned(self):
360
+ if "owned" in self._ns:
361
+ self._ns.pop("owned").close()
362
+
363
+ def _swap_owned(self, im: Image.Image, /):
364
+ self._close_owned()
365
+ self._ns["owned"] = im
366
+
367
+ def _chars(self):
368
+ char_set = self.char_set
369
+ sort = self.sort_glyphs
370
+ if char_set is None:
371
+ if self.font.path is uf.VGA437:
372
+ from ._curses import cp437_printable as printable
373
+ else:
374
+ from ._curses import ascii_printable as printable
375
+ char_set = printable()
376
+ if not sort:
377
+ char_set = shuffle_char_set(char_set)
378
+ if sort:
379
+ from ._glyph import sort_glyphs
380
+
381
+ char_set = sort_glyphs(char_set, self.font, reverse=sort == -1)
382
+ chars = np.frombuffer(char_set.encode("utf-32-le"), dtype="<U1")
383
+ if not chars.size:
384
+ raise ValueError("empty charset")
385
+ return chars
386
+
387
+ def _push_image(self, im, /):
388
+ if isinstance(im, (str, os.PathLike)):
389
+ im = Image.open(im)
390
+ self._swap_owned(im)
391
+ if _is_image(im):
392
+ if getattr(im, "is_animated", False):
393
+ arr = np.stack(
394
+ ImageSequence.all_frames(
395
+ im, lambda x: x.convert("RGB") # type: ignore
396
+ ),
397
+ dtype=np.uint8,
398
+ )
399
+ else:
400
+ arr = np.asarray(im.convert("RGB"), dtype=np.uint8)
401
+ elif _is_array(im):
402
+ arr = im.astype(np.uint8)
403
+ else:
404
+ raise TypeError
405
+ if not arr.size:
406
+ raise ValueError
407
+ if arr.ndim == 2:
408
+ arr = cv.cvtColor(arr[:, :, 0], cv.COLOR_GRAY2RGB)
409
+ elif arr.shape[-1] == 4:
410
+ arr = cv.cvtColor(arr, cv.COLOR_RGBA2RGB)
411
+ if arr.ndim not in {3, 4}:
412
+ raise ValueError
413
+ self._ns["rgb"] = arr
414
+ return arr
415
+
416
+ def __init__(
417
+ self,
418
+ font: _tp.FontArgType = uf.VGA437,
419
+ *,
420
+ factor=200,
421
+ char_set=None,
422
+ sort_glyphs=True,
423
+ ansi_type=None,
424
+ equalize=False,
425
+ bg=None,
426
+ ):
427
+ self.font = get_font_object(font)
428
+ self.factor = factor
429
+ self.char_set = char_set
430
+ self.sort_glyphs = sort_glyphs
431
+ self.ansi_type = core.get_ansi_type(ansi_type)
432
+ self.equalize = equalize
433
+ self.bg = bg
434
+ self._ns = {}
435
+
436
+ def __enter__(self):
437
+ return self
438
+
439
+ def __exit__(self, *exc_info):
440
+ self._close_owned()
441
+ self._ns.clear()
442
+
443
+ def to_ascii(self, im, /):
444
+ rgb = self._push_image(im)
445
+ if is_single := rgb.ndim == 3:
446
+ rgb = rgb[None, :]
447
+ chars = self._chars()
448
+ ch, cw = _get_bbox_shape(self.font)
449
+ out_shape = (
450
+ int(self.factor / (rgb.shape[2] / rgb.shape[1]) / ceil(cw / ch)),
451
+ self.factor,
452
+ )
453
+ interp = np.empty((rgb.shape[0], *out_shape), dtype="<U1")
454
+ for i in range(rgb.shape[0]):
455
+ grey = cv.cvtColor(rgb[i], cv.COLOR_RGB2GRAY)
456
+ blur = grey.astype(np.float64) / 255.0
457
+ for t, (ax_in, ax_out) in enumerate(zip(grey.shape, out_shape)):
458
+ if (s := (ax_in / ax_out - 1) / 2) <= 0:
459
+ continue
460
+ k = cv.getGaussianKernel(2 * int(4.0 * s + 0.5) + 1, s)
461
+ if t:
462
+ k = k.T
463
+ blur = cv.filter2D(blur, -1, k, borderType=cv.BORDER_REFLECT_101)
464
+ grey = (
465
+ cv.resize(blur, out_shape[::-1], interpolation=cv.INTER_LINEAR) * 255.0
466
+ ).astype(np.uint8)
467
+ interp[i] = chars[np.rint(grey / 255 * (chars.size - 1)).astype(np.intp)]
468
+ return interp[0] if is_single else interp
469
+
470
+ def to_ansi(self, im, /):
471
+ interp = self.to_ascii(im)
472
+ rgb = self._ns["rgb"]
473
+ assert interp.ndim == rgb.ndim - 1
474
+ if is_single := rgb.ndim == 3:
475
+ interp = interp[None, :]
476
+ rgb = rgb[None, :]
477
+ assert interp.shape[0] == rgb.shape[0]
478
+ if self.equalize is True:
479
+ rgb[:] = [contrast_stretch(x) for x in rgb]
480
+ elif self.equalize == "white_point":
481
+ rgb[:] = [equalize_white_point(x) for x in rgb]
482
+ rgb[:] = ansi_quantize(rgb, ansi_type=self.ansi_type)
483
+ newshape = interp.shape[-2:]
484
+ n_frames = interp.shape[0]
485
+ out = np.empty((n_frames, *newshape), dtype=core.color_chain.dtype)
486
+ for i in range(n_frames):
487
+ with (
488
+ Image.fromarray(rgb[i], mode="RGB") as x,
489
+ x.resize(newshape[::-1], resample=Image.Resampling.LANCZOS) as xr,
490
+ ):
491
+ out["char"][i] = interp[i]
492
+ out["rgb"][i, ..., 0, 0] = self.ansi_type.typecode
493
+ out["rgb"][i, ..., 0, 1:] = np.asarray(xr, dtype=np.uint8)
494
+ if self.bg is None:
495
+ continue
496
+ out["rgb"][i, ..., 1, 0] = self.ansi_type.typecode
497
+ out["rgb"][i, ..., 1, 1:] = self.bg
498
+ return out[0] if is_single else out
408
499
 
409
500
 
410
501
  @tp.overload
@@ -416,23 +507,49 @@ def img2ascii(
416
507
  char_set: tp.Optional[str] = ...,
417
508
  sort_glyphs: bool | tp.Literal[-1] = ...,
418
509
  *,
419
- ret_img: tp.Literal[False] = False,
420
- ) -> str: ...
510
+ outarray: tp.Literal[False] = False,
511
+ ) -> str | list[str]: ...
421
512
 
422
513
 
423
514
  @tp.overload
424
515
  def img2ascii(
425
- img: str | os.PathLike[str] | _tp.RGBImageLike,
516
+ img: _tp.RGBArray,
517
+ /,
518
+ font: _tp.FontArgType = ...,
519
+ factor: int = ...,
520
+ char_set: tp.Optional[str] = ...,
521
+ sort_glyphs: bool | tp.Literal[-1] = ...,
522
+ *,
523
+ outarray: tp.Literal[True],
524
+ ) -> _tp.ShapedNDArray[tuple[int, int], np.str_]: ...
525
+
526
+
527
+ @tp.overload
528
+ def img2ascii(
529
+ img: _tp.RGBArray3d,
530
+ /,
531
+ font: _tp.FontArgType = ...,
532
+ factor: int = ...,
533
+ char_set: tp.Optional[str] = ...,
534
+ sort_glyphs: bool | tp.Literal[-1] = ...,
535
+ *,
536
+ outarray: tp.Literal[True],
537
+ ) -> _tp.ShapedNDArray[tuple[int, int, int], np.str_]: ...
538
+
539
+
540
+ @tp.overload
541
+ def img2ascii(
542
+ img: str | os.PathLike[str] | Image.Image,
426
543
  /,
427
544
  font: _tp.FontArgType = ...,
428
545
  factor: int = ...,
429
546
  char_set: tp.Optional[str] = ...,
430
547
  sort_glyphs: bool | tp.Literal[-1] = ...,
431
548
  *,
432
- ret_img: tp.Literal[True],
433
- ) -> tuple[
549
+ outarray: tp.Literal[True],
550
+ ) -> tp.Union[
434
551
  _tp.ShapedNDArray[tuple[int, int], np.str_],
435
- _tp.ShapedNDArray[tuple[int, int, tp.Literal[3]], np.uint8],
552
+ _tp.ShapedNDArray[tuple[int, int, int], np.str_],
436
553
  ]: ...
437
554
 
438
555
 
@@ -444,7 +561,7 @@ def img2ascii( # type: ignore
444
561
  char_set=None,
445
562
  sort_glyphs=True,
446
563
  *,
447
- ret_img=False,
564
+ outarray=False,
448
565
  ):
449
566
  """Convert an image to a multiline ASCII string.
450
567
 
@@ -462,17 +579,15 @@ def img2ascii( # type: ignore
462
579
  char_set : Iterable[str], optional
463
580
  Characters to be mapped to greyscale values of 'img'.
464
581
 
465
- sort_glyphs : {True, False, ``-1``}
582
+ sort_glyphs : {True, False, -1}
466
583
  Specifies to sort `char_set` or leave it unsorted before mapping to greyscale.
467
584
 
468
585
  Glyph bitmasks obtained from 'font' are compared when sorting the string.
469
586
 
470
- ``-1`` specifies reverse sorting order.
587
+ `-1` specifies reverse sorting order.
471
588
 
472
- ret_img : bool, default=False
473
- Specifies to return both the output string and original RGB array.
474
-
475
- Used by ``img2ansi`` to lazily obtain the base ASCII chars and original RGB array.
589
+ outarray : bool, default=False
590
+ If True, returns the raw chararray. Otherwise, returns a string or list of strings.
476
591
 
477
592
  Returns
478
593
  -------
@@ -488,58 +603,19 @@ def img2ascii( # type: ignore
488
603
  --------
489
604
  ascii2img : Render an ASCII string as an image.
490
605
  """
491
- rgb, font = _get_asciidraw_vars(img, font)
492
- assert isinstance(rgb, np.ndarray)
493
- grey: _tp.MatrixLike[np.uint8] = cv.cvtColor(rgb, cv.COLOR_RGB2GRAY)
494
- shape = grey.shape
495
- img_aspect = shape[-1] / shape[0]
496
- ch, cw = _get_bbox_shape(font)
497
- char_aspect = ceil(cw / ch)
498
- out_h = int(factor / img_aspect / char_aspect)
499
- out_w = factor
500
- blur = grey.astype(np.float64) / 255.0
501
- if (sy := (shape[0] / out_h - 1) / 2) > 0:
502
- blur = cv.filter2D(
503
- blur,
504
- -1,
505
- cv.getGaussianKernel(2 * int(4.0 * sy + 0.5) + 1, sy),
506
- borderType=cv.BORDER_REFLECT_101,
507
- )
508
- if (sx := (shape[1] / out_w - 1) / 2) > 0:
509
- blur = cv.filter2D(
510
- blur,
511
- -1,
512
- cv.getGaussianKernel(2 * int(4.0 * sx + 0.5) + 1, sx).T,
513
- borderType=cv.BORDER_REFLECT_101,
514
- )
515
- grey = (
516
- cv.resize(blur, (out_w, out_h), interpolation=cv.INTER_LINEAR) * 255.0
517
- ).astype(np.uint8)
518
- if char_set is None:
519
- if font.path is uf.VGA437:
520
- from ._curses import cp437_printable
521
-
522
- char_set = cp437_printable()
523
- else:
524
- from ._curses import ascii_printable
525
-
526
- char_set = ascii_printable()
527
- if not sort_glyphs:
528
- char_set = shuffle_char_set(char_set)
529
- if sort_glyphs:
530
- from ._glyph import sort_glyphs as glyph_sort
531
-
532
- char_set = glyph_sort(char_set, font, reverse=not ~int(sort_glyphs))
533
- chars = np.frombuffer(char_set.encode("utf-32-le"), dtype="<U1")
534
- if not chars.size:
535
- raise ValueError("empty charset")
536
- interp = chars[np.rint(grey / 255 * (chars.size - 1)).astype(np.intp)]
537
- if ret_img is True:
538
- return interp, rgb
539
- newlines = np.zeros((interp.shape[0], 1), dtype="<U1")
606
+ with _ConversionHandler(
607
+ font, factor=factor, char_set=char_set, sort_glyphs=sort_glyphs
608
+ ) as h:
609
+ out = h.to_ascii(img)
610
+ if outarray is True:
611
+ return out
612
+ newlines = np.zeros((*out.shape[:-1], 1), dtype="<U1")
540
613
  newlines[:-1] = "\n"
541
- interp = np.concatenate((interp, newlines), axis=1)
542
- return "".join(interp.flat)
614
+ out = np.concatenate((out, newlines), axis=-1)
615
+ if out.ndim == 3:
616
+ return "".join(out.flat)
617
+ else:
618
+ return ["".join(x.flat) for x in out]
543
619
 
544
620
 
545
621
  @tp.overload
@@ -555,12 +631,12 @@ def img2ansi(
555
631
  bg: tp.Optional[_tp.Int3Tuple | str] = ...,
556
632
  *,
557
633
  outarray: tp.Literal[False] = False,
558
- ) -> core.color_chain: ...
634
+ ) -> core.color_chain | list[core.color_chain]: ...
559
635
 
560
636
 
561
637
  @tp.overload
562
638
  def img2ansi(
563
- img: str | os.PathLike[str] | _tp.RGBImageLike,
639
+ img: _tp.RGBArray,
564
640
  /,
565
641
  font: _tp.FontArgType = ...,
566
642
  factor: int = ...,
@@ -574,17 +650,52 @@ def img2ansi(
574
650
  ) -> _tp.ShapedNDArray[tuple[int, int], np.void]: ...
575
651
 
576
652
 
577
- @rgb_dispatch("bg")
653
+ @tp.overload
578
654
  def img2ansi(
579
- img: str | os.PathLike[str] | _tp.RGBImageLike,
655
+ img: _tp.RGBArray3d,
580
656
  /,
581
- font: _tp.FontArgType = uf.VGA437,
582
- factor: int = 200,
583
- char_set: tp.Optional[str] = None,
584
- sort_glyphs: bool | tp.Literal[-1] = True,
585
- ansi_type: tp.Optional[core.AnsiColorParam] = None,
586
- equalize: bool | tp.Literal["white_point"] = False,
587
- bg: tp.Optional[_tp.Int3Tuple | str] = None,
657
+ font: _tp.FontArgType = ...,
658
+ factor: int = ...,
659
+ char_set: tp.Optional[str] = ...,
660
+ sort_glyphs: bool | tp.Literal[-1] = ...,
661
+ ansi_type: tp.Optional[core.AnsiColorParam] = ...,
662
+ equalize: bool | tp.Literal["white_point"] = ...,
663
+ bg: tp.Optional[_tp.Int3Tuple | str] = ...,
664
+ *,
665
+ outarray: tp.Literal[True],
666
+ ) -> _tp.ShapedNDArray[tuple[int, int, int], np.void]: ...
667
+
668
+
669
+ @tp.overload
670
+ def img2ansi(
671
+ img: str | os.PathLike[str] | Image.Image,
672
+ /,
673
+ font: _tp.FontArgType = ...,
674
+ factor: int = ...,
675
+ char_set: tp.Optional[str] = ...,
676
+ sort_glyphs: bool | tp.Literal[-1] = ...,
677
+ ansi_type: tp.Optional[core.AnsiColorParam] = ...,
678
+ equalize: bool | tp.Literal["white_point"] = ...,
679
+ bg: tp.Optional[_tp.Int3Tuple | str] = ...,
680
+ *,
681
+ outarray: tp.Literal[True],
682
+ ) -> tp.Union[
683
+ _tp.ShapedNDArray[tuple[int, int], np.void],
684
+ _tp.ShapedNDArray[tuple[int, int, int], np.void],
685
+ ]: ...
686
+
687
+
688
+ @rgb_dispatch("bg")
689
+ def img2ansi( # type: ignore
690
+ img,
691
+ /,
692
+ font=uf.VGA437,
693
+ factor=200,
694
+ char_set=None,
695
+ sort_glyphs=True,
696
+ ansi_type=None,
697
+ equalize=False,
698
+ bg=None,
588
699
  *,
589
700
  outarray=False,
590
701
  ):
@@ -607,12 +718,12 @@ def img2ansi(
607
718
 
608
719
  If None (default), the character set will be determined based on the 'font' parameter.
609
720
 
610
- sort_glyphs : {True, False, ``-1``}
721
+ sort_glyphs : {True, False, -1}
611
722
  Specifies to sort `char_set` or leave it unsorted before mapping to greyscale.
612
723
 
613
724
  Glyph bitmasks obtained from 'font' are compared when sorting the string.
614
725
 
615
- ``-1`` specifies reverse sorting order.
726
+ `-1` specifies reverse sorting order.
616
727
 
617
728
  ansi_type : AnsiColorParam
618
729
  ANSI color format to map the RGB values to.
@@ -656,30 +767,22 @@ def img2ansi(
656
767
  ansi2img : Render an ANSI array as an image.
657
768
  img2ascii : Used to obtain the base ASCII characters.
658
769
  """
659
- if bg is None:
660
- pass
661
- elif not (isinstance(bg, tuple) and len(bg) == 3):
662
- raise TypeError
663
- s, rgb = img2ascii(img, font, factor, char_set, sort_glyphs, ret_img=True)
664
- h, w = s.shape
665
- if equalize is True:
666
- rgb = contrast_stretch(rgb)
667
- elif equalize == "white_point":
668
- rgb = equalize_white_point(rgb)
669
- ansi_type = core.get_ansi_type(ansi_type)
670
- rgb = ansi_quantize(rgb, ansi_type=ansi_type)
671
- with (
672
- PIL.Image.fromarray(rgb, mode="RGB") as img,
673
- img.resize((w, h), resample=PIL.Image.Resampling.LANCZOS) as resized,
674
- ):
675
- out = np.zeros(s.shape, dtype=core.color_chain.dtype)
676
- out["char"] = s
677
- out["rgb"][..., 0, 0] = ansi_type.typecode
678
- out["rgb"][..., 0, 1:] = np.asarray(resized, dtype=np.uint8)
679
- if bg is not None:
680
- out["rgb"][..., 1, 0] = ansi_type.typecode
681
- out["rgb"][..., 1, 1:] = bg
682
- return out if outarray is True else core.color_chain.fromarray(out)
770
+ with _ConversionHandler(
771
+ font,
772
+ factor=factor,
773
+ char_set=char_set,
774
+ sort_glyphs=sort_glyphs,
775
+ ansi_type=ansi_type,
776
+ equalize=equalize,
777
+ bg=bg,
778
+ ) as h:
779
+ out = h.to_ansi(img)
780
+ if outarray is True:
781
+ return out
782
+ elif out.ndim == 2:
783
+ return core.color_chain.fromarray(out)
784
+ else:
785
+ return [core.color_chain.fromarray(x) for x in out]
683
786
 
684
787
 
685
788
  @rgb_dispatch("fg", "bg")
@@ -714,20 +817,20 @@ def ascii2img(
714
817
  Returns
715
818
  -------
716
819
  ascii_img : Image
717
- A `PIL.Image.Image` object of the rendered ASCII string.
820
+ A `Image.Image` object of the rendered ASCII string.
718
821
 
719
822
  See Also
720
823
  --------
721
824
  img2ascii : Convert an image into an ASCII string.
722
825
  """
723
- font = PIL.ImageFont.truetype(get_font_object(font, retpath=True), font_size)
826
+ font = ImageFont.truetype(get_font_object(font, retpath=True), font_size)
724
827
  lines = s.split("\n")
725
828
  n_rows, n_cols = map(len, (lines, lines[0]))
726
829
  cw, ch = _get_bbox_shape(font)
727
830
  iw, ih = (int(i * j) for i, j in zip((cw, ch), (n_cols, n_rows)))
728
831
  r, g, b = tuple(map(int, bg))
729
- img = PIL.Image.new("RGB", (iw, ih), (r, g, b))
730
- draw = PIL.ImageDraw.Draw(img)
832
+ img = Image.new("RGB", (iw, ih), (r, g, b))
833
+ draw = ImageDraw.Draw(img)
731
834
  y_offset = 0
732
835
  for line in lines:
733
836
  draw.text((0, y_offset), line, font=font, fill=fg)
@@ -740,7 +843,6 @@ def ansi2img(
740
843
  arr: (
741
844
  _tp.ShapedNDArray[tuple[int, int], np.void]
742
845
  | core.color_chain
743
- | list[core.color_chain]
744
846
  | list[list[core.ColorStr]]
745
847
  ),
746
848
  /,
@@ -772,7 +874,7 @@ def ansi2img(
772
874
  Returns
773
875
  -------
774
876
  ansi_img : Image
775
- The rendered ANSI array as an `PIL.Image.Image` object.
877
+ The rendered ANSI array as an `Image.Image` object.
776
878
 
777
879
  Raises
778
880
  ------
@@ -792,7 +894,7 @@ def ansi2img(
792
894
  if not arr.size:
793
895
  raise ValueError("input array is empty")
794
896
 
795
- font = PIL.ImageFont.truetype(get_font_object(font, retpath=True), font_size)
897
+ font = ImageFont.truetype(get_font_object(font, retpath=True), font_size)
796
898
  bbox_h = _get_bbox_shape(font)[-1]
797
899
  widths = np.asarray(
798
900
  [[font.getbbox(c)[2] for c in x["char"]] for x in arr], dtype=np.uint32
@@ -813,10 +915,8 @@ def ansi2img(
813
915
  if rgba:
814
916
  mode = "RGBA"
815
917
  rgba_descr = arr.dtype.descr.copy()
816
- rgb_field = rgba_descr[-1]
817
- subarr_shape = rgb_field[-1]
818
- subarr_shape = subarr_shape[:-1] + (subarr_shape[-1] + 1,)
819
- rgba_descr[-1] = rgb_field[:-1] + (subarr_shape,)
918
+ *rgb_args, (subd1, subd2) = rgba_descr[-1]
919
+ rgba_descr[-1] = (*rgb_args, (subd1, subd2 + 1))
820
920
  arr = arr.astype(rgba_descr)
821
921
  arr["rgb"][..., 0, -1] = 0xFF
822
922
  else:
@@ -827,8 +927,8 @@ def ansi2img(
827
927
  arr["rgb"][mask, i, 0] = 1
828
928
  arr["rgb"][mask, i, 1 : len(fill) + 1] = fill
829
929
 
830
- img = PIL.Image.new(mode, (iw, ih), bg_default)
831
- draw = PIL.ImageDraw.Draw(img)
930
+ img = Image.new(mode, (iw, ih), bg_default)
931
+ draw = ImageDraw.Draw(img)
832
932
  y_offset = 0
833
933
  for y in range(arr.shape[0]):
834
934
  x_offset = 0
@@ -860,36 +960,41 @@ def ansify(
860
960
  fg: _tp.Int3Tuple | str = (170, 170, 170),
861
961
  bg: _tp.Int3Tuple | str = (0, 0, 0),
862
962
  ):
863
- ansi_type = core.get_ansi_type(ansi_type)
864
- return ansi2img(
865
- img2ansi(
866
- img,
867
- font,
868
- factor=factor,
869
- char_set=char_set,
870
- ansi_type=ansi_type,
871
- sort_glyphs=sort_glyphs,
872
- equalize=equalize,
873
- bg=bg,
874
- outarray=True,
875
- ),
963
+ arr = img2ansi(
964
+ img,
876
965
  font,
877
- font_size=font_size,
878
- fg_default=fg,
879
- bg_default=bg,
966
+ factor=factor,
967
+ char_set=char_set,
968
+ ansi_type=ansi_type,
969
+ sort_glyphs=sort_glyphs,
970
+ equalize=equalize,
971
+ bg=bg,
972
+ outarray=True,
880
973
  )
974
+ if arr.ndim == 4:
975
+ arr = arr[0]
976
+ assert _is_cc_array2d(arr)
977
+ return ansi2img(arr, font, font_size=font_size, fg_default=fg, bg_default=bg)
881
978
 
882
979
 
883
980
  def _is_array(obj: tp.Any, /) -> tp.TypeGuard[np.ndarray]:
884
981
  return isinstance(obj, np.ndarray)
885
982
 
886
983
 
887
- def _is_rgb_array(obj: tp.Any, /) -> tp.TypeGuard[_tp.RGBArray]:
888
- return _is_array(obj) and obj.ndim == 3 and np.issubdtype(obj.dtype, np.uint8)
984
+ def _is_cc_array(
985
+ obj: tp.Any, /
986
+ ) -> tp.TypeGuard[_tp.ShapedNDArray[tuple[int, ...], np.void]]:
987
+ return _is_array(obj) and np.issubdtype(obj.dtype, core.color_chain.dtype)
988
+
989
+
990
+ def _is_cc_array2d(
991
+ obj: tp.Any, /
992
+ ) -> tp.TypeGuard[_tp.ShapedNDArray[tuple[int, int], np.void]]:
993
+ return _is_cc_array(obj) and obj.ndim == 2
889
994
 
890
995
 
891
- def _is_image(obj: tp.Any, /) -> tp.TypeGuard[PIL.Image.Image]:
892
- return isinstance(obj, PIL.Image.Image)
996
+ def _is_image(obj: tp.Any, /) -> tp.TypeGuard[Image.Image]:
997
+ return isinstance(obj, Image.Image)
893
998
 
894
999
 
895
1000
  @lru_cache(maxsize=1)
@@ -1235,7 +1340,7 @@ def render_ans(
1235
1340
  font_size: int = 16,
1236
1341
  *,
1237
1342
  bg_default: _tp.Int3Tuple | _tp.TupleOf4[int] | str = (0, 0, 0),
1238
- ) -> PIL.Image.Image:
1343
+ ) -> Image.Image:
1239
1344
  """Return an image render of an ANS file.
1240
1345
 
1241
1346
  Parameters
@@ -1278,7 +1383,7 @@ def render_ans(
1278
1383
 
1279
1384
 
1280
1385
  def otsu_mask(
1281
- img: _tp.MatrixLike[np.uint8] | cv.typing.MatLike | PIL.Image.Image,
1386
+ img: _tp.MatrixLike[np.uint8] | cv.typing.MatLike | Image.Image,
1282
1387
  ) -> _tp.MatrixLike[np.uint8]:
1283
1388
  img = np.asarray(img, dtype=np.uint8)
1284
1389
  kernel = cv.getStructuringElement(cv.MORPH_RECT, (2, 2))
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: chromatic-python
3
- Version: 0.7.4
3
+ Version: 0.8.1
4
4
  Summary: ANSI art image processing and colored terminal text
5
5
  Author: crypt0lith
6
6
  License-Expression: MIT
@@ -0,0 +1,8 @@
1
+ {
2
+ "tag": "0.8.1",
3
+ "distance": 0,
4
+ "node": "ga30deae4290bd7c74aa5aa02102cba691f0d3519",
5
+ "dirty": false,
6
+ "branch": "HEAD",
7
+ "node_date": "2026-09-12"
8
+ }
@@ -1,8 +0,0 @@
1
- {
2
- "tag": "0.7.4",
3
- "distance": 0,
4
- "node": "gbbb16e4437791f8a31ab74d68f7d176626c3b88e",
5
- "dirty": false,
6
- "branch": "HEAD",
7
- "node_date": "2026-08-27"
8
- }