initial commit

This commit is contained in:
2023-02-18 07:39:08 +01:00
commit e63ab7b77e
63 changed files with 7122 additions and 0 deletions
Executable
+220
View File
@@ -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()