#!/usr/bin/env python3 # # ASCII Photobooth live preview - curses TUI front end. # # Grabs frames directly via OpenCV/v4l2 (no fswebcam process-spawn per # frame) and still shells out to the ascii-image-converter binary for the # actual conversion, and to txt2printr.sh to print a capture. Reads the # same webcam.conf/ascii.conf/keybinds.conf used by the bash scripts. import curses import os import re import subprocess import sys import time from datetime import datetime try: import cv2 except ImportError: sys.exit( "photobooth.py requires OpenCV's Python bindings " "(pacman: python-opencv). Install it and retry." ) SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__)) def load_conf(name): cfg = {} path = os.path.join(SCRIPT_DIR, name) if not os.path.isfile(path): return cfg with open(path) as f: for line in f: line = line.strip() if not line or line.startswith("#") or "=" not in line: continue key, _, value = line.partition("=") key, value = key.strip(), value.strip() if len(value) >= 2 and value[0] == value[-1] and value[0] in "\"'": value = value[1:-1] cfg[key] = value return cfg def as_bool(value, default=False): if not value: return default return value.strip().lower() in ("1", "true", "yes", "on") class Config: def __init__(self): webcam = load_conf("webcam.conf") ascii_ = load_conf("ascii.conf") keys = load_conf("keybinds.conf") printer = load_conf("printer.conf") self.device = webcam.get("DEVICE", "/dev/video0") w, _, h = webcam.get("RESOLUTION", "640x480").partition("x") self.cap_width = int(w or 640) self.cap_height = int(h or 480) self.jpeg_quality = int(webcam.get("JPEG_QUALITY", "85")) self.default_brightness = webcam.get("DEFAULT_BRIGHTNESS", "auto") self.default_exposure = webcam.get("DEFAULT_EXPOSURE", "auto") self.step = int(webcam.get("STEP", "5")) # v4l2-ctl control IDs (snake_case) - a different naming convention # than BRIGHTNESS_CONTROL/EXPOSURE_CONTROL, which hold fswebcam's # human-readable names for the bash scripts. self.brightness_ctrl = webcam.get("BRIGHTNESS_CTRL_ID", "brightness") self.exposure_ctrl = webcam.get("EXPOSURE_CTRL_ID", "exposure_absolute") self.width = ascii_.get("WIDTH", "").strip() self.height = ascii_.get("HEIGHT", "").strip() self.complex = as_bool(ascii_.get("COMPLEX")) self.char_map = ascii_.get("CHAR_MAP", "").strip() self.grayscale = as_bool(ascii_.get("GRAYSCALE")) self.negative = as_bool(ascii_.get("NEGATIVE")) self.flip_x = as_bool(ascii_.get("FLIP_X")) self.flip_y = as_bool(ascii_.get("FLIP_Y")) self.printer_a4 = as_bool(printer.get("PRINTER_A4")) self.printer_max_lines = int(printer.get("PRINTER_MAX_LINES", "64") or 64) self.printer_max_cols = int(printer.get("PRINTER_MAX_COLS", "54") or 54) self.key_capture = keys.get("KEY_CAPTURE", " ") self.key_brightness_up = keys.get("KEY_BRIGHTNESS_UP", "+") self.key_brightness_down = keys.get("KEY_BRIGHTNESS_DOWN", "-") self.key_exposure_up = keys.get("KEY_EXPOSURE_UP", ".") self.key_exposure_down = keys.get("KEY_EXPOSURE_DOWN", ",") self.key_reset = keys.get("KEY_RESET", "r") self.key_quit = keys.get("KEY_QUIT", "q") def converter_flags(self): flags = [] if self.char_map: flags += ["--map", self.char_map] elif self.complex: flags.append("--complex") if self.grayscale: flags.append("--grayscale") if self.negative: flags.append("--negative") if self.flip_x: flags.append("--flipX") if self.flip_y: flags.append("--flipY") return flags def convert_to_ascii(jpg_bytes, width, height, flags): # height=None means "roll printer": only the width is fixed, so let the # converter derive height from the image's own aspect ratio instead of # forcing both dimensions and distorting or cropping the art. dim_flags = ["-W", str(width)] if height is None else ["-d", f"{width},{height}"] proc = subprocess.run( ["ascii-image-converter", "-", *dim_flags, *flags], input=jpg_bytes, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, ) return proc.stdout.decode("utf-8", "replace").splitlines() _ctrl_ranges = {} def ctrl_range(device, name): if name in _ctrl_ranges: return _ctrl_ranges[name] try: out = subprocess.run( ["v4l2-ctl", "-d", device, "--list-ctrls"], capture_output=True, text=True, timeout=2, ).stdout except (subprocess.SubprocessError, FileNotFoundError): out = "" rng = None for line in out.splitlines(): line = line.strip() if line.startswith(name + " ") or line.startswith(name + "\t"): m = re.search(r"min=(-?\d+) max=(-?\d+)", line) if m: rng = (int(m.group(1)), int(m.group(2))) break _ctrl_ranges[name] = rng return rng def apply_ctrl_pct(device, name, pct): rng = ctrl_range(device, name) if rng is None: return lo, hi = rng val = round(lo + (hi - lo) * pct / 100) subprocess.run( ["v4l2-ctl", "-d", device, "--set-ctrl", f"{name}={val}"], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, ) def clamp_pct(value): return max(0, min(100, value)) def adjust(current, delta): base = 50 if current == "auto" else int(current) return clamp_pct(base + delta) def matches(key, bound): return bool(key) and bool(bound) and key.lower() == bound.lower() def capture_label(cfg): return "space" if cfg.key_capture == " " else cfg.key_capture def box_dims_for(term_rows, term_cols): return max(term_rows - 2, 3), max(term_cols, 10) _CHAR_ASPECT = 0.5 # width:height ratio of a typical monospace glyph cell def visual_aspect(cols, rows): """Physical width:height ratio an ascii grid of cols x rows renders as.""" return (cols / rows) * _CHAR_ASPECT if rows else 1.0 def crop_to_aspect(frame, aspect): """Center-crop a (h, w, ...) frame to the given width:height ratio.""" h, w = frame.shape[:2] if w / h > aspect: new_w = max(1, round(h * aspect)) x0 = (w - new_w) // 2 return frame[:, x0:x0 + new_w] new_h = max(1, round(w / aspect)) y0 = (h - new_h) // 2 return frame[y0:y0 + new_h, :] def prepare_print_frame(frame, cfg, inner_cols, inner_rows): """Crop (and rotate, for A4) a raw frame ahead of the print conversion. Order matters for intelligibility: crop to roughly the right aspect ratio *before* rotating, then rotate the already-correctly-shaped image, and only then hand it to ascii-image-converter - rather than converting first and mangling already-rendered ascii text, which doesn't account for non-square character cells and reads as visibly distorted. """ if cfg.printer_max_lines == 0: # Roll printer: paper feeds continuously, so there's no page height # to crop to - only the carriage width (PRINTER_MAX_COLS) limits the # print. Leave the frame uncropped and pass rows=None downstream so # ascii-image-converter derives height from the image's own aspect # ratio instead of forcing a row count. cols, rows = cfg.printer_max_cols, None if cfg.printer_a4: frame = cv2.rotate(frame, cv2.ROTATE_90_CLOCKWISE) elif cfg.printer_a4: cols, rows = cfg.printer_max_cols, cfg.printer_max_lines # Cropping happens before the 90-degree rotation below, which swaps # width and height, so crop to the inverse of the final aspect ratio. frame = crop_to_aspect(frame, 1 / visual_aspect(cols, rows)) frame = cv2.rotate(frame, cv2.ROTATE_90_CLOCKWISE) else: cols, rows = inner_cols, inner_rows frame = crop_to_aspect(frame, visual_aspect(cols, rows)) return frame, cols, rows def main(stdscr): cfg = Config() conv_flags = cfg.converter_flags() curses.curs_set(0) stdscr.nodelay(True) cap = cv2.VideoCapture(cfg.device, cv2.CAP_V4L2) cap.set(cv2.CAP_PROP_FRAME_WIDTH, cfg.cap_width) cap.set(cv2.CAP_PROP_FRAME_HEIGHT, cfg.cap_height) if not cap.isOpened(): raise SystemExit(f"Could not open camera device {cfg.device}") brightness, exposure = cfg.default_brightness, cfg.default_exposure if brightness != "auto": apply_ctrl_pct(cfg.device, cfg.brightness_ctrl, int(brightness)) if exposure != "auto": apply_ctrl_pct(cfg.device, cfg.exposure_ctrl, int(exposure)) frame_times = [] message, message_until = "", 0.0 rows, cols = stdscr.getmaxyx() box_dims = box_dims_for(rows, cols) box_win = curses.newwin(box_dims[0], box_dims[1], 0, 0) try: while True: ok, frame = cap.read() if not ok: continue now = time.time() frame_times.append(now) frame_times = [t for t in frame_times if now - t < 1.0] fps = len(frame_times) rows, cols = stdscr.getmaxyx() box_rows, box_cols = box_dims_for(rows, cols) inner_rows, inner_cols = max(box_rows - 2, 1), max(box_cols - 2, 1) if (box_rows, box_cols) != box_dims: box_win = curses.newwin(box_rows, box_cols, 0, 0) box_dims = (box_rows, box_cols) stdscr.erase() # Live preview: always upright, cropped to roughly the box's own # aspect ratio - independent of PRINTER_A4, whose rotated/resized # capture is done separately at capture time (see below). preview_frame = crop_to_aspect(frame, visual_aspect(inner_cols, inner_rows)) _, buf = cv2.imencode( ".jpg", preview_frame, [cv2.IMWRITE_JPEG_QUALITY, cfg.jpeg_quality] ) width = cfg.width or str(inner_cols) height = cfg.height or str(inner_rows) ascii_lines = convert_to_ascii(buf.tobytes(), width, height, conv_flags) box_win.erase() box_win.box() for i, line in enumerate(ascii_lines[:inner_rows]): try: box_win.addstr(1 + i, 1, line[:inner_cols]) except curses.error: pass box_win.overwrite(stdscr) if now < message_until: status = f" {message} " else: status = f" brightness: {brightness} exposure: {exposure} fps: {fps} " help_line = ( f" [{cfg.key_brightness_up}/{cfg.key_brightness_down}] brightness " f"[{cfg.key_exposure_up}/{cfg.key_exposure_down}] exposure " f"[{cfg.key_reset}] reset [{capture_label(cfg)}] capture " f"[{cfg.key_quit}] quit " ) try: stdscr.addstr(box_rows, 0, status[:cols].ljust(cols)) stdscr.addstr(box_rows + 1, 0, help_line[:cols].ljust(cols - 1)) except curses.error: pass stdscr.refresh() ch = stdscr.getch() if ch == -1: continue key = chr(ch) if 0 <= ch < 256 else "" if matches(key, cfg.key_quit): break elif matches(key, cfg.key_brightness_up): brightness = adjust(brightness, cfg.step) apply_ctrl_pct(cfg.device, cfg.brightness_ctrl, brightness) elif matches(key, cfg.key_brightness_down): brightness = adjust(brightness, -cfg.step) apply_ctrl_pct(cfg.device, cfg.brightness_ctrl, brightness) elif matches(key, cfg.key_exposure_up): exposure = adjust(exposure, cfg.step) apply_ctrl_pct(cfg.device, cfg.exposure_ctrl, exposure) elif matches(key, cfg.key_exposure_down): exposure = adjust(exposure, -cfg.step) apply_ctrl_pct(cfg.device, cfg.exposure_ctrl, exposure) elif matches(key, cfg.key_reset): brightness, exposure = cfg.default_brightness, cfg.default_exposure if brightness != "auto": apply_ctrl_pct(cfg.device, cfg.brightness_ctrl, int(brightness)) if exposure != "auto": apply_ctrl_pct(cfg.device, cfg.exposure_ctrl, int(exposure)) elif matches(key, cfg.key_capture): timestamp = datetime.now().strftime("%Y%m%d-%H%M%S") photo_path = os.path.join(SCRIPT_DIR, f"photo-{timestamp}.jpg") ascii_path = os.path.join(SCRIPT_DIR, f"ascii-img-{timestamp}.txt") print_frame, print_cols, print_rows = prepare_print_frame( frame, cfg, inner_cols, inner_rows ) _, print_buf = cv2.imencode( ".jpg", print_frame, [cv2.IMWRITE_JPEG_QUALITY, cfg.jpeg_quality] ) print_jpg_bytes = print_buf.tobytes() print_width = cfg.width or str(print_cols) print_height = cfg.height or (str(print_rows) if print_rows is not None else None) print_ascii_lines = convert_to_ascii( print_jpg_bytes, print_width, print_height, conv_flags ) with open(photo_path, "wb") as f: f.write(print_jpg_bytes) with open(ascii_path, "w") as f: f.write("\n".join(print_ascii_lines) + "\n") env = os.environ.copy() if cfg.printer_a4: # The image was already rotated above; tell txt2printr.sh # not to also apply its own text-transpose rotation. env["ALREADY_ROTATED"] = "true" subprocess.run( [os.path.join(SCRIPT_DIR, "txt2printr.sh"), ascii_path], env=env ) message = f"Saved {os.path.basename(photo_path)}" message_until = time.time() + 2 finally: cap.release() if __name__ == "__main__": curses.wrapper(main)