Thumbnail.

Buckets

Description

A question from our most active contributor Samuel35 prompted me to revisit an idea I had very early in the project (but had abandoned for technical reasons at the time), and I, in turn, prompted Chatgpt to turn it into Python code to run on a Google Colab notebook:

#!/usr/bin/env python3
"""
allRGB image-guided bucket completion

Creates a 4096 x 4096 PNG containing every 24-bit RGB color exactly once.
The input image is used as a visual guide. Unique colors from the source image
are placed first, then every remaining RGB color is grouped into 16 palette
buckets and assigned to remaining pixels whose guide color belongs to the same
or nearest available bucket.

Dependencies:
    pip install pillow numpy

CLI:
    python allrgb_image_guided_buckets.py input.png output.png

Google Colab:
    Run this cell/file without command-line arguments. It will open an upload
    dialog and then download the generated PNG.

Local web server:
    Import process_image() from this file and call it from your route/worker.
    Do not run this directly inside a blocking request handler for production;
    it is CPU/RAM intensive and is better handled by a job queue.
"""

from __future__ import annotations

import argparse
import os
import sys
import time
from pathlib import Path
from typing import Iterable, Literal, Optional, Tuple

import numpy as np
from PIL import Image, ImageOps

# Pillow sometimes protects against very large images. For this use case, large
# image files are expected.
Image.MAX_IMAGE_PIXELS = None

CANVAS_SIZE = 4096
TOTAL_COLORS = CANVAS_SIZE * CANVAS_SIZE  # 16,777,216 == 2^24

# Integer Rec.709-ish luminance coefficients scaled to 256.
# 54 + 183 + 19 = 256.
LUMA_R = 54
LUMA_G = 183
LUMA_B = 19

# Used when the adaptive palette produces fewer than 16 distinct colors.
# The adaptive colors are kept first; these only complete the bucket set.
FALLBACK_16 = np.array(
    [
        (0, 0, 0),
        (255, 255, 255),
        (128, 128, 128),
        (255, 0, 0),
        (0, 255, 0),
        (0, 0, 255),
        (255, 255, 0),
        (0, 255, 255),
        (255, 0, 255),
        (255, 128, 0),
        (128, 0, 255),
        (255, 0, 128),
        (128, 64, 0),
        (0, 128, 128),
        (0, 64, 128),
        (128, 128, 0),
    ],
    dtype=np.uint8,
)


try:
    RESAMPLING_LANCZOS = Image.Resampling.LANCZOS
    RESAMPLING_NEAREST = Image.Resampling.NEAREST
    QUANTIZE_MEDIANCUT = Image.Quantize.MEDIANCUT
except AttributeError:  # Older Pillow fallback.
    RESAMPLING_LANCZOS = Image.LANCZOS
    RESAMPLING_NEAREST = Image.NEAREST
    QUANTIZE_MEDIANCUT = Image.MEDIANCUT


FitMode = Literal["cover", "contain", "stretch"]
PaletteMode = Literal["adaptive", "fixed"]


def log(message: str, quiet: bool = False) -> None:
    if not quiet:
        print(message, flush=True)


def parse_hex_rgb(value: str) -> Tuple[int, int, int]:
    value = value.strip()
    if value.startswith("#"):
        value = value[1:]
    if len(value) != 6:
        raise ValueError("Expected a hex color like #ffffff")
    return (int(value[0:2], 16), int(value[2:4], 16), int(value[4:6], 16))


def normalize_to_rgb(path: str | Path, alpha_background: Tuple[int, int, int]) -> Image.Image:
    """Load an image, respect EXIF orientation, and flatten transparency to RGB."""
    img = Image.open(path)
    img = ImageOps.exif_transpose(img)

    has_alpha = img.mode in {"RGBA", "LA"} or "transparency" in img.info
    if has_alpha:
        rgba = img.convert("RGBA")
        bg = Image.new("RGBA", rgba.size, (*alpha_background, 255))
        bg.alpha_composite(rgba)
        return bg.convert("RGB")

    return img.convert("RGB")


