Created
January 20, 2025 03:21
-
-
Save osoleve/193c39023de810dec9de3124c0c2204b to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import cv2 | |
import numpy as np | |
import itertools as it | |
import tkinter as tk | |
from tkinter import ttk, font as tkfont | |
from typing import List, Dict, Any | |
import functools as ft | |
import time | |
def image_to_ascii(img: np.ndarray, width: int, height: int, ramp: str) -> str: | |
"""Convert an image to ASCII art.""" | |
img = cv2.resize(img, (width, height)) | |
if len(img.shape) == 3: | |
img = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) | |
normalized = img / 255.0 | |
indices = (normalized * (len(ramp) - 1)).astype(int) | |
ascii_lines = [''.join(ramp[idx] for idx in row) for row in indices] | |
return '\n'.join(ascii_lines) | |
class ASCIICamera: | |
def __init__(self, title="ASCII Camera"): | |
self.root = tk.Tk() | |
self.root.title(title) | |
self.root.geometry("1920x1080") | |
# Main layout frames | |
self.control_frame = ttk.Frame(self.root) | |
self.control_frame.pack(side='left', fill='y', padx=5, pady=5) | |
self.display_frame = ttk.Frame(self.root) | |
self.display_frame.pack(side='right', expand=True, fill='both', padx=5, pady=5) | |
# Define control variables | |
self.camera_var = tk.IntVar(value=0) | |
self.aspect_var = tk.DoubleVar(value=3.0) | |
self.height_var = tk.IntVar(value=60) | |
self.fps_var = tk.IntVar(value=30) | |
self.font_size_var = tk.IntVar(value=10) | |
self.ramp_var = tk.StringVar(value='█▓▒░ ') | |
self.invert_ramp_var = tk.BooleanVar(value=True) | |
self.mask_replace_char_var = tk.StringVar(value='*') | |
self.mask_text_var = tk.StringVar(value='Hello, World!') | |
# Define control configurations | |
self.controls: List[Dict[str, Any]] = [ | |
{ | |
"label": "Camera", | |
"var": self.camera_var, | |
"type": "combobox", | |
"values": self.get_available_cameras(), | |
"trace": self.on_camera_change | |
}, | |
{ | |
"label": "Aspect Ratio", | |
"var": self.aspect_var, | |
"type": "scale", | |
"min": 1.0, | |
"max": 3.0, | |
"step": 0.1, | |
"format": lambda x: f"{float(x):.1f}" | |
}, | |
{ | |
"label": "Height", | |
"var": self.height_var, | |
"type": "scale", | |
"min": 20, | |
"max": 200, | |
"step": 2, | |
"format": lambda x: str(int(x)) | |
}, | |
{ | |
"label": "FPS", | |
"var": self.fps_var, | |
"type": "scale", | |
"min": 1, | |
"max": 60, | |
"step": 1, | |
"format": lambda x: str(int(x)) | |
}, | |
{ | |
"label": "Font Size", | |
"var": self.font_size_var, | |
"type": "scale", | |
"min": 6, | |
"max": 24, | |
"step": 1, | |
"format": lambda x: str(int(x)), | |
"trace": self.update_font | |
}, | |
{ | |
"label": "Characters", | |
"var": self.ramp_var, | |
"type": "entry", | |
"width": 20 | |
}, | |
{ | |
"label": "Invert Ramp", | |
"var": self.invert_ramp_var, | |
"type": "checkbutton", | |
"values": ["False", "True"] | |
}, | |
{ | |
"label": "Mask Replace Char", | |
"var": self.mask_replace_char_var, | |
"type": "entry", | |
"width": 1 | |
}, | |
{ | |
"label": "Mask Text", | |
"var": self.mask_text_var, | |
"type": "entry", | |
"width": 20 | |
} | |
] | |
# Create controls | |
self.create_controls() | |
# Setup display | |
self.font = tkfont.Font(family="Courier", size=self.controls[4]["var"].get()) | |
self.text = tk.Text(self.display_frame, font=self.font, bg='black', fg='white') | |
self.text.pack(expand=True, fill='both') | |
# Camera setup | |
self.cap = None | |
self.setup_camera() | |
# Running flag | |
self.running = True | |
self.root.protocol("WM_DELETE_WINDOW", self.quit) | |
def get_available_cameras(self) -> List[int]: | |
"""Get a list of available camera indices.""" | |
available_cameras = [] | |
for i in range(5): # Check first 5 camera indices | |
cap = cv2.VideoCapture(i) | |
if cap.isOpened(): | |
available_cameras.append(i) | |
cap.release() | |
return available_cameras if available_cameras else [0] | |
@ft.cache | |
def wrap_ascii_art(self, pattern: str, width, timestep) -> str: | |
"""Repeat a string with newlines horizontally to fit the screen.""" | |
lines = pattern.split('\n') | |
max_width = max(len(line) for line in lines) | |
lines = [line.ljust(max_width) for line in lines] | |
repeat_count = (width // max_width) + 1 | |
wrapped_lines = [(line * repeat_count).ljust(width)[:width] for line in lines] | |
timestep = int(time.time() * 10) % len(wrapped_lines[0]) | |
wrapped_lines = [line[-timestep:] + line[:-timestep] for line in wrapped_lines] | |
wrapped_lines = [line.center(width) for line in wrapped_lines] | |
return '\n'.join(wrapped_lines) | |
def create_text_mask(self, text: str) -> np.ndarray: | |
"""Repeat a string to fill the ASCII art.""" | |
text = self.wrap_ascii_art(r""" | |
_ ___ ___ ___ ___ ___ ___ ___ ___ ___ ___ | |
_| |_ |_| _| |_ |_| _| |_ |_| _| |_ |_| _| |_ |_| _| | |
| _ | _ |_ _| _ | _ | _ |_ _| _ | _ | _ |_ | |
|_| |_| | |___| |___| | |_| |_| | |___| |___| | |_| |_| | |___| | |
_ _ | ___ ___ | _ _ |_ _____ _| _ _ | ___ | |
| |_| | |_| _| |_ |_| | |_| | _| |_ _| |_ | |_| | |_| _| | |
|_ _| _ |_ _| _ |_ _| | _ | | _ | |_ _| _ |_ | |
_| |___| |___| |___| |___| |_ |_| |_| |_| |_| _| |___| |___| | |
| ___ ___ _ ___ ___ | _ _ _ _ |_ ___ ___ | |
|_| _| |_ |_| |_| _| |_ |_| | |_| | | |_| | _| |_ |_| _| | |
_ |_ _| _ _ |_ _| _ |_ _| |_ _| | _ | _ |_ | |
| |___| |___| | | |___| |___| | _| |_____| |_ |_| |_| | |___| | |
|_ _____ _| |_ _____ _| | ___ ___ | _ _ | ___ | |
_| |_ _| |_ _| |_ _| |_ |_| _| |_ |_| | |_| | |_| _| | |
| _ | | _ | | _ | | _ | _ |_ _| _ |_ _| _ |_ | |
|_| |_| |_| |_| |_| |_| |_| |_| | |___| |___| |___| |___| |___| | |
_ _ _ _ _ _ _ _ | ___ ___ ___ ___ ___ | |
| |_| | | |_| | | |_| | | |_| | |_| _| |_ |_| _| |_ |_| _| | |
|_ _| |_ _| |_ _| |_ _| _ |_ _| _ | _ | _ |_ | |
_| |_____| |_ _| |_____| |_ | |___| |___| | |_| |_| | |___| | |
| ___ ___ | | ___ ___ | |_ _____ _| _ _ | ___ | |
|_| _| |_ |_| |_| _| |_ |_| _| |_ _| |_ | |_| | |_| _| | |
_ |_ _| _ _ |_ _| _ | _ | | _ | |_ _| _ |_ | |
| |___| |___| |_| |___| |___| | |_| |_| |_| |_| _| |___| |___| | |
|_ ___ ___ ___ ___ _| _ _ _ _ |_ ___ ___ | |
_| |_ |_| _| |_ |_| _| |_ | |_| | | |_| | _| |_ |_| _| | |
| _ | _ |_ _| _ | _ | |_ _| |_ _| | _ | _ |_ | |
|_| |_| | |___| |___| | |_| |_| _| |_____| |_ |_| |_| | |___| | |
_ _ | ___ ___ | _ _ | ___ ___ | _ _ | ___ | |
| |_| | |_| _| |_ |_| | |_| | |_| _| |_ |_| | |_| | |_| _| | |
|_ _| _ |_ _| _ |_ _| _ |_ _| _ |_ _| _ |_ | |
_| |___| |___| |___| |___| |___| |___| |___| |___| |___| |___| | |
""", int(self.height_var.get() * self.aspect_var.get()), int(time.time() * 10) % self.height_var.get() * int(self.aspect_var.get())) | |
mask = np.array([ord(c) for c in text]) | |
mask = np.tile(mask, int(self.height_var.get() * self.aspect_var.get())) | |
mask = mask.reshape(self.height_var.get(), -1) | |
return mask | |
def create_controls(self): | |
"""Create all control widgets with appropriate types and layouts.""" | |
for control in self.controls: | |
frame = ttk.Frame(self.control_frame) | |
frame.pack(fill='x', padx=5, pady=2) | |
ttk.Label(frame, text=control["label"]).pack(anchor='w') | |
if control["type"] == "scale": | |
scale = ttk.Scale( | |
frame, | |
from_=control["min"], | |
to=control["max"], | |
variable=control["var"], | |
orient='horizontal' | |
) | |
scale.pack(fill='x') | |
# Add formatted value label | |
value_label = ttk.Label(frame, text="") | |
value_label.pack(anchor='e') | |
def update_label(var, value_label=value_label, format_func=control["format"]): | |
value_label.config(text=format_func(var.get())) | |
control["var"].trace_add('write', | |
lambda *args, v=control["var"], l=value_label, f=control["format"]: | |
update_label(v, l, f)) | |
update_label(control["var"]) # Initial update | |
elif control["type"] == "combobox": | |
combo = ttk.Combobox( | |
frame, | |
values=control["values"], | |
state='readonly', | |
width=5 | |
) | |
combo.set(control["var"].get()) | |
combo.pack(fill='x') | |
combo.bind('<<ComboboxSelected>>', | |
lambda e: control["var"].set(int(combo.get()))) | |
elif control["type"] == "entry": | |
entry = ttk.Entry( | |
frame, | |
textvariable=control["var"], | |
width=control.get("width", 20) | |
) | |
entry.pack(fill='x') | |
elif control["type"] == "checkbutton": | |
check = ttk.Checkbutton( | |
frame, | |
text=control["label"], | |
variable=control["var"] | |
) | |
check.pack(anchor='w') | |
# Add trace if specified | |
if "trace" in control: | |
control["var"].trace_add('write', control["trace"]) | |
def setup_camera(self): | |
"""Initialize or switch camera.""" | |
if self.cap is not None: | |
self.cap.release() | |
self.cap = cv2.VideoCapture(self.camera_var.get()) | |
if not self.cap.isOpened(): | |
self.text.delete('1.0', tk.END) | |
self.text.insert('1.0', f"Error: Could not open camera {self.camera_var.get()}") | |
def on_camera_change(self, *args): | |
"""Handle camera device changes.""" | |
self.setup_camera() | |
def update_font(self, *args): | |
"""Update the display font size.""" | |
self.font.configure(size=self.font_size_var.get()) | |
def update(self): | |
"""Update the ASCII display.""" | |
if not self.cap or not self.running: | |
return | |
ret, frame = self.cap.read() | |
if not ret: | |
self.text.delete('1.0', tk.END) | |
self.text.insert('1.0', "Error: Could not read from camera") | |
return | |
# Get current values from controls | |
height = self.controls[2]["var"].get() # Height | |
width = int(self.controls[1]["var"].get() * height) # Aspect Ratio * Height | |
ramp = self.controls[5]["var"].get() # Characters | |
if not ramp: # Fallback if ramp is empty | |
ramp = '█▓▒░ ' | |
if self.controls[6]["var"].get(): # Invert Ramp | |
ramp = ramp[::-1] | |
# Convert to ASCII and display | |
ascii_art = image_to_ascii(frame, width, height, ramp) | |
mask_text = self.controls[8]["var"].get() | |
if mask_text: | |
mask_text = self.create_text_mask(mask_text) | |
mask_text = mask_text.flatten() | |
mask_text = ''.join(chr(c) for c in mask_text) | |
# If the mask_replace_char is not a space, then replace any instances | |
# of the mask_replace_char in the ASCII art with the mask_text in that position | |
mask_replace_char = self.controls[7]["var"].get() | |
if mask_replace_char and mask_replace_char != ' ': | |
ascii_art = ''.join( | |
mask_text[i % len(mask_text)] if char == mask_replace_char else char | |
for i, char in enumerate(ascii_art) | |
) | |
self.text.delete('1.0', tk.END) | |
self.text.insert('1.0', ascii_art) | |
# Schedule next update based on FPS | |
delay = int(1000 / self.controls[3]["var"].get()) # Convert to milliseconds | |
if self.running: | |
self.root.after(delay, self.update) | |
def quit(self): | |
self.running = False | |
if self.cap: | |
self.cap.release() | |
self.root.destroy() | |
def run(self): | |
self.update() | |
self.root.mainloop() | |
def main(): | |
app = ASCIICamera() | |
app.run() | |
if __name__ == "__main__": | |
main() |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment