| import os, time, glob |
| import numpy as np |
| from PIL import Image, ImageDraw |
| import gradio as gr |
|
|
| DEFAULT_TILE_DIR = "./tiles" |
| EXAMPLES_DIR = "./examples" |
|
|
| |
| def norm_path(p: str) -> str: |
| return os.path.abspath(os.path.expanduser((p or "").rstrip("/"))) |
|
|
| def pil_to_np(img: Image.Image) -> np.ndarray: |
| if img.mode != "RGB": img = img.convert("RGB") |
| return np.asarray(img).astype(np.float32) / 255.0 |
|
|
| def np_to_pil(arr: np.ndarray) -> Image.Image: |
| arr = np.clip(arr * 255.0, 0, 255).astype(np.uint8) |
| return Image.fromarray(arr) |
|
|
| def crop_to_grid(img_np, ty, tx): |
| H, W, _ = img_np.shape |
| ch, cw = max(1, H // ty), max(1, W // tx) |
| new_h, new_w = ch * ty, cw * tx |
| cropped = img_np[:new_h, :new_w, :] |
| |
| |
| return cropped, ch, cw |
|
|
| def mse(a, b): return float(np.mean((a - b) ** 2)) |
|
|
| def simple_ssim(a, b): |
| g1, g2 = a.mean(axis=2), b.mean(axis=2) |
| mu1, mu2 = g1.mean(), g2.mean() |
| var1, var2 = g1.var(), g2.var() |
| cov = ((g1 - mu1) * (g2 - mu2)).mean() |
| C1, C2 = 0.01**2, 0.03**2 |
| den = (mu1**2+mu2**2+C1)*(var1+var2+C2) |
| return float(((2*mu1*mu2+C1)*(2*cov+C2))/den) if den else 0.0 |
|
|
| |
| def cell_stats_vec(img_np, ty, tx): |
| """Vectorized: reshape and reduce without Python loops.""" |
| cropped, ch, cw = crop_to_grid(img_np, ty, tx) |
| grid = cropped.reshape(ty, ch, tx, cw, 3).swapaxes(1,2) |
| return grid.mean((2,3)), np.median(grid,(2,3)), cropped, ch, cw |
|
|
| def cell_stats_loop(img_np, ty, tx): |
| """Loop-based: compute mean/median per cell with explicit loops.""" |
| cropped, ch, cw = crop_to_grid(img_np, ty, tx) |
| means = np.zeros((ty, tx, 3), dtype=np.float32) |
| meds = np.zeros((ty, tx, 3), dtype=np.float32) |
| for i in range(ty): |
| for j in range(tx): |
| block = cropped[i*ch:(i+1)*ch, j*cw:(j+1)*cw, :] |
| means[i, j] = block.mean(axis=(0, 1)) |
| meds[i, j] = np.median(block, axis=(0, 1)) |
| return means, meds, cropped, ch, cw |
|
|
| |
| def list_images(folder): |
| folder = norm_path(folder) |
| exts = ("jpg","jpeg","png","bmp","webp") |
| files=[] |
| for e in exts: files += glob.glob(os.path.join(folder, f"*.{e}")) |
| return sorted(set(files)) |
|
|
| def load_tiles(tile_dir,h,w,stat="Mean"): |
| files = list_images(norm_path(tile_dir)) |
| if not files: raise ValueError(f"No images in {tile_dir}") |
| tiles,cols=[],[] |
| for f in files: |
| try: |
| arr = pil_to_np(Image.open(f).resize((w,h),Image.BOX)) |
| tiles.append(arr) |
| cols.append(np.median(arr,(0,1)) if stat=="Median" else arr.mean((0,1))) |
| except: |
| continue |
| return np.stack(tiles), np.stack(cols) |
|
|
| |
| def nearest_indices(cell_cols,tile_cols): |
| ty,tx,_=cell_cols.shape |
| cm=cell_cols.reshape(-1,3) |
| d=((cm[:,None,:]-tile_cols[None,:,:])**2).sum(2) |
| return d.argmin(1).reshape(ty,tx) |
|
|
| def assemble(indices,tiles,ch,cw): |
| ty,tx=indices.shape |
| out=np.zeros((ty*ch,tx*cw,3), dtype=np.float32) |
| for i in range(ty): |
| for j in range(tx): |
| out[i*ch:(i+1)*ch, j*cw:(j+1)*cw, :] = tiles[indices[i,j]] |
| return out |
|
|
| |
| def add_tile_grid_overlay(cropped_np, ch, cw, ty, tx, color=(255, 0, 0), width=2): |
| """Draw grid lines over the cropped preview to visualize the ty×tx tiling.""" |
| H, W, _ = cropped_np.shape |
| preview = np_to_pil(cropped_np.copy()) |
| draw = ImageDraw.Draw(preview) |
| for i in range(1, ty): |
| y = i * ch |
| draw.line([(0, y), (W - 1, y)], fill=color, width=width) |
| for j in range(1, tx): |
| x = j * cw |
| draw.line([(x, 0), (x, H - 1)], fill=color, width=width) |
| return preview |
|
|
| |
| def build_mosaic(pil_img, tiles_side, tile_dir, cell_stat="Mean", tile_stat="Mean"): |
| """Vectorized default, returns (cropped+grid preview, mosaic, metrics, elapsed).""" |
| t0=time.time() |
| img_np=pil_to_np(pil_img) |
| means,meds,cropped,ch,cw=cell_stats_vec(img_np,tiles_side,tiles_side) |
| cell_cols=means if cell_stat=="Mean" else meds |
| tbank,tcols=load_tiles(tile_dir,ch,cw,stat=tile_stat) |
| idx=nearest_indices(cell_cols,tcols) |
| mosaic=assemble(idx,tbank,ch,cw) |
| m,s=mse(cropped,mosaic),simple_ssim(cropped,mosaic) |
| overlay_preview = add_tile_grid_overlay(cropped, ch, cw, tiles_side, tiles_side, color=(255,0,0), width=2) |
| return overlay_preview, np_to_pil(mosaic), f"MSE: {m:.6f} | SSIM*: {s:.6f}", f"{time.time()-t0:.3f}s" |
|
|
| def build_mosaic_mode(pil_img, tiles_side, tile_dir, cell_stat="Mean", tile_stat="Mean", use_vectorized=True): |
| """Switchable vec/loop (for benchmark). Returns (mosaic, MSE, SSIM, elapsed).""" |
| t0=time.time() |
| img_np=pil_to_np(pil_img) |
| if use_vectorized: |
| means,meds,cropped,ch,cw=cell_stats_vec(img_np,tiles_side,tiles_side) |
| else: |
| means,meds,cropped,ch,cw=cell_stats_loop(img_np,tiles_side,tiles_side) |
| cell_cols=means if cell_stat=="Mean" else meds |
| tbank,tcols=load_tiles(tile_dir,ch,cw,stat=tile_stat) |
| idx=nearest_indices(cell_cols,tcols) |
| mosaic=assemble(idx,tbank,ch,cw) |
| m,s=mse(cropped,mosaic),simple_ssim(cropped,mosaic) |
| return np_to_pil(mosaic), m, s, time.time()-t0 |
|
|
| |
| def _time_run(pil_img, grid, tile_dir, cell_stat, tile_stat, vectorized: bool): |
| _mos, _m, _s, elapsed = build_mosaic_mode( |
| pil_img, tiles_side=grid, tile_dir=tile_dir, |
| cell_stat=cell_stat, tile_stat=tile_stat, use_vectorized=vectorized |
| ) |
| return elapsed |
|
|
| def run_benchmark_table(pil_img, grids=(16,32,64), tile_dir=DEFAULT_TILE_DIR, cell_stat="Mean", tile_stat="Mean"): |
| rows=[] |
| for g in grids: |
| t_vec = _time_run(pil_img, g, tile_dir, cell_stat, tile_stat, True) |
| t_loop = _time_run(pil_img, g, tile_dir, cell_stat, tile_stat, False) |
| speed = t_loop / max(t_vec, 1e-9) |
| rows.append((g, t_vec, t_loop, speed)) |
| header = "Grid | Vectorized Time (s) | Loop Time (s) | Speedup (loop/vec)" |
| sep = "-" * len(header) |
| lines = [header, sep] + [f"{g}×{g} | {tv:.3f} | {tl:.3f} | {sp:.2f}×" for g,tv,tl,sp in rows] |
| table = "\n".join(lines) |
| analysis = ( |
| "Analysis:\n" |
| "- Runtime increases with grid size because cells grow as (tiles_per_side)^2.\n" |
| "- Matching cost ~ O(#cells × #tiles). Doubling tiles_per_side ≈ 4× more cells.\n" |
| "- Vectorized NumPy is consistently faster (often ~10–20×) than loops due to optimized C.\n" |
| "- Larger grids improve visual detail but cost more time; pick based on quality vs speed." |
| ) |
| return table, analysis |
|
|
| |
| with gr.Blocks(title="CS 5130 – Mosaic") as demo: |
| gr.Markdown("### Image Mosaic\nUpload on the left → Mosaic result on the right.") |
|
|
| |
| last_image = gr.State() |
|
|
| |
| with gr.Tab("Build Mosaic"): |
| with gr.Row(): |
| in_img = gr.Image(type="pil", label="Upload main image") |
| orig_preview = gr.Image(type="pil", label="Cropped-for-grid (preview)") |
| out_mosa = gr.Image(type="pil", label="Mosaic result") |
|
|
| tile_dir_box = gr.Textbox(value=DEFAULT_TILE_DIR, label="Tiles folder path") |
| grid = gr.Slider(8,128,value=32,step=1,label="Tiles per side") |
| cell_stat = gr.Radio(["Mean","Median"],value="Mean",label="Cell color") |
| tile_stat = gr.Radio(["Mean","Median"],value="Mean",label="Tile color") |
| btn = gr.Button("Generate Mosaic") |
|
|
| metrics = gr.Textbox(label="Similarity (MSE/SSIM*)", interactive=False) |
| perf = gr.Textbox(label="Elapsed (s)", interactive=False) |
|
|
| def ui_run(img, tps, cstat, tstat, tdir): |
| if img is None: |
| return None, None, "Upload an image.", "" |
| try: |
| tdir = norm_path(tdir or DEFAULT_TILE_DIR) |
| orig, mos, m, s = build_mosaic(img, int(tps), tdir, cstat, tstat) |
| return orig, mos, m, s |
| except Exception as e: |
| return None, None, f"Error: {e}", "" |
|
|
| btn.click( |
| ui_run, |
| inputs=[in_img, grid, cell_stat, tile_stat, tile_dir_box], |
| outputs=[orig_preview, out_mosa, metrics, perf] |
| ) |
|
|
| |
| def _remember_image(img): |
| return img |
| in_img.change(_remember_image, inputs=[in_img], outputs=[last_image]) |
|
|
| |
| exdir = norm_path(EXAMPLES_DIR) |
| if os.path.isdir(exdir): |
| ex_files = [os.path.join(exdir, f) for f in os.listdir(exdir) |
| if f.lower().endswith((".jpg",".jpeg",".png"))][:6] |
| if ex_files: |
| gr.Examples(ex_files, inputs=[in_img], label="Examples (Build)") |
|
|
| |
| with gr.Tab("Benchmark"): |
| gr.Markdown( |
| "Compare **Vectorized** vs **Loop** timings across grid sizes. " |
| "Use an example or click **Use uploaded image** to reuse the image from the Build tab." |
| ) |
| bench_img = gr.Image(type="pil", label="Main image for benchmark (RGB)") |
| use_current = gr.Button("Use uploaded image") |
|
|
| def _use_uploaded(img): |
| if img is None: |
| return None |
| return img |
|
|
| use_current.click(_use_uploaded, inputs=[last_image], outputs=[bench_img]) |
|
|
| grids_box = gr.Textbox(value="16, 32, 64", label="Grid sizes (comma-separated)") |
| b_cell = gr.Radio(["Mean","Median"], value="Mean", label="Cell color") |
| b_tile = gr.Radio(["Mean","Median"], value="Mean", label="Tile color") |
| b_folder = gr.Textbox(value=DEFAULT_TILE_DIR, label="Tile folder path") |
| run_bench = gr.Button("Run Benchmark") |
|
|
| bench_table = gr.Code(label="Results Table") |
| bench_note = gr.Textbox(label="Brief Analysis", lines=6) |
|
|
| def ui_bench(img, grids_csv, cstat, tstat, tdir): |
| if img is None: return "Please upload/select an image (or click 'Use uploaded image').", "" |
| try: |
| grids = [int(x.strip()) for x in grids_csv.split(",") if x.strip()] |
| except: |
| grids = [16,32,64] |
| tdir = norm_path(tdir or DEFAULT_TILE_DIR) |
| table, analysis = run_benchmark_table(img, tuple(grids), tdir, cstat, tstat) |
| return table, analysis |
|
|
| run_bench.click( |
| ui_bench, |
| inputs=[bench_img, grids_box, b_cell, b_tile, b_folder], |
| outputs=[bench_table, bench_note] |
| ) |
|
|
| |
| if os.path.isdir(exdir): |
| ex_files_bench = [os.path.join(exdir, f) for f in os.listdir(exdir) |
| if f.lower().endswith((".jpg",".jpeg",".png"))][:6] |
| if ex_files_bench: |
| gr.Examples(ex_files_bench, inputs=[bench_img], label="Examples (Benchmark)") |
|
|
| if __name__=="__main__": |
| demo.launch(allowed_paths=[norm_path(DEFAULT_TILE_DIR), norm_path(EXAMPLES_DIR)]) |
|
|