-
Notifications
You must be signed in to change notification settings - Fork 11
/
Copy pathascii.py
273 lines (230 loc) · 9.25 KB
/
ascii.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
#!/usr/bin/env python3
'''
Alex Eidt
Converts videos/images into ASCII video/images in various formats.
'''
import os
import argparse
import imageio
import imageio_ffmpeg
import numpy as np
from tqdm import tqdm as ProgressBar
from PIL import Image, ImageFont, ImageDraw
def get_font_bitmaps(fontsize, boldness, reverse, background, chars, font):
'''
Returns a list of font bitmaps.
Parameters
fontsize - Font size to use for ASCII characters.
boldness - Stroke size to use when drawing ASCII characters.
reverse - Reverse the ordering of the ASCII characters.
background - Background color.
chars - ASCII characters to use in media.
font - Font to use.
Returns
List of font bitmaps corresponding to the characters in 'chars'.
'''
bitmaps = {}
min_width = min_height = float('inf')
font_ttf = ImageFont.truetype(font, size=fontsize)
for char in chars:
if char in bitmaps:
continue
w, h = font_ttf.getsize(char)
min_width, min_height = min(min_width, w), min(min_height, h)
# Draw font character as a w x h image.
image = Image.new('RGB', (w, h), (background,) * 3)
draw = ImageDraw.Draw(image)
draw.text(
(0, -(fontsize // 6)),
char,
fill=(255 - background,) * 3,
font=font_ttf,
stroke_width=boldness,
)
bitmap = np.array(image)
if background == 255:
np.subtract(255, bitmap, out=bitmap)
bitmaps[char] = bitmap.astype(np.uint8)
# Crop the font bitmaps to all have the same dimensions based on the
# minimum font width and height of all font bitmaps.
fonts = [bitmaps[char][: int(min_height), : int(min_width)] for char in chars]
# Sort font bitmaps by pixel density.
fonts.sort(key=lambda x: x.sum(), reverse=not reverse)
return np.array(fonts)
def draw_ascii(frame, chars, background, clip, monochrome, font_bitmaps, buffer=None):
'''
Draws an ASCII Image.
Parameters
frame - Numpy array representing image. Must be 3 channels RGB.
chars - ASCII characters to use in media.
background - Background color.
clip - Clip characters to not go outside of image bounds.
monochrome - Color to use for monochromatic. None if not monochromatic.
font_bitmaps - List of font bitmaps.
buffer - Optional buffer for intermediary calculations.
Must have shape: ((h // fh + 1) * fh, (w // fw + 1) * fw, 3) where
h, w are the height and width of the frame and fw, fh are the font width and height.
NOTE: Characters such as q, g, y, etc... are not rendered properly in this implementation
due to the lower ends being cut off.
'''
# fh -> font height, fw -> font width.
fh, fw = font_bitmaps[0].shape[:2]
# oh -> Original height, ow -> Original width.
oh, ow = frame.shape[:2]
# Sample original frame at steps of font width and height.
frame = frame[::fh, ::fw]
h, w = frame.shape[:2]
if buffer is None:
buffer = np.empty((h * fh, w * fw, 3), dtype=np.uint16 if len(chars) < 32 else np.uint32)
buffer_view = buffer[:h, :w]
if len(monochrome) != 0:
buffer_view[:] = 1
if background == 255:
monochrome = 255 - monochrome
np.multiply(buffer_view, monochrome, out=buffer_view)
else:
if background == 255:
np.subtract(255, frame, out=buffer_view)
else:
buffer_view[:] = frame
colors = buffer_view.repeat(fw, 1).astype(np.uint16, copy=False).repeat(fh, 0)
# Grayscale original frame and normalize to ASCII index.
buffer_view = buffer_view[..., 0]
np.sum(frame * np.array([3, 4, 1]), axis=2, dtype=buffer.dtype, out=buffer_view)
buffer_view *= len(chars)
buffer_view >>= 11
# Create a new list with each font bitmap based on the grayscale value.
image = (
font_bitmaps[buffer_view]
.transpose(0, 2, 1, 3, 4)
.reshape(h * fh, w * fw, 3)
)
if clip:
colors = colors[:oh, :ow]
image = image[:oh, :ow]
buffer = buffer[:oh, :ow]
np.multiply(image, colors, out=buffer)
np.floor_divide(buffer, 255, out=buffer)
buffer = buffer.astype(np.uint8, copy=False)
if background == 255:
np.subtract(255, buffer, out=buffer)
return buffer
def ascii_video(
filename,
output,
chars,
monochrome,
fontsize=20,
boldness=2,
reverse=False,
background=255,
clip=True,
font='cour.ttf',
audio=False,
quality=5,
):
font_bitmaps = get_font_bitmaps(fontsize, boldness, reverse, background, chars, font)
video = imageio_ffmpeg.read_frames(filename)
data = next(video)
w, h = data['size']
frame_size = (h, w, 3)
# Read and convert first frame to figure out frame size.
first_frame = np.frombuffer(next(video), dtype=np.uint8).reshape(frame_size)
first_frame = draw_ascii(first_frame, chars, background, clip, monochrome, font_bitmaps)
# Smaller data types can speed up operations. The minimum data type required will be
# 2^n / (255 * 8) > len(chars) where n = 16 or 32.
buffer = np.empty_like(first_frame, dtype=np.uint16 if len(chars) < 32 else np.uint32)
h, w = first_frame.shape[:2]
kwargs = {'fps': data['fps'], 'quality': int(min(max(quality, 0), 10))}
if audio:
kwargs['audio_path'] = filename
writer = imageio_ffmpeg.write_frames(output, (w, h), **kwargs)
writer.send(None)
writer.send(first_frame)
del first_frame
for frame in ProgressBar(video, total=int(data['fps'] * data['duration'] - 0.5)):
frame = np.frombuffer(frame, dtype=np.uint8).reshape(frame_size)
writer.send(draw_ascii(frame, chars, background, clip, monochrome, font_bitmaps, buffer))
writer.close()
def ascii_image(
filename,
output,
chars,
monochrome,
fontsize=20,
boldness=2,
reverse=False,
background=255,
clip=True,
font='cour.ttf',
):
image = imageio.imread(filename)[:, :, :3]
font_bitmaps = get_font_bitmaps(fontsize, boldness, reverse, background, chars, font)
image = draw_ascii(image, chars, background, clip, monochrome, font_bitmaps)
imageio.imsave(output, image)
def parse_args():
parser = argparse.ArgumentParser(description='Blazing fast ASCII Media converter.')
parser.add_argument('filename', help='File name of the input image.')
parser.add_argument('output', help='File name of the output image.')
parser.add_argument('-chars', '--characters', help='ASCII chars to use in media.', default='@%#*+=-:. ')
parser.add_argument('-r', '--reverse', help='Reverse the character order.', action='store_true')
parser.add_argument('-f', '--fontsize', help='Font size.', type=int, default=20)
parser.add_argument('-b', '--bold', help='Boldness of characters. Recommended: 1/10 font size.', type=int, default=2)
parser.add_argument('-bg', '--background', help='Background color. Must be 255 (white) or 0 (black).', type=int, default=255)
parser.add_argument('-m', '--monochrome', help='Color to use for Monochromatic characters in "R,G,B" format.')
parser.add_argument('-c', '--clip', help='Clip characters to not go outside of image bounds.', action='store_false')
parser.add_argument('-font', '--font', help='Font to use.', type=str, default='cour.ttf')
parser.add_argument('-a', '--audio', help='Add audio from the input file to the output file.', action='store_true')
parser.add_argument('-q', '--quality', help='Quality of the output video. (0-10), 0 worst, 10 best.', type=int, default=5)
return parser.parse_args()
def convert_ascii(args, filename, output, chars, monochrome):
try:
imageio.imread(filename)
except Exception:
ascii_video(
filename,
output,
chars,
monochrome,
args.fontsize,
args.bold,
args.reverse,
args.background,
args.clip,
args.font,
args.audio,
args.quality,
)
else:
ascii_image(
filename,
output,
chars,
monochrome,
args.fontsize,
args.bold,
args.reverse,
args.background,
args.clip,
args.font,
)
def main():
args = parse_args()
assert args.fontsize > 0, 'Font size must be > 0.'
assert args.bold >= 0, 'Boldness must be >= 0.'
assert args.background in [0, 255], 'Background must be either 0 or 255.'
chars = np.array(list(args.characters))
monochrome = np.array(
list(map(int, args.monochrome.split(','))) if args.monochrome else [],
dtype=np.uint16,
)
if os.path.isdir(args.filename):
os.makedirs(args.output, exist_ok=True)
for filename in ProgressBar(os.listdir(args.filename)):
path = os.path.join(args.filename, filename)
output = os.path.join(args.output, filename)
convert_ascii(args, path, output, chars, monochrome)
else:
convert_ascii(args, args.filename, args.output, chars, monochrome)
if __name__ == '__main__':
main()