Last active
March 4, 2026 05:23
-
-
Save j6k4m8/c831e496f3223ea9890ee8d188cd1390 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
| """BMP-only image-to-base64 art encoder.""" | |
| # https://blog.jordan.matelsky.com/bb64/ | |
| from __future__ import annotations | |
| import base64 | |
| import math | |
| import struct | |
| import subprocess | |
| import textwrap | |
| from dataclasses import dataclass | |
| from pathlib import Path | |
| from typing import Iterable | |
| import numpy as np | |
| BASE64_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/" | |
| # Dark -> light order measured from rendered glyph luminance. | |
| DARK_TO_LIGHT_BASE64 = "N0BMW8QRDgOH96KUqGEdpbAmPSa5X4Z3hkeV2woFIyCnu1JTYsftj7xLzvcil+/r" | |
| _B64_VALUE = {ch: idx for idx, ch in enumerate(BASE64_ALPHABET)} | |
| BMP_PIXEL_OFFSET = 54 | |
| BMP_HEADER_B64_CHARS = 72 # 54 bytes -> 72 base64 chars. | |
| @dataclass(frozen=True) | |
| class EncodeResult: | |
| """Result of one BMP base64-art encoding run. | |
| Attributes: | |
| data_uri: Full `data:image/bmp;base64,...` string. | |
| base64_text: Raw base64 payload without wrapping. | |
| wrapped_base64: Base64 payload wrapped to `line_length`. | |
| art_block: Extracted `art_rows x art_cols` block from base64 payload. | |
| width: Encoded BMP width in pixels. | |
| height: Encoded BMP height in pixels. | |
| art_cols: Base64-art block columns. | |
| art_rows: Base64-art block rows. | |
| line_length: Wrap width used for `wrapped_base64`. | |
| skip_chars: Base64 offset where `art_block` starts. | |
| mutable_char_run: Number of consecutively mutable chars from `skip_chars`. | |
| """ | |
| data_uri: str | |
| base64_text: str | |
| wrapped_base64: str | |
| art_block: str | |
| width: int | |
| height: int | |
| art_cols: int | |
| art_rows: int | |
| line_length: int | |
| skip_chars: int | |
| mutable_char_run: int | |
| def encode_image_to_bmp_art_base64( | |
| image_path: str | Path, | |
| width: int = 64, | |
| height: int = 64, | |
| *, | |
| art_cols: int | None = None, | |
| art_rows: int | None = None, | |
| line_length: int | None = None, | |
| skip_chars: int | None = None, | |
| passes: int = 2, | |
| neighbor_radius: int = 3, | |
| char_weight: float = 1.0, | |
| pixel_weight: float = 0.0, | |
| thumbnail_size: int | None = None, | |
| char_order: str = DARK_TO_LIGHT_BASE64, | |
| ) -> EncodeResult: | |
| """Encode an image so a base64 window resembles source luminance. | |
| Args: | |
| image_path: Input image path readable by `ffmpeg`. | |
| width: Output BMP width in pixels. | |
| height: Output BMP height in pixels. | |
| art_cols: Base64-art columns. If omitted, inferred from mutable run. | |
| art_rows: Base64-art rows. If omitted, inferred from mutable run. | |
| line_length: Wrap width for `wrapped_base64`. Defaults to `art_cols`. | |
| skip_chars: Base64 offset where art starts. Defaults to 72. | |
| passes: Greedy optimization passes over the art window. | |
| neighbor_radius: Rank search radius for candidate characters. | |
| char_weight: Weight for character-rank mismatch. | |
| pixel_weight: Weight for squared byte drift. Use 0.0 for fully lossy fitting. | |
| thumbnail_size: Optional thumbnail edge length in art cells. If set, | |
| inserts a full-image thumbnail into the top-left `N x N` region | |
| of the target base64-art map before optimization. | |
| char_order: 64-char dark-to-light permutation over base64 alphabet. | |
| Returns: | |
| EncodeResult with data URI and text artifacts. | |
| Raises: | |
| RuntimeError: If ffmpeg cannot decode the input. | |
| ValueError: If settings or derived geometry are invalid. | |
| """ | |
| _validate_inputs( | |
| width=width, | |
| height=height, | |
| passes=passes, | |
| neighbor_radius=neighbor_radius, | |
| thumbnail_size=thumbnail_size, | |
| char_order=char_order, | |
| ) | |
| src_rgb = _read_raw_frame(image_path, width, height, pix_fmt="rgb24").reshape(height, width, 3) | |
| blob, pixel_offset, row_stride = _rgb_to_bmp(src_rgb) | |
| # Keep geometry simple: disallow BMP row padding. | |
| if row_stride != width * 3: | |
| raise ValueError("width must be divisible by 4 in BMP mode (no row padding)") | |
| mutable_bytes = np.zeros(blob.size, dtype=bool) | |
| mutable_bytes[pixel_offset:] = True | |
| char_mutable = _char_mutability_mask(blob.size, mutable_bytes) | |
| skip_chars = BMP_HEADER_B64_CHARS if skip_chars is None else skip_chars | |
| if skip_chars < 0: | |
| raise ValueError("skip_chars must be >= 0") | |
| if skip_chars >= char_mutable.size: | |
| raise ValueError("skip_chars is past end of base64 payload") | |
| run_len = _run_length_from(char_mutable, skip_chars) | |
| if run_len <= 0: | |
| raise ValueError("skip_chars does not start in a mutable char run") | |
| if art_cols is None and art_rows is None: | |
| art_cols, art_rows = _best_factor_pair(run_len) | |
| elif art_cols is None or art_rows is None: | |
| raise ValueError("set both art_cols and art_rows, or neither") | |
| assert art_cols is not None and art_rows is not None | |
| if art_cols <= 0 or art_rows <= 0: | |
| raise ValueError("art_cols and art_rows must be > 0") | |
| needed = art_cols * art_rows | |
| if needed > run_len: | |
| raise ValueError( | |
| f"requested art block ({needed} chars) exceeds mutable run ({run_len} chars)" | |
| ) | |
| target_gray = _read_raw_frame(image_path, art_cols, art_rows, pix_fmt="gray").reshape( | |
| art_rows, art_cols | |
| ).copy() | |
| if thumbnail_size is not None: | |
| if thumbnail_size > art_cols or thumbnail_size > art_rows: | |
| raise ValueError("thumbnail_size exceeds art block dimensions") | |
| thumb_gray = _read_raw_frame( | |
| image_path, thumbnail_size, thumbnail_size, pix_fmt="gray" | |
| ).reshape(thumbnail_size, thumbnail_size) | |
| target_gray[:thumbnail_size, :thumbnail_size] = thumb_gray | |
| target_rank = np.rint( | |
| (target_gray.astype(np.float32) / 255.0) * (len(char_order) - 1) | |
| ).astype(np.int16).reshape(-1) | |
| _optimize_window_masked( | |
| blob=blob, | |
| mutable=mutable_bytes, | |
| target_rank=target_rank, | |
| skip_chars=skip_chars, | |
| passes=passes, | |
| neighbor_radius=neighbor_radius, | |
| char_weight=char_weight, | |
| pixel_weight=pixel_weight, | |
| char_order=char_order, | |
| ) | |
| b64 = base64.b64encode(blob.tobytes()).decode("ascii") | |
| if skip_chars + needed > len(b64): | |
| raise ValueError("requested art window exceeds base64 payload") | |
| line_length = art_cols if line_length is None else line_length | |
| if line_length <= 0: | |
| raise ValueError("line_length must be > 0") | |
| wrapped = "\n".join(textwrap.wrap(b64, line_length)) | |
| art = _extract_art_block(b64, cols=art_cols, rows=art_rows, skip_chars=skip_chars) | |
| return EncodeResult( | |
| data_uri=f"data:image/bmp;base64,{b64}", | |
| base64_text=b64, | |
| wrapped_base64=wrapped, | |
| art_block=art, | |
| width=width, | |
| height=height, | |
| art_cols=art_cols, | |
| art_rows=art_rows, | |
| line_length=line_length, | |
| skip_chars=skip_chars, | |
| mutable_char_run=run_len, | |
| ) | |
| def _validate_inputs( | |
| *, | |
| width: int, | |
| height: int, | |
| passes: int, | |
| neighbor_radius: int, | |
| thumbnail_size: int | None, | |
| char_order: str, | |
| ) -> None: | |
| """Validate shared encoder settings.""" | |
| if width <= 0 or height <= 0: | |
| raise ValueError("width and height must be > 0") | |
| if passes <= 0: | |
| raise ValueError("passes must be > 0") | |
| if neighbor_radius < 0: | |
| raise ValueError("neighbor_radius must be >= 0") | |
| if thumbnail_size is not None and thumbnail_size <= 0: | |
| raise ValueError("thumbnail_size must be > 0 when set") | |
| if len(char_order) != 64 or set(char_order) != set(BASE64_ALPHABET): | |
| raise ValueError("char_order must be a permutation of the 64 base64 chars") | |
| def _read_raw_frame( | |
| image_path: str | Path, width: int, height: int, *, pix_fmt: str | |
| ) -> np.ndarray: | |
| """Load and resize one frame from an image via ffmpeg.""" | |
| channels = 1 if pix_fmt == "gray" else 3 | |
| cmd = [ | |
| "ffmpeg", | |
| "-v", | |
| "error", | |
| "-i", | |
| str(image_path), | |
| "-vf", | |
| f"scale={width}:{height}:flags=lanczos,format={pix_fmt}", | |
| "-f", | |
| "rawvideo", | |
| "-pix_fmt", | |
| pix_fmt, | |
| "-", | |
| ] | |
| proc = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, check=False) | |
| if proc.returncode != 0: | |
| raise RuntimeError( | |
| "ffmpeg failed while reading image: " | |
| f"{proc.stderr.decode('utf-8', errors='replace').strip()}" | |
| ) | |
| arr = np.frombuffer(proc.stdout, dtype=np.uint8) | |
| expected = width * height * channels | |
| if arr.size != expected: | |
| raise RuntimeError(f"ffmpeg produced {arr.size} bytes, expected {expected}") | |
| return arr | |
| def _rgb_to_bmp(rgb: np.ndarray) -> tuple[np.ndarray, int, int]: | |
| """Convert RGB uint8 image to contiguous 24-bit BMP bytes.""" | |
| height, width, _ = rgb.shape | |
| row_bytes = width * 3 | |
| row_stride = (row_bytes + 3) & ~3 | |
| bgr_rows = rgb[::-1, :, ::-1].reshape(height, row_bytes) | |
| pixel = np.zeros((height, row_stride), dtype=np.uint8) | |
| pixel[:, :row_bytes] = bgr_rows | |
| pixel_blob = pixel.reshape(-1) | |
| file_size = BMP_PIXEL_OFFSET + pixel_blob.size | |
| header = struct.pack("<2sIHHI", b"BM", file_size, 0, 0, BMP_PIXEL_OFFSET) | |
| dib = struct.pack( | |
| "<IIIHHIIIIII", | |
| 40, | |
| width, | |
| height, | |
| 1, | |
| 24, | |
| 0, | |
| pixel_blob.size, | |
| 2835, | |
| 2835, | |
| 0, | |
| 0, | |
| ) | |
| blob = np.concatenate((np.frombuffer(header + dib, dtype=np.uint8), pixel_blob)) | |
| return blob, BMP_PIXEL_OFFSET, row_stride | |
| def _char_mutability_mask(blob_size: int, mutable_bytes: np.ndarray) -> np.ndarray: | |
| """Build bool mask for base64 chars fully controlled by mutable bytes.""" | |
| if mutable_bytes.size != blob_size: | |
| raise ValueError("mutable byte mask has wrong size") | |
| max_chars = 4 * ((blob_size + 2) // 3) | |
| out = np.zeros(max_chars, dtype=bool) | |
| for pos in range(max_chars): | |
| idxs = _affected_byte_indices(pos, blob_size) | |
| if idxs and all(mutable_bytes[i] for i in idxs): | |
| out[pos] = True | |
| return out | |
| def _affected_byte_indices(pos: int, blob_size: int) -> tuple[int, ...]: | |
| """Return affected byte indices for one base64 character position.""" | |
| group = pos // 4 | |
| slot = pos % 4 | |
| b0 = group * 3 | |
| b1 = b0 + 1 | |
| b2 = b0 + 2 | |
| if b0 >= blob_size: | |
| return () | |
| if slot == 0: | |
| return (b0,) | |
| if slot == 1: | |
| return (b0, b1) if b1 < blob_size else () | |
| if slot == 2: | |
| return (b1, b2) if b2 < blob_size else () | |
| return (b2,) if b2 < blob_size else () | |
| def _run_length_from(mask: np.ndarray, start: int) -> int: | |
| """Count contiguous True values starting at index `start`.""" | |
| n = mask.size | |
| i = start | |
| while i < n and bool(mask[i]): | |
| i += 1 | |
| return i - start | |
| def _best_factor_pair(n: int) -> tuple[int, int]: | |
| """Pick a near-square factor pair that uses all chars in `n`.""" | |
| if n <= 0: | |
| raise ValueError("n must be > 0") | |
| root = int(math.isqrt(n)) | |
| for a in range(root, 0, -1): | |
| if n % a == 0: | |
| b = n // a | |
| return b, a | |
| return n, 1 | |
| def _optimize_window_masked( | |
| *, | |
| blob: np.ndarray, | |
| mutable: np.ndarray, | |
| target_rank: np.ndarray, | |
| skip_chars: int, | |
| passes: int, | |
| neighbor_radius: int, | |
| char_weight: float, | |
| pixel_weight: float, | |
| char_order: str, | |
| ) -> None: | |
| """Greedy search to align base64 character ranks with target luminance.""" | |
| original = blob.copy() | |
| target_len = int(target_rank.size) | |
| for _ in range(passes): | |
| for i in range(target_len): | |
| pos = skip_chars + i | |
| t_rank = int(target_rank[i]) | |
| best_score = float("inf") | |
| best_changes: list[tuple[int, int]] = [] | |
| for rank in _candidate_ranks(t_rank, neighbor_radius, len(char_order)): | |
| ch = char_order[rank] | |
| changes = _propose_char_mutation(blob, pos, _B64_VALUE[ch]) | |
| if not changes or any(not mutable[idx] for idx, _ in changes): | |
| continue | |
| char_cost = abs(rank - t_rank) | |
| score = char_weight * char_cost | |
| if pixel_weight > 0.0: | |
| byte_cost = 0.0 | |
| for idx, new_val in changes: | |
| d = float(new_val) - float(original[idx]) | |
| byte_cost += d * d | |
| score += pixel_weight * byte_cost | |
| if score < best_score: | |
| best_score = score | |
| best_changes = changes | |
| if score == 0.0: | |
| break | |
| for idx, new_val in best_changes: | |
| blob[idx] = new_val | |
| def _candidate_ranks(center: int, radius: int, n: int) -> Iterable[int]: | |
| """Yield candidate rank indices around a center rank.""" | |
| yield center | |
| for step in range(1, radius + 1): | |
| if center - step >= 0: | |
| yield center - step | |
| if center + step < n: | |
| yield center + step | |
| def _propose_char_mutation( | |
| blob: np.ndarray, pos: int, value: int | |
| ) -> list[tuple[int, int]]: | |
| """Return byte edits needed to force one base64 sextet value.""" | |
| group = pos // 4 | |
| slot = pos % 4 | |
| b0 = group * 3 | |
| b1 = b0 + 1 | |
| b2 = b0 + 2 | |
| if b0 >= blob.size: | |
| return [] | |
| changes: dict[int, int] = {} | |
| if slot == 0: | |
| changes[b0] = (value << 2) | (int(blob[b0]) & 0x03) | |
| elif slot == 1: | |
| if b1 >= blob.size: | |
| return [] | |
| changes[b0] = (int(blob[b0]) & 0xFC) | ((value >> 4) & 0x03) | |
| changes[b1] = (int(blob[b1]) & 0x0F) | ((value & 0x0F) << 4) | |
| elif slot == 2: | |
| if b2 >= blob.size: | |
| return [] | |
| changes[b1] = (int(blob[b1]) & 0xF0) | ((value >> 2) & 0x0F) | |
| changes[b2] = (int(blob[b2]) & 0x3F) | ((value & 0x03) << 6) | |
| else: | |
| if b2 >= blob.size: | |
| return [] | |
| changes[b2] = (int(blob[b2]) & 0xC0) | (value & 0x3F) | |
| return [(idx, new_val) for idx, new_val in changes.items()] | |
| def _extract_art_block(base64_text: str, *, cols: int, rows: int, skip_chars: int) -> str: | |
| """Extract and render a fixed-size block from base64 text.""" | |
| needed = cols * rows | |
| chunk = base64_text[skip_chars : skip_chars + needed] | |
| if len(chunk) < needed: | |
| raise ValueError("not enough base64 chars for requested block") | |
| return "\n".join(chunk[i : i + cols] for i in range(0, needed, cols)) |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment