chromatic-python 0.7.4__tar.gz → 0.8.0__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.0}/PKG-INFO +1 -1
  2. {chromatic_python-0.7.4 → chromatic_python-0.8.0}/chromatic/_typing.py +3 -2
  3. {chromatic_python-0.7.4 → chromatic_python-0.8.0}/chromatic/_version.py +3 -3
  4. {chromatic_python-0.7.4 → chromatic_python-0.8.0}/chromatic/color/palette.py +1 -1
  5. {chromatic_python-0.7.4 → chromatic_python-0.8.0}/chromatic/image/__init__.pyi +1 -6
  6. {chromatic_python-0.7.4 → chromatic_python-0.8.0}/chromatic/image/_array.py +312 -200
  7. {chromatic_python-0.7.4 → chromatic_python-0.8.0}/chromatic_python.egg-info/PKG-INFO +1 -1
  8. chromatic_python-0.8.0/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.0}/.gitattributes +0 -0
  11. {chromatic_python-0.7.4 → chromatic_python-0.8.0}/.github/dependabot.yml +0 -0
  12. {chromatic_python-0.7.4 → chromatic_python-0.8.0}/.github/workflows/docs.yml +0 -0
  13. {chromatic_python-0.7.4 → chromatic_python-0.8.0}/.github/workflows/release.yml +0 -0
  14. {chromatic_python-0.7.4 → chromatic_python-0.8.0}/.gitignore +0 -0
  15. {chromatic_python-0.7.4 → chromatic_python-0.8.0}/README.md +0 -0
  16. {chromatic_python-0.7.4 → chromatic_python-0.8.0}/banner.png +0 -0
  17. {chromatic_python-0.7.4 → chromatic_python-0.8.0}/chromatic/__init__.py +0 -0
  18. {chromatic_python-0.7.4 → chromatic_python-0.8.0}/chromatic/__init__.pyi +0 -0
  19. {chromatic_python-0.7.4 → chromatic_python-0.8.0}/chromatic/__main__.py +0 -0
  20. {chromatic_python-0.7.4 → chromatic_python-0.8.0}/chromatic/color/__init__.py +0 -0
  21. {chromatic_python-0.7.4 → chromatic_python-0.8.0}/chromatic/color/__init__.pyi +0 -0
  22. {chromatic_python-0.7.4 → chromatic_python-0.8.0}/chromatic/color/colorconv.py +0 -0
  23. {chromatic_python-0.7.4 → chromatic_python-0.8.0}/chromatic/color/colorconv.pyi +0 -0
  24. {chromatic_python-0.7.4 → chromatic_python-0.8.0}/chromatic/color/core.py +0 -0
  25. {chromatic_python-0.7.4 → chromatic_python-0.8.0}/chromatic/color/core.pyi +0 -0
  26. {chromatic_python-0.7.4 → chromatic_python-0.8.0}/chromatic/color/palette.pyi +0 -0
  27. {chromatic_python-0.7.4 → chromatic_python-0.8.0}/chromatic/data/__init__.py +0 -0
  28. {chromatic_python-0.7.4 → chromatic_python-0.8.0}/chromatic/data/__init__.pyi +0 -0
  29. {chromatic_python-0.7.4 → chromatic_python-0.8.0}/chromatic/data/_fetchers.py +0 -0
  30. {chromatic_python-0.7.4 → chromatic_python-0.8.0}/chromatic/data/fonts/userfont.schema.json +0 -0
  31. {chromatic_python-0.7.4 → chromatic_python-0.8.0}/chromatic/data/fonts/vga437.ttf +0 -0
  32. {chromatic_python-0.7.4 → chromatic_python-0.8.0}/chromatic/data/images/butterfly.jpg +0 -0
  33. {chromatic_python-0.7.4 → chromatic_python-0.8.0}/chromatic/data/images/escher.png +0 -0
  34. {chromatic_python-0.7.4 → chromatic_python-0.8.0}/chromatic/data/images/goblin_virus.png +0 -0
  35. {chromatic_python-0.7.4 → chromatic_python-0.8.0}/chromatic/data/images/registry.json +0 -0
  36. {chromatic_python-0.7.4 → chromatic_python-0.8.0}/chromatic/data/userfont.py +0 -0
  37. {chromatic_python-0.7.4 → chromatic_python-0.8.0}/chromatic/data/userfont.pyi +0 -0
  38. {chromatic_python-0.7.4 → chromatic_python-0.8.0}/chromatic/demo.py +0 -0
  39. {chromatic_python-0.7.4 → chromatic_python-0.8.0}/chromatic/image/__init__.py +0 -0
  40. {chromatic_python-0.7.4 → chromatic_python-0.8.0}/chromatic/image/_curses.py +0 -0
  41. {chromatic_python-0.7.4 → chromatic_python-0.8.0}/chromatic/image/_glyph.py +0 -0
  42. {chromatic_python-0.7.4 → chromatic_python-0.8.0}/chromatic_python.egg-info/SOURCES.txt +0 -0
  43. {chromatic_python-0.7.4 → chromatic_python-0.8.0}/chromatic_python.egg-info/dependency_links.txt +0 -0
  44. {chromatic_python-0.7.4 → chromatic_python-0.8.0}/chromatic_python.egg-info/entry_points.txt +0 -0
  45. {chromatic_python-0.7.4 → chromatic_python-0.8.0}/chromatic_python.egg-info/requires.txt +0 -0
  46. {chromatic_python-0.7.4 → chromatic_python-0.8.0}/chromatic_python.egg-info/scm_file_list.json +0 -0
  47. {chromatic_python-0.7.4 → chromatic_python-0.8.0}/chromatic_python.egg-info/top_level.txt +0 -0
  48. {chromatic_python-0.7.4 → chromatic_python-0.8.0}/docs/api/chromatic.md +0 -0
  49. {chromatic_python-0.7.4 → chromatic_python-0.8.0}/docs/api/color.md +0 -0
  50. {chromatic_python-0.7.4 → chromatic_python-0.8.0}/docs/api/data.md +0 -0
  51. {chromatic_python-0.7.4 → chromatic_python-0.8.0}/docs/api/image.md +0 -0
  52. {chromatic_python-0.7.4 → chromatic_python-0.8.0}/docs/index.md +0 -0
  53. {chromatic_python-0.7.4 → chromatic_python-0.8.0}/mkdocs.yml +0 -0
  54. {chromatic_python-0.7.4 → chromatic_python-0.8.0}/pyproject.toml +0 -0
  55. {chromatic_python-0.7.4 → chromatic_python-0.8.0}/setup.cfg +0 -0
  56. {chromatic_python-0.7.4 → chromatic_python-0.8.0}/tests/__init__.py +0 -0
  57. {chromatic_python-0.7.4 → chromatic_python-0.8.0}/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.0
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.0'
22
+ __version_tuple__ = version_tuple = (0, 8, 0)
23
23
 
