From 6eb0ab7d5a4718306889f5b7463dca7a2d4d2717 Mon Sep 17 00:00:00 2001 From: Abdussamet Kocak Date: Thu, 8 Oct 2026 11:27:31 +0000 Subject: [PATCH] feat(imageset_thumbnails): split sheets into pages --- imageset_thumbnails.py | 67 ++++++++++++++++++++++++++++++------------ 1 file changed, 49 insertions(+), 18 deletions(-) diff --git a/imageset_thumbnails.py b/imageset_thumbnails.py index c8ce2c7..a8c8ab5 100755 --- a/imageset_thumbnails.py +++ b/imageset_thumbnails.py @@ -59,6 +59,7 @@ def parse_args() -> argparse.Namespace: parser.add_argument("--output", type=Path, help="JPEG output for a single existing input; defaults to _thumbs.jpg beside each input") parser.add_argument("--width", type=int, default=2500, help="Sheet width in pixels") parser.add_argument("--target-height", type=int, default=300, help="Preferred row height in pixels; rows may be taller or shorter") + parser.add_argument("--max-rows-per-page", type=int, help="Maximum rows per output image; additional rows are written to numbered pages") parser.add_argument("--gap", type=int, default=8, help="Gap between images and at sheet edges in pixels") parser.add_argument("--bg-color", default="white", help="Background color as a color name or hex value") parser.add_argument("--workers", type=int, default=os.cpu_count() or 1, help="Number of resize worker processes") @@ -69,6 +70,8 @@ def parse_args() -> argparse.Namespace: parser.error("width must exceed twice the gap") if arguments.workers <= 0: parser.error("workers must be positive") + if arguments.max_rows_per_page is not None and arguments.max_rows_per_page <= 0: + parser.error("maximum rows per page must be positive") try: ImageColor.getcolor(color=arguments.bg_color, mode="RGB") except ValueError as error: @@ -141,7 +144,14 @@ def discover_sources(input_path: Path, output_path: Path, archive: ZipFile | Non candidates = [ Source(name=path.relative_to(input_path).as_posix(), path=path) for path in input_path.rglob("*") - if path.is_file() and path.suffix.lower() in extensions and path.resolve() != output_path.resolve() + if path.is_file() + and path.suffix.lower() in extensions + and path.resolve() != output_path.resolve() + and not ( + path.parent.resolve() == output_path.parent.resolve() + and path.suffix.lower() == output_path.suffix.lower() + and re.fullmatch(pattern=rf"{re.escape(output_path.stem)}_page_[0-9]+", string=path.stem) + ) ] sources = [] logging.info(f"Reading metadata for {len(candidates)} images") @@ -331,6 +341,7 @@ def create_sheet( gap: int, bg_color: str = "white", workers: int | None = None, + max_rows_per_page: int | None = None, processor: Callable[..., bytes] = resize_with_pillow, ) -> None: if width <= 0 or target_height <= 0 or gap < 0 or width <= 2 * gap: @@ -343,30 +354,49 @@ def create_sheet( raise ValueError("Output must differ from input") if workers is not None and workers <= 0: raise ValueError("Workers must be positive") + if max_rows_per_page is not None and max_rows_per_page <= 0: + raise ValueError("Maximum rows per page must be positive") background = ImageColor.getcolor(color=bg_color, mode="RGB") with ExitStack() as stack: archive = None if input_path.is_dir() else stack.enter_context(ZipFile(file=input_path)) sources = discover_sources(input_path=input_path, output_path=output_path, archive=archive) rows = layout_rows(sources=sources, width=width, gap=gap, target_height=target_height) - height = sum(row.height for row in rows) + gap * (len(rows) + 1) worker_count = min(workers if workers is not None else os.cpu_count() or 1, len(sources)) - logging.info(f"Rendering {len(sources)} images in {len(rows)} rows on a {width}x{height} sheet using {worker_count} workers") - sheet = stack.enter_context(Image.new(mode="RGB", size=(width, height), color=background)) - started = perf_counter() - rendered_count = render_rows( - sheet=sheet, - rows=rows, - input_path=input_path, - workers=worker_count, - gap=gap, - processor=processor, - ) - if not rendered_count: + page_size = max_rows_per_page or len(rows) + total_rendered = 0 + page_count = (len(rows) + page_size - 1) // page_size + for page_index, start in enumerate(range(0, len(rows), page_size), start=1): + page_rows = rows[start : start + page_size] + page_sources = sum(len(row.sources) for row in page_rows) + height = sum(row.height for row in page_rows) + gap * (len(page_rows) + 1) + page_output = output_path if page_index == 1 else output_path.with_name( + f"{output_path.stem}_page_{page_index}{output_path.suffix}" + ) + logging.info( + f"Rendering page {page_index}/{page_count}: {page_sources} images in {len(page_rows)} rows " + f"on a {width}x{height} sheet using {worker_count} workers" + ) + started = perf_counter() + with Image.new(mode="RGB", size=(width, height), color=background) as sheet: + rendered_count = render_rows( + sheet=sheet, + rows=page_rows, + input_path=input_path, + workers=worker_count, + gap=gap, + processor=processor, + ) + if not rendered_count: + logging.warning(f"Skipping empty page {page_index}: no images could be decoded") + continue + total_rendered += rendered_count + logging.info(f"Rendering finished in {perf_counter() - started:.1f}s; saving {page_output}") + page_output.parent.mkdir(parents=True, exist_ok=True) + sheet.save(fp=page_output, quality=95) + logging.info(f"Wrote {page_output} ({rendered_count} images, {width}x{height})") + if not total_rendered: raise ValueError("No usable images could be decoded") - logging.info(f"Rendering finished in {perf_counter() - started:.1f}s; saving {output_path}") - output_path.parent.mkdir(parents=True, exist_ok=True) - sheet.save(fp=output_path, quality=95) - logging.info(f"Wrote {output_path} ({rendered_count} images, {width}x{height})") + logging.info(f"Wrote {total_rendered} images across {page_count} page(s)") def main() -> None: @@ -400,6 +430,7 @@ def main() -> None: gap=arguments.gap, bg_color=arguments.bg_color, workers=arguments.workers, + max_rows_per_page=arguments.max_rows_per_page, processor=processor, ) except (OSError, ValueError, RuntimeError, BadZipFile) as error: