initial commit
This commit is contained in:
Executable
+220
@@ -0,0 +1,220 @@
|
||||
#!/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()
|
||||
Reference in New Issue
Block a user