VanKee commited on
Commit
bdaf195
·
1 Parent(s): ce54710

update logic to no longer resize image.

Browse files
.gitignore CHANGED
@@ -1,2 +1,4 @@
1
 
2
  .DS_Store
 
 
 
1
 
2
  .DS_Store
3
+ cifar_data/
4
+ __pycache__/
out/{mosaic_avg_32.png → cells_outline_3378.png} RENAMED
File without changes
out/cells_outline_5090.png ADDED

Git LFS Details

  • SHA256: 7b13de0f565f58704f48fc0e05ed728b61ff1f2a3be438253a7a8a2bb0411be5
  • Pointer size: 131 Bytes
  • Size of remote file: 161 kB
out/cells_outline_akaza.png ADDED

Git LFS Details

  • SHA256: bef9261c5396cce35fe33d0fc83f05b2287bcb45003b1491fcb8f4906ac2502a
  • Pointer size: 131 Bytes
  • Size of remote file: 352 kB
out/{mosaic_palette_16_32.png → mosaic_adaptive_3378.png} RENAMED
File without changes
out/mosaic_adaptive_5090.png ADDED

Git LFS Details

  • SHA256: 8185b3ea84980ce519603127538ae9ed6764cbd3a66df4a4eb17dc89228ee750
  • Pointer size: 130 Bytes
  • Size of remote file: 38.1 kB
out/mosaic_adaptive_akaza.png ADDED

Git LFS Details

  • SHA256: 67376de8f1ce3e78ff9ae37fdf033030cdccfd3928a6c3c73969538c54910f62
  • Pointer size: 131 Bytes
  • Size of remote file: 287 kB
out/mosaic_cifar_adaptive_3378.png ADDED

Git LFS Details

  • SHA256: 4816c74054bbf3172cb794991c1ad9c69ef96d2bfc18dbc9f5c15424113193bd
  • Pointer size: 131 Bytes
  • Size of remote file: 230 kB
out/mosaic_cifar_adaptive_3378_10.png ADDED

Git LFS Details

  • SHA256: 4816c74054bbf3172cb794991c1ad9c69ef96d2bfc18dbc9f5c15424113193bd
  • Pointer size: 131 Bytes
  • Size of remote file: 230 kB
out/mosaic_cifar_adaptive_3378_100.png ADDED

Git LFS Details

  • SHA256: 24c1e7b0d2e572ba503970a021714552452530564762da2bb31d7f03454592a1
  • Pointer size: 131 Bytes
  • Size of remote file: 275 kB
out/mosaic_cifar_adaptive_5090.png ADDED

Git LFS Details

  • SHA256: d74d8aaca6ad7cf7b2bbbc03b65229b4593393c0de9451102e50a0a78b70e641
  • Pointer size: 131 Bytes
  • Size of remote file: 252 kB
out/mosaic_cifar_adaptive_5090_10.png ADDED

Git LFS Details

  • SHA256: d0d687aebb9ed858d757ce18633e83a998b9c14be42a4440d7b2f8fe3235df4e
  • Pointer size: 131 Bytes
  • Size of remote file: 242 kB
out/mosaic_cifar_adaptive_5090_100.png ADDED

Git LFS Details

  • SHA256: bb62c46edc3f3c0597149b3043596a25cde5696fe47261a327c8de5942ac1f33
  • Pointer size: 131 Bytes
  • Size of remote file: 340 kB
out/mosaic_cifar_adaptive_akaza_10.png ADDED

Git LFS Details

  • SHA256: 0badc6ca8764f2919e1b505af8017d3f876d6a20f4b4e37a08ae1996c98e3d97
  • Pointer size: 132 Bytes
  • Size of remote file: 2.15 MB
out/mosaic_cifar_adaptive_akaza_100.png ADDED

Git LFS Details

  • SHA256: 3fa6a00da729013522c1b0d944367183f21969319a696e7b6777313786f155ca
  • Pointer size: 132 Bytes
  • Size of remote file: 2.65 MB
samples/akaza.jpg ADDED

