mirror of
https://github.com/YusufB5/ASCILINE.git
synced 2026-06-23 22:48:06 +02:00
302 lines
12 KiB
Python
302 lines
12 KiB
Python
"""
|
||
ascii_video_player.py
|
||
=====================
|
||
Modular, True Color (24-bit ANSI), zero-flicker ASCII video player.
|
||
|
||
- VideoDecoder : Produces (gray, color) frame pairs from video.
|
||
- AsciiMapper : Gray matrix -> ASCII character + ANSI True Color code -> String.
|
||
- TerminalRenderer: Main loop, FPS control, orientation detection, rendering.
|
||
|
||
Dependencies:
|
||
pip install opencv-python numpy
|
||
"""
|
||
|
||
import sys
|
||
import time
|
||
import shutil
|
||
import numpy as np
|
||
import cv2
|
||
import os
|
||
|
||
# Enable ANSI color codes on PowerShell/CMD (Windows):
|
||
os.system("")
|
||
|
||
|
||
# ─────────────────────────────────────────────
|
||
# MODULE 1 ─ VideoDecoder
|
||
# ─────────────────────────────────────────────
|
||
class VideoDecoder:
|
||
"""
|
||
Opens the video file and yields (gray, bgr) pair for each frame.
|
||
|
||
For color rendering, both gray (for character selection) and
|
||
original BGR (for color sampling) matrices are needed.
|
||
Both undergo the same resize operation -> size consistency guaranteed.
|
||
"""
|
||
|
||
def __init__(self, path: str, cols: int, rows: int) -> None:
|
||
self._cap = cv2.VideoCapture(path)
|
||
if not self._cap.isOpened():
|
||
raise FileNotFoundError(f"Video açılamadı: {path!r}")
|
||
|
||
self.fps : float = self._cap.get(cv2.CAP_PROP_FPS) or 24.0
|
||
self.frame_count : int = int(self._cap.get(cv2.CAP_PROP_FRAME_COUNT))
|
||
self.vid_w : int = int(self._cap.get(cv2.CAP_PROP_FRAME_WIDTH))
|
||
self.vid_h : int = int(self._cap.get(cv2.CAP_PROP_FRAME_HEIGHT))
|
||
self._size : tuple = (cols, rows)
|
||
|
||
def __iter__(self):
|
||
return self
|
||
|
||
def __next__(self) -> tuple[np.ndarray, np.ndarray]:
|
||
"""
|
||
:return: (gray[H,W] uint8, bgr[H,W,3] uint8)
|
||
"""
|
||
ok, frame = self._cap.read()
|
||
if not ok:
|
||
raise StopIteration
|
||
|
||
small = cv2.resize(frame, self._size, interpolation=cv2.INTER_LINEAR)
|
||
gray = cv2.cvtColor(small, cv2.COLOR_BGR2GRAY)
|
||
return gray, small # small = downscaled BGR frame
|
||
|
||
def release(self):
|
||
self._cap.release()
|
||
|
||
def __del__(self):
|
||
self.release()
|
||
|
||
|
||
# ─────────────────────────────────────────────
|
||
# MODULE 2 ─ AsciiMapper
|
||
# ─────────────────────────────────────────────
|
||
class AsciiMapper:
|
||
"""
|
||
Converts Gray + BGR matrix into a string of ASCII characters
|
||
colored with ANSI True Color codes.
|
||
|
||
── True Color ANSI Format ─────────────────────────────────────────────
|
||
\033[38;2;R;G;Bm{character}\033[0m
|
||
└─ foreground color ───────┘
|
||
|
||
── Color Quantization (Performance Optimization) ───────────────────────
|
||
Instead of generating a separate escape code for every pixel, color values
|
||
are downsampled to 6-bit (>> 2 << 2, 64 levels/channel).
|
||
This allows consecutive pixels with the same color to share a single escape code
|
||
-> reduces string size and stdout.write overhead.
|
||
There is no visually perceptible loss of color (16M -> ~262K colors).
|
||
|
||
── RLE (Run-Length Encoding) ───────────────────────────────────────────
|
||
The escape code is not repeated for consecutive characters of the same color;
|
||
a new code is appended only when the color changes.
|
||
This provides a 40-60% reduction in string size for a typical frame.
|
||
"""
|
||
|
||
DEFAULT_PALETTE = list(
|
||
" `.-':_,^=;><+!rc*/z?sLTv)J7(|Fi{C}fI31tlu[neoZ5Yxjya]2ESwqkP6h9d4VpOGbUAKXHm8RD#$Bg0MNWQ%&@"
|
||
)
|
||
|
||
# ANSI reset + carriage return
|
||
_RESET = "\033[0m"
|
||
|
||
def __init__(self, palette: list[str] | None = None, quantize_bits: int = 0) -> None:
|
||
"""
|
||
:param palette: Character list (None -> 93 level default)
|
||
:param quantize_bits: Right bit shift amount for color quantization.
|
||
2 -> 64 levels/channel (fast),
|
||
0 -> full 8-bit (highest quality, default).
|
||
"""
|
||
p = palette or self.DEFAULT_PALETTE
|
||
self._n = len(p)
|
||
self._lut = np.array(p, dtype='U1')
|
||
self._qb = quantize_bits # quantization bit shift amount
|
||
|
||
def convert(self, gray: np.ndarray, bgr: np.ndarray) -> str:
|
||
"""
|
||
For each pixel:
|
||
1. Gray value -> ASCII character (intensity LUT)
|
||
2. BGR color -> ANSI True Color escape code (quantized + RLE)
|
||
|
||
:param gray: shape=(H,W) uint8 gray matrix
|
||
:param bgr: shape=(H,W,3) uint8 BGR color matrix
|
||
:return: Colored ASCII string ready to be written directly to the terminal
|
||
"""
|
||
H, W = gray.shape
|
||
|
||
# ── Step 1: Pixel intensity -> character index ──────────────────
|
||
indices = np.floor_divide(gray, max(1, 256 // self._n))
|
||
np.clip(indices, 0, self._n - 1, out=indices)
|
||
char_matrix = self._lut[indices] # shape=(H,W), dtype='U1'
|
||
|
||
# ── Step 2: Color quantization ────────────────────────────────────
|
||
# BGR -> RGB order (ANSI code is in R,G,B order)
|
||
rgb = bgr[:, :, ::-1] # BGR -> RGB view, no copy
|
||
|
||
if self._qb > 0:
|
||
# Zero out the lower bits -> reduce color precision, increase speed
|
||
qb = self._qb
|
||
rgb = (rgb >> qb) << qb # e.g., qb=2: 0b11111100 masking
|
||
|
||
# ── Step 3: RLE and colored string construction ─────────────────────
|
||
# Since RLE cannot be done with pure NumPy, this part uses a Python loop.
|
||
# However, the escape code is only written when the color changes per row;
|
||
# loop overhead is minimized for repeated colors.
|
||
lines = []
|
||
prev_r = prev_g = prev_b = -1 # previous color (first pixel is always different)
|
||
|
||
for row_idx in range(H):
|
||
row_chars = char_matrix[row_idx] # shape=(W,) char array
|
||
row_colors = rgb[row_idx] # shape=(W,3) uint8 array
|
||
buf = []
|
||
|
||
for col_idx in range(W):
|
||
r, g, b = int(row_colors[col_idx, 0]), \
|
||
int(row_colors[col_idx, 1]), \
|
||
int(row_colors[col_idx, 2])
|
||
|
||
# RLE: only add a new escape code if the color changes
|
||
if r != prev_r or g != prev_g or b != prev_b:
|
||
buf.append(f"\033[38;2;{r};{g};{b}m")
|
||
prev_r, prev_g, prev_b = r, g, b
|
||
|
||
buf.append(row_chars[col_idx])
|
||
|
||
lines.append("".join(buf))
|
||
|
||
return self._RESET + "\n".join(lines) + self._RESET
|
||
|
||
|
||
# ─────────────────────────────────────────────
|
||
# MODULE 3 ─ TerminalRenderer
|
||
# ─────────────────────────────────────────────
|
||
class TerminalRenderer:
|
||
"""
|
||
Manages the flow: VideoDecoder -> AsciiMapper -> stdout.
|
||
|
||
Additional features (colored version):
|
||
- Sets terminal background to black initially (\033[40m)
|
||
-> colored characters appear more prominent.
|
||
- Resets color with \033[0m at the end of each frame
|
||
-> prevents affecting subsequent terminal commands.
|
||
"""
|
||
|
||
_CURSOR_HOME = "\033[H"
|
||
_HIDE_CURSOR = "\033[?25l"
|
||
_SHOW_CURSOR = "\033[?25h"
|
||
_BLACK_BG = "\033[40m" # black background — for contrast
|
||
_RESET_ALL = "\033[0m"
|
||
_CLEAR_SCREEN = "\033[2J"
|
||
|
||
CHAR_RATIO = 0.45 # terminal character aspect ratio correction
|
||
|
||
def __init__(
|
||
self,
|
||
path : str,
|
||
palette : list[str] | None = None,
|
||
quantize_bits: int = 0,
|
||
) -> None:
|
||
"""
|
||
:param path: Path to video file
|
||
:param palette: Custom character palette (None -> 93 levels)
|
||
:param quantize_bits: Color quantization (0=full quality, 2=fast)
|
||
"""
|
||
# ── Video metadata ────────────────────────────────────────────
|
||
_probe = VideoDecoder(path, 2, 2)
|
||
vid_w, vid_h = _probe.vid_w, _probe.vid_h
|
||
src_fps = _probe.fps
|
||
_probe.release()
|
||
|
||
# ── Terminal dimensions ────────────────────────────────────────────
|
||
term = shutil.get_terminal_size(fallback=(220, 50))
|
||
t_cols = term.columns
|
||
t_lines = term.lines - 2
|
||
|
||
# ── Orientation detection & aspect-ratio-preserving resizing ─────────────
|
||
orientation = "portrait" if vid_h > vid_w else "landscape"
|
||
aspect = vid_h / vid_w
|
||
|
||
if orientation == "landscape":
|
||
cols = t_cols
|
||
rows = max(1, int(cols * aspect * self.CHAR_RATIO))
|
||
if rows > t_lines:
|
||
rows = t_lines
|
||
cols = max(1, int(rows / (aspect * self.CHAR_RATIO)))
|
||
else:
|
||
rows = t_lines
|
||
cols = max(1, int(rows / (aspect * self.CHAR_RATIO)))
|
||
if cols > t_cols:
|
||
cols = t_cols
|
||
rows = max(1, int(cols * aspect * self.CHAR_RATIO))
|
||
|
||
# ── Info screen ──────────────────────────────────────────────────
|
||
print(self._CLEAR_SCREEN)
|
||
print(
|
||
f"\033[1m[ASCII Player — True Color]\033[0m\n"
|
||
f" Orientation : {orientation.upper()}\n"
|
||
f" Video : {vid_w}x{vid_h}\n"
|
||
f" ASCII : {cols}x{rows} characters\n"
|
||
f" FPS : {src_fps:.1f}\n"
|
||
f" Quantization: {2**(8-quantize_bits)} levels/channel\n"
|
||
f" Exit : Ctrl+C\n"
|
||
)
|
||
time.sleep(2.0)
|
||
|
||
self._decoder = VideoDecoder(path, cols, rows)
|
||
self._mapper = AsciiMapper(palette, quantize_bits)
|
||
self._fps = self._decoder.fps
|
||
self._frame_t = 1.0 / self._fps
|
||
|
||
def play(self) -> None:
|
||
"""Main playback loop."""
|
||
stdout = sys.stdout
|
||
|
||
stdout.write(self._HIDE_CURSOR + self._BLACK_BG)
|
||
stdout.flush()
|
||
|
||
try:
|
||
for gray_frame, bgr_frame in self._decoder:
|
||
t0 = time.perf_counter()
|
||
|
||
ascii_frame = self._mapper.convert(gray_frame, bgr_frame)
|
||
|
||
stdout.write(self._CURSOR_HOME + ascii_frame)
|
||
stdout.flush()
|
||
|
||
wait = self._frame_t - (time.perf_counter() - t0)
|
||
if wait > 0:
|
||
time.sleep(wait)
|
||
|
||
except KeyboardInterrupt:
|
||
pass
|
||
|
||
finally:
|
||
stdout.write(self._SHOW_CURSOR + self._RESET_ALL + "\n")
|
||
stdout.flush()
|
||
self._decoder.release()
|
||
|
||
|
||
# ─────────────────────────────────────────────
|
||
# ENTRY POINT
|
||
# ─────────────────────────────────────────────
|
||
if __name__ == "__main__":
|
||
import argparse
|
||
|
||
parser = argparse.ArgumentParser(
|
||
description="True Color ANSI ASCII video player — zero flicker"
|
||
)
|
||
parser.add_argument("video",
|
||
help="Path to video file (MP4, AVI, MKV ...)")
|
||
parser.add_argument("--palette", default=None,
|
||
help="Custom character palette, space-separated")
|
||
parser.add_argument("--quality", type=int, choices=[0, 1, 2, 3], default=0,
|
||
help="Color quality: 0=max quality, 3=max speed (default: 0)")
|
||
args = parser.parse_args()
|
||
|
||
custom_palette = args.palette.split() if args.palette else None
|
||
|
||
renderer = TerminalRenderer(
|
||
path = args.video,
|
||
palette = custom_palette,
|
||
quantize_bits = args.quality,
|
||
)
|
||
renderer.play()
|