def fit_image(
    img: Image.Image,
    size: Tuple[int, int],
    mode: FitMode,
    resample,
    background: Tuple[int, int, int],
) -> Image.Image:
    """Return an RGB image fitted to size."""
    if mode == "stretch":
        return img.resize(size, resample=resample)

    if mode == "cover":
        return ImageOps.fit(img, size, method=resample, centering=(0.5, 0.5))

    if mode == "contain":
        contained = ImageOps.contain(img, size, method=resample)
        canvas = Image.new("RGB", size, background)
        x = (size[0] - contained.size[0]) // 2
        y = (size[1] - contained.size[1]) // 2
        canvas.paste(contained, (x, y))
        return canvas

    raise ValueError(f"Unknown fit mode: {mode}")


def rgb_flat_to_ids(rgb_flat: np.ndarray) -> np.ndarray:
    """Convert flat RGB rows to 24-bit integer ids: 0xRRGGBB."""
    rgb = rgb_flat.astype(np.uint32, copy=False)
    return (rgb[:, 0] << 16) | (rgb[:, 1] << 8) | rgb[:, 2]


def rgb_image_to_ids(img: Image.Image) -> np.ndarray:
    arr = np.asarray(img, dtype=np.uint8)
    return rgb_flat_to_ids(arr.reshape(-1, 3))


def ids_to_rgb_array(ids: np.ndarray, size: int = CANVAS_SIZE) -> np.ndarray:
    rgb = np.empty((ids.size, 3), dtype=np.uint8)
    rgb[:, 0] = ((ids >> 16) & 255).astype(np.uint8)
    rgb[:, 1] = ((ids >> 8) & 255).astype(np.uint8)
    rgb[:, 2] = (ids & 255).astype(np.uint8)
    return rgb.reshape((size, size, 3))


def luma_from_rgb_flat(rgb_flat: np.ndarray) -> np.ndarray:
    rgb = rgb_flat.astype(np.uint16, copy=False)
    return (
        LUMA_R * rgb[:, 0]
        + LUMA_G * rgb[:, 1]
        + LUMA_B * rgb[:, 2]
    ).astype(np.uint16)


def luma_from_ids(ids: np.ndarray) -> np.ndarray:
    r = ((ids >> 16) & 255).astype(np.uint32)
    g = ((ids >> 8) & 255).astype(np.uint32)
    b = (ids & 255).astype(np.uint32)
    return (LUMA_R * r + LUMA_G * g + LUMA_B * b).astype(np.uint16)


def unique_rows_preserve_order(rows: Iterable[Tuple[int, int, int]]) -> list[Tuple[int, int, int]]:
    seen: set[Tuple[int, int, int]] = set()
    out: list[Tuple[int, int, int]] = []
    for row in rows:
        t = (int(row[0]), int(row[1]), int(row[2]))
        if t not in seen:
            seen.add(t)
            out.append(t)
    return out


def build_palette_16(guide_img: Image.Image, mode: PaletteMode, quiet: bool = False) -> np.ndarray:
    """
    Build the 16 bucket colors.

    adaptive: median-cut palette from a small guide image, completed with fallback
    colors if the guide has fewer than 16 distinct colors.

    fixed: deterministic 16-color reference palette.
    """
    if mode == "fixed":
        log("Using fixed 16-color bucket palette.", quiet)
        return FALLBACK_16.copy()

    small = guide_img.copy()
    small.thumbnail((512, 512), RESAMPLING_LANCZOS)
    q = small.quantize(colors=16, method=QUANTIZE_MEDIANCUT)
    raw_palette = q.getpalette() or []
    counts = q.getcolors(maxcolors=512) or []

    # Use most frequent quantized colors first.
    colors: list[Tuple[int, int, int]] = []
    for _count, index in sorted(counts, reverse=True):
        offset = int(index) * 3
        if offset + 2 < len(raw_palette):
            colors.append(
                (
                    int(raw_palette[offset]),
                    int(raw_palette[offset + 1]),
                    int(raw_palette[offset + 2]),
                )
            )

    # Complete with fixed reference colors if necessary.
    colors.extend(tuple(map(int, row)) for row in FALLBACK_16)
    unique = unique_rows_preserve_order(colors)[:16]

    if len(unique) != 16:
        raise RuntimeError("Could not construct a 16-color palette.")

    palette = np.array(unique, dtype=np.uint8)
    log("16 bucket palette:", quiet)
    for i, (r, g, b) in enumerate(palette):
        log(f"  {i:02d}: #{int(r):02x}{int(g):02x}{int(b):02x}", quiet)
    return palette


def nearest_palette_labels_for_rgb_flat(
    rgb_flat: np.ndarray,
    palette: np.ndarray,
    chunk_pixels: int,
    quiet: bool = False,
    label: str = "guide pixels",
) -> np.ndarray:
    n = rgb_flat.shape[0]
    labels = np.empty(n, dtype=np.uint8)

    pal = palette.astype(np.int32)
    pal_norm = np.einsum("ij,ij->i", pal, pal).astype(np.int32)

    t0 = time.time()
    for start in range(0, n, chunk_pixels):
        end = min(start + chunk_pixels, n)
        chunk = rgb_flat[start:end].astype(np.int32, copy=False)
        chunk_norm = np.einsum("ij,ij->i", chunk, chunk).astype(np.int32)
        dots = chunk @ pal.T
        dist = chunk_norm[:, None] + pal_norm[None, :] - 2 * dots
        labels[start:end] = np.argmin(dist, axis=1).astype(np.uint8)

        if not quiet and (start == 0 or end == n or (time.time() - t0) > 10):
            pct = 100.0 * end / n
            print(f"  Bucketed {label}: {pct:5.1f}%", flush=True)
            t0 = time.time()

    return labels


def nearest_palette_labels_for_ids(
    ids: np.ndarray,
    palette: np.ndarray,
    chunk_colors: int,
    quiet: bool = False,
    label: str = "colors",
) -> np.ndarray:
    n = ids.size
    labels = np.empty(n, dtype=np.uint8)

    pal = palette.astype(np.int32)
    pr = pal[:, 0]
    pg = pal[:, 1]
    pb = pal[:, 2]
    pal_norm = (pr * pr + pg * pg + pb * pb).astype(np.int32)

    t0 = time.time()
    for start in range(0, n, chunk_colors):
        end = min(start + chunk_colors, n)
        chunk = ids[start:end]

        r = ((chunk >> 16) & 255).astype(np.int32)
        g = ((chunk >> 8) & 255).astype(np.int32)
        b = (chunk & 255).astype(np.int32)

        chunk_norm = r * r + g * g + b * b
        dots = r[:, None] * pr[None, :] + g[:, None] * pg[None, :] + b[:, None] * pb[None, :]
        dist = chunk_norm[:, None] + pal_norm[None, :] - 2 * dots
        labels[start:end] = np.argmin(dist, axis=1).astype(np.uint8)

        if not quiet and n and (start == 0 or end == n or (time.time() - t0) > 10):
            pct = 100.0 * end / n
            print(f"  Bucketed {label}: {pct:5.1f}%", flush=True)
            t0 = time.time()

    return labels


def evenly_spaced_indices(length: int, take: int) -> np.ndarray:
    """Return 'take' indices spread through range(length)."""
    if take <= 0:
        return np.empty(0, dtype=np.int64)
    if take >= length:
        return np.arange(length, dtype=np.int64)
    return np.linspace(0, length - 1, take, dtype=np.int64)


def assign_sorted_by_luma(
    out_ids: np.ndarray,
    assigned_pos: np.ndarray,
    positions: np.ndarray,
    colors: np.ndarray,
    guide_luma_flat: np.ndarray,
    color_luma: Optional[np.ndarray] = None,
) -> None:
    """Assign colors to positions, matching dark-to-light within the given set."""
    if positions.size != colors.size:
        raise ValueError("positions and colors must have the same length")
    if colors.size == 0:
        return

    pos_order = np.argsort(guide_luma_flat[positions], kind="stable")
    if color_luma is None:
        color_luma = luma_from_ids(colors)
    color_order = np.argsort(color_luma, kind="stable")

    sorted_positions = positions[pos_order]
    sorted_colors = colors[color_order]
    out_ids[sorted_positions] = sorted_colors
    assigned_pos[sorted_positions] = True


