rewrite photobooth.py capture pipeline: rotate the image, not the text

Rearranging already-rendered ascii characters (the old text-transpose
rotation) ignores non-square character cells and read as visibly
distorted. Instead, crop the raw camera frame to roughly the right
aspect ratio, rotate it (only for PRINTER_A4) with OpenCV, and only
then convert to ascii - so ascii-image-converter picks shading
characters for correctly-oriented pixel data instead of us hacking
around after the fact.

The live curses viewfinder is now a separate, always-upright,
aspect-corrected preview independent of PRINTER_A4; the rotated/resized
version used for printing is computed fresh from the raw frame only at
capture time.

txt2printr.sh gains ALREADY_ROTATED, set automatically by photobooth.py
so it doesn't redundantly apply its own text-rotation on top of the
image-level one. The bash pipeline (webcam2ascii.sh/exec-cycle.sh),
which can't rotate images itself, still falls back to that text
rotation unchanged.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
master
Amir Alexander Abdelbaki 2026-07-14 10:46:48 +02:00
parent 063626d150
commit 96194f6d77
2 changed files with 89 additions and 41 deletions

View File

@ -173,39 +173,54 @@ def capture_label(cfg):
return "space" if cfg.key_capture == " " else cfg.key_capture return "space" if cfg.key_capture == " " else cfg.key_capture
def a4_landscape_chars(max_lines, max_cols): def box_dims_for(term_rows, term_cols):
"""(cols, rows) to capture/preview for an A4 landscape printout. return max(term_rows - 2, 3), max(term_cols, 10)
txt2printr.sh prints on a normally-fed (portrait) page and rotates the
ascii art 90 degrees in software; the reader turns the finished sheet _CHAR_ASPECT = 0.5 # width:height ratio of a typical monospace glyph cell
90 degrees to view it. That second turn cancels the software rotation,
so this buffer's own dimensions equal the final visual result - but its
width becomes the printed *line count* after rotation (bounded by def visual_aspect(cols, rows):
max_lines, the page's real per-page line capacity) and its height """Physical width:height ratio an ascii grid of cols x rows renders as."""
becomes each printed line's length (bounded by max_cols, the real return (cols / rows) * _CHAR_ASPECT if rows else 1.0
per-line character capacity). Both are measured directly against the
printer with pagetest.sh rather than derived from paper/pitch math,
which doesn't reliably match real-world margins. 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.
""" """
return max_lines, max_cols if cfg.printer_a4:
cols, rows = cfg.printer_max_cols, cfg.printer_max_lines
# Cropping happens before the 90-degree rotation below, which swaps
def box_dims_for(term_rows, term_cols, a4_target): # width and height, so crop to the inverse of the final aspect ratio.
box_rows, box_cols = max(term_rows - 2, 3), max(term_cols, 10) frame = crop_to_aspect(frame, 1 / visual_aspect(cols, rows))
if a4_target: frame = cv2.rotate(frame, cv2.ROTATE_90_CLOCKWISE)
a4_cols, a4_rows = a4_target else:
box_rows = min(box_rows, a4_rows + 2) cols, rows = inner_cols, inner_rows
box_cols = min(box_cols, a4_cols + 2) frame = crop_to_aspect(frame, visual_aspect(cols, rows))
return box_rows, box_cols return frame, cols, rows
def main(stdscr): def main(stdscr):
cfg = Config() cfg = Config()
conv_flags = cfg.converter_flags() conv_flags = cfg.converter_flags()
a4_target = (
a4_landscape_chars(cfg.printer_max_lines, cfg.printer_max_cols)
if cfg.printer_a4 else None
)
curses.curs_set(0) curses.curs_set(0)
stdscr.nodelay(True) stdscr.nodelay(True)
@ -225,7 +240,7 @@ def main(stdscr):
frame_times = [] frame_times = []
message, message_until = "", 0.0 message, message_until = "", 0.0
rows, cols = stdscr.getmaxyx() rows, cols = stdscr.getmaxyx()
box_dims = box_dims_for(rows, cols, a4_target) box_dims = box_dims_for(rows, cols)
box_win = curses.newwin(box_dims[0], box_dims[1], 0, 0) box_win = curses.newwin(box_dims[0], box_dims[1], 0, 0)
try: try:
@ -239,13 +254,8 @@ def main(stdscr):
frame_times = [t for t in frame_times if now - t < 1.0] frame_times = [t for t in frame_times if now - t < 1.0]
fps = len(frame_times) fps = len(frame_times)
_, buf = cv2.imencode(
".jpg", frame, [cv2.IMWRITE_JPEG_QUALITY, cfg.jpeg_quality]
)
jpg_bytes = buf.tobytes()
rows, cols = stdscr.getmaxyx() rows, cols = stdscr.getmaxyx()
box_rows, box_cols = box_dims_for(rows, cols, a4_target) box_rows, box_cols = box_dims_for(rows, cols)
inner_rows, inner_cols = max(box_rows - 2, 1), max(box_cols - 2, 1) inner_rows, inner_cols = max(box_rows - 2, 1), max(box_cols - 2, 1)
if (box_rows, box_cols) != box_dims: if (box_rows, box_cols) != box_dims:
@ -253,9 +263,16 @@ def main(stdscr):
box_dims = (box_rows, box_cols) box_dims = (box_rows, box_cols)
stdscr.erase() 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) width = cfg.width or str(inner_cols)
height = cfg.height or str(inner_rows) height = cfg.height or str(inner_rows)
ascii_lines = convert_to_ascii(jpg_bytes, width, height, conv_flags) ascii_lines = convert_to_ascii(buf.tobytes(), width, height, conv_flags)
box_win.erase() box_win.erase()
box_win.box() box_win.box()
@ -312,11 +329,33 @@ def main(stdscr):
timestamp = datetime.now().strftime("%Y%m%d-%H%M%S") timestamp = datetime.now().strftime("%Y%m%d-%H%M%S")
photo_path = os.path.join(SCRIPT_DIR, f"photo-{timestamp}.jpg") photo_path = os.path.join(SCRIPT_DIR, f"photo-{timestamp}.jpg")
ascii_path = os.path.join(SCRIPT_DIR, f"ascii-img-{timestamp}.txt") 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)
print_ascii_lines = convert_to_ascii(
print_jpg_bytes, print_width, print_height, conv_flags
)
with open(photo_path, "wb") as f: with open(photo_path, "wb") as f:
f.write(jpg_bytes) f.write(print_jpg_bytes)
with open(ascii_path, "w") as f: with open(ascii_path, "w") as f:
f.write("\n".join(ascii_lines) + "\n") f.write("\n".join(print_ascii_lines) + "\n")
subprocess.run([os.path.join(SCRIPT_DIR, "txt2printr.sh"), ascii_path])
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 = f"Saved {os.path.basename(photo_path)}"
message_until = time.time() + 2 message_until = time.time() + 2
finally: finally:

View File

@ -4,6 +4,10 @@
# #
# Usage: ./txt2printr.sh [ascii-art.txt] # Usage: ./txt2printr.sh [ascii-art.txt]
# Env: PRINTER_DEV - printer device node (default: /dev/lp0) # Env: PRINTER_DEV - printer device node (default: /dev/lp0)
# ALREADY_ROTATED - skip the text-rotation below because the caller
# already rotated the source image itself before
# converting it to ascii (photobooth.py does this
# for better quality; set by it automatically)
# Reads printer.conf for PRINTER_A4 (landscape rotation) and PRINTER_MAX_LINES # Reads printer.conf for PRINTER_A4 (landscape rotation) and PRINTER_MAX_LINES
# (per-page line capacity, measured with pagetest.sh). # (per-page line capacity, measured with pagetest.sh).
@ -11,6 +15,7 @@ dir="$(dirname "$0")"
[ -f "$dir/printer.conf" ] && source "$dir/printer.conf" [ -f "$dir/printer.conf" ] && source "$dir/printer.conf"
: "${PRINTER_A4:=false}" : "${PRINTER_A4:=false}"
: "${PRINTER_MAX_LINES:=64}" : "${PRINTER_MAX_LINES:=64}"
: "${ALREADY_ROTATED:=false}"
infile="${1:-ascii-img.txt}" infile="${1:-ascii-img.txt}"
# Source - https://stackoverflow.com/a/638980 # Source - https://stackoverflow.com/a/638980
@ -40,8 +45,12 @@ fi
# Rotates the ascii art 90 degrees clockwise so it reads correctly once the # Rotates the ascii art 90 degrees clockwise so it reads correctly once the
# printed page itself is turned 90 degrees counter-clockwise - the printer's # printed page itself is turned 90 degrees counter-clockwise - the printer's
# scan/feed directions are fixed, so this is the only way to get a landscape # scan/feed directions are fixed, so this is a fallback for callers (e.g. the
# reading orientation out of it. # bash webcam2ascii.sh/exec-cycle.sh pipeline) that can't rotate the source
# image itself first. Rearranging already-rendered ascii characters ignores
# non-square character cells and reads as distorted, so photobooth.py instead
# rotates the source image before conversion and sets ALREADY_ROTATED to
# skip this.
rotate90() { rotate90() {
awk ' awk '
{ if (length($0) > maxlen) maxlen = length($0); lines[NR] = $0 } { if (length($0) > maxlen) maxlen = length($0); lines[NR] = $0 }
@ -60,7 +69,7 @@ rotate90() {
} }
body() { body() {
if [ "$PRINTER_A4" = "true" ]; then if [ "$PRINTER_A4" = "true" ] && [ "$ALREADY_ROTATED" != "true" ]; then
rotate90 rotate90
else else
cat "$infile" cat "$infile"