diff --git a/README.MD b/README.MD index 1e1bcfb..7051eac 100644 --- a/README.MD +++ b/README.MD @@ -216,7 +216,7 @@ colorize-ascii photo.png art.txt --animate --loops 3 # play in - `--loops N` : Terminal playback repeats; `0` plays until Ctrl-C (default: 3) - `--reveal` : The rain uncovers the colorized image, which persists beneath it — the loop ends (and holds) on the fully revealed picture -All `--matrix-*` knobs (seed, gamma, intensity ranges, mask biasing, `--matrix-color` themes) apply to the rain too, and `--caption*` works in every animation sink — the caption stays static (matching the rain tint by default, or any `--caption-color` including `image`) while the rain falls. The web GUI exposes the same controls under *Matrix mode → Animate*, previews the animation live, and adds a `.gif` download. Programmatic use: `asciimagic.pipeline.animate(ctx, ...)` returns an object with `frames_ansi()`, `play()`, `to_gif_bytes()`, and `to_html()`. +All `--matrix-*` knobs (seed, gamma, intensity ranges, mask biasing, `--matrix-color` themes) apply to the rain too. Drop positions loop seamlessly; glyph identities re-roll at the wrap point, which reads as the same flicker the rain has everywhere else. `--caption*` works in every animation sink — the caption stays static (matching the rain tint by default, or any `--caption-color` including `image`) while the rain falls. The web GUI exposes the same controls under *Matrix mode → Animate*, previews the animation live, and adds a `.gif` download. Programmatic use: `asciimagic.pipeline.animate(ctx, ...)` returns an object with `frames_ansi()`, `play()`, `to_gif_bytes()`, and `to_html()`. ### Text to ASCII (`text_to_ascii.py`) diff --git a/src/asciimagic/animate.py b/src/asciimagic/animate.py index 2655f48..c956e23 100644 --- a/src/asciimagic/animate.py +++ b/src/asciimagic/animate.py @@ -407,7 +407,7 @@ def flush(): key = ((r_ >> 5) << 5, (g_ >> 5) << 5, (b_ >> 5) << 5) ch = html_mod.escape(ach) else: - key = "h" if head[y, x] else f"c{green[y, x] >> 4}" + key = f"h{green[y, x] >> 4}" if head[y, x] else f"c{green[y, x] >> 4}" ch = html_mod.escape(self.chars[i]) if key != prev: flush() @@ -424,7 +424,18 @@ def flush(): for i in range(16) for (r, g, b) in [tint_rgb(min(255, i * 17), self.tint)] ) - hr, hg, hb = (c + (255 - c) * 216 // 255 for c in self.tint) + # Head classes brightness-track the drop like the ANSI/GIF sinks + # (_cell_rgb): base tint scaled by intensity, blended 80% toward it. + def _head_rgb(level: int): + g = min(255, level * 17) + base = tint_rgb(g, self.tint) + return tuple(c + (g - c) * 4 // 5 for c in base) + + css_heads = "\n".join( + " .h{i} {{ color: rgb({r},{g},{b}); }}".format(i=i, r=r, g=g, b=b) + for i in range(16) + for (r, g, b) in [_head_rgb(i)] + ) cap_top = cap_bottom = "" if self.caption: @@ -448,7 +459,7 @@ def flush(): f" font-size: {font_size_px}px;\n line-height: {font_size_px}px;\n" " }\n" f"{css_levels}\n" - f" .h {{ color: rgb({hr},{hg},{hb}); }}\n" + f"{css_heads}\n" " \n\n
\n" f"{cap_top}" ' \n' diff --git a/src/asciimagic/image_to_ascii.py b/src/asciimagic/image_to_ascii.py index 4e696bd..6345c56 100755 --- a/src/asciimagic/image_to_ascii.py +++ b/src/asciimagic/image_to_ascii.py @@ -400,26 +400,36 @@ def image_to_text_glyph_from_image( def _floyd_steinberg_dots(ink: np.ndarray, threshold: float) -> np.ndarray: """Error-diffusion dither: returns a boolean dot map preserving gradients - that a hard threshold would flatten.""" - arr = ink.astype(np.float32).copy() - h, w = arr.shape + that a hard threshold would flatten. + + Error diffusion is inherently serial per pixel, so the inner loop runs + on plain Python lists — roughly an order of magnitude faster than + per-element NumPy indexing at large sizes. + """ + h, w = ink.shape + rows = ink.astype(np.float64).tolist() out = np.zeros((h, w), dtype=bool) + last = w - 1 for y in range(h): - row = arr[y] - below = arr[y + 1] if y + 1 < h else None + row = rows[y] + below = rows[y + 1] if y + 1 < h else None + bits = bytearray(w) for x in range(w): old = row[x] - new = old >= threshold - out[y, x] = new - err = old - (1.0 if new else 0.0) - if x + 1 < w: - row[x + 1] += err * (7 / 16) + if old >= threshold: + bits[x] = 1 + err = old - 1.0 + else: + err = old + if x < last: + row[x + 1] += err * 0.4375 # 7/16 if below is not None: - if x > 0: - below[x - 1] += err * (3 / 16) - below[x] += err * (5 / 16) - if x + 1 < w: - below[x + 1] += err * (1 / 16) + if x: + below[x - 1] += err * 0.1875 # 3/16 + below[x] += err * 0.3125 # 5/16 + if x < last: + below[x + 1] += err * 0.0625 # 1/16 + out[y] = np.frombuffer(bytes(bits), dtype=np.uint8).astype(bool) return out diff --git a/tests/test_matrix_color.py b/tests/test_matrix_color.py index adb473f..ec36789 100644 --- a/tests/test_matrix_color.py +++ b/tests/test_matrix_color.py @@ -94,4 +94,7 @@ def test_animation_default_tint_is_green(): m = MatrixOptions(enabled=True, seed=5) anim = generate(ASCII, _img(), m=m, a=AnimationOptions(frames=2)) html = anim.to_html() - assert ".h { color: rgb(216,255,216); }" in html + # Head classes brightness-track the drop; full-intensity green head is + # (0,255,0) blended 80% toward its own intensity -> (204,255,204). + assert ".h15 { color: rgb(204,255,204); }" in html + assert ".h0 { color: rgb(0,0,0); }" in html