import gradio as gr import numpy as np from numba import njit from PIL import Image, ImageOps import io import tempfile import os from huggingface_hub import hf_hub_download files = ["a.jpg", "b.png", "c.png", 'd.png'] paths = [ hf_hub_download( repo_id="Rothfeld/drostescher", filename=f, repo_type="space" ) for f in files ] import shutil for a,b in zip(files,paths): shutil.copy(b,a) A,B,C,D = files # ── forward transformation ────────────────────────────────────────────────── @njit(fastmath=True) def tl_to_yx(t, l, w, h): y = (1 - t / h) * 2 - 1 x = (l / w) * 2 - 1 return y, x @njit(fastmath=True) def yx_to_ra(y, x): a = np.arctan2(y, x) % (2 * np.pi) r = (x * x + y * y) ** 0.5 return r, a @njit(fastmath=True) def a_to_na(a): return a / (2 * np.pi) @njit(fastmath=True) def r_to_logr(r): return np.log2(r) @njit(fastmath=True) def encode_to_logrna(l, t, w, h): y, x = tl_to_yx(t, l, w, h) r, a = yx_to_ra(y, x) logr = r_to_logr(r) na = a_to_na(a) return logr, na # ── inverse transformation ────────────────────────────────────────────────── @njit(fastmath=True) def na_to_a(na): return na * (2 * np.pi) @njit(fastmath=True) def logr_to_r(logr): return 2 ** logr @njit(fastmath=True) def ra_to_yx(r, a): x = r * np.cos(a) y = r * np.sin(a) return y, x @njit(fastmath=True) def yx_to_tl(y, x, w, h): t = (1 - (y + 1) / 2) * h l = ((x + 1) / 2) * w t = np.round(t).astype(np.int64) l = np.round(l).astype(np.int64) return t, l @njit(fastmath=True) def decode_to_tl(logr, na, w, h): a = na_to_a(na) r = logr_to_r(logr) y, x = ra_to_yx(r, a) t, l = yx_to_tl(y, x, w, h) t %= h l %= w return t, l # ── utilities ─────────────────────────────────────────────────────────────── def drostify(o: Image.Image): w, h = o.width, o.height scale = 1 / 2 w2, h2 = int(w * scale), int(h * scale) hb = (w - w2) // 2 vb = (h - h2) // 2 l, t, r, b = (hb, vb, w - hb, h - vb) small = o.resize((r - l, b - t)) o.paste(small, (l, t, r, b), mask=small.getchannel('A')) def pad_to_aspect(img, target_ratio, fill=0): w, h = img.size current_ratio = w / h if current_ratio > target_ratio: new_h = int(round(w / target_ratio)) pad_total = new_h - h top = pad_total // 2 bottom = pad_total - top left = right = 0 else: new_w = int(round(h * target_ratio)) pad_total = new_w - w left = pad_total // 2 right = pad_total - left top = bottom = 0 return ImageOps.expand(img, (left, top, right, bottom), fill=fill) def make_still(img_pil, output_size): """Single Droste-effect still frame.""" o = img_pil.convert("RGBA") o = pad_to_aspect(o, output_size[0] / output_size[1]) o = o.resize(output_size, Image.Resampling.NEAREST) drostify(o) w, h = o.width, o.height t, l = np.meshgrid(np.arange(h), np.arange(w), indexing="ij") logr, na = encode_to_logrna(l, t, w, h) logr -= na logr %= -1 t2, l2 = decode_to_tl(logr, na, w, h) oa = np.array(o) ia = oa[t2, l2] for i in range(1, 10): transparent = ia[:, :, 3] == 0 if not transparent.any(): break dt, dl = decode_to_tl(logr[transparent] - i, na[transparent], w, h) ia[transparent] = oa[dt, dl] return Image.fromarray(ia) def make_animation(img_pil, output_size, n_frames, n_rotations): """Animated Droste zoom loop.""" o = img_pil.convert("RGBA") o = pad_to_aspect(o, output_size[0] / output_size[1]) o = o.resize(output_size, Image.Resampling.NEAREST) drostify(o) origa = np.array(o.copy()) w, h = o.width, o.height t0, l0 = np.meshgrid(np.arange(h), np.arange(w), indexing="ij") steps = np.linspace(0.0, 1.0, n_frames, endpoint=False) frames = [] for s in steps: logr, na = encode_to_logrna(l0, t0, w, h) logr -= s logr -= na logr %= -1 nac = na + s * n_rotations t2, l2 = decode_to_tl(logr, nac, w, h) ia = origa[t2, l2] for i in range(1, 5): transparent = ia[:, :, 3] == 0 if not transparent.any(): break dt, dl = decode_to_tl(logr[transparent] - i, nac[transparent], w, h) ia[transparent] = origa[dt, dl] frames.append(Image.fromarray(ia).convert("RGB")) return frames # ── Gradio callbacks ──────────────────────────────────────────────────────── def run_still(image, width, height): if image is None: return None, "⚠️ Please upload an image." try: pil = Image.fromarray(image) result = make_still(pil, (int(width), int(height))) return np.array(result.convert("RGB")), "✅ Done!" except Exception as e: return None, f"❌ Error: {e}" def run_animation(image, width, height, n_frames, n_rotations, fps): if image is None: return None, "⚠️ Please upload an image." try: pil = Image.fromarray(image) frames = make_animation(pil, (int(width), int(height)), int(n_frames), int(n_rotations)) tmp = tempfile.NamedTemporaryFile(suffix=".gif", delete=False) frames[0].save( tmp.name, save_all=True, append_images=frames[1:], duration=int(1000 / fps), loop=0, disposal=2, ) return tmp.name, f"✅ {len(frames)} frames @ {fps} fps" except Exception as e: return None, f"❌ Error: {e}" # ── UI ─────────────────────────────────────────────────────────────────────── DESCRIPTION = """ # 🌀 Droste Effect Upload any image (transparency supported) and apply the **Droste effect** — an infinite self-similar zoom that maps the image into a logarithmic spiral. Two modes: - **Still** – a single warped frame - **Animation** – a seamlessly looping GIF zoom """ with gr.Blocks(title="Droste Effect") as demo: gr.Markdown(DESCRIPTION) with gr.Row(): with gr.Column(scale=1): image_input = gr.Image(label="Input image", type="numpy", image_mode='RGBA') with gr.Accordion("Output size", open=False): width = gr.Slider(64, 1024, value=400, step=8, label="Width") height = gr.Slider(64, 1024, value=400, step=8, label="Height") with gr.Column(scale=2): with gr.Tab("🖼️ Still"): still_btn = gr.Button("Generate still", variant="primary") still_out = gr.Image(label="Droste still", type="numpy", format='png') still_status = gr.Textbox(label="Status", interactive=False, max_lines=1) still_btn.click( run_still, inputs=[image_input, width, height], outputs=[still_out, still_status], ) with gr.Tab("🎞️ Animation"): with gr.Row(): n_frames = gr.Slider(12, 120, value=60, step=4, label="Frames") n_rotations = gr.Slider(0, 8, value=0, step=1, label="Rotations per loop") fps = gr.Slider(6, 60, value=30, step=2, label="FPS") anim_btn = gr.Button("Generate animation", variant="primary") anim_out = gr.Image(label="Droste animation (GIF)") anim_status = gr.Textbox(label="Status", interactive=False, max_lines=1) anim_btn.click( run_animation, inputs=[image_input, width, height, n_frames, n_rotations, fps], outputs=[anim_out, anim_status], ) # ── Examples ───────────────────────────────────────────────────────────── gr.Markdown("### 🖼️ Example images — click to load") # Still examples: [image, width, height] gr.Examples( examples=[ [A, 1024, 1024], [B, 400, 400], [C, 512, 512], [D, 400, 400], ], inputs=[image_input, width, height], outputs=[still_out, still_status], fn=run_still, cache_examples=False, label="Still examples", ) gr.Markdown(""" --- **How it works:** each pixel's Cartesian coordinates are converted to polar form, the radius is log₂-transformed, and the angle is used to modulate the radius offset, turning circles into spirals. The image is then resampled in that warped space, creating the recursive Droste zoom. Transparent pixels are resolved by stepping one ring inward until opaque colour is found. Space generated from ./_orig.py using claude. """) if __name__ == "__main__": demo.launch()