mirror of
https://github.com/YusufB5/ASCILINE.git
synced 2026-06-17 22:35:13 +02:00
302 lines
12 KiB
Python
302 lines
12 KiB
Python
"""
|
||
ascii_video_player.py
|
||
=====================
|
||
Modüler, renkli (True Color / 24-bit ANSI), sıfır titremeli ASCII video oynatıcı.
|
||
|
||
- VideoDecoder : Video → (gray, color) kare çifti üretir.
|
||
- AsciiMapper : Gri matris → ASCII karakter + ANSI True Color kodu → String.
|
||
- TerminalRenderer: Ana döngü, FPS kontrolü, yön tespiti, render.
|
||
|
||
Bağımlılıklar:
|
||
pip install opencv-python numpy
|
||
"""
|
||
|
||
import sys
|
||
import time
|
||
import shutil
|
||
import numpy as np
|
||
import cv2
|
||
import os
|
||
|
||
# PowerShell/CMD (Windows) üzerinde ANSI renk kodlarını aktif etmek için:
|
||
os.system("")
|
||
|
||
|
||
# ─────────────────────────────────────────────
|
||
# MODÜL 1 ─ VideoDecoder
|
||
# ─────────────────────────────────────────────
|
||
class VideoDecoder:
|
||
"""
|
||
Video dosyasını açar ve her kare için (gray, bgr) çifti üretir.
|
||
|
||
Renkli render için hem gri (karakter seçimi) hem de
|
||
orijinal BGR (renk örnekleme) matrisine ihtiyaç var.
|
||
İkisi de aynı resize işleminden geçer → boyut tutarlılığı garantili.
|
||
"""
|
||
|
||
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 = küçültülmüş BGR karesi
|
||
|
||
def release(self):
|
||
self._cap.release()
|
||
|
||
def __del__(self):
|
||
self.release()
|
||
|
||
|
||
# ─────────────────────────────────────────────
|
||
# MODÜL 2 ─ AsciiMapper
|
||
# ─────────────────────────────────────────────
|
||
class AsciiMapper:
|
||
"""
|
||
Gri + BGR matrisini ANSI True Color kodlarıyla renklendirilmiş
|
||
ASCII çerçeve dizisine dönüştürür.
|
||
|
||
── True Color ANSI Formatı ─────────────────────────────────────────────
|
||
\033[38;2;R;G;Bm{karakter}\033[0m
|
||
└─ ön plan rengi ─────────┘
|
||
|
||
── Renk Kuantizasyonu (Performans Optimizasyonu) ───────────────────────
|
||
Her piksel için ayrı bir escape kodu üretmek yerine renk değerleri
|
||
6-bit'e indirilir (>> 2 << 2, 64 seviye/kanal).
|
||
Bu sayede ardışık aynı renkli pikseller tek bir escape koduyla
|
||
temsil edilir → string boyutu ve stdout.write yükü azalır.
|
||
Gözle algılanabilir renk kaybı olmaz (16M → ~262K renk).
|
||
|
||
── RLE (Run-Length Encoding) ───────────────────────────────────────────
|
||
Aynı renkteki ardışık karakterler için escape kodu tekrar yazılmaz;
|
||
yalnızca renk değiştiğinde yeni kod eklenir.
|
||
Tipik bir karede %40-60 oranında string küçülmesi sağlar.
|
||
"""
|
||
|
||
DEFAULT_PALETTE = list(
|
||
" `.-':_,^=;><+!rc*/z?sLTv)J7(|Fi{C}fI31tlu[neoZ5Yxjya]2ESwqkP6h9d4VpOGbUAKXHm8RD#$Bg0MNWQ%&@"
|
||
)
|
||
|
||
# ANSI sıfırlama + satır başı
|
||
_RESET = "\033[0m"
|
||
|
||
def __init__(self, palette: list[str] | None = None, quantize_bits: int = 0) -> None:
|
||
"""
|
||
:param palette: Karakter listesi (None → 93 seviyeli varsayılan)
|
||
:param quantize_bits: Renk kuantizasyonu için sağdan kaydırma miktarı.
|
||
2 → 64 seviye/kanal (hızlı),
|
||
0 → tam 8-bit (en yüksek kalite, varsayılan).
|
||
"""
|
||
p = palette or self.DEFAULT_PALETTE
|
||
self._n = len(p)
|
||
self._lut = np.array(p, dtype='U1')
|
||
self._qb = quantize_bits # kuantizasyon bit kaydırma miktarı
|
||
|
||
def convert(self, gray: np.ndarray, bgr: np.ndarray) -> str:
|
||
"""
|
||
Her piksel için:
|
||
1. Gri değeri → ASCII karakter (yoğunluk LUT)
|
||
2. BGR rengi → ANSI True Color escape kodu (kuantize + RLE)
|
||
|
||
:param gray: shape=(H,W) uint8 gri matris
|
||
:param bgr: shape=(H,W,3) uint8 BGR renk matrisi
|
||
:return: Terminale doğrudan yazılabilecek renkli ASCII dizesi
|
||
"""
|
||
H, W = gray.shape
|
||
|
||
# ── Adım 1: Piksel yoğunluğu → karakter indeksi ──────────────────
|
||
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'
|
||
|
||
# ── Adım 2: Renk kuantizasyonu ────────────────────────────────────
|
||
# BGR → RGB sıralaması (ANSI kodu R,G,B sırasında)
|
||
rgb = bgr[:, :, ::-1] # BGR → RGB view, kopyasız
|
||
|
||
if self._qb > 0:
|
||
# Düşük bitleri sıfırla → renk hassasiyetini düşür, hızı artır
|
||
qb = self._qb
|
||
rgb = (rgb >> qb) << qb # örn. qb=2: 0b11111100 maskeleme
|
||
|
||
# ── Adım 3: RLE ile renkli string birleştirme ─────────────────────
|
||
# Saf NumPy ile RLE yapılamadığından bu kısım Python döngüsüdür.
|
||
# Ancak satır başına yalnızca renk değişimlerinde escape kodu yazılır;
|
||
# tekrarlanan renkler için döngü yükü minimize edilir.
|
||
lines = []
|
||
prev_r = prev_g = prev_b = -1 # önceki renk (ilk piksel hep farklı)
|
||
|
||
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: sadece renk değişince yeni escape kodu ekle
|
||
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
|
||
|
||
|
||
# ─────────────────────────────────────────────
|
||
# MODÜL 3 ─ TerminalRenderer
|
||
# ─────────────────────────────────────────────
|
||
class TerminalRenderer:
|
||
"""
|
||
VideoDecoder → AsciiMapper → stdout akışını yönetir.
|
||
|
||
Ek özellikler (renkli sürüm):
|
||
- Başlangıçta terminal arka planını siyaha alır (\033[40m)
|
||
→ renkli karakterler daha belirgin görünür.
|
||
- Her kare sonunda \033[0m ile renk sıfırlanır
|
||
→ sonraki terminal komutları etkilenmez.
|
||
"""
|
||
|
||
_CURSOR_HOME = "\033[H"
|
||
_HIDE_CURSOR = "\033[?25l"
|
||
_SHOW_CURSOR = "\033[?25h"
|
||
_BLACK_BG = "\033[40m" # siyah arka plan — kontrast için
|
||
_RESET_ALL = "\033[0m"
|
||
_CLEAR_SCREEN = "\033[2J"
|
||
|
||
CHAR_RATIO = 0.45 # terminal karakter en-boy oranı düzeltmesi
|
||
|
||
def __init__(
|
||
self,
|
||
path : str,
|
||
palette : list[str] | None = None,
|
||
quantize_bits: int = 0,
|
||
) -> None:
|
||
"""
|
||
:param path: Video dosyası yolu
|
||
:param palette: Özel karakter paleti (None → 93 seviyeli)
|
||
:param quantize_bits: Renk kuantizasyonu (0=tam kalite, 2=hızlı)
|
||
"""
|
||
# ── Video meta bilgisi ────────────────────────────────────────────
|
||
_probe = VideoDecoder(path, 2, 2)
|
||
vid_w, vid_h = _probe.vid_w, _probe.vid_h
|
||
src_fps = _probe.fps
|
||
_probe.release()
|
||
|
||
# ── Terminal boyutları ────────────────────────────────────────────
|
||
term = shutil.get_terminal_size(fallback=(220, 50))
|
||
t_cols = term.columns
|
||
t_lines = term.lines - 2
|
||
|
||
# ── Yön tespiti & en-boy oranı korumalı boyutlandırma ─────────────
|
||
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))
|
||
|
||
# ── Bilgi ekranı ──────────────────────────────────────────────────
|
||
print(self._CLEAR_SCREEN)
|
||
print(
|
||
f"\033[1m[ASCII Player — True Color]\033[0m\n"
|
||
f" Yön : {orientation.upper()}\n"
|
||
f" Video : {vid_w}×{vid_h}\n"
|
||
f" ASCII : {cols}×{rows} karakter\n"
|
||
f" FPS : {src_fps:.1f}\n"
|
||
f" Kuantizasyon: {2**(8-quantize_bits)} seviye/kanal\n"
|
||
f" Çıkış : 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:
|
||
"""Ana oynatma döngüsü."""
|
||
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()
|
||
|
||
|
||
# ─────────────────────────────────────────────
|
||
# GİRİŞ NOKTASI
|
||
# ─────────────────────────────────────────────
|
||
if __name__ == "__main__":
|
||
import argparse
|
||
|
||
parser = argparse.ArgumentParser(
|
||
description="True Color ANSI ASCII video oynatıcı — sıfır titreme"
|
||
)
|
||
parser.add_argument("video",
|
||
help="Video dosyası yolu (MP4, AVI, MKV …)")
|
||
parser.add_argument("--palette", default=None,
|
||
help="Özel karakter paleti, boşlukla ayrılmış")
|
||
parser.add_argument("--quality", type=int, choices=[0, 1, 2, 3], default=0,
|
||
help="Renk kalitesi: 0=maksimum kalite, 3=maksimum hız (varsayılan: 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()
|