221 lines
7.1 KiB
Python
Executable File
221 lines
7.1 KiB
Python
Executable File
#!/usr/bin/env python3.9
|
|
import argparse
|
|
import dataclasses
|
|
import io
|
|
import itertools
|
|
import logging
|
|
import math
|
|
import statistics
|
|
import sys
|
|
import typing
|
|
from pathlib import Path
|
|
|
|
from PIL import Image
|
|
from PIL import ImageDraw
|
|
from PIL import ImageFont
|
|
|
|
T = typing.TypeVar("T")
|
|
|
|
logger = logging.getLogger("imgsheet")
|
|
|
|
|
|
def chunk(it: typing.Iterable[T], size: int) -> typing.Iterable[typing.Iterable[T]]:
|
|
it = iter(it)
|
|
sentinel = ()
|
|
return iter(lambda: tuple(itertools.islice(it, size)), sentinel)
|
|
|
|
|
|
@dataclasses.dataclass
|
|
class PositionedImage:
|
|
path: Path
|
|
image_size: typing.Tuple[int, int]
|
|
size: typing.Tuple[int, int] = dataclasses.field(default_factory=lambda: (0, 0))
|
|
position: typing.Tuple[int, int] = dataclasses.field(default_factory=lambda: (0, 0))
|
|
|
|
@property
|
|
def top(self) -> int:
|
|
return self.position[1]
|
|
|
|
@property
|
|
def left(self) -> int:
|
|
return self.position[0]
|
|
|
|
@property
|
|
def bottom(self) -> int:
|
|
return self.height + self.top
|
|
|
|
@property
|
|
def right(self) -> int:
|
|
return self.width + self.left
|
|
|
|
@property
|
|
def width(self) -> int:
|
|
return self.size[0]
|
|
|
|
@property
|
|
def height(self) -> int:
|
|
return self.size[1]
|
|
|
|
def __post_init__(self):
|
|
self.size = self.image_size
|
|
|
|
def scale(self, scale: float) -> None:
|
|
self.size = (math.ceil(self.width * scale), math.ceil(self.height * scale))
|
|
|
|
|
|
class ImageSheet:
|
|
def __init__(
|
|
self,
|
|
width: int,
|
|
columns: int = 6,
|
|
font_path: typing.Optional[Path] = None,
|
|
label_format: typing.Optional[str] = None,
|
|
label_font_size: typing.Optional[int] = 10,
|
|
):
|
|
self.width = width
|
|
self.padding = 20
|
|
self.columns = columns
|
|
self.font_path = font_path
|
|
self.label_format = label_format
|
|
self.label_font_size = label_font_size
|
|
|
|
def create(self, image_paths: typing.Iterable[Path]) -> io.BytesIO:
|
|
images = []
|
|
for it in image_paths:
|
|
with Image.open(it) as img:
|
|
images.append(PositionedImage(it, img.size))
|
|
|
|
if self.font_path and self.font_path.is_file():
|
|
font = ImageFont.truetype(str(self.font_path), self.label_font_size)
|
|
else:
|
|
font = None
|
|
|
|
positioned = self._calculate_positions(images)
|
|
image_sheet = Image.new("RGB", (self.width, 0))
|
|
for row_images in positioned:
|
|
height = row_images[0].top + row_images[0].size[1]
|
|
|
|
extended_sheet = Image.new("RGB", (self.width, height + self.padding))
|
|
extended_sheet.paste(image_sheet, (0, 0))
|
|
image_sheet = extended_sheet
|
|
|
|
draw = ImageDraw.Draw(image_sheet)
|
|
for it in row_images:
|
|
img = Image.open(it.path)
|
|
img.thumbnail(it.size, Image.LANCZOS)
|
|
image_sheet.paste(img, it.position)
|
|
|
|
if font:
|
|
# write filename on image
|
|
text_x, text_y = it.left + 2, it.bottom + 2
|
|
filesize = it.path.stat().st_size / 1_048_576
|
|
|
|
text = self._render_text(
|
|
width=it.image_size[0],
|
|
height=it.image_size[1],
|
|
size_mb=filesize,
|
|
filename=it.path.name,
|
|
)
|
|
if text:
|
|
draw.text((text_x, text_y), text, (255, 255, 255), font=font, align="left", anchor="la")
|
|
|
|
print(f"{it.path.name}\t{it.position}\t{it.size}")
|
|
|
|
f = io.BytesIO()
|
|
image_sheet.save(f, format="JPEG")
|
|
return f
|
|
|
|
def _render_text(self, width: int, height: int, size_mb: float, filename: str) -> typing.Optional[str]:
|
|
if not self.label_format:
|
|
return None
|
|
|
|
return self.label_format.format(width=width, height=height, size_mb=size_mb, filename=filename)
|
|
|
|
def _calculate_positions(self, images: list[PositionedImage]) -> list[list[PositionedImage]]:
|
|
cols = self.columns
|
|
width = self.width
|
|
padding = self.padding
|
|
|
|
positioned = []
|
|
|
|
rows = list(chunk(images, cols))
|
|
x, y = 0, 0
|
|
for row, row_images in enumerate(rows):
|
|
row_images = list(row_images)
|
|
|
|
usable_width = width - (len(row_images) - 1) * padding
|
|
max_height = max(image.height for image in row_images)
|
|
# check if there are enough images to fill the row
|
|
if len(row_images) < cols:
|
|
mean_height = statistics.median(prow[0].height for prow in positioned)
|
|
max_height = mean_height
|
|
|
|
for img in row_images:
|
|
y_scaling = max_height / img.height
|
|
img.scale(y_scaling)
|
|
|
|
if len(row_images) >= cols:
|
|
total_width = sum(image.width for image in row_images)
|
|
x_scaling = usable_width / total_width
|
|
max_height = math.ceil(max_height * x_scaling)
|
|
for img in row_images:
|
|
img.scale(x_scaling)
|
|
|
|
is_last_row = row == len(rows) - 1
|
|
x = 0
|
|
for i, img in enumerate(row_images):
|
|
img.position = (x, y)
|
|
is_last_col = i == len(row_images) - 1
|
|
x = x + img.width + (0 if is_last_col else padding)
|
|
y = y + max_height + (0 if is_last_row else padding)
|
|
|
|
positioned.append(row_images)
|
|
|
|
return positioned
|
|
|
|
|
|
def parse_args(argv: typing.List[str]) -> argparse.Namespace:
|
|
arger = argparse.ArgumentParser(
|
|
description="Create a sheet of images", formatter_class=argparse.ArgumentDefaultsHelpFormatter
|
|
)
|
|
arger.add_argument("--width", "-w", type=int, default=2000, help="Width of the sheet")
|
|
arger.add_argument("--columns", "-c", type=int, default=4, help="Number of columns")
|
|
arger.add_argument("--output", "-o", type=Path, default=Path("image_sheet.jpg"), help="Output file")
|
|
arger.add_argument("--font", dest="font_path", type=Path, help="Label font to use")
|
|
arger.add_argument("--font-size", dest="font_size", default=10, type=int, help="Label font size")
|
|
arger.add_argument(
|
|
"--label",
|
|
dest="label_format",
|
|
default="{width}x{height} {size_mb:.1f}MB {filename}",
|
|
help="Label format. Available tokens: width, height, size_mb, filename",
|
|
)
|
|
arger.add_argument("images", nargs="+", type=Path, help="Images or image directory to include in the sheet")
|
|
if len(argv) == 0:
|
|
arger.print_help()
|
|
sys.exit(1)
|
|
return arger.parse_args(argv)
|
|
|
|
|
|
def main():
|
|
args = parse_args(sys.argv[1:])
|
|
|
|
images: list[Path] = args.images
|
|
if len(images) == 1 and images[0].expanduser().is_dir():
|
|
images = sorted(it for it in images[0].resolve().glob("*.jpg"))
|
|
|
|
sheet = ImageSheet(
|
|
width=args.width,
|
|
columns=args.columns,
|
|
font_path=args.font_path,
|
|
label_font_size=args.font_size,
|
|
label_format=args.label_format,
|
|
)
|
|
|
|
sheet_img = sheet.create(images)
|
|
with args.output.open("wb") as f:
|
|
f.write(sheet_img.getvalue())
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|