Skip to content

Instantly share code, notes, and snippets.

@osoleve
Created January 20, 2025 03:21
Show Gist options
  • Save osoleve/193c39023de810dec9de3124c0c2204b to your computer and use it in GitHub Desktop.
Save osoleve/193c39023de810dec9de3124c0c2204b to your computer and use it in GitHub Desktop.
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