def place_color_ids_by_buckets(
    *,
    out_ids: np.ndarray,
    assigned_pos: np.ndarray,
    guide_labels: np.ndarray,
    guide_luma_flat: np.ndarray,
    color_ids: np.ndarray,
    palette: np.ndarray,
    chunk_colors: int,
    quiet: bool,
    stage_name: str,
) -> None:
    """
    Place a unique set of color ids into still-unassigned pixels.

    First pass: exact bucket-to-bucket assignment.
    Fallback: any remaining colors are assigned to any remaining pixels by
    luminance. This guarantees completion even when bucket counts do not match.
    """
    if color_ids.size == 0:
        log(f"{stage_name}: no colors to place.", quiet)
        return

    available_before = int(np.count_nonzero(~assigned_pos))
    if color_ids.size > available_before:
        raise RuntimeError(
            f"{stage_name}: trying to place {color_ids.size:,} colors into "
            f"only {available_before:,} open pixels."
        )

    log(f"{stage_name}: bucket-matching {color_ids.size:,} colors.", quiet)
    color_labels = nearest_palette_labels_for_ids(
        color_ids,
        palette,
        chunk_colors=chunk_colors,
        quiet=quiet,
        label=stage_name,
    )
    color_luma_all = luma_from_ids(color_ids)
    color_was_assigned = np.zeros(color_ids.size, dtype=bool)

    for bucket in range(16):
        color_local = np.flatnonzero(color_labels == bucket)
        if color_local.size == 0:
            continue

        # Current open positions whose guide pixel wants this bucket.
        positions = np.flatnonzero((guide_labels == bucket) & (~assigned_pos))
        if positions.size == 0:
            continue

        n = min(color_local.size, positions.size)
        if n == 0:
            continue

        # Sort both sides by luminance, then take evenly if one side is bigger.
        # This avoids always taking the darkest or brightest colors from an
        # over-supplied bucket.
        colors_for_bucket = color_ids[color_local]
        luma_for_bucket = color_luma_all[color_local]

        color_order = np.argsort(luma_for_bucket, kind="stable")
        pos_order = np.argsort(guide_luma_flat[positions], kind="stable")

        color_take = evenly_spaced_indices(color_order.size, n)
        pos_take = evenly_spaced_indices(pos_order.size, n)

        chosen_color_local = color_local[color_order[color_take]]
        chosen_positions = positions[pos_order[pos_take]]
        chosen_colors = color_ids[chosen_color_local]

        # The chosen arrays are already dark-to-light because they are selected
        # from sorted orders.
        out_ids[chosen_positions] = chosen_colors
        assigned_pos[chosen_positions] = True
        color_was_assigned[chosen_color_local] = True

        log(
            f"  Bucket {bucket:02d}: placed {n:,} / {color_local.size:,} colors "
            f"into {positions.size:,} matching pixels.",
            quiet,
        )

    leftover = color_ids[~color_was_assigned]
    if leftover.size:
        open_positions = np.flatnonzero(~assigned_pos)
        if leftover.size > open_positions.size:
            raise RuntimeError(
                f"{stage_name}: fallback has {leftover.size:,} colors but only "
                f"{open_positions.size:,} pixels."
            )

        # Use all leftover colors. If there are more open positions than leftover
        # colors, choose positions spread over the remaining luminance range.
        pos_order = np.argsort(guide_luma_flat[open_positions], kind="stable")
        pos_take = evenly_spaced_indices(pos_order.size, leftover.size)
        chosen_positions = open_positions[pos_order[pos_take]]
        assign_sorted_by_luma(
            out_ids=out_ids,
            assigned_pos=assigned_pos,
            positions=chosen_positions,
            colors=leftover,
            guide_luma_flat=guide_luma_flat,
        )
        log(f"{stage_name}: fallback-placed {leftover.size:,} colors.", quiet)
    else:
        log(f"{stage_name}: no fallback needed.", quiet)


