Enhance thumbnail generation
This commit is contained in:
+144
-11
@@ -2,6 +2,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import hashlib
|
import hashlib
|
||||||
import os
|
import os
|
||||||
|
import re
|
||||||
import shutil
|
import shutil
|
||||||
import tempfile
|
import tempfile
|
||||||
import zipfile
|
import zipfile
|
||||||
@@ -34,6 +35,9 @@ except Exception: # pragma: no cover
|
|||||||
ImageDraw = None
|
ImageDraw = None
|
||||||
ImageFont = 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]:
|
def load_plate_sources(job: PlateJob) -> list[PlateSource]:
|
||||||
if job.copies < 1:
|
if job.copies < 1:
|
||||||
@@ -99,6 +103,14 @@ def process_plate_gcode(
|
|||||||
return text.rstrip("\n") + "\n"
|
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:
|
def update_preview_label(png_bytes: bytes, label: str, small: bool = False) -> bytes:
|
||||||
if Image is None or ImageDraw is None:
|
if Image is None or ImageDraw is None:
|
||||||
return png_bytes
|
return png_bytes
|
||||||
@@ -129,9 +141,133 @@ def update_preview_label(png_bytes: bytes, label: str, small: bool = False) -> b
|
|||||||
x = margin
|
x = margin
|
||||||
y = image.height - margin - (text_bbox[3] - text_bbox[1])
|
y = image.height - margin - (text_bbox[3] - text_bbox[1])
|
||||||
draw.text((x, y), label, font=font, fill=(0, 255, 0, 255))
|
draw.text((x, y), label, font=font, fill=(0, 255, 0, 255))
|
||||||
output = io.BytesIO()
|
return save_png_bytes(image)
|
||||||
image.save(output, format="PNG")
|
|
||||||
return output.getvalue()
|
|
||||||
|
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:
|
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]:
|
def preview_members_for_gcode_member(gcode_member: str) -> set[str]:
|
||||||
match = GCODE_MEMBER_RE.match(gcode_member)
|
full_member = preview_member_name_for_gcode_member(gcode_member, small=False)
|
||||||
if not match:
|
small_member = preview_member_name_for_gcode_member(gcode_member, small=True)
|
||||||
|
if full_member is None or small_member is None:
|
||||||
return set()
|
return set()
|
||||||
plate_index = match.group(1)
|
return {full_member, small_member}
|
||||||
return {
|
|
||||||
f"Metadata/plate_{plate_index}.png",
|
|
||||||
f"Metadata/plate_{plate_index}_small.png",
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def write_output_3mf(base_3mf: Path, output_3mf: Path, gcode_bytes: bytes, sources: list[PlateSource], options: BuildOptions) -> str:
|
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":
|
if name == "Metadata/slice_info.config":
|
||||||
data = update_first_slice_info(data, sources, options)
|
data = update_first_slice_info(data, sources, options)
|
||||||
elif options.add_preview_label and name in preview_members:
|
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(item, data)
|
||||||
dst.writestr(gcode_member, gcode_bytes)
|
dst.writestr(gcode_member, gcode_bytes)
|
||||||
dst.writestr(f"{gcode_member}.md5", md5)
|
dst.writestr(f"{gcode_member}.md5", md5)
|
||||||
|
|||||||
@@ -9,11 +9,17 @@ from pathlib import Path
|
|||||||
from a1_swap_mod_packer.builder import (
|
from a1_swap_mod_packer.builder import (
|
||||||
preview_members_for_gcode_member,
|
preview_members_for_gcode_member,
|
||||||
resolve_output_gcode_member,
|
resolve_output_gcode_member,
|
||||||
|
select_preview_sources,
|
||||||
write_output_3mf,
|
write_output_3mf,
|
||||||
)
|
)
|
||||||
from a1_swap_mod_packer.metadata import read_model_settings_gcode_members
|
from a1_swap_mod_packer.metadata import read_model_settings_gcode_members
|
||||||
from a1_swap_mod_packer.models import BuildOptions, PlateSource
|
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):
|
class ActivePlateMemberTest(unittest.TestCase):
|
||||||
def test_write_output_preserves_configured_plate_member(self) -> None:
|
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(archive.read("Metadata/plate_2.gcode.md5").decode(), md5)
|
||||||
self.assertEqual(md5, hashlib.md5(gcode_bytes).hexdigest())
|
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
|
@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"?>
|
model_settings = """<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<config>
|
<config>
|
||||||
<plate>
|
<plate>
|
||||||
@@ -82,8 +152,20 @@ class ActivePlateMemberTest(unittest.TestCase):
|
|||||||
archive.writestr("Metadata/slice_info.config", slice_info)
|
archive.writestr("Metadata/slice_info.config", slice_info)
|
||||||
archive.writestr("Metadata/plate_2.gcode", "G1 X0\n")
|
archive.writestr("Metadata/plate_2.gcode", "G1 X0\n")
|
||||||
archive.writestr("Metadata/plate_2.gcode.md5", "old")
|
archive.writestr("Metadata/plate_2.gcode.md5", "old")
|
||||||
archive.writestr("Metadata/plate_2.png", b"png")
|
if image_color is None or Image is None:
|
||||||
archive.writestr("Metadata/plate_2_small.png", b"png")
|
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__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
Reference in New Issue
Block a user