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
parent
063626d150
commit
96194f6d77
113
photobooth.py
113
photobooth.py
|
|
@ -173,39 +173,54 @@ def capture_label(cfg):
|
|||
return "space" if cfg.key_capture == " " else cfg.key_capture
|
||||
|
||||
|
||||
def a4_landscape_chars(max_lines, max_cols):
|
||||
"""(cols, rows) to capture/preview for an A4 landscape printout.
|
||||
def box_dims_for(term_rows, term_cols):
|
||||
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
|
||||
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
|
||||
max_lines, the page's real per-page line capacity) and its height
|
||||
becomes each printed line's length (bounded by max_cols, the real
|
||||
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.
|
||||
|
||||
_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.
|
||||
"""
|
||||
return max_lines, max_cols
|
||||
|
||||
|
||||
def box_dims_for(term_rows, term_cols, a4_target):
|
||||
box_rows, box_cols = max(term_rows - 2, 3), max(term_cols, 10)
|
||||
if a4_target:
|
||||
a4_cols, a4_rows = a4_target
|
||||
box_rows = min(box_rows, a4_rows + 2)
|
||||
box_cols = min(box_cols, a4_cols + 2)
|
||||
return box_rows, box_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
|
||||
# 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()
|
||||
a4_target = (
|
||||
a4_landscape_chars(cfg.printer_max_lines, cfg.printer_max_cols)
|
||||
if cfg.printer_a4 else None
|
||||
)
|
||||
|
||||
curses.curs_set(0)
|
||||
stdscr.nodelay(True)
|
||||
|
|
@ -225,7 +240,7 @@ def main(stdscr):
|
|||
frame_times = []
|
||||
message, message_until = "", 0.0
|
||||
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)
|
||||
|
||||
try:
|
||||
|
|
@ -239,13 +254,8 @@ def main(stdscr):
|
|||
frame_times = [t for t in frame_times if now - t < 1.0]
|
||||
fps = len(frame_times)
|
||||
|
||||
_, buf = cv2.imencode(
|
||||
".jpg", frame, [cv2.IMWRITE_JPEG_QUALITY, cfg.jpeg_quality]
|
||||
)
|
||||
jpg_bytes = buf.tobytes()
|
||||
|
||||
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)
|
||||
|
||||
if (box_rows, box_cols) != box_dims:
|
||||
|
|
@ -253,9 +263,16 @@ def main(stdscr):
|
|||
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(jpg_bytes, width, height, conv_flags)
|
||||
ascii_lines = convert_to_ascii(buf.tobytes(), width, height, conv_flags)
|
||||
|
||||
box_win.erase()
|
||||
box_win.box()
|
||||
|
|
@ -312,11 +329,33 @@ def main(stdscr):
|
|||
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)
|
||||
print_ascii_lines = convert_to_ascii(
|
||||
print_jpg_bytes, print_width, print_height, conv_flags
|
||||
)
|
||||
|
||||
with open(photo_path, "wb") as f:
|
||||
f.write(jpg_bytes)
|
||||
f.write(print_jpg_bytes)
|
||||
with open(ascii_path, "w") as f:
|
||||
f.write("\n".join(ascii_lines) + "\n")
|
||||
subprocess.run([os.path.join(SCRIPT_DIR, "txt2printr.sh"), ascii_path])
|
||||
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:
|
||||
|
|
|
|||
|
|
@ -3,7 +3,11 @@
|
|||
# Sends an ascii-art text file to a parallel port needle/dot-matrix printer.
|
||||
#
|
||||
# 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
|
||||
# (per-page line capacity, measured with pagetest.sh).
|
||||
|
||||
|
|
@ -11,6 +15,7 @@ dir="$(dirname "$0")"
|
|||
[ -f "$dir/printer.conf" ] && source "$dir/printer.conf"
|
||||
: "${PRINTER_A4:=false}"
|
||||
: "${PRINTER_MAX_LINES:=64}"
|
||||
: "${ALREADY_ROTATED:=false}"
|
||||
|
||||
infile="${1:-ascii-img.txt}"
|
||||
# Source - https://stackoverflow.com/a/638980
|
||||
|
|
@ -40,8 +45,12 @@ fi
|
|||
|
||||
# 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
|
||||
# scan/feed directions are fixed, so this is the only way to get a landscape
|
||||
# reading orientation out of it.
|
||||
# scan/feed directions are fixed, so this is a fallback for callers (e.g. the
|
||||
# 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() {
|
||||
awk '
|
||||
{ if (length($0) > maxlen) maxlen = length($0); lines[NR] = $0 }
|
||||
|
|
@ -60,7 +69,7 @@ rotate90() {
|
|||
}
|
||||
|
||||
body() {
|
||||
if [ "$PRINTER_A4" = "true" ]; then
|
||||
if [ "$PRINTER_A4" = "true" ] && [ "$ALREADY_ROTATED" != "true" ]; then
|
||||
rotate90
|
||||
else
|
||||
cat "$infile"
|
||||
|
|
|
|||
Loading…
Reference in New Issue