def place_first_visible_source_occurrences(
    *,
    out_ids: np.ndarray,
    assigned_pos: np.ndarray,
    source_mask: np.ndarray,
    placed_source_mask: np.ndarray,
    stamp_ids: np.ndarray,
    quiet: bool,
) -> int:
    """
    Place source colors where they first occur in a nearest-neighbor fitted guide.

    This gives the source palette a spatial anchor before bucket filling starts.
    """
    log("Placing first visible occurrence of each source color.", quiet)
    values, first_indices = np.unique(stamp_ids, return_index=True)
    is_source_color = source_mask[values]
    values = values[is_source_color].astype(np.uint32, copy=False)
    first_indices = first_indices[is_source_color]

    # If two colors somehow map to the same first index, this would be a problem,
    # but np.unique returns one first index per distinct value, so they are unique.
    out_ids[first_indices] = values
    assigned_pos[first_indices] = True
    placed_source_mask[values] = True

    placed = int(values.size)
    log(f"Placed {placed:,} source colors at visible source locations.", quiet)
    return placed


def verify_allrgb(out_ids: np.ndarray, quiet: bool = False) -> None:
    log("Verifying exact allRGB coverage.", quiet)

    if out_ids.size != TOTAL_COLORS:
        raise RuntimeError(f"Output has {out_ids.size:,} pixels, expected {TOTAL_COLORS:,}.")

    # Strong practical proof: with exactly 16,777,216 pixels, seeing all possible
    # color ids means every color appears exactly once.
    seen = np.zeros(TOTAL_COLORS, dtype=bool)
    seen[out_ids] = True
    seen_count = int(np.count_nonzero(seen))
    if seen_count != TOTAL_COLORS:
        raise RuntimeError(
            f"Verification failed: saw {seen_count:,} unique RGB colors, "
            f"expected {TOTAL_COLORS:,}."
        )

    # Additional cheap invariants. Useful when debugging.
    expected_sum = TOTAL_COLORS * (TOTAL_COLORS - 1) // 2
    actual_sum = int(out_ids.astype(np.uint64).sum())
    if actual_sum != expected_sum:
        raise RuntimeError(f"Verification failed: sum mismatch {actual_sum} != {expected_sum}.")

    actual_xor = int(np.bitwise_xor.reduce(out_ids).item())
    if actual_xor != 0:
        raise RuntimeError(f"Verification failed: xor mismatch {actual_xor} != 0.")

    log("Verification passed: every 24-bit RGB color is present exactly once.", quiet)


