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.
- chromatic/__init__.py +26 -0
- chromatic/_typing.py +168 -0
- chromatic/ascii/__init__.py +5 -0
- chromatic/ascii/_array.py +1059 -0
- chromatic/ascii/_curses.py +153 -0
- chromatic/ascii/_glyph_proc.py +107 -0
- chromatic/color/__init__.py +6 -0
- chromatic/color/colorconv.py +316 -0
- chromatic/color/core.py +1677 -0
- chromatic/color/core.pyi +421 -0
- chromatic/color/palette.py +693 -0
- chromatic/color/palette.pyi +330 -0
- chromatic/data/__init__.py +189 -0
- chromatic/data/__init__.pyi +15 -0
- chromatic/data/fonts/IBM_VGA_437_8x16.ttf +0 -0
- chromatic/data/fonts/consolas.ttf +0 -0
- chromatic/data/images/butterfly.jpg +0 -0
- chromatic/data/images/escher.png +0 -0
- chromatic/data/images/goblin_virus.png +0 -0
- chromatic/data/images/hotdog.jpg +0 -0
- chromatic/demo.py +417 -0
- chromatic_python-0.1.0.dist-info/LICENSE +21 -0
- chromatic_python-0.1.0.dist-info/METADATA +39 -0
- chromatic_python-0.1.0.dist-info/RECORD +26 -0
- chromatic_python-0.1.0.dist-info/WHEEL +5 -0
- chromatic_python-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -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])
|