24
- __commit_id__ = commit_id = 'gbbb16e443'
24
+ __commit_id__ = commit_id = 'g1f588062b'
@@ -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,158 @@ 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 = np.asarray(im, dtype=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
+ char_aspect = ceil(cw / ch)
450
+ img_aspect = rgb.shape[2] / rgb.shape[1]
451
+ out_w = self.factor
452
+ out_h = int(self.factor / img_aspect / char_aspect)
453
+ interp = np.empty((rgb.shape[0], out_h, out_w), 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
+ if (sy := (grey.shape[0] / out_h - 1) / 2) > 0:
458
+ blur = cv.filter2D(
459
+ blur,
460
+ -1,
461
+ cv.getGaussianKernel(2 * int(4.0 * sy + 0.5) + 1, sy),
462
+ borderType=cv.BORDER_REFLECT_101,
463
+ )
464
+ if (sx := (grey.shape[1] / out_w - 1) / 2) > 0:
465
+ blur = cv.filter2D(
466
+ blur,
467
+ -1,
468
+ cv.getGaussianKernel(2 * int(4.0 * sx + 0.5) + 1, sx).T,
469
+ borderType=cv.BORDER_REFLECT_101,
470
+ )
471
+ grey = (
472
+ cv.resize(blur, (out_w, out_h), interpolation=cv.INTER_LINEAR) * 255.0
473
+ ).astype(np.uint8)
474
+ interp[i] = chars[np.rint(grey / 255 * (chars.size - 1)).astype(np.intp)]
475
+ return interp[0] if is_single else interp
476
+
477
+ def to_ansi(self, im, /):
478
+ interp = self.to_ascii(im)
479
+ rgb = self._ns["rgb"]
480
+ assert interp.ndim == rgb.ndim - 1
481
+ if is_single := rgb.ndim == 3:
482
+ interp = interp[None, :]
483
+ rgb = rgb[None, :]
484
+ assert interp.shape[0] == rgb.shape[0]
485
+ if self.equalize is True:
486
+ rgb[:] = [contrast_stretch(x) for x in rgb]
487
+ elif self.equalize == "white_point":
488
+ rgb[:] = [equalize_white_point(x) for x in rgb]
489
+ rgb[:] = ansi_quantize(rgb, ansi_type=self.ansi_type)
490
+ newshape = interp.shape[-2:]
491
+ n_frames = interp.shape[0]
492
+ out = np.empty((n_frames, *newshape), dtype=core.color_chain.dtype)
493
+ for i in range(n_frames):
494
+ with (
495
+ Image.fromarray(rgb[i], mode="RGB") as x,
496
+ x.resize(newshape[::-1], resample=Image.Resampling.LANCZOS) as xr,
497
+ ):
498
+ out["char"][i] = interp[i]
499
+ out["rgb"][i, ..., 0, 0] = self.ansi_type.typecode
500
+ out["rgb"][i, ..., 0, 1:] = np.asarray(xr, dtype=np.uint8)
501
+ if self.bg is None:
502
+ continue
503
+ out["rgb"][i, ..., 1, 0] = self.ansi_type.typecode
504
+ out["rgb"][i, ..., 1, 1:] = self.bg
505
+ return out[0] if is_single else out
408
506
 
409
507
 
410
508
  @tp.overload
@@ -416,23 +514,49 @@ def img2ascii(
416
514
  char_set: tp.Optional[str] = ...,
417
515
  sort_glyphs: bool | tp.Literal[-1] = ...,
418
516
  *,
419
- ret_img: tp.Literal[False] = False,
420
- ) -> str: ...
517
+ outarray: tp.Literal[False] = False,
518
+ ) -> str | list[str]: ...
421
519
 
422
520
 
423
521
  @tp.overload
424
522
  def img2ascii(
425
- img: str | os.PathLike[str] | _tp.RGBImageLike,
523
+ img: _tp.RGBArray,
524
+ /,
525
+ font: _tp.FontArgType = ...,
526
+ factor: int = ...,
527
+ char_set: tp.Optional[str] = ...,
528
+ sort_glyphs: bool | tp.Literal[-1] = ...,
529
+ *,
530
+ outarray: tp.Literal[True],
531
+ ) -> _tp.ShapedNDArray[tuple[int, int], np.str_]: ...
532
+
533
+
534
+ @tp.overload
535
+ def img2ascii(
536
+ img: _tp.RGBArray3d,
537
+ /,
538
+ font: _tp.FontArgType = ...,
539
+ factor: int = ...,
540
+ char_set: tp.Optional[str] = ...,
541
+ sort_glyphs: bool | tp.Literal[-1] = ...,
542
+ *,
543
+ outarray: tp.Literal[True],
544
+ ) -> _tp.ShapedNDArray[tuple[int, int, int], np.str_]: ...
545
+
546
+
547
+ @tp.overload
548
+ def img2ascii(
549
+ img: str | os.PathLike[str] | Image.Image,
426
550
  /,
427
551
  font: _tp.FontArgType = ...,
428
552
  factor: int = ...,
429
553
  char_set: tp.Optional[str] = ...,
430
554
  sort_glyphs: bool | tp.Literal[-1] = ...,
431
555
  *,
432
- ret_img: tp.Literal[True],
433
- ) -> tuple[
556
+ outarray: tp.Literal[True],
557
+ ) -> tp.Union[
434
558
  _tp.ShapedNDArray[tuple[int, int], np.str_],
435
- _tp.ShapedNDArray[tuple[int, int, tp.Literal[3]], np.uint8],
559
+ _tp.ShapedNDArray[tuple[int, int, int], np.str_],
436
560
  ]: ...
437
561
 
438
562
 
@@ -444,7 +568,7 @@ def img2ascii( # type: ignore
444
568
  char_set=None,
445
569
  sort_glyphs=True,
446
570
  *,
447
- ret_img=False,
571
+ outarray=False,
448
572
  ):
449
573
  """Convert an image to a multiline ASCII string.
450
574
 
@@ -462,17 +586,15 @@ def img2ascii( # type: ignore
462
586
  char_set : Iterable[str], optional
463
587
  Characters to be mapped to greyscale values of 'img'.
464
588
 
465
- sort_glyphs : {True, False, ``-1``}
589
+ sort_glyphs : {True, False, -1}
466
590
  Specifies to sort `char_set` or leave it unsorted before mapping to greyscale.
467
591
 
468
592
  Glyph bitmasks obtained from 'font' are compared when sorting the string.
469
593
 
470
- ``-1`` specifies reverse sorting order.
471
-
472
- ret_img : bool, default=False
473
- Specifies to return both the output string and original RGB array.
594
+ `-1` specifies reverse sorting order.
474
595
 
475
- Used by ``img2ansi`` to lazily obtain the base ASCII chars and original RGB array.
596
+ outarray : bool, default=False
597
+ If True, returns the raw chararray. Otherwise, returns a string or list of strings.
476
598
 
477
599
  Returns
478
600
  -------
@@ -488,58 +610,19 @@ def img2ascii( # type: ignore
488
610
  --------
489
611
  ascii2img : Render an ASCII string as an image.
490
612
  """
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")
613
+ with _ConversionHandler(
614
+ font, factor=factor, char_set=char_set, sort_glyphs=sort_glyphs
615
+ ) as h:
616
+ out = h.to_ascii(img)
617
+ if outarray is True:
618
+ return out
619
+ newlines = np.zeros((*out.shape[:-1], 1), dtype="<U1")
540
620
  newlines[:-1] = "\n"
541
- interp = np.concatenate((interp, newlines), axis=1)
542
- return "".join(interp.flat)
621
+ out = np.concatenate((out, newlines), axis=-1)
622
+ if out.ndim == 3:
623
+ return "".join(out.flat)
624
+ else:
625
+ return ["".join(x.flat) for x in out]
543
626
 
544
627
 
545
628
  @tp.overload
@@ -555,12 +638,12 @@ def img2ansi(
555
638
  bg: tp.Optional[_tp.Int3Tuple | str] = ...,
556
639
  *,
557
640
  outarray: tp.Literal[False] = False,
558
- ) -> core.color_chain: ...
641
+ ) -> core.color_chain | list[core.color_chain]: ...
559
642
 