def process_image(
    input_path: str | Path,
    output_path: str | Path,
    *,
    fit: FitMode = "cover",
    palette_mode: PaletteMode = "adaptive",
    alpha_background: Tuple[int, int, int] = (255, 255, 255),
    chunk_pixels: int = 262_144,
    chunk_colors: int = 262_144,
    verify: bool = True,
    png_compress_level: int = 6,
    quiet: bool = False,
) -> Path:
    """
    Main importable function.

    Args:
        input_path: Source image path.
        output_path: PNG output path.
        fit: cover, contain, or stretch.
        palette_mode: adaptive or fixed.
        alpha_background: RGB background used for transparent pixels/padding.
        chunk_pixels/chunk_colors: Lower these if your machine has limited RAM.
        verify: If True, prove all 24-bit RGB colors are present once.
        png_compress_level: 0..9; lower is faster/larger, higher is smaller/slower.
        quiet: Suppress progress messages.
    """
    input_path = Path(input_path)
    output_path = Path(output_path)
    output_path.parent.mkdir(parents=True, exist_ok=True)

    log(f"Input:  {input_path}", quiet)
    log(f"Output: {output_path}", quiet)

    src = normalize_to_rgb(input_path, alpha_background)
    log(f"Loaded source image: {src.size[0]:,} x {src.size[1]:,}", quiet)

    log("Extracting unique source RGB colors.", quiet)
    source_ids_all = rgb_image_to_ids(src)
    source_unique_ids = np.unique(source_ids_all).astype(np.uint32, copy=False)
    del source_ids_all

    if source_unique_ids.size > TOTAL_COLORS:
        # This cannot happen for RGB, but keep the guard for clarity.
        raise RuntimeError("Source image has more unique RGB colors than the output can contain.")

    log(f"Source unique colors: {source_unique_ids.size:,}", quiet)

    source_mask = np.zeros(TOTAL_COLORS, dtype=bool)
    source_mask[source_unique_ids] = True
    placed_source_mask = np.zeros(TOTAL_COLORS, dtype=bool)

    log(f"Creating {CANVAS_SIZE:,} x {CANVAS_SIZE:,} guide images.", quiet)
    guide_img = fit_image(
        src,
        (CANVAS_SIZE, CANVAS_SIZE),
        mode=fit,
        resample=RESAMPLING_LANCZOS,
        background=alpha_background,
    )
    stamp_img = fit_image(
        src,
        (CANVAS_SIZE, CANVAS_SIZE),
        mode=fit,
        resample=RESAMPLING_NEAREST,
        background=alpha_background,
    )

    palette = build_palette_16(guide_img, mode=palette_mode, quiet=quiet)

    log("Bucket-labeling guide pixels.", quiet)
    guide_arr = np.asarray(guide_img, dtype=np.uint8)
    guide_flat = guide_arr.reshape(-1, 3)
    guide_labels = nearest_palette_labels_for_rgb_flat(
        guide_flat,
        palette,
        chunk_pixels=chunk_pixels,
        quiet=quiet,
        label="guide pixels",
    )
    guide_luma_flat = luma_from_rgb_flat(guide_flat)
    del guide_arr, guide_flat, guide_img

    out_ids = np.empty(TOTAL_COLORS, dtype=np.uint32)
    assigned_pos = np.zeros(TOTAL_COLORS, dtype=bool)

    stamp_ids = rgb_image_to_ids(stamp_img)
    del stamp_img

    place_first_visible_source_occurrences(
        out_ids=out_ids,
        assigned_pos=assigned_pos,
        source_mask=source_mask,
        placed_source_mask=placed_source_mask,
        stamp_ids=stamp_ids,
        quiet=quiet,
    )
    del stamp_ids

    unplaced_source_ids = source_unique_ids[~placed_source_mask[source_unique_ids]]
    log(f"Unplaced source colors after visible pass: {unplaced_source_ids.size:,}", quiet)

    place_color_ids_by_buckets(
        out_ids=out_ids,
        assigned_pos=assigned_pos,
        guide_labels=guide_labels,
        guide_luma_flat=guide_luma_flat,
        color_ids=unplaced_source_ids,
        palette=palette,
        chunk_colors=chunk_colors,
        quiet=quiet,
        stage_name="Source-color completion",
    )
    del unplaced_source_ids, placed_source_mask

    log("Building remaining RGB color set.", quiet)
    remaining_ids = np.flatnonzero(~source_mask).astype(np.uint32, copy=False)
    del source_mask, source_unique_ids
    log(f"Remaining non-source colors: {remaining_ids.size:,}", quiet)

    place_color_ids_by_buckets(
        out_ids=out_ids,
        assigned_pos=assigned_pos,
        guide_labels=guide_labels,
        guide_luma_flat=guide_luma_flat,
        color_ids=remaining_ids,
        palette=palette,
        chunk_colors=chunk_colors,
        quiet=quiet,
        stage_name="Remaining allRGB completion",
    )
    del remaining_ids, guide_labels, guide_luma_flat

    open_count = int(np.count_nonzero(~assigned_pos))
    if open_count != 0:
        raise RuntimeError(f"Internal error: {open_count:,} pixels were left unassigned.")
    del assigned_pos

    if verify:
        verify_allrgb(out_ids, quiet=quiet)

    log("Converting color ids to RGB image.", quiet)
    out_rgb = ids_to_rgb_array(out_ids, size=CANVAS_SIZE)
    del out_ids

    log("Saving PNG.", quiet)
    Image.fromarray(out_rgb, mode="RGB").save(
        output_path,
        format="PNG",
        optimize=False,
        compress_level=int(png_compress_level),
    )
    log(f"Done: {output_path}", quiet)
    return output_path


def default_output_path(input_path: str | Path) -> Path:
    input_path = Path(input_path)
    stamp = time.strftime("%Y%m%d-%H%M%S")
    return input_path.with_name(f"{input_path.stem}-allrgb-{stamp}.png")


def in_google_colab() -> bool:
    try:
        import google.colab  # type: ignore  # noqa: F401
        return True
    except Exception:
        return False


