ASCILINE/ascii_video_player2.py

302 lines
12 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""
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()