Git LFS Details

  • SHA256: ae2799f75879022947f6346c97460d857671f729aa059d4c8f8fd9111bf5bc50
  • Pointer size: 131 Bytes
  • Size of remote file: 336 kB
simple_mosaic.py CHANGED
@@ -2,7 +2,8 @@
2
  from pathlib import Path
3
  from typing import List, Tuple, Iterator
4
  import numpy as np
5
- from PIL import Image
 
6
 
7
  class SimpleMosaicImage:
8
  def __init__(self, path: str):
@@ -22,13 +23,29 @@ class SimpleMosaicImage:
22
  print(f"[INFO] Resized to {new_w}x{new_h}")
23
  return self
24
 
 
 
 
 
 
 
 
25
  def crop_to_grid(self, grid_size: int = 32) -> "SimpleMosaicImage":
 
 
26
  new_w = (self.width // grid_size) * grid_size
27
  new_h = (self.height // grid_size) * grid_size
28
- if new_w != self.width or new_h != self.height:
 
 
 
 
 
29
  self.img = self.img.crop((0, 0, new_w, new_h))
30
  self.width, self.height = new_w, new_h
31
- print(f"[INFO] Cropped to {new_w}x{new_h} for grid {grid_size}")
 
 
32
  return self
33
 
34
  def _as_array(self):
@@ -38,7 +55,20 @@ class SimpleMosaicImage:
38
  for y in range(0, self.height, grid_size):
39
  for x in range(0, self.width, grid_size):
40
  yield (x, y, grid_size, grid_size)
41
-
 
 
 
 
 
 
 
 
 
 
 
 
 
42
  @staticmethod
43
  def _cell_mean(arr, x, y, w, h):
44
  block = arr[y:y+h, x:x+w, :]
@@ -53,23 +83,99 @@ class SimpleMosaicImage:
53
  dist2 = np.sum(diff*diff, axis=1)
54
  idx = int(np.argmin(dist2))
55
  return tuple(int(v) for v in pal[idx])
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
56
 
57
- def mosaic_average_color(self, grid_size: int = 32):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
58
  arr = self._as_array()
59
  out = np.empty_like(arr)
60
- for (x, y, w, h) in self.iter_cells(grid_size):
61
  color = self._cell_mean(arr, x, y, w, h)
62
  out[y:y+h, x:x+w, :] = color
63
  return Image.fromarray(out, mode="RGB")
64
 
65
- def mosaic_with_palette(self, grid_size: int, palette: List[Tuple[int,int,int]]):
 
 
 
 
66
  arr = self._as_array()
67
- out = np.empty_like(arr)
68
- for (x, y, w, h) in self.iter_cells(grid_size):
69
- avg = self._cell_mean(arr, x, y, w, h)
70
- color = self._nearest_color(avg, palette)
71
- out[y:y+h, x:x+w, :] = color
72
- return Image.fromarray(out, mode="RGB")
 
 
 
 
73
 
74
  def save(self, image: Image.Image, out_path: str) -> None:
75
  Path(out_path).parent.mkdir(parents=True, exist_ok=True)
@@ -77,16 +183,27 @@ class SimpleMosaicImage:
77
  print(f"[INFO] Saved: {out_path}")
78
 
79
 
80
- PALETTE_16 = [
81
- (0,0,0), (255,255,255), (255,0,0), (0,255,0), (0,0,255),
82
- (255,255,0), (255,0,255), (0,255,255),
83
- (128,128,128), (128,0,0), (0,128,0), (0,0,128),
84
- (128,128,0), (128,0,128), (0,128,128), (200,200,200)
85
- ]
 
 
 
 
 
 
 
 
 
86
 
87
- loader = SimpleMosaicImage("./samples/IMG_5090.jpg")
88
- loader.resize(longest_side=512).crop_to_grid(grid_size=2)
89
 
90
- mosaic = loader.mosaic_average_color(grid_size=2)
91
- loader.save(mosaic, "./out/mosaic_avg_32.png")
92
 
 
 
 
2
  from pathlib import Path
3
  from typing import List, Tuple, Iterator
4
  import numpy as np
5
+ from PIL import Image, ImageDraw
6
+ from tile_library import build_cifar10_tile_library, build_cifar100_tile_library
7
 
8
  class SimpleMosaicImage:
9
  def __init__(self, path: str):
 
23
  print(f"[INFO] Resized to {new_w}x{new_h}")
24
  return self
25
 
26
+ def quantize_colors(self, n_colors: int = 16) -> "SimpleMosaicImage":
27
+ """Apply color quantization using PIL's built-in algorithm"""
28
+ quantized = self.img.quantize(colors=n_colors, method=Image.MEDIANCUT)
29
+ self.img = quantized.convert('RGB')
30
+ print(f"[INFO] Color quantized to {n_colors} colors")
31
+ return self
32
+
33
  def crop_to_grid(self, grid_size: int = 32) -> "SimpleMosaicImage":
34
+ """Smart boundary handling: preserve original size when possible"""
35
+ # Only crop if loss is minimal (<2%), otherwise preserve original size
36
  new_w = (self.width // grid_size) * grid_size
37
  new_h = (self.height // grid_size) * grid_size
38
+
39
+ lost_pixels = (self.width - new_w) + (self.height - new_h)
40
+ total_pixels = self.width + self.height
41
+ loss_ratio = lost_pixels / total_pixels
42
+
43
+ if loss_ratio < 0.02: # Only crop if loss < 2%
44
  self.img = self.img.crop((0, 0, new_w, new_h))
45
  self.width, self.height = new_w, new_h
46
+ print(f"[INFO] Cropped to {new_w}x{new_h} for grid {grid_size} (loss: {loss_ratio:.1%})")
47
+ else:
48
+ print(f"[INFO] Preserved original size {self.width}x{self.height} (would lose {loss_ratio:.1%})")
49
  return self
50
 
51
  def _as_array(self):
 
55
  for y in range(0, self.height, grid_size):
56
  for x in range(0, self.width, grid_size):
57
  yield (x, y, grid_size, grid_size)
58
+
59
+ def draw_cells(self, cells, outline=(255, 0, 0), width=0.1):
60
+ """
61
+ Draw cell borders on the original image, returns a new image.
62
+ outline: border color
63
+ width: border line width
64
+ """
65
+ canvas = self.img.copy()
66
+ draw = ImageDraw.Draw(canvas)
67
+ for (x, y, w, h) in cells:
68
+ # PIL rectangle bottom-right is inclusive, -1 to avoid overflow
69
+ draw.rectangle((x, y, x + w - 1, y + h - 1), outline=outline, width=width)
70
+ return canvas
71
+
72
  @staticmethod
73
  def _cell_mean(arr, x, y, w, h):
74
  block = arr[y:y+h, x:x+w, :]
 
83
  dist2 = np.sum(diff*diff, axis=1)
84
  idx = int(np.argmin(dist2))
85
  return tuple(int(v) for v in pal[idx])
86
+
87
+ def build_adaptive_cells(
88
+ self,
89
+ start_size: int = 64,
90
+ min_size: int = 16,
91
+ threshold: float = 20.0, # Use grayscale variance as complexity measure
92
+ ) -> list[tuple[int,int,int,int]]:
93
+ """
94
+ Returns [(x,y,w,h), ...]: Quadtree-style adaptive grid using iterative stack.
95
+ Requirement: Image should be resized/cropped to be divisible by start_size for better alignment.
96
+ """
97
+ arr = self._as_array()
98
+ # Grayscale (BT.601)
99
+ gray = (0.299*arr[...,0] + 0.587*arr[...,1] + 0.114*arr[...,2]).astype(np.float32)
100
+
101
+ cells: list[tuple[int,int,int,int]] = []
102
+
103
+ # First rough division by start_size, push large blocks to stack
104
+ stack: list[tuple[int,int,int,int]] = []
105
+ for yy in range(0, self.height, start_size):
106
+ for xx in range(0, self.width, start_size):
107
+ ww = min(start_size, self.width - xx)
108
+ hh = min(start_size, self.height - yy)
109
+ stack.append((xx, yy, ww, hh))
110
+
111
+ # Process stack: decide whether to keep or subdivide into 4 blocks
112
+ while stack:
113
+ x, y, w, h = stack.pop()
114
+
115
+ # Keep if reached minimum size
116
+ if w <= min_size or h <= min_size:
117
+ cells.append((x, y, w, h))
118
+ continue
119
+
120
+ # Complexity: grayscale variance
121
+ region = gray[y:y+h, x:x+w]
122
+ score = float(region.var())
123
+
124
+ # Below threshold -> keep without subdivision
125
+ if score < threshold:
126
+ cells.append((x, y, w, h))
127
+ continue
128
 
129
+ # Otherwise subdivide into 4 blocks (try to halve), handle boundary remainder
130
+ w2 = max(min_size, w // 2)
131
+ h2 = max(min_size, h // 2)
132
+ # Fallback: keep if cannot subdivide further (avoid infinite loop)
133
+ if w2 == w and h2 == h:
134
+ cells.append((x, y, w, h))
135
+ continue
136
+
137
+ # Top-left
138
+ stack.append((x, y, w2, h2))
139
+ # Top-right
140
+ x2 = x + w2
141
+ wR = min(w - w2, self.width - x2)
142
+ if wR > 0:
143
+ stack.append((x2, y, wR, h2))
144
+ # Bottom-left
145
+ y2 = y + h2
146
+ hB = min(h - h2, self.height - y2)
147
+ if hB > 0:
148
+ stack.append((x, y2, w2, hB))
149
+ # Bottom-right
150
+ if wR > 0 and hB > 0:
151
+ stack.append((x2, y2, wR, hB))
152
+
153
+ return cells
154
+
155
+ def mosaic_average_color_adaptive(self, cells):
156
  arr = self._as_array()
157
  out = np.empty_like(arr)
158
+ for (x, y, w, h) in cells:
159
  color = self._cell_mean(arr, x, y, w, h)
160
  out[y:y+h, x:x+w, :] = color
161
  return Image.fromarray(out, mode="RGB")
162
 
163
+ def mosaic_with_tiles_adaptive(self, cells, tiles, tile_means: np.ndarray):
164
+ """
165
+ Adaptive grid version: pass in cells from build_adaptive_cells.
166
+ """
167
+ out_img = Image.new("RGB", (self.width, self.height))
168
  arr = self._as_array()
169
+ means = tile_means.astype(np.float32)
170
+
171
+ for (x, y, w, h) in cells:
172
+ block_mean = np.array(self._cell_mean(arr, x, y, w, h), dtype=np.float32)
173
+ diff = means - block_mean[None, :]
174
+ idx = int(np.argmin(np.sum(diff*diff, axis=1)))
175
+ tile = tiles[idx].resize((w, h), Image.BILINEAR)
176
+ out_img.paste(tile, (x, y))
177
+ return out_img
178
+
179
 
180
  def save(self, image: Image.Image, out_path: str) -> None:
181
  Path(out_path).parent.mkdir(parents=True, exist_ok=True)
 
183
  print(f"[INFO] Saved: {out_path}")
184
 
185
 
186
+ loader = SimpleMosaicImage("./samples/akaza.jpg")
187
+ loader.quantize_colors(16).crop_to_grid(2)
188
+
189
+ cells = loader.build_adaptive_cells(
190
+ start_size=64, # Initial block size
191
+ min_size=4, # Minimum block size
192
+ threshold=5.0 # Lower value = more subdivision
193
+ )
194
+
195
+ tiles_10, tile_means_10, tile_labels_10 = build_cifar10_tile_library(max_per_class=1000)
196
+ tiles_100, tile_means_100, tile_labels_100 = build_cifar100_tile_library(max_per_class=400)
197
+
198
+
199
+ vis1 = loader.draw_cells(cells, outline=(0, 255, 0), width=1)
200
+ vis1.save("./out/cells_outline_akaza.png")
201
 
202
+ mosaic_adapt = loader.mosaic_average_color_adaptive(cells)
203
+ loader.save(mosaic_adapt, "./out/mosaic_adaptive_akaza.png")
204
 
205
+ mosaic_tiles_adapt_10 = loader.mosaic_with_tiles_adaptive(cells, tiles=tiles_10, tile_means=tile_means_10)
206
+ loader.save(mosaic_tiles_adapt_10, "./out/mosaic_cifar_adaptive_akaza_10.png")
207
 
208
+ mosaic_tiles_adapt_100 = loader.mosaic_with_tiles_adaptive(cells, tiles=tiles_100, tile_means=tile_means_100)
209
+ loader.save(mosaic_tiles_adapt_100, "./out/mosaic_cifar_adaptive_akaza_100.png")
tile_library.py ADDED
@@ -0,0 +1,64 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Tile library
3
+ """
4
+ import numpy as np
5
+ from PIL import Image
6
+ from torchvision import datasets, transforms
7
+
8
+ def build_cifar10_tile_library(root="./cifar_data", max_per_class=500):
9
+ """
10
+ Download/load CIFAR-10 training set as tile library.
11
+ For speed control, defaults to max_per_class tiles per class (10 classes, total <= 10*max_per_class).
12
+ Returns:
13
+ tiles: List[PIL.Image], original 32x32
14
+ means: np.ndarray [N,3], RGB average color of each tile (0..255)
15
+ labels: np.ndarray [N], class labels (0..9)
16
+ """
17
+ ds = datasets.CIFAR10(root=root, train=True, download=True,
18
+ transform=transforms.ToTensor())
19
+
20
+ counts = {c : 0 for c in range(10)}
21
+ tiles, means, labels = [], [], []
22
+ for img_tensor, lab in ds:
23
+ if counts[lab] >= max_per_class:
24
+ continue
25
+ arr= (img_tensor.numpy().transpose(1,2,0) * 255).astype(np.uint8)
26
+ pil = Image.fromarray(arr, mode="RGB")
27
+ tiles.append(pil)
28
+ means.append(arr.reshape(-1,3).mean(axis=0))
29
+ labels.append(lab)
30
+ counts[lab]+=1
31
+
32
+ means = np.asarray(means, dtype=np.float32)
33
+ labels = np.asarray(labels, dtype=np.int64)
34
+ print(f"[INFO] CIFAR10 tiles: {len(tiles)} (each 32x32). Per-class cap={max_per_class}")
35
+ return tiles, means, labels
36
+
37
+ def build_cifar100_tile_library(root="./cifar_data", max_per_class=500):
38
+ """
39
+ Download/load CIFAR-100 training set as tile library.
40
+ For speed control, defaults to max_per_class tiles per class (100 classes, total <= 100*max_per_class).
41
+ Returns:
42
+ tiles: List[PIL.Image], original 32x32
43
+ means: np.ndarray [N,3], RGB average color of each tile (0..255)
44
+ labels: np.ndarray [N], class labels (0..99)
45
+ """
46
+ ds = datasets.CIFAR100(root=root, train=True, download=True,
47
+ transform=transforms.ToTensor())
48
+
49
+ counts = {c : 0 for c in range(100)}
50
+ tiles, means, labels = [], [], []
51
+ for img_tensor, lab in ds:
52
+ if counts[lab] >= max_per_class:
53
+ continue
54
+ arr= (img_tensor.numpy().transpose(1,2,0) * 255).astype(np.uint8)
55
+ pil = Image.fromarray(arr, mode="RGB")
56
+ tiles.append(pil)
57
+ means.append(arr.reshape(-1,3).mean(axis=0))
58
+ labels.append(lab)
59
+ counts[lab]+=1
60
+
61
+ means = np.asarray(means, dtype=np.float32)
62
+ labels = np.asarray(labels, dtype=np.int64)
63
+ print(f"[INFO] CIFAR10 tiles: {len(tiles)} (each 32x32). Per-class cap={max_per_class}")
64
+ return tiles, means, labels