def run_colab_sketch() -> None:
    from google.colab import files  # type: ignore

    print("Upload a source image.")
    uploaded = files.upload()
    if not uploaded:
        print("No file uploaded.")
        return

    input_name = next(iter(uploaded.keys()))
    output_name = f"{Path(input_name).stem}-allrgb.png"

    result = process_image(
        input_name,
        output_name,
        fit="cover",
        palette_mode="adaptive",
        alpha_background=(255, 255, 255),
        verify=True,
        png_compress_level=6,
        quiet=False,
    )
    files.download(str(result))


def build_arg_parser() -> argparse.ArgumentParser:
    parser = argparse.ArgumentParser(
        description="Create a 4096x4096 allRGB image guided by an input image."
    )
    parser.add_argument("input", nargs="?", help="Input image file")
    parser.add_argument("output", nargs="?", help="Output PNG file")
    parser.add_argument(
        "--fit",
        choices=["cover", "contain", "stretch"],
        default="cover",
        help="How to fit the source image into the square output. Default: cover.",
    )
    parser.add_argument(
        "--palette-mode",
        choices=["adaptive", "fixed"],
        default="adaptive",
        help="Use source-adaptive or fixed 16-color buckets. Default: adaptive.",
    )
    parser.add_argument(
        "--alpha-bg",
        default="#ffffff",
        help="Background for transparency and contain-padding. Default: #ffffff.",
    )
    parser.add_argument(
        "--chunk-pixels",
        type=int,
        default=262_144,
        help="Guide-pixel chunk size. Lower this to reduce RAM. Default: 262144.",
    )
    parser.add_argument(
        "--chunk-colors",
        type=int,
        default=262_144,
        help="RGB-color chunk size. Lower this to reduce RAM. Default: 262144.",
    )
    parser.add_argument(
        "--no-verify",
        action="store_true",
        help="Skip exact allRGB verification. Not recommended.",
    )
    parser.add_argument(
        "--png-compress-level",
        type=int,
        default=6,
        choices=range(0, 10),
        metavar="0..9",
        help="PNG compression level. Lower is faster/larger. Default: 6.",
    )
    parser.add_argument("--quiet", action="store_true", help="Suppress progress messages.")
    return parser


def strip_notebook_kernel_args(argv: list[str]) -> list[str]:
    """
    Remove arguments injected by Jupyter/Colab when code is run from a notebook.

    In Colab, sys.argv often looks like:
        ["-f", "/root/.local/share/jupyter/runtime/kernel-xxxx.json"]

    Those arguments belong to the notebook kernel, not to this script. Without this
    cleanup, argparse exits with "unrecognized arguments: -f".
    """
    cleaned: list[str] = []
    i = 0
    while i < len(argv):
        arg = argv[i]

        # Jupyter/IPython kernel connection file. It can appear as either
        # "-f path/to/kernel.json" or "--f=path/to/kernel.json".
        if arg == "-f" and i + 1 < len(argv):
            i += 2
            continue
        if arg.startswith("--f=") or arg.startswith("-f="):
            i += 1
            continue

        # Be conservative: only drop obvious kernel json files, not arbitrary
        # positional paths the user may have supplied.
        if arg.endswith(".json") and "kernel-" in Path(arg).name:
            i += 1
            continue

        cleaned.append(arg)
        i += 1

    return cleaned


def main(argv: Optional[list[str]] = None) -> int:
    if argv is None:
        argv = sys.argv[1:]

    if in_google_colab():
        argv = strip_notebook_kernel_args(list(argv))
        if not argv:
            run_colab_sketch()
            return 0

    parser = build_arg_parser()
    args = parser.parse_args(argv)

    if not args.input:
        parser.print_help()
        return 2

    output = args.output or str(default_output_path(args.input))
    alpha_background = parse_hex_rgb(args.alpha_bg)

    process_image(
        args.input,
        output,
        fit=args.fit,
        palette_mode=args.palette_mode,
        alpha_background=alpha_background,
        chunk_pixels=args.chunk_pixels,
        chunk_colors=args.chunk_colors,
        verify=not args.no_verify,
        png_compress_level=args.png_compress_level,
        quiet=args.quiet,
    )
    return 0


if __name__ == "__main__":
    raise SystemExit(main())

Author

ACJ
76 entries

Stats

Date
Colors16,777,216
Pixels16,777,216
Dimensions4,096 × 4,096
Bytes47,440,187