chromatic-python 0.1.0__py3-none-any.whl

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.
@@ -0,0 +1,1059 @@
1
+ from __future__ import annotations
2
+
3
+ import math
4
+ import os.path
5
+ import random
6
+ from collections.abc import Mapping
7
+ from functools import lru_cache, partial
8
+ from os import PathLike
9
+ from typing import (
10
+ Any, Callable, cast, Iterable, Literal, Optional, overload, Self, Sequence, TYPE_CHECKING,
11
+ TypeGuard, Union
12
+ )
13
+
14
+ import cv2 as cv
15
+ import numpy as np
16
+ import skimage as ski
17
+ from numpy import dtype, float64, issubdtype, ndarray, uint8
18
+ from PIL import Image, ImageDraw
19
+ from PIL.Image import Image as ImageType
20
+ from PIL.ImageFont import FreeTypeFont, truetype
21
+ from sklearn.cluster import DBSCAN
22
+
23
+ from .._typing import (
24
+ FontArgType, GreyscaleArray, GreyscaleGlyphArray, Int3Tuple, MatrixLike,
25
+ RGBArray, RGBImageLike, TupleOf2, type_error_msg
26
+ )
27
+ from ..color.colorconv import nearest_ansi_4bit_rgb, nearest_ansi_8bit_rgb
28
+ from ..color.core import (
29
+ ansicolor24Bit, ansicolor4Bit, ansicolor8Bit, AnsiColorParam, AnsiColorType, Color,
30
+ ColorStr, DEFAULT_ANSI, get_ansi_type, SGR_RESET, SgrSequence
31
+ )
32
+ from ..color.palette import rgb_dispatch
33
+ from ..data import UserFont
34
+
35
+ if TYPE_CHECKING:
36
+ from _typeshed import AnyStr_co
37
+
38
+ __all__ = ['ansi2img', 'ansi_quantize', 'ascii2img', 'contrast_stretch', 'equalize_white_point',
39
+ 'get_font_key', 'get_font_object', 'img2ansi', 'img2ascii', 'is_csi_param',
40
+ 'read_ans', 'render_ans', 'render_font_char', 'render_font_str',
41
+ 'reshape_ansi', 'scale_saturation', 'shuffle_char_set', 'to_sgr_array']
42
+
43
+
44
+ def get_font_key(font: FreeTypeFont):
45
+ font = get_font_object(font)
46
+ font_key = font.getname()
47
+ if not all(font_key):
48
+ missing = []
49
+ s = 'font %s'
50
+ if font_key[0] is None:
51
+ missing.append(f"{s % 'name'!r}")
52
+ if font_key[-1] is None:
53
+ missing.append(f"{s % 'family'!r}")
54
+ raise ValueError(
55
+ f"Unable to generate font key due to missing fields {' and '.join(missing)}: "
56
+ f"{font_key}")
57
+ return cast(tuple[str, str], font_key)
58
+
59
+
60
+ @overload
61
+ def get_font_object(
62
+ font: FontArgType,
63
+ *,
64
+ retpath: Literal[False] = False
65
+ ) -> FreeTypeFont:
66
+ ...
67
+
68
+
69
+ @overload
70
+ def get_font_object(
71
+ font: FontArgType,
72
+ *,
73
+ retpath: Literal[True]
74
+ ) -> str:
75
+ ...
76
+
77
+
78
+ @lru_cache
79
+ def get_font_object(font: FontArgType,
80
+ *,
81
+ retpath: bool = False) -> Union[FreeTypeFont, str]:
82
+ def path2obj(__path: AnyStr_co):
83
+ return truetype(__path, 24)
84
+
85
+ if retpath:
86
+ out_f = lambda x: x
87
+ else:
88
+ out_f = path2obj
89
+ if isinstance(font, FreeTypeFont):
90
+ return font.path if retpath else font
91
+ if hasattr(font, '__fspath__'):
92
+ return out_f(font)
93
+ if isinstance(font, UserFont):
94
+ return out_f(font.path)
95
+ if font in set(UserFont._value2member_map_):
96
+ return out_f(UserFont(font).path)
97
+ if isinstance(font, str):
98
+ if font in set(UserFont._member_names_):
99
+ return out_f(UserFont[font].path)
100
+ try:
101
+ font_obj = path2obj(font)
102
+ return font_obj.path if retpath else font_obj
103
+ except OSError:
104
+ raise FileNotFoundError(
105
+ font) from None
106
+ raise TypeError(
107
+ f"Expected {FreeTypeFont.__qualname__!r}, got {type(font).__qualname__!r} instead")
108
+
109
+
110
+ def shuffle_char_set(char_set: Iterable[str],
111
+ *xi: *tuple[Union[int, slice, None], ...]):
112
+ if not isinstance(char_set, Iterable):
113
+ raise TypeError(
114
+ f"Expected 'char_set' to be iterable type, "
115
+ f"got {type(char_set).__qualname__!r} instead")
116
+ if xi:
117
+ if (n_args := len(xi)) > 3:
118
+ raise ValueError(
119
+ f"Unexpected extra args: expected max 3, got {n_args}")
120
+ if n_args == 1:
121
+ xi = xi[0]
122
+ vt = type(xi)
123
+ if vt not in {int, slice}:
124
+ raise TypeError(
125
+ f"Unexpected arg type: {vt.__qualname__!r}")
126
+ if vt is int:
127
+ xi = slice(xi)
128
+ else:
129
+ good_types = {int, type(None)}
130
+ if bad_types := set(map(type, xi)) - good_types:
131
+ err = type_error_msg(
132
+ ', '.join(sorted(repr(t.__qualname__) for t in bad_types)), *good_types,
133
+ obj_repr=True).removeprefix('expected').lstrip()
134
+ raise ValueError(
135
+ f"Multiple args must be {err}")
136
+ xi = slice(*xi)
137
+ else:
138
+ xi = slice(0, None)
139
+ char_list = list(char_set)
140
+ random.shuffle(char_list)
141
+ return ''.join(char_list)[xi]
142
+
143
+
144
+ def render_font_str(__s: str, font: FontArgType):
145
+ font = get_font_object(font)
146
+ __s = __s.translate({ord('\t'): ' ' * 4})
147
+ if len(__s) > 1:
148
+ lines = __s.splitlines()
149
+ maxlen = max(map(len, lines))
150
+ stacked = np.vstack(
151
+ [np.hstack(
152
+ [np.array(render_font_char(c, font=font), dtype=np.uint8)
153
+ for c in line])
154
+ for line in map(lambda x: f'{x:<{maxlen}}', lines)])
155
+ return Image.fromarray(stacked)
156
+ return render_font_char(__s, font)
157
+
158
+
159
+ def render_font_char(__c: str,
160
+ font: FontArgType,
161
+ size=(24, 24),
162
+ fill: Int3Tuple = (255, 255, 255)):
163
+ if len(__c) > 1:
164
+ raise TypeError(
165
+ f"{render_font_char.__name__}() expected a character, "
166
+ f"but string of length {len(__c)} found")
167
+ img = Image.new('RGB', size=size)
168
+ draw = ImageDraw.Draw(img)
169
+ font_obj = get_font_object(font)
170
+ bbox = draw.textbbox((0, 0), __c, font=font_obj)
171
+ x_offset, y_offset = (
172
+ (size[i] - (bbox[i + 2] - bbox[i])) // 2 - bbox[i]
173
+ for i in range(2))
174
+ draw.text((x_offset, y_offset), __c, font=font_obj, fill=fill)
175
+ return img
176
+
177
+
178
+ def get_rgb_array(__img: Union[RGBImageLike, str, PathLike[str]]) -> RGBArray:
179
+ if hasattr(__img, '__fspath__'):
180
+ img = ski.io.imread(__img.__fspath__())
181
+ elif isinstance(__img, str):
182
+ img = ski.io.imread(__img)
183
+ else:
184
+ img = __img
185
+ if not is_rgb_array(img):
186
+ if is_image(img):
187
+ img = img.convert('RGB')
188
+ elif is_array(img):
189
+ conv = {
190
+ 2: lambda im: cv.cvtColor(im[:, :, 0], cv.COLOR_GRAY2RGB),
191
+ 4: lambda im: cv.cvtColor(im, cv.COLOR_RGBA2RGB)
192
+ }.get(img.ndim)
193
+ if conv is None:
194
+ raise ValueError(
195
+ f"unexpected array shape: {img.shape!r}")
196
+ img = conv(img)
197
+ else:
198
+ raise TypeError(
199
+ type_error_msg(img, PathLike, ImageType, ndarray))
200
+ img = uint8(img)
201
+ return img
202
+
203
+
204
+ def _rgb_transform2vec(pyfunc: Callable[[Int3Tuple], Int3Tuple]):
205
+ return np.frompyfunc(lambda *rgb: pyfunc(rgb), 3, 3)
206
+
207
+
208
+ def _apply_rgb_ufunc(img: RGBArray, rgb_ufunc: np.ufunc) -> RGBArray:
209
+ return uint8(rgb_ufunc(*np.moveaxis(img, -1, 0))).transpose(1, 2, 0)
210
+
211
+
212
+ _ANSI_QUANTIZERS = {
213
+ t: partial(_apply_rgb_ufunc, rgb_ufunc=_rgb_transform2vec(f))
214
+ for (t, f) in zip(
215
+ (ansicolor4Bit, ansicolor8Bit),
216
+ (nearest_ansi_4bit_rgb, nearest_ansi_8bit_rgb))}
217
+
218
+
219
+ def ansi_quantize(img: RGBArray,
220
+ ansi_type: type[ansicolor4Bit | ansicolor8Bit],
221
+ *,
222
+ equalize: bool | Literal['white_point'] = True) -> RGBArray:
223
+ """Color-quantize an RGB array into ANSI 4-bit or 8-bit color space.
224
+
225
+ Parameters
226
+ ----------
227
+ img : ndarray[Any, dtype[uint8]]
228
+ Input image, as an RGB array.
229
+
230
+ ansi_type : type[ansicolor4Bit | ansicolor8Bit]
231
+ ANSI color format to map the quantized image to.
232
+
233
+ equalize : {True, False, 'white_point'}
234
+ Apply contrast equalization before ANSI color quantization.
235
+ If True, performs contrast stretching;
236
+ if 'white_point', applies white-point equalization.
237
+
238
+ Raises
239
+ ------
240
+ TypeError
241
+ If `ansi_type` is not `ansi_color_4Bit` or `ansi_color_8Bit`.
242
+
243
+ Returns
244
+ -------
245
+ ansi_array : ndarray[Any, dtype[uint8]]
246
+ The image with RGB values transformed into ANSI color space.
247
+ """
248
+ try:
249
+ quantizer = _ANSI_QUANTIZERS.get(ansi_type)
250
+ except TypeError:
251
+ quantizer = None
252
+ if quantizer is None:
253
+ from .._typing import pseudo_union
254
+
255
+ context = "{}=type[{}]".format(
256
+ f"{ansi_type=}".partition('=')[0], ' | '.join(
257
+ getattr(x, '__name__', f"{x}") for x in
258
+ pseudo_union(_ANSI_QUANTIZERS.keys()).__args__))
259
+ raise TypeError(
260
+ type_error_msg(ansi_type, context=context))
261
+ if eq_f := {True: contrast_stretch,
262
+ 'white_point': equalize_white_point
263
+ }.get(equalize):
264
+ img = eq_f(img)
265
+ if img.size > 1024 ** 2: # downsize for faster quantization
266
+ w, h, _ = img.shape
267
+ new_w, new_h = (int(x * 768 / max(w, h)) for x in (w, h))
268
+ img = np.array(
269
+ Image.fromarray(img, mode='RGB')
270
+ .resize((new_h, new_w), resample=Image.Resampling.LANCZOS))
271
+ return quantizer(img)
272
+
273
+
274
+ def equalize_white_point(img: RGBArray) -> RGBArray:
275
+ """Apply histogram equalization to the L-channel (lightness) in LAB color space.
276
+
277
+ Enhances contrast while preserving color, ideal for pronounced light/dark effects.
278
+
279
+ Parameters
280
+ ----------
281
+ img : numpy.ndarray[Any, dtype[uint8]]
282
+
283
+ Returns
284
+ -------
285
+ eq_img : numpy.ndarray[Any, dtype[uint8]]
286
+
287
+ See Also
288
+ --------
289
+ contrast_stretch
290
+ """
291
+ lab_img = cv.cvtColor(img, cv.COLOR_RGB2LAB)
292
+ Lc, Ac, Bc = cv.split(lab_img)
293
+ Lc_eq = cv.equalizeHist(Lc)
294
+ lab_eq_img = cv.merge((Lc_eq, Ac, Bc))
295
+ eq_img = cv.cvtColor(lab_eq_img, cv.COLOR_LAB2RGB)
296
+ return eq_img
297
+
298
+
299
+ def contrast_stretch(img: RGBArray) -> RGBArray:
300
+ """Rescale the intensities of an RGB image using linear contrast stretching.
301
+
302
+ Provides subtle, balanced contrast enhancement across both lightness and color.
303
+
304
+ Parameters
305
+ ----------
306
+ img : numpy.ndarray[Any, dtype[uint8]]
307
+
308
+ Returns
309
+ -------
310
+ eq_img : numpy.ndarray[Any, dtype[uint8]]
311
+
312
+ See Also
313
+ --------
314
+ equalize_white_point
315
+ """
316
+ p2, p98 = np.percentile(img, (2, 98))
317
+ return cast(RGBArray, ski.exposure.rescale_intensity(cast(..., img), in_range=(p2, p98)))
318
+
319
+
320
+ def scale_saturation(img: RGBArray, alpha: float = None) -> RGBArray:
321
+ img = cv.cvtColor(img, cv.COLOR_RGB2HSV)
322
+ img[:, :, 1] = cv.convertScaleAbs(img[:, :, 1], alpha=alpha or 1.0)
323
+ img[:] = cv.cvtColor(img, cv.COLOR_HSV2RGB)
324
+ return img
325
+
326
+
327
+ def _get_asciidraw_vars(__img: Union[RGBImageLike, str, PathLike[str]],
328
+ __font: FontArgType):
329
+ img = get_rgb_array(__img)
330
+ font = get_font_object(__font)
331
+ return img, font
332
+
333
+
334
+ def _get_bbox_shape(__font: FreeTypeFont):
335
+ return cast(tuple[float, float], __font.getbbox(' ')[2:])
336
+
337
+
338
+ @overload
339
+ def img2ascii(
340
+ __img: RGBImageLike | str | PathLike[str],
341
+ __font: FontArgType = ...,
342
+ factor: int = ...,
343
+ char_set: Optional[str] = ...,
344
+ sort_glyphs: bool | type[reversed] = ...,
345
+ *,
346
+ ret_img: Literal[False] = False
347
+ ) -> str:
348
+ ...
349
+
350
+
351
+ @overload
352
+ def img2ascii(
353
+ __img: RGBImageLike | str | PathLike[str],
354
+ __font: FontArgType = ...,
355
+ factor: int = ...,
356
+ char_set: Optional[str] = ...,
357
+ sort_glyphs: bool | type[reversed] = ...,
358
+ *,
359
+ ret_img: Literal[True]
360
+ ) -> tuple[str, RGBArray]:
361
+ ...
362
+
363
+
364
+ def img2ascii(__img: RGBImageLike | str | PathLike[str],
365
+ __font: FontArgType = 'arial.ttf',
366
+ factor: int = 100,
367
+ char_set: Iterable[str] = None,
368
+ sort_glyphs: bool | type[reversed] = True,
369
+ *,
370
+ ret_img: bool = False) -> str | tuple[str, RGBArray]:
371
+ """Convert an image to a multiline ASCII string.
372
+
373
+ Parameters
374
+ ----------
375
+ __img : RGBImageLike | str | PathLike[str]
376
+ Base image being converted to ASCII.
377
+
378
+ __font : FreeTypeFont | UserFont | str | int
379
+ Font to use for glyph comparisons and representation.
380
+
381
+ factor : int
382
+ Length of each line in characters per line in `output_str`. Affects level of detail.
383
+
384
+ char_set : Iterable[str], optional
385
+ Characters to be mapped to greyscale values of `__img`.
386
+
387
+ sort_glyphs : {True, False, `reversed`}
388
+ Specifies to sort `char_set` or leave it unsorted before mapping to greyscale.
389
+ Glyph bitmasks obtained from `__font` are compared when sorting the string.
390
+ :py:class:`reversed` specifies reverse sorting order.
391
+
392
+ ret_img : bool, default=False
393
+ Specifies to return both the output string and original RGB array.
394
+ Used by :py:func:`img2ansi` to lazily obtain the base ASCII chars and original RGB array.
395
+
396
+ Returns
397
+ -------
398
+ output_str : str
399
+ Characters from `char_set` mapped to the input image, as a multi-line string.
400
+
401
+ Raises
402
+ ------
403
+ TypeError
404
+ If `char_set` is of an unexpected type.
405
+
406
+ Notes
407
+ -----
408
+ * 'row length' and 'absolute width' are synonymous with the `factor` param.
409
+ * `factor` equals n characters per row.
410
+
411
+ * `char_set`: ASCII printable is default for most fonts, but some fonts are mapped to
412
+ specific encodings.
413
+ * For example, if `__font` is `UserFont.IBM_VGA_437_8X16`, the default will be printable
414
+ characters from 'cp437'.
415
+
416
+ * ASCII interpolation maps the greyscale value range (0.0 to 1.0) across `char_set`.
417
+ * To illustrate, `char_set=' *#█'` contains 4 characters:
418
+ [' ', '*', '#', '█']
419
+ * The interpolation ranges map to characters:
420
+ {' ': (0.0, 0.25), '*': (0.25, 0.5), '#': (0.5, 0.75), '█': (0.75, 1.0)}
421
+ * If `char_set='ABCD'` and is left unsorted, the ranges would map to the literal string:
422
+ {'A': (0.0, 0.25), 'B': (0.25, 0.5), 'C': (0.5, 0.75), 'D': (0.75, 1.0)}
423
+
424
+ See Also
425
+ --------
426
+ ascii2img : Render an ASCII string as an image.
427
+ """
428
+ img, font = _get_asciidraw_vars(__img, __font)
429
+ greyscale: MatrixLike[uint8] = cv.cvtColor(img, cv.COLOR_RGB2GRAY)
430
+ img_shape = greyscale.shape
431
+ img_aspect = img_shape[-1] / img_shape[0]
432
+ ch, cw = _get_bbox_shape(font)
433
+ char_aspect = math.ceil(cw / ch)
434
+ new_height = int(factor / img_aspect / char_aspect)
435
+ greyscale = ski.transform.resize(greyscale, (new_height, factor))
436
+ if char_set is None:
437
+ from ._curses import ascii_printable, cp437_printable
438
+
439
+ cursed_fonts = {UserFont.IBM_VGA_437_8X16: cp437_printable}
440
+ char_set = shuffle_char_set(cursed_fonts.get(__font, ascii_printable)())
441
+ elif type(char_set) is not str:
442
+ char_set = ''.join(char_set)
443
+ if sort_glyphs in {True, reversed}:
444
+ from ._glyph_proc import sort_glyphs as glyph_sort
445
+
446
+ char_set = glyph_sort(char_set, font, reverse=(sort_glyphs is reversed))
447
+ maxlen = len(char_set) - 1
448
+ interp_charset = np.frompyfunc(lambda x: char_set[int(x * maxlen)], 1, 1)
449
+ ascii_str = '\n'.join(map(''.join, interp_charset(greyscale)))
450
+ if ret_img:
451
+ return ascii_str, img
452
+ return ascii_str
453
+
454
+
455
+ def img2ansi(
456
+ __img: RGBImageLike | PathLike[str] | str,
457
+ __font: FontArgType = 'arial.ttf',
458
+ factor: int = 100,
459
+ char_set: Iterable[str] = None,
460
+ ansi_type: AnsiColorParam = DEFAULT_ANSI,
461
+ sort_glyphs: Union[bool, type[reversed]] = True,
462
+ equalize: Union[bool, Literal['white_point']] = True,
463
+ bg: Union[Color, Int3Tuple] = (0, 0, 0)
464
+ ):
465
+ """Convert an image to an ANSI array.
466
+
467
+ Parameters
468
+ ----------
469
+ __img : RGBImageLike | str | PathLike[str]
470
+ Base image or path to image being convert into ANSI.
471
+
472
+ __font : FreeTypeFont | UserFont | str | int
473
+ Font to use for glyph comparisons and representation.
474
+
475
+ factor : int
476
+ Length of each line in characters per line in `output_str`. Affects level of detail.
477
+
478
+ char_set : Iterable[str], optional
479
+ The literal string or sequence of strings to use for greyscale interpolation and
480
+ visualization.
481
+ If None (default), the character set will be determined based on the `__font` parameter.
482
+
483
+ ansi_type : str or type[ansi_color_4Bit | ansi_color_8Bit | ansi_color_24Bit], optional
484
+ ANSI color format to map the RGB values to.
485
+ Can be 4-bit, 8-bit, or 24-bit ANSI color space.
486
+ If 4-bit or 8-bit, the RGB array will be color-quantized into ANSI color space;
487
+ if 24-bit, colors are sourced from the base RGB array;
488
+ if None (default), uses default ANSI type (4-bit or 8-bit, depending on the system).
489
+
490
+ sort_glyphs : {True, False, `reversed`}
491
+ Specifies to sort `char_set` or leave it unsorted before mapping to greyscale.
492
+ Glyph bitmasks obtained from `__font` are compared when sorting the string.
493
+ :py:class:`reversed` specifies reverse sorting order.
494
+
495
+ equalize : {True, False, 'white_point'}
496
+ Apply contrast equalization to the input image.
497
+ If True, performs contrast stretching;
498
+ if 'white_point', applies white-point equalization.
499
+
500
+ bg : sequence of ints or ndarray[Any, dtype[uint8]]
501
+ Background color to use for all :py:class:`ColorStr` objects in the array.
502
+
503
+ Returns
504
+ -------
505
+ ansi_array : list[list[ColorStr]]
506
+ The ANSI-converted image, as an array of :py:class:`ColorStr` objects.
507
+
508
+ Raises
509
+ ------
510
+ ValueError
511
+ If `bg` cannot be coerced into a :py:class:`Color` object.
512
+
513
+ TypeError
514
+ If `ansi_type` is not a valid ANSI type.
515
+
516
+ See Also
517
+ --------
518
+ ansi2img : Render an ANSI array as an image.
519
+ img2ascii : Used to obtain the base ASCII characters.
520
+ """
521
+ if ansi_type is not DEFAULT_ANSI:
522
+ ansi_type = get_ansi_type(ansi_type)
523
+ bg_wrapper = ColorStr(
524
+ '{}',
525
+ color_spec={'bg': bg},
526
+ ansi_type=ansi_type,
527
+ no_reset=True)
528
+ base_ascii, color_arr = img2ascii(
529
+ __img, __font, factor, char_set, sort_glyphs,
530
+ ret_img=True)
531
+ lines = base_ascii.splitlines()
532
+ h, w = map(len, (lines, lines[0]))
533
+ if ansi_type is not ansicolor24Bit:
534
+ color_arr = ansi_quantize(
535
+ color_arr,
536
+ ansi_type=ansi_type,
537
+ equalize=equalize)
538
+ elif eq_func := {True: contrast_stretch,
539
+ 'white_point': equalize_white_point
540
+ }.get(equalize):
541
+ color_arr = eq_func(color_arr)
542
+ color_arr = Image.fromarray(
543
+ color_arr, mode='RGB'
544
+ ).resize((w, h), resample=Image.Resampling.LANCZOS)
545
+ xs = []
546
+ for i in range(h):
547
+ x = []
548
+ for j in range(w):
549
+ char = lines[i][j]
550
+ fg_color = Color.from_rgb(
551
+ color_arr.getpixel([j, i]))
552
+ if j > 0 and x[-1].fg == fg_color:
553
+ x[-1] += char
554
+ else:
555
+ x.append(bg_wrapper.format(char).recolor(fg=fg_color))
556
+ xs.append(x)
557
+ return xs
558
+
559
+
560
+ @rgb_dispatch
561
+ def ascii2img(__ascii: str,
562
+ __font: FontArgType = 'arial.ttf',
563
+ font_size=24,
564
+ *,
565
+ fg: Int3Tuple | str = (0, 0, 0),
566
+ bg: Int3Tuple | str = (255, 255, 255)):
567
+ """Render an ASCII string as an image.
568
+
569
+ Parameters
570
+ ----------
571
+ __ascii : str
572
+ The ASCII string to convert into an image.
573
+
574
+ __font : FreeTypeFont | UserFont | str | int
575
+ Font to use for rendering the ASCII characters.
576
+
577
+ font_size : int
578
+ Font size in pixels for the rendered ASCII characters.
579
+
580
+ fg : tuple[int, int, int]
581
+ Foreground (text) color.
582
+
583
+ bg : tuple[int, int, int]
584
+ Background color.
585
+
586
+ Returns
587
+ -------
588
+ ascii_img : Image.Image
589
+ An Image object of the rendered ASCII string.
590
+
591
+ See Also
592
+ --------
593
+ img2ascii : Convert an image into an ASCII string.
594
+ """
595
+ font = truetype(get_font_object(__font, retpath=True), font_size)
596
+ lines = __ascii.split('\n')
597
+ n_rows, n_cols = map(len, (lines, lines[0]))
598
+ cw, ch = _get_bbox_shape(font)
599
+ iw, ih = (int(i * j) for i, j in zip((cw, ch), (n_cols, n_rows)))
600
+ img = Image.new('RGB', (iw, ih), tuple(map(int, bg)))
601
+ draw = ImageDraw.Draw(img)
602
+ y_offset = 0
603
+ for line in lines:
604
+ draw.text((0, y_offset), line, font=font, fill=fg)
605
+ y_offset += ch
606
+ return img
607
+
608
+
609
+ @rgb_dispatch
610
+ def ansi2img(__ansi_array: list[list[ColorStr]],
611
+ __font: FontArgType = 'arial.ttf',
612
+ font_size=24,
613
+ *,
614
+ fg_default: Int3Tuple | str = (170, 170, 170),
615
+ bg_default: Int3Tuple | str | Literal['auto'] = (0, 0, 0)):
616
+ """Render an ANSI array as an image.
617
+
618
+ Parameters
619
+ ----------
620
+ __ansi_array : list[list[ColorStr]]
621
+ An array-like, row-major list of lists of `ColorStr` objects
622
+
623
+ __font : FreeTypeFont | UserFont | str | int
624
+ Font to render the ANSI strings with.
625
+
626
+ font_size : int
627
+ Font size in pixels.
628
+
629
+ fg_default : tuple[int, int, int]
630
+ Default foreground color of rendered text.
631
+
632
+ bg_default : tuple[int, int, int]
633
+ Default background color of rendered text, and the fill color of the base canvas.
634
+
635
+ Returns
636
+ -------
637
+ ansi_img : Image.Image
638
+ PIL Image of the rendered ANSI array.
639
+
640
+ Raises
641
+ ------
642
+ ValueError
643
+ If the input ANSI array is empty.
644
+
645
+ See Also
646
+ --------
647
+ img2ansi : Create an ANSI array from an input image, font, and character set.
648
+ """
649
+ if not (n_rows := len(__ansi_array)):
650
+ raise ValueError(
651
+ 'ANSI string input is empty')
652
+ font = truetype(get_font_object(__font, retpath=True), font_size)
653
+ row_height = _get_bbox_shape(font)[-1]
654
+ max_row_width = max(
655
+ sum(
656
+ font.getbbox(color_str.base_str)[2]
657
+ for color_str in row)
658
+ for row in __ansi_array)
659
+ iw, ih = map(int, (max_row_width, n_rows * row_height))
660
+ input_fg = fg_default
661
+ if auto := bg_default == 'auto':
662
+ input_bg = bg_default = None
663
+ else:
664
+ input_bg = bg_default
665
+ img = Image.new('RGB', (iw, ih), cast(tuple[float, ...], bg_default))
666
+ draw = ImageDraw.Draw(img)
667
+ y_offset = 0
668
+ for row in __ansi_array:
669
+ x_offset = 0
670
+ for color_str in row:
671
+ text_width = font.getbbox(color_str.base_str)[2]
672
+ if color_str._sgr_.is_reset():
673
+ fg_default = None
674
+ bg_default = input_bg
675
+ if fg_color := getattr(color_str.fg, 'rgb', fg_default):
676
+ fg_default = fg_color
677
+ if bg_color := getattr(color_str.bg, 'rgb', bg_default):
678
+ if auto:
679
+ bg_default = bg_color
680
+ draw.rectangle(
681
+ [x_offset, y_offset, x_offset + text_width, y_offset + row_height],
682
+ fill=bg_color or (0, 0, 0))
683
+ draw.text(
684
+ (x_offset, y_offset),
685
+ color_str.base_str,
686
+ font=font,
687
+ fill=fg_color or input_fg)
688
+ x_offset += text_width
689
+ y_offset += row_height
690
+ return img
691
+
692
+
693
+ def is_array(__obj: Any) -> TypeGuard[ndarray]:
694
+ return isinstance(__obj, ndarray)
695
+
696
+
697
+ def is_rgb_array(__obj: Any) -> TypeGuard[RGBArray]:
698
+ return is_array(__obj) and __obj.ndim == 3 and issubdtype(__obj.dtype, uint8)
699
+
700
+
701
+ def is_greyscale_array(__obj: Any) -> TypeGuard[GreyscaleArray]:
702
+ return is_array(__obj) and __obj.ndim == 2 and issubdtype(__obj.dtype, float64)
703
+
704
+
705
+ def is_greyscale_glyph(__obj: Any) -> TypeGuard[GreyscaleGlyphArray]:
706
+ return is_greyscale_array(__obj) and __obj.shape == (24, 24)
707
+
708
+
709
+ def is_image(__obj: Any) -> TypeGuard[ImageType]:
710
+ return isinstance(__obj, ImageType)
711
+
712
+
713
+ def is_rgb_image(__obj: Any) -> TypeGuard[ImageType]:
714
+ return is_image(__obj) and __obj.mode == 'RGB'
715
+
716
+
717
+ def is_rgb_imagelike(__obj: Any) -> TypeGuard[Union[RGBArray, ImageType]]:
718
+ return is_rgb_array(__obj) or is_rgb_image(__obj)
719
+
720
+
721
+ type _LiteralDigitStr = Sequence[Literal[
722
+ '0', '1', '2', '3', '4',
723
+ '5', '6', '7', '8', '9']]
724
+
725
+
726
+ def is_csi_param(__c: str) -> TypeGuard[Literal[';'] | _LiteralDigitStr]:
727
+ return __c == ';' or __c.isdigit()
728
+
729
+
730
+ def reshape_ansi(__str: str, h: int, w: int):
731
+ arr = [['\x00'] * w for _ in range(h)]
732
+ offsets = dict.fromkeys(range(h), 0)
733
+ flat_iter = (divmod(idx, w) for idx in range(h * w))
734
+ str_len = len(__str)
735
+ j = 0
736
+
737
+ def increment(__c: str = ' '):
738
+ nonlocal x, y
739
+ arr[x][y] += __c
740
+ offsets[x] += 1
741
+ try:
742
+ x, y = next(flat_iter)
743
+ except StopIteration:
744
+ pass
745
+
746
+ try:
747
+ x, y = next(flat_iter)
748
+ while j < str_len:
749
+ if __str[j:(i := j + 2)] == '\x1b[':
750
+ j = i
751
+ while is_csi_param(c := __str[j]):
752
+ j += 1
753
+ params = __str[i:j]
754
+ if c == 'C':
755
+ for _ in range(int(params)):
756
+ increment()
757
+ elif c == 'm':
758
+ arr[x][y] += str(
759
+ SgrSequence(list(map(int, params.split(';')))))
760
+ elif (c := __str[j]) == '\n':
761
+ while y < w - 1:
762
+ increment()
763
+ x, y = next(flat_iter)
764
+ else:
765
+ increment(c)
766
+ j += 1
767
+ except StopIteration:
768
+ pass
769
+ return '\n'.join(
770
+ ' ' * (w - offsets[idx]) + ''.join(
771
+ c for c in row if c != '\x00')
772
+ for idx, row in enumerate(arr))
773
+
774
+
775
+ def to_sgr_array(__str: str, ansi_type: AnsiColorParam = DEFAULT_ANSI):
776
+ ansi_typ = get_ansi_type(ansi_type)
777
+ new_cs = partial(ColorStr, ansi_type=ansi_typ, no_reset=True)
778
+ fix_bold = lambda v: (b'1;%s' % v) if type(v) is ansicolor4Bit else v
779
+ resets_btok = {b'39': 'fg', b'49': 'bg'}
780
+ resets = frozenset(resets_btok)
781
+ pad_esc = {0x1b: '\x00\x1b'}
782
+ x = []
783
+ for line in __str.splitlines():
784
+ xs = []
785
+ ansi_meta = {}
786
+ prev: ColorStr | None = None
787
+ for s in filter(None, line.translate(pad_esc).split('\x00')):
788
+ sgr = None
789
+ if s[:(i := min(2, len(s) - 1))] == '\x1b[':
790
+ params, _, text = s[i:].partition('m')
791
+ cs = new_cs(
792
+ text,
793
+ sgr := SgrSequence([int(b) for b in params.split(';')]))
794
+ if sgr.is_color():
795
+ prev = cs
796
+ else:
797
+ cs = new_cs(s)
798
+ if sgr is None:
799
+ sgr = cs._sgr_
800
+ if sgr.is_reset():
801
+ ansi_meta.clear()
802
+ for k in resets.intersection(sgr.values()):
803
+ del ansi_meta[resets_btok[k]]
804
+ if sgr.is_color():
805
+ for k in sgr.rgb_dict.keys():
806
+ ansi_meta[k] = sgr.get_color(k)
807
+ if sgr.has_bright_colors:
808
+ ansi_meta['bright'] = True
809
+ elif (ansi_meta.get('bright')
810
+ or (prev is not None
811
+ and b'1' in sgr.values())):
812
+ if sgr.is_color():
813
+ new_sgr = SgrSequence([fix_bold(v) for v in sgr.values()])
814
+ for k in new_sgr.rgb_dict.keys():
815
+ ansi_meta[k] = new_sgr.get_color(k)
816
+ prev = cs = new_cs(
817
+ cs.base_str,
818
+ color_spec=new_sgr)
819
+ elif prev:
820
+ if not ansi_meta.get('bright'):
821
+ ansi_meta['bright'] = True
822
+ color_values = [p._value_ for p in prev._sgr_ if p.is_color()]
823
+ color_values += sgr.values()
824
+ prev = cs = new_cs(cs.base_str, SgrSequence(color_values))
825
+ xs.append(cs.as_ansi_type(ansi_typ))
826
+ x.append(xs)
827
+ return x
828
+
829
+
830
+ def render_ans(
831
+ __str: str,
832
+ shape: tuple[int, int],
833
+ font: FontArgType = UserFont.IBM_VGA_437_8X16,
834
+ font_size: int = 16,
835
+ *,
836
+ bg_default: Union[tuple[int, int, int], Literal['auto'], str] = (0, 0, 0)
837
+ ) -> ImageType:
838
+ """Parse and render literal ANSI text as an image.
839
+
840
+ Parameters
841
+ ----------
842
+ __str : str
843
+ Literal ANSI text.
844
+ shape : tuple[int, int]
845
+ (height, width) of the expected output, in ASCII characters.
846
+ font : FreeTypeFont | UserFont | str | int
847
+ Font to use when rendering the image.
848
+ font_size : int
849
+ Font size in pixels.
850
+ bg_default : tuple[int, int, int] | Literal['auto']
851
+ Default background color to use when rendering the image.
852
+ """
853
+ reshaped = reshape_ansi(__str, *shape)
854
+ ansi_array = to_sgr_array(reshaped)
855
+ return ansi2img(ansi_array, font, font_size, bg_default=bg_default)
856
+
857
+
858
+ def read_ans[AnyStr: (str, bytes)](
859
+ __path: Union[PathLike[AnyStr], AnyStr],
860
+ encoding='cp437'
861
+ ) -> str:
862
+ """Read an ANSI file and return the content as a string.
863
+
864
+ Extends code page 437 translation if `encoding='cp437'`, and truncates any SAUCE metadata.
865
+ Otherwise, the function is just a wrapped text file read operation.
866
+ """
867
+
868
+ with open(__path, mode='r', encoding=encoding) as f:
869
+ content = f.read().translate({0: ' '})
870
+ if ~(sauce_idx := content.rfind('\x1aSAUCE00')):
871
+ content = content[:sauce_idx]
872
+ if encoding == 'cp437':
873
+ from ._curses import translate_cp437
874
+
875
+ content = translate_cp437(content, ignore=(0x0a, 0x1a, 0x1b))
876
+ return content
877
+
878
+
879
+ class AnsiImage:
880
+
881
+ @classmethod
882
+ def open[AnyStr: (str, bytes)](
883
+ cls,
884
+ fp: Union[PathLike[AnyStr], AnyStr],
885
+ shape: TupleOf2[int],
886
+ encoding='cp437',
887
+ ansi_type: AnsiColorParam = DEFAULT_ANSI
888
+ ) -> Self:
889
+ inst = super().__new__(cls)
890
+ inst._height_, inst._width_ = shape
891
+ inst._ansi_format_ = get_ansi_type(ansi_type)
892
+ inst.fp = os.path.abspath(fp)
893
+ inst.data = to_sgr_array(
894
+ reshape_ansi(
895
+ read_ans(inst.fp, encoding=encoding),
896
+ inst._height_, inst._width_),
897
+ ansi_type=inst._ansi_format_)
898
+ return inst
899
+
900
+ @property
901
+ def ansi_format(self) -> AnsiColorType:
902
+ return self._ansi_format_
903
+
904
+ @property
905
+ def height(self):
906
+ return self._height_
907
+
908
+ @property
909
+ def width(self):
910
+ return self._width_
911
+
912
+ def render(
913
+ self,
914
+ font: FontArgType = UserFont.IBM_VGA_437_8X16,
915
+ font_size: int = 16,
916
+ bg_default=None,
917
+ **kwargs
918
+ ) -> ImageType:
919
+ return ansi2img(
920
+ self.data, font, font_size,
921
+ bg_default=bg_default or 'auto',
922
+ **kwargs)
923
+
924
+ def translate(self, __table: Mapping[int, str | int | None]):
925
+ table = {
926
+ k: v if v not in frozenset(
927
+ x
928
+ for c in ' \t\n\r\v\f'
929
+ for x in (c, ord(c)))
930
+ else ' '
931
+ for (k, v) in __table.items()
932
+ if k != ord('\n')
933
+ }
934
+ data = self.data
935
+ for row in range(self.height):
936
+ for col in range(self.width):
937
+ data[row][col] = data[row][col].translate(table)
938
+ return type(self)(data, ansi_type=self.ansi_format)
939
+
940
+ def __init__(
941
+ self,
942
+ arr: list[list[ColorStr]],
943
+ *,
944
+ ansi_type: AnsiColorParam = DEFAULT_ANSI
945
+ ):
946
+ self.data = arr
947
+ self._height_, self._width_ = map(len, (arr, arr[0]))
948
+ assert all(sum(map(len, r)) == self._width_ for r in arr)
949
+ self._ansi_format_ = get_ansi_type(ansi_type)
950
+ self.fp = None
951
+
952
+ def __str__(self) -> str:
953
+ if not hasattr(self, '__s'):
954
+ ansi_repr = '\n'.join(''.join(r) for r in self.data)
955
+ setattr(self, '__s', ansi_repr + SGR_RESET)
956
+ return getattr(self, '__s')
957
+
958
+
959
+ def otsu_mask(img: MatrixLike[np.uint8] | ImageType) -> MatrixLike[np.uint8]:
960
+ if type(img) is not np.ndarray:
961
+ img = np.uint8(img)
962
+ kernel = cv.getStructuringElement(cv.MORPH_RECT, (2, 2))
963
+ img = cv.morphologyEx(img, cv.MORPH_OPEN, kernel)
964
+ return cv.threshold(img, 0, 255, cv.THRESH_BINARY + cv.THRESH_OTSU)[1]
965
+
966
+
967
+ def zt_canny_edges(arr: MatrixLike) -> MatrixLike:
968
+ return ski.feature.canny(
969
+ arr, sigma=0.1, low_threshold=0.1, high_threshold=0.2, use_quantiles=False)
970
+
971
+
972
+ def scaled_hu_moments(arr: MatrixLike):
973
+ if {0, 255}.isdisjoint(np.unique_values(arr)):
974
+ arr = otsu_mask(arr)
975
+ hms = cv.HuMoments(cv.moments(arr)).ravel()
976
+ nz = hms.nonzero()
977
+ out = np.zeros_like(hms)
978
+ out[nz] = -np.sign(hms[nz]) * np.log10(np.abs(hms[nz]))
979
+ return out
980
+
981
+
982
+ def approx_gridlike(
983
+ __fp: PathLike[str] | str,
984
+ shape: TupleOf2[int],
985
+ font: FontArgType = UserFont.IBM_VGA_437_8X16
986
+ ):
987
+ def _get_grid_indices(arr: np.ndarray):
988
+ regions = ski.measure.regionprops(ski.measure.label(zt_canny_edges(arr)))
989
+ area_bboxes = np.zeros([np.shape(regions)[0]])
990
+ bboxes = np.int32([area_bboxes] * 4).T
991
+ for n, region in enumerate(regions):
992
+ bboxes[n], area_bboxes[n] = region.bbox, region.area_bbox
993
+ bboxes = bboxes[area_bboxes < np.std(area_bboxes) * 2]
994
+ r, c = cast(
995
+ TupleOf2[Int3Tuple],
996
+ zip(
997
+ np.min(bboxes[:, :2], axis=0),
998
+ np.max(bboxes[:, 2:], axis=0),
999
+ shape)
1000
+ )
1001
+ h, w = map(round, ((x[1] - x[0]) / x[-1] for x in (r, c)))
1002
+ rr = r[0] + np.asarray(rs := range(r[-1])) * h
1003
+ cc = c[0] + np.asarray(cs := range(c[-1])) * w
1004
+ return cast(
1005
+ list[TupleOf2[slice]],
1006
+ [np.index_exp[
1007
+ rr[rx]:(rr + h)[rx],
1008
+ cc[cx]:(cc + w)[cx]]
1009
+ for rx in rs
1010
+ for cx in cs]
1011
+ )
1012
+
1013
+ with Image.open(__fp).convert('L') as grey:
1014
+ thresh = otsu_mask(np.array(grey))
1015
+ from ._curses import cp437_printable
1016
+
1017
+ grid_indices = _get_grid_indices(thresh)
1018
+ cell_shape = thresh[grid_indices[0]].shape
1019
+ clustered_grid = np.reshape(
1020
+ getattr(
1021
+ DBSCAN(eps=0.5, min_samples=2, metric='euclidean').fit(
1022
+ np.array(
1023
+ [scaled_hu_moments(thresh[ind])
1024
+ for ind in grid_indices])),
1025
+ 'labels_'),
1026
+ shape)
1027
+ char_grid = np.full_like(clustered_grid, ' ', dtype=np.str_)
1028
+ glyph_map = {
1029
+ c: otsu_mask(
1030
+ render_font_char(
1031
+ c, font,
1032
+ size=cell_shape[::-1]
1033
+ ).convert('L'))
1034
+ for c in cp437_printable()
1035
+ }
1036
+
1037
+ def _normalize_cell(arr: np.ndarray):
1038
+ cell = np.zeros(cell_shape, dtype=np.uint8)
1039
+ coords = np.argwhere(arr)
1040
+ if coords.size == 0:
1041
+ return cell
1042
+ y0, x0 = coords.min(axis=0)
1043
+ y1, x1 = coords.max(axis=0) + 1
1044
+ cropped = arr[y0:y1, x0:x1]
1045
+ dy, dx = cropped.shape
1046
+ ys, xs = map(lambda t, d: (t - d) // 2, cell_shape, (dy, dx))
1047
+ cell[ys:ys + dy, xs:xs + dx] = cropped
1048
+ return cell
1049
+
1050
+ for u_indices in map(
1051
+ clustered_grid.__eq__,
1052
+ np.unique_values(clustered_grid)):
1053
+ u_slice = thresh[
1054
+ grid_indices[next(idx for (idx, v) in enumerate(np.ravel(u_indices)) if v is True)]]
1055
+ char_grid[u_indices] = min(
1056
+ glyph_map, key=lambda k: ski.metrics.mean_squared_error(
1057
+ *map(_normalize_cell, (glyph_map[k], u_slice))))
1058
+
1059
+ return AnsiImage([[ColorStr(s) for s in r] for r in char_grid])