Enhance thumbnail generation
This commit is contained in:
+144
-11
@@ -2,6 +2,7 @@ from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import tempfile
|
||||
import zipfile
|
||||
@@ -34,6 +35,9 @@ except Exception: # pragma: no cover
|
||||
ImageDraw = None
|
||||
ImageFont = None
|
||||
|
||||
PREVIEW_MEMBER_RE = re.compile(r"^Metadata/plate_(\d+)(?:_small)?\.png$", re.IGNORECASE)
|
||||
MAX_COMPOSITE_PREVIEW_INPUTS = 9
|
||||
|
||||
|
||||
def load_plate_sources(job: PlateJob) -> list[PlateSource]:
|
||||
if job.copies < 1:
|
||||
@@ -99,6 +103,14 @@ def process_plate_gcode(
|
||||
return text.rstrip("\n") + "\n"
|
||||
|
||||
|
||||
def save_png_bytes(image: object) -> bytes:
|
||||
import io
|
||||
|
||||
output = io.BytesIO()
|
||||
image.save(output, format="PNG")
|
||||
return output.getvalue()
|
||||
|
||||
|
||||
def update_preview_label(png_bytes: bytes, label: str, small: bool = False) -> bytes:
|
||||
if Image is None or ImageDraw is None:
|
||||
return png_bytes
|
||||
@@ -129,9 +141,133 @@ def update_preview_label(png_bytes: bytes, label: str, small: bool = False) -> b
|
||||
x = margin
|
||||
y = image.height - margin - (text_bbox[3] - text_bbox[1])
|
||||
draw.text((x, y), label, font=font, fill=(0, 255, 0, 255))
|
||||
output = io.BytesIO()
|
||||
image.save(output, format="PNG")
|
||||
return output.getvalue()
|
||||
return save_png_bytes(image)
|
||||
|
||||
|
||||
def preview_member_name_for_gcode_member(gcode_member: str, small: bool = False) -> str | None:
|
||||
match = GCODE_MEMBER_RE.match(gcode_member)
|
||||
if not match:
|
||||
return None
|
||||
suffix = "_small" if small else ""
|
||||
return f"Metadata/plate_{match.group(1)}{suffix}.png"
|
||||
|
||||
|
||||
def preview_member_sort_key(member_name: str) -> tuple[int, int, str]:
|
||||
match = PREVIEW_MEMBER_RE.match(member_name)
|
||||
plate_number = int(match.group(1)) if match else 9999
|
||||
is_small = 1 if member_name.lower().endswith("_small.png") else 0
|
||||
return (is_small, plate_number, member_name.lower())
|
||||
|
||||
|
||||
def select_preview_sources(sources: list[PlateSource], max_inputs: int = MAX_COMPOSITE_PREVIEW_INPUTS) -> list[PlateSource]:
|
||||
selected: list[PlateSource] = []
|
||||
seen_paths: set[Path] = set()
|
||||
for source in sources:
|
||||
key = source.source_3mf.resolve(strict=False)
|
||||
if key in seen_paths:
|
||||
continue
|
||||
seen_paths.add(key)
|
||||
selected.append(source)
|
||||
if len(selected) >= max_inputs:
|
||||
break
|
||||
return selected
|
||||
|
||||
|
||||
def source_preview_member(archive: zipfile.ZipFile, source: PlateSource, small: bool = False) -> str | None:
|
||||
names = set(archive.namelist())
|
||||
active_member = resolve_output_gcode_member(archive, source.member_name)
|
||||
preferred = preview_member_name_for_gcode_member(active_member, small)
|
||||
if preferred in names:
|
||||
return preferred
|
||||
alternate = preview_member_name_for_gcode_member(active_member, not small)
|
||||
if alternate in names:
|
||||
return alternate
|
||||
|
||||
candidates = [
|
||||
name
|
||||
for name in names
|
||||
if PREVIEW_MEMBER_RE.match(name) and name.lower().endswith("_small.png") == small
|
||||
]
|
||||
if not candidates:
|
||||
candidates = [name for name in names if PREVIEW_MEMBER_RE.match(name)]
|
||||
if not candidates:
|
||||
candidates = [
|
||||
name
|
||||
for name in names
|
||||
if name.lower().startswith("metadata/") and name.lower().endswith(".png")
|
||||
]
|
||||
if not candidates:
|
||||
return None
|
||||
return sorted(candidates, key=preview_member_sort_key)[0]
|
||||
|
||||
|
||||
def read_source_preview_image(source: PlateSource, small: bool = False) -> object | None:
|
||||
if Image is None:
|
||||
return None
|
||||
import io
|
||||
|
||||
try:
|
||||
with zipfile.ZipFile(source.source_3mf, "r") as archive:
|
||||
member = source_preview_member(archive, source, small)
|
||||
if member is None:
|
||||
return None
|
||||
data = archive.read(member)
|
||||
return Image.open(io.BytesIO(data)).convert("RGBA")
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def preview_grid_dimensions(count: int) -> tuple[int, int]:
|
||||
if count <= 1:
|
||||
return (1, 1)
|
||||
if count <= 2:
|
||||
return (2, 1)
|
||||
if count <= 4:
|
||||
return (2, 2)
|
||||
return (3, (count + 2) // 3)
|
||||
|
||||
|
||||
def resize_image_to_fit(image: object, width: int, height: int) -> object:
|
||||
resized = image.copy()
|
||||
resampling = getattr(getattr(Image, "Resampling", Image), "LANCZOS", Image.BICUBIC)
|
||||
resized.thumbnail((max(1, width), max(1, height)), resampling)
|
||||
return resized
|
||||
|
||||
|
||||
def compose_preview_image(png_bytes: bytes, sources: list[PlateSource], label: str, small: bool = False) -> bytes:
|
||||
if Image is None:
|
||||
return png_bytes
|
||||
import io
|
||||
|
||||
try:
|
||||
base_image = Image.open(io.BytesIO(png_bytes)).convert("RGBA")
|
||||
except Exception:
|
||||
return update_preview_label(png_bytes, label, small)
|
||||
|
||||
source_images = [
|
||||
image
|
||||
for image in (read_source_preview_image(source, small) for source in select_preview_sources(sources))
|
||||
if image is not None
|
||||
]
|
||||
if not source_images:
|
||||
return update_preview_label(png_bytes, label, small)
|
||||
|
||||
width, height = base_image.size
|
||||
canvas = Image.new("RGBA", (width, height), (255, 255, 255, 0))
|
||||
columns, rows = preview_grid_dimensions(len(source_images))
|
||||
for index, image in enumerate(source_images):
|
||||
column = index % columns
|
||||
row = index // columns
|
||||
left = width * column // columns
|
||||
right = width * (column + 1) // columns
|
||||
top = height * row // rows
|
||||
bottom = height * (row + 1) // rows
|
||||
fitted = resize_image_to_fit(image, right - left, bottom - top)
|
||||
x = left + max(0, (right - left - fitted.width) // 2)
|
||||
y = top + max(0, (bottom - top - fitted.height) // 2)
|
||||
canvas.paste(fitted, (x, y), fitted)
|
||||
|
||||
return update_preview_label(save_png_bytes(canvas), label, small)
|
||||
|
||||
|
||||
def resolve_output_gcode_member(archive: zipfile.ZipFile, fallback_member: str) -> str:
|
||||
@@ -148,14 +284,11 @@ def resolve_output_gcode_member(archive: zipfile.ZipFile, fallback_member: str)
|
||||
|
||||
|
||||
def preview_members_for_gcode_member(gcode_member: str) -> set[str]:
|
||||
match = GCODE_MEMBER_RE.match(gcode_member)
|
||||
if not match:
|
||||
full_member = preview_member_name_for_gcode_member(gcode_member, small=False)
|
||||
small_member = preview_member_name_for_gcode_member(gcode_member, small=True)
|
||||
if full_member is None or small_member is None:
|
||||
return set()
|
||||
plate_index = match.group(1)
|
||||
return {
|
||||
f"Metadata/plate_{plate_index}.png",
|
||||
f"Metadata/plate_{plate_index}_small.png",
|
||||
}
|
||||
return {full_member, small_member}
|
||||
|
||||
|
||||
def write_output_3mf(base_3mf: Path, output_3mf: Path, gcode_bytes: bytes, sources: list[PlateSource], options: BuildOptions) -> str:
|
||||
@@ -171,7 +304,7 @@ def write_output_3mf(base_3mf: Path, output_3mf: Path, gcode_bytes: bytes, sourc
|
||||
if name == "Metadata/slice_info.config":
|
||||
data = update_first_slice_info(data, sources, options)
|
||||
elif options.add_preview_label and name in preview_members:
|
||||
data = update_preview_label(data, f"{len(sources)} plates", small=name.endswith("_small.png"))
|
||||
data = compose_preview_image(data, sources, f"{len(sources)} P", small=name.endswith("_small.png"))
|
||||
dst.writestr(item, data)
|
||||
dst.writestr(gcode_member, gcode_bytes)
|
||||
dst.writestr(f"{gcode_member}.md5", md5)
|
||||
|
||||
@@ -9,11 +9,17 @@ from pathlib import Path
|
||||
from a1_swap_mod_packer.builder import (
|
||||
preview_members_for_gcode_member,
|
||||
resolve_output_gcode_member,
|
||||
select_preview_sources,
|
||||
write_output_3mf,
|
||||
)
|
||||
from a1_swap_mod_packer.metadata import read_model_settings_gcode_members
|
||||
from a1_swap_mod_packer.models import BuildOptions, PlateSource
|
||||
|
||||
try:
|
||||
from PIL import Image
|
||||
except Exception: # pragma: no cover
|
||||
Image = None
|
||||
|
||||
|
||||
class ActivePlateMemberTest(unittest.TestCase):
|
||||
def test_write_output_preserves_configured_plate_member(self) -> None:
|
||||
@@ -56,8 +62,72 @@ class ActivePlateMemberTest(unittest.TestCase):
|
||||
self.assertEqual(archive.read("Metadata/plate_2.gcode.md5").decode(), md5)
|
||||
self.assertEqual(md5, hashlib.md5(gcode_bytes).hexdigest())
|
||||
|
||||
def test_preview_composite_uses_first_nine_unique_input_files(self) -> None:
|
||||
if Image is None:
|
||||
self.skipTest("Pillow is not installed")
|
||||
|
||||
colors = [
|
||||
(255, 0, 0, 255),
|
||||
(0, 0, 255, 255),
|
||||
(255, 255, 0, 255),
|
||||
(255, 0, 255, 255),
|
||||
(0, 255, 255, 255),
|
||||
(128, 0, 0, 255),
|
||||
(0, 0, 128, 255),
|
||||
(128, 128, 0, 255),
|
||||
(128, 0, 128, 255),
|
||||
(255, 128, 0, 255),
|
||||
]
|
||||
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = Path(directory)
|
||||
sources: list[PlateSource] = []
|
||||
for index, color in enumerate(colors):
|
||||
source_3mf = root / f"source_{index}.3mf"
|
||||
self.write_plate2_archive(source_3mf, image_color=color, image_size=300)
|
||||
sources.append(
|
||||
PlateSource(
|
||||
source_3mf=source_3mf,
|
||||
member_name="Metadata/plate_2.gcode",
|
||||
gcode_text="G1 X0\n",
|
||||
)
|
||||
)
|
||||
|
||||
selected = select_preview_sources(sources)
|
||||
self.assertEqual(len(selected), 9)
|
||||
self.assertEqual(selected[-1].source_3mf.name, "source_8.3mf")
|
||||
|
||||
output_3mf = root / "out.3mf"
|
||||
options = BuildOptions(
|
||||
swap_gcode="",
|
||||
output_3mf=output_3mf,
|
||||
add_preview_label=True,
|
||||
apply_gcode_patches=False,
|
||||
)
|
||||
write_output_3mf(sources[0].source_3mf, output_3mf, b"G1 X1\n", sources, options)
|
||||
|
||||
with zipfile.ZipFile(output_3mf, "r") as archive:
|
||||
image_bytes = archive.read("Metadata/plate_2.png")
|
||||
|
||||
import io
|
||||
|
||||
image = Image.open(io.BytesIO(image_bytes)).convert("RGBA")
|
||||
sampled_colors = {
|
||||
image.getpixel((50, 50)),
|
||||
image.getpixel((150, 50)),
|
||||
image.getpixel((250, 50)),
|
||||
image.getpixel((50, 150)),
|
||||
image.getpixel((150, 150)),
|
||||
image.getpixel((250, 150)),
|
||||
image.getpixel((50, 250)),
|
||||
image.getpixel((150, 250)),
|
||||
image.getpixel((250, 250)),
|
||||
}
|
||||
self.assertTrue(set(colors[:9]).issubset(sampled_colors))
|
||||
self.assertNotIn(colors[9], sampled_colors)
|
||||
|
||||
@staticmethod
|
||||
def write_plate2_archive(path: Path) -> None:
|
||||
def write_plate2_archive(path: Path, image_color: tuple[int, int, int, int] | None = None, image_size: int = 128) -> None:
|
||||
model_settings = """<?xml version="1.0" encoding="UTF-8"?>
|
||||
<config>
|
||||
<plate>
|
||||
@@ -82,8 +152,20 @@ class ActivePlateMemberTest(unittest.TestCase):
|
||||
archive.writestr("Metadata/slice_info.config", slice_info)
|
||||
archive.writestr("Metadata/plate_2.gcode", "G1 X0\n")
|
||||
archive.writestr("Metadata/plate_2.gcode.md5", "old")
|
||||
archive.writestr("Metadata/plate_2.png", b"png")
|
||||
archive.writestr("Metadata/plate_2_small.png", b"png")
|
||||
if image_color is None or Image is None:
|
||||
archive.writestr("Metadata/plate_2.png", b"png")
|
||||
archive.writestr("Metadata/plate_2_small.png", b"png")
|
||||
else:
|
||||
import io
|
||||
|
||||
image = Image.new("RGBA", (image_size, image_size), image_color)
|
||||
output = io.BytesIO()
|
||||
image.save(output, format="PNG")
|
||||
archive.writestr("Metadata/plate_2.png", output.getvalue())
|
||||
small = image.resize((max(1, image_size // 2), max(1, image_size // 2)))
|
||||
output = io.BytesIO()
|
||||
small.save(output, format="PNG")
|
||||
archive.writestr("Metadata/plate_2_small.png", output.getvalue())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
Reference in New Issue
Block a user