| |
| from pathlib import Path |
| from typing import List, Tuple, Iterator |
| import numpy as np |
| from PIL import Image, ImageDraw |
| from tile_library import build_cifar10_tile_library, build_cifar100_tile_library |
|
|
| class SimpleMosaicImage: |
| def __init__(self, path: str): |
| self.path = path |
| self.img = Image.open(path).convert("RGB") |
| self.width, self.height = self.img.size |
| print(f"[INFO] Loaded: {path} | size={self.width}x{self.height}") |
|
|
| def resize(self, longest_side: int = 4000) -> "SimpleMosaicImage": |
| w, h = self.width, self.height |
| scale = longest_side / max(w, h) |
| if scale < 1.0: |
| new_w = int(w * scale) |
| new_h = int(h * scale) |
| self.img = self.img.resize((new_w, new_h), Image.BICUBIC) |
| self.width, self.height = new_w, new_h |
| print(f"[INFO] Resized to {new_w}x{new_h}") |
| return self |
|
|
| def quantize_colors(self, n_colors: int = 16) -> "SimpleMosaicImage": |
| """Apply color quantization using PIL's built-in algorithm""" |
| quantized = self.img.quantize(colors=n_colors, method=Image.MEDIANCUT) |
| self.img = quantized.convert('RGB') |
| print(f"[INFO] Color quantized to {n_colors} colors") |
| return self |
|
|
| def crop_to_grid(self, grid_size: int = 32) -> "SimpleMosaicImage": |
| """Smart boundary handling: preserve original size when possible""" |
| |
| new_w = (self.width // grid_size) * grid_size |
| new_h = (self.height // grid_size) * grid_size |
|
|
| lost_pixels = (self.width - new_w) + (self.height - new_h) |
| total_pixels = self.width + self.height |
| loss_ratio = lost_pixels / total_pixels |
|
|
| if loss_ratio < 0.02: |
| self.img = self.img.crop((0, 0, new_w, new_h)) |
| self.width, self.height = new_w, new_h |
| print(f"[INFO] Cropped to {new_w}x{new_h} for grid {grid_size} (loss: {loss_ratio:.1%})") |
| else: |
| print(f"[INFO] Preserved original size {self.width}x{self.height} (would lose {loss_ratio:.1%})") |
| return self |
|
|
| def _as_array(self): |
| return np.asarray(self.img, dtype=np.uint8) |
|
|
| def iter_cells(self, grid_size: int): |
| for y in range(0, self.height, grid_size): |
| for x in range(0, self.width, grid_size): |
| yield (x, y, grid_size, grid_size) |
| |
| def draw_cells(self, cells, outline=(255, 0, 0), width=0.1): |
| """ |
| Draw cell borders on the original image, returns a new image. |
| outline: border color |
| width: border line width |
| """ |
| canvas = self.img.copy() |
| draw = ImageDraw.Draw(canvas) |
| for (x, y, w, h) in cells: |
| |
| draw.rectangle((x, y, x + w - 1, y + h - 1), outline=outline, width=width) |
| return canvas |
| |
| @staticmethod |
| def _cell_mean(arr, x, y, w, h): |
| block = arr[y:y+h, x:x+w, :] |
| mean = block.mean(axis=(0,1)) |
| return tuple(int(round(v)) for v in mean) |
|
|
| @staticmethod |
| def _nearest_color(target, palette): |
| tr, tg, tb = target |
| pal = np.array(palette, dtype=np.int16) |
| diff = pal - np.array([tr, tg, tb], dtype=np.int16) |
| dist2 = np.sum(diff*diff, axis=1) |
| idx = int(np.argmin(dist2)) |
| return tuple(int(v) for v in pal[idx]) |
| |
| def build_adaptive_cells( |
| self, |
| start_size: int = 64, |
| min_size: int = 16, |
| threshold: float = 20.0, |
| ) -> list[tuple[int,int,int,int]]: |
| """ |
| Returns [(x,y,w,h), ...]: Quadtree-style adaptive grid using iterative stack. |
| Requirement: Image should be resized/cropped to be divisible by start_size for better alignment. |
| """ |
| arr = self._as_array() |
| |
| gray = (0.299*arr[...,0] + 0.587*arr[...,1] + 0.114*arr[...,2]).astype(np.float32) |
|
|
| cells: list[tuple[int,int,int,int]] = [] |
|
|
| |
| stack: list[tuple[int,int,int,int]] = [] |
| for yy in range(0, self.height, start_size): |
| for xx in range(0, self.width, start_size): |
| ww = min(start_size, self.width - xx) |
| hh = min(start_size, self.height - yy) |
| stack.append((xx, yy, ww, hh)) |
|
|
| |
| while stack: |
| x, y, w, h = stack.pop() |
|
|
| |
| if w <= min_size or h <= min_size: |
| cells.append((x, y, w, h)) |
| continue |
|
|
| |
| region = gray[y:y+h, x:x+w] |
| score = float(region.var()) |
|
|
| |
| if score < threshold: |
| cells.append((x, y, w, h)) |
| continue |
|
|
| |
| w2 = max(min_size, w // 2) |
| h2 = max(min_size, h // 2) |
| |
| if w2 == w and h2 == h: |
| cells.append((x, y, w, h)) |
| continue |
|
|
| |
| stack.append((x, y, w2, h2)) |
| |
| x2 = x + w2 |
| wR = min(w - w2, self.width - x2) |
| if wR > 0: |
| stack.append((x2, y, wR, h2)) |
| |
| y2 = y + h2 |
| hB = min(h - h2, self.height - y2) |
| if hB > 0: |
| stack.append((x, y2, w2, hB)) |
| |
| if wR > 0 and hB > 0: |
| stack.append((x2, y2, wR, hB)) |
|
|
| return cells |
|
|
| def mosaic_average_color_adaptive(self, cells): |
| arr = self._as_array() |
| out = np.empty_like(arr) |
| for (x, y, w, h) in cells: |
| color = self._cell_mean(arr, x, y, w, h) |
| out[y:y+h, x:x+w, :] = color |
| return Image.fromarray(out, mode="RGB") |
|
|
| def mosaic_with_tiles_adaptive(self, cells, tiles, tile_means: np.ndarray): |
| """ |
| Adaptive grid version: pass in cells from build_adaptive_cells. |
| """ |
| out_img = Image.new("RGB", (self.width, self.height)) |
| arr = self._as_array() |
| means = tile_means.astype(np.float32) |
|
|
| for (x, y, w, h) in cells: |
| block_mean = np.array(self._cell_mean(arr, x, y, w, h), dtype=np.float32) |
| diff = means - block_mean[None, :] |
| idx = int(np.argmin(np.sum(diff*diff, axis=1))) |
| tile = tiles[idx].resize((w, h), Image.BILINEAR) |
| out_img.paste(tile, (x, y)) |
| return out_img |
| |
|
|
| def save(self, image: Image.Image, out_path: str) -> None: |
| Path(out_path).parent.mkdir(parents=True, exist_ok=True) |
| image.save(out_path) |
| print(f"[INFO] Saved: {out_path}") |
|
|
|
|
| |
| |
|
|
| |
| |
| |
| |
| |
|
|
| |
| |
|
|
|
|
| |
| |
|
|
| |
| |
|
|
| |
| |
|
|
| |
| |