560
643
 
561
644
  @tp.overload
562
645
  def img2ansi(
563
- img: str | os.PathLike[str] | _tp.RGBImageLike,
646
+ img: _tp.RGBArray,
564
647
  /,
565
648
  font: _tp.FontArgType = ...,
566
649
  factor: int = ...,
@@ -574,17 +657,52 @@ def img2ansi(
574
657
  ) -> _tp.ShapedNDArray[tuple[int, int], np.void]: ...
575
658
 
576
659
 
577
- @rgb_dispatch("bg")
660
+ @tp.overload
578
661
  def img2ansi(
579
- img: str | os.PathLike[str] | _tp.RGBImageLike,
662
+ img: _tp.RGBArray3d,
580
663
  /,
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,
664
+ font: _tp.FontArgType = ...,
665
+ factor: int = ...,
666
+ char_set: tp.Optional[str] = ...,
667
+ sort_glyphs: bool | tp.Literal[-1] = ...,
668
+ ansi_type: tp.Optional[core.AnsiColorParam] = ...,
669
+ equalize: bool | tp.Literal["white_point"] = ...,
670
+ bg: tp.Optional[_tp.Int3Tuple | str] = ...,
671
+ *,
672
+ outarray: tp.Literal[True],
673
+ ) -> _tp.ShapedNDArray[tuple[int, int, int], np.void]: ...
674
+
675
+
676
+ @tp.overload
677
+ def img2ansi(
678
+ img: str | os.PathLike[str] | Image.Image,
679
+ /,
680
+ font: _tp.FontArgType = ...,
681
+ factor: int = ...,
682
+ char_set: tp.Optional[str] = ...,
683
+ sort_glyphs: bool | tp.Literal[-1] = ...,
684
+ ansi_type: tp.Optional[core.AnsiColorParam] = ...,
685
+ equalize: bool | tp.Literal["white_point"] = ...,
686
+ bg: tp.Optional[_tp.Int3Tuple | str] = ...,
687
+ *,
688
+ outarray: tp.Literal[True],
689
+ ) -> tp.Union[
690
+ _tp.ShapedNDArray[tuple[int, int], np.void],
691
+ _tp.ShapedNDArray[tuple[int, int, int], np.void],
692
+ ]: ...
693
+
694
+
695
+ @rgb_dispatch("bg")
696
+ def img2ansi( # type: ignore
697
+ img,
698
+ /,
699
+ font=uf.VGA437,
700
+ factor=200,
701
+ char_set=None,
702
+ sort_glyphs=True,
703
+ ansi_type=None,
704
+ equalize=False,
705
+ bg=None,
588
706
  *,
589
707
  outarray=False,
590
708
  ):
@@ -607,12 +725,12 @@ def img2ansi(
607
725
 
608
726
  If None (default), the character set will be determined based on the 'font' parameter.
609
727
 
610
- sort_glyphs : {True, False, ``-1``}
728
+ sort_glyphs : {True, False, -1}
611
729
  Specifies to sort `char_set` or leave it unsorted before mapping to greyscale.
612
730
 
613
731
  Glyph bitmasks obtained from 'font' are compared when sorting the string.
614
732
 
615
- ``-1`` specifies reverse sorting order.
733
+ `-1` specifies reverse sorting order.
616
734
 
617
735
  ansi_type : AnsiColorParam
618
736
  ANSI color format to map the RGB values to.
@@ -656,30 +774,22 @@ def img2ansi(
656
774
  ansi2img : Render an ANSI array as an image.
657
775
  img2ascii : Used to obtain the base ASCII characters.
658
776
  """
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)
777
+ with _ConversionHandler(
778
+ font,
779
+ factor=factor,
780
+ char_set=char_set,
781
+ sort_glyphs=sort_glyphs,
782
+ ansi_type=ansi_type,
783
+ equalize=equalize,
784
+ bg=bg,
785
+ ) as h:
786
+ out = h.to_ansi(img)
787
+ if outarray is True:
788
+ return out
789
+ elif out.ndim == 2:
790
+ return core.color_chain.fromarray(out)
791
+ else:
792
+ return [core.color_chain.fromarray(x) for x in out]
683
793
 
684
794
 
685
795
  @rgb_dispatch("fg", "bg")
@@ -714,20 +824,20 @@ def ascii2img(
714
824
  Returns
715
825
  -------
716
826
  ascii_img : Image
717
- A `PIL.Image.Image` object of the rendered ASCII string.
827
+ A `Image.Image` object of the rendered ASCII string.
718
828
 
719
829
  See Also
720
830
  --------
721
831
  img2ascii : Convert an image into an ASCII string.
722
832
  """
723
- font = PIL.ImageFont.truetype(get_font_object(font, retpath=True), font_size)
833
+ font = ImageFont.truetype(get_font_object(font, retpath=True), font_size)
724
834
  lines = s.split("\n")
725
835
  n_rows, n_cols = map(len, (lines, lines[0]))
726
836
  cw, ch = _get_bbox_shape(font)
727
837
  iw, ih = (int(i * j) for i, j in zip((cw, ch), (n_cols, n_rows)))
728
838
  r, g, b = tuple(map(int, bg))
729
- img = PIL.Image.new("RGB", (iw, ih), (r, g, b))
730
- draw = PIL.ImageDraw.Draw(img)
839
+ img = Image.new("RGB", (iw, ih), (r, g, b))
840
+ draw = ImageDraw.Draw(img)
731
841
  y_offset = 0
732
842
  for line in lines:
733
843
  draw.text((0, y_offset), line, font=font, fill=fg)
@@ -740,7 +850,6 @@ def ansi2img(
740
850
  arr: (
741
851
  _tp.ShapedNDArray[tuple[int, int], np.void]
742
852
  | core.color_chain
743
- | list[core.color_chain]
744
853
  | list[list[core.ColorStr]]
745
854
  ),
746
855
  /,
@@ -772,7 +881,7 @@ def ansi2img(
772
881
  Returns
773
882
  -------
774
883
  ansi_img : Image
775
- The rendered ANSI array as an `PIL.Image.Image` object.
884
+ The rendered ANSI array as an `Image.Image` object.
776
885
 
777
886
  Raises
778
887
  ------
@@ -792,7 +901,7 @@ def ansi2img(
792
901
  if not arr.size:
793
902
  raise ValueError("input array is empty")
794
903
 
795
- font = PIL.ImageFont.truetype(get_font_object(font, retpath=True), font_size)
904
+ font = ImageFont.truetype(get_font_object(font, retpath=True), font_size)
796
905
  bbox_h = _get_bbox_shape(font)[-1]
797
906
  widths = np.asarray(
798
907
  [[font.getbbox(c)[2] for c in x["char"]] for x in arr], dtype=np.uint32
@@ -813,10 +922,8 @@ def ansi2img(
813
922
  if rgba:
814
923
  mode = "RGBA"
815
924
  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,)
925
+ *rgb_args, (subd1, subd2) = rgba_descr[-1]
926
+ rgba_descr[-1] = (*rgb_args, (subd1, subd2 + 1))
820
927
  arr = arr.astype(rgba_descr)
821
928
  arr["rgb"][..., 0, -1] = 0xFF
822
929
  else:
@@ -827,8 +934,8 @@ def ansi2img(
827
934
  arr["rgb"][mask, i, 0] = 1
828
935
  arr["rgb"][mask, i, 1 : len(fill) + 1] = fill
829
936
 
830
- img = PIL.Image.new(mode, (iw, ih), bg_default)
831
- draw = PIL.ImageDraw.Draw(img)
937
+ img = Image.new(mode, (iw, ih), bg_default)
938
+ draw = ImageDraw.Draw(img)
832
939
  y_offset = 0
833
940
  for y in range(arr.shape[0]):
834
941
  x_offset = 0
@@ -860,36 +967,41 @@ def ansify(
860
967
  fg: _tp.Int3Tuple | str = (170, 170, 170),
861
968
  bg: _tp.Int3Tuple | str = (0, 0, 0),
862
969
  ):
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
- ),
970
+ arr = img2ansi(
971
+ img,
876
972
  font,
877
- font_size=font_size,
878
- fg_default=fg,
879
- bg_default=bg,
973
+ factor=factor,
974
+ char_set=char_set,
975
+ ansi_type=ansi_type,
976
+ sort_glyphs=sort_glyphs,
977
+ equalize=equalize,
978
+ bg=bg,
979
+ outarray=True,
880
980
  )
981
+ if arr.ndim == 4:
982
+ arr = arr[0]
983
+ assert _is_cc_array2d(arr)
984
+ return ansi2img(arr, font, font_size=font_size, fg_default=fg, bg_default=bg)
881
985
 
882
986
 
883
987
  def _is_array(obj: tp.Any, /) -> tp.TypeGuard[np.ndarray]:
884
988
  return isinstance(obj, np.ndarray)
885
989
 
886
990
 
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)
991
+ def _is_cc_array(
992
+ obj: tp.Any, /
993
+ ) -> tp.TypeGuard[_tp.ShapedNDArray[tuple[int, ...], np.void]]:
994
+ return _is_array(obj) and np.issubdtype(obj.dtype, core.color_chain.dtype)
995
+
996
+
997
+ def _is_cc_array2d(
998
+ obj: tp.Any, /
999
+ ) -> tp.TypeGuard[_tp.ShapedNDArray[tuple[int, int], np.void]]:
1000
+ return _is_cc_array(obj) and obj.ndim == 2
889
1001
 
890
1002
 
891
- def _is_image(obj: tp.Any, /) -> tp.TypeGuard[PIL.Image.Image]:
892
- return isinstance(obj, PIL.Image.Image)
1003
+ def _is_image(obj: tp.Any, /) -> tp.TypeGuard[Image.Image]:
1004
+ return isinstance(obj, Image.Image)
893
1005
 
894
1006
 
895
1007
  @lru_cache(maxsize=1)
@@ -1235,7 +1347,7 @@ def render_ans(
1235
1347
  font_size: int = 16,
1236
1348
  *,
1237
1349
  bg_default: _tp.Int3Tuple | _tp.TupleOf4[int] | str = (0, 0, 0),
1238
- ) -> PIL.Image.Image:
1350
+ ) -> Image.Image:
1239
1351
  """Return an image render of an ANS file.
1240
1352
 
1241
1353
  Parameters
@@ -1278,7 +1390,7 @@ def render_ans(
1278
1390
 
1279
1391
 
1280
1392
  def otsu_mask(
1281
- img: _tp.MatrixLike[np.uint8] | cv.typing.MatLike | PIL.Image.Image,
1393
+ img: _tp.MatrixLike[np.uint8] | cv.typing.MatLike | Image.Image,
1282
1394
  ) -> _tp.MatrixLike[np.uint8]:
1283
1395
  img = np.asarray(img, dtype=np.uint8)
1284
1396
  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.0
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.0",
3
+ "distance": 0,
4
+ "node": "g1f588062b6f6b9d37a3c4efd86878008e6372a07",
5
+ "dirty": false,
6
+ "branch": "HEAD",
7
+ "node_date": "2026-09-10"
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
- }