Skip to content

Commit 6893b69

Browse files
committed
Vectorize mapping and add color cache for ASCII
Improve ASCII art rendering performance by replacing per-pixel Python loops with vectorized operations and a byte-level cache. _map_luminance_to_chars now uses a numpy char LUT and joins rows from a (H,W) array of 1-char strings. _color_ascii_lines avoids cv.split and per-pixel f-strings: it computes luminance in float, uses a uint16 index map, packs RGB into a uint32 color key, and caches formatted ANSI-colored byte sequences keyed by (color<<8)|idx. Lines are built as bytearrays and decoded once. Functionality/formatting is preserved while reducing Python-level overhead.
1 parent 756836b commit 6893b69

File tree

1 file changed

+54
-11
lines changed

1 file changed

+54
-11
lines changed

dlclivegui/assets/ascii_art.py

Lines changed: 54 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -194,27 +194,70 @@ def resize_for_terminal(img: np.ndarray, width: int | None, aspect: float | None
194194

195195
def _map_luminance_to_chars(gray: np.ndarray, fine: bool) -> Iterable[str]:
196196
ramp = ASCII_RAMP_FINE if fine else ASCII_RAMP_SIMPLE
197+
ramp_arr = np.array(list(ramp), dtype="<U1") # vectorized char LUT
198+
197199
idx = (gray.astype(np.float32) / 255.0 * (len(ramp) - 1)).astype(np.int32)
198-
lines = ["".join(ramp[i] for i in row) for row in idx]
199-
return lines
200+
chars = ramp_arr[idx] # (H,W) array of 1-char strings
201+
202+
# Join per-row (still Python per row, but NOT per pixel)
203+
return ["".join(row.tolist()) for row in chars]
200204

201205

202206
def _color_ascii_lines(img_bgr: np.ndarray, fine: bool, invert: bool) -> Iterable[str]:
203207
ramp = ASCII_RAMP_FINE if fine else ASCII_RAMP_SIMPLE
204-
b, g, r = cv.split(img_bgr)
205-
lum = (0.0722 * b + 0.7152 * g + 0.2126 * r).astype(np.float32)
208+
ramp_bytes = [c.encode("utf-8") for c in ramp] # 1-byte ASCII in practice
209+
reset = b"\x1b[0m"
210+
211+
# luminance in float32 like your current code
212+
b = img_bgr[..., 0].astype(np.float32)
213+
g = img_bgr[..., 1].astype(np.float32)
214+
r = img_bgr[..., 2].astype(np.float32)
215+
lum = 0.0722 * b + 0.7152 * g + 0.2126 * r
206216
if invert:
207217
lum = 255.0 - lum
208-
idx = (lum / 255.0 * (len(ramp) - 1)).astype(np.int32)
218+
219+
idx = (lum / 255.0 * (len(ramp) - 1)).astype(np.uint16) # small dtype is fine
220+
221+
# Pack color into one int: 0xRRGGBB (faster dict key than tuple)
222+
rr = img_bgr[..., 2].astype(np.uint32)
223+
gg = img_bgr[..., 1].astype(np.uint32)
224+
bb = img_bgr[..., 0].astype(np.uint32)
225+
color_key = (rr << 16) | (gg << 8) | bb # (H,W) uint32
226+
227+
# Cache: (color_key<<8)|idx -> bytes for full colored char INCLUDING reset
228+
cache: dict[int, bytes] = {}
229+
209230
h, w = idx.shape
210-
lines = []
231+
lines: list[str] = []
232+
211233
for y in range(h):
212-
seg = []
234+
ba = bytearray()
235+
ck_row = color_key[y]
236+
idx_row = idx[y]
237+
img_bgr[y] # for extracting r/g/b when cache miss
238+
213239
for x in range(w):
214-
ch = ramp[idx[y, x]]
215-
bb, gg, rr = img_bgr[y, x]
216-
seg.append(f"\x1b[38;2;{rr};{gg};{bb}m{ch}\x1b[0m")
217-
lines.append("".join(seg))
240+
ik = int(idx_row[x])
241+
ck = int(ck_row[x])
242+
subkey = (ck << 8) | ik
243+
244+
piece = cache.get(subkey)
245+
if piece is None:
246+
# Decode r,g,b from packed key (same as current rr,gg,bb)
247+
rr_i = (ck >> 16) & 255
248+
gg_i = (ck >> 8) & 255
249+
bb_i = ck & 255
250+
251+
# EXACT same formatting as before
252+
# \x1b[38;2;{rr};{gg};{bb}m{ch}\x1b[0m
253+
prefix = f"\x1b[38;2;{rr_i};{gg_i};{bb_i}m".encode("ascii")
254+
piece = prefix + ramp_bytes[ik] + reset
255+
cache[subkey] = piece
256+
257+
ba.extend(piece)
258+
259+
lines.append(ba.decode("utf-8", errors="strict"))
260+
218261
return lines
219262

220263

0 commit comments

Comments
 (0)