| import streamlit as st |
| import numpy as np |
| from PIL import Image |
| import io |
| import sys |
| import os |
|
|
| |
| sys.setrecursionlimit(10000) |
|
|
| sys.path.insert(0, os.path.dirname(__file__)) |
| from quadtree_engine import ( |
| read_ppm_bytes, process_image, arr_to_pil, compress_image, decompress_image, pad_to_square_pow2 |
| ) |
|
|
| st.set_page_config( |
| page_title="Image Manipulation Engine", |
| page_icon="β¨", |
| layout="wide", |
| initial_sidebar_state="collapsed" |
| ) |
|
|
| |
| st.markdown(""" |
| <style> |
| @import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap'); |
| |
| html, body, [data-testid="stAppViewContainer"], .stApp { |
| background: #FAF6F0 !important; /* Claude's warm light orange/beige background */ |
| color: #2D2D2D !important; |
| font-family: 'Inter', sans-serif !important; |
| } |
| |
| header[data-testid="stHeader"] { |
| background: transparent !important; |
| } |
| |
| /* Hide deploy buttons and toolbar */ |
| header[data-testid="stHeader"] .stAppDeployButton, |
| header[data-testid="stHeader"] [data-testid="stToolbar"], |
| header[data-testid="stHeader"] [data-testid="stHeaderActionElements"] { |
| display: none !important; |
| } |
| |
| h1, h2, h3, h4, h5, h6, [data-testid="stMarkdownContainer"] h1 { |
| font-family: 'Inter', sans-serif !important; |
| font-weight: 600 !important; |
| color: #1A1A1A !important; |
| } |
| |
| /* Claude UI Buttons */ |
| .stButton > button { |
| background-color: #1A1A1A !important; |
| color: #FFFFFF !important; |
| border: none !important; |
| border-radius: 8px !important; |
| font-family: 'Inter', sans-serif !important; |
| font-weight: 500 !important; |
| padding: 0.5rem 1rem !important; |
| transition: all 0.2s ease; |
| width: 100%; |
| } |
| |
| .stButton > button:hover { |
| background-color: #333333 !important; |
| transform: translateY(-1px); |
| box-shadow: 0 4px 12px rgba(0,0,0,0.1); |
| } |
| |
| /* Claude UI Secondary Button (Download) */ |
| .stDownloadButton > button { |
| background-color: #FFFFFF !important; |
| color: #1A1A1A !important; |
| border: 1px solid #D1D1D1 !important; |
| border-radius: 8px !important; |
| font-family: 'Inter', sans-serif !important; |
| font-weight: 500 !important; |
| width: 100%; |
| padding: 0.5rem 1rem !important; |
| transition: all 0.2s ease; |
| } |
| |
| .stDownloadButton > button:hover { |
| background-color: #F8F8F8 !important; |
| border-color: #1A1A1A !important; |
| } |
| |
| /* Inputs */ |
| .stSelectbox > div > div { |
| background-color: #FFFFFF !important; |
| border-radius: 8px !important; |
| border: 1px solid #E5E0D8 !important; |
| color: #1A1A1A !important; |
| } |
| |
| .stTextInput > div > div > input { |
| background-color: #FFFFFF !important; |
| border-radius: 8px !important; |
| border: 1px solid #E5E0D8 !important; |
| color: #1A1A1A !important; |
| } |
| |
| /* Slider Customization */ |
| .stSlider [data-testid="stTickBar"] { |
| background-color: #E5E0D8 !important; |
| } |
| .stSlider [data-testid="stSliderThumb"] { |
| background-color: #D97757 !important; /* Claude's Peach/Orange accent */ |
| } |
| |
| /* Tabs Styling */ |
| .stTabs [data-baseweb="tab-list"] { |
| gap: 2rem; |
| } |
| .stTabs [data-baseweb="tab"] { |
| font-family: 'Inter', sans-serif !important; |
| font-weight: 500 !important; |
| padding-bottom: 0.5rem !important; |
| color: #777777 !important; |
| } |
| .stTabs [aria-selected="true"] { |
| color: #1A1A1A !important; |
| border-bottom-color: #D97757 !important; |
| } |
| |
| /* Hide Sidebar */ |
| [data-testid="stSidebar"] { |
| display: none !important; |
| } |
| [data-testid="collapsedControl"] { |
| display: none !important; |
| } |
| |
| /* Custom CSS classes for components */ |
| .claude-stats-container { |
| display: flex; |
| gap: 1rem; |
| margin-top: 1.5rem; |
| flex-wrap: wrap; |
| } |
| .claude-stat-card { |
| flex: 1; |
| background-color: #FFFFFF; |
| border: 1px solid #E5E0D8; |
| border-radius: 12px; |
| padding: 1.25rem; |
| text-align: center; |
| min-width: 140px; |
| box-shadow: 0 2px 8px rgba(0,0,0,0.02); |
| } |
| .claude-stat-label { |
| font-size: 0.75rem; |
| color: #888888; |
| text-transform: uppercase; |
| letter-spacing: 0.05em; |
| margin-bottom: 0.5rem; |
| font-weight: 600; |
| } |
| .claude-stat-value { |
| font-size: 1.5rem; |
| font-weight: 700; |
| color: #1A1A1A; |
| } |
| .claude-stat-sub { |
| font-size: 0.75rem; |
| color: #D97757; |
| margin-top: 0.25rem; |
| } |
| |
| .step-row { |
| display: flex; |
| align-items: center; |
| margin-bottom: 0.5rem; |
| background-color: #FFFFFF; |
| border: 1px solid #E5E0D8; |
| padding: 0.75rem; |
| border-radius: 8px; |
| } |
| .step-num { |
| color: #D97757; |
| font-weight: 700; |
| margin-right: 1rem; |
| min-width: 60px; |
| } |
| |
| .code-block-custom { |
| background-color: #F8F9FA; |
| border: 1px solid #E5E0D8; |
| padding: 1rem; |
| border-radius: 8px; |
| color: #2D2D2D; |
| } |
| |
| hr { |
| border-color: #E5E0D8 !important; |
| } |
| |
| /* Make radio button labels dark */ |
| [data-testid="stRadio"] label { |
| color: #1A1A1A !important; |
| } |
| </style> |
| """, unsafe_allow_html=True) |
|
|
| |
|
|
| PPM_DIR = os.path.join(os.path.dirname(__file__), "c_project_files") |
|
|
| def list_ppms(): |
| result = [] |
| if not os.path.isdir(PPM_DIR): |
| return result |
| for f in sorted(os.listdir(PPM_DIR)): |
| if not f.endswith(".ppm"): |
| continue |
| fpath = os.path.join(PPM_DIR, f) |
| try: |
| with open(fpath, "rb") as fh: |
| header = fh.read(3) |
| if header.startswith(b'P6'): |
| result.append(f) |
| except Exception: |
| pass |
| return result |
|
|
| def load_array(uploaded=None, ppm_name=None): |
| if uploaded is not None: |
| uploaded.seek(0) |
| raw = uploaded.read() |
| elif ppm_name: |
| path = os.path.join(PPM_DIR, ppm_name) |
| with open(path, "rb") as f: |
| raw = f.read() |
| else: |
| return None, None |
|
|
| try: |
| arr = np.array(Image.open(io.BytesIO(raw)).convert("RGB"), dtype=np.uint8) |
| return arr, raw |
| except Exception: |
| pass |
|
|
| try: |
| arr = read_ppm_bytes(raw) |
| return arr, raw |
| except Exception as e: |
| fname = uploaded.name if uploaded else ppm_name |
| raise ValueError(f"Cannot read '{fname}' as an image.\nDetail: {e}") |
|
|
| def count_nodes_and_leaves(node, depth=0): |
| if node is None: |
| return 0, 0, 0 |
| if node.is_leaf(): |
| return 1, 1, depth |
| counts = [count_nodes_and_leaves(c, depth+1) for c in [node.topLeft, node.topRight, node.bottomLeft, node.bottomRight]] |
| total = 1 + sum(c[0] for c in counts) |
| leaves = sum(c[1] for c in counts) |
| max_d = max(c[2] for c in counts) |
| return total, leaves, max_d |
|
|
| |
| st.markdown("<h1 style='text-align: center; margin-bottom: 2rem; font-size: 2.5rem; letter-spacing: -0.02em;'>Image Manipulation Engine</h1>", unsafe_allow_html=True) |
|
|
| |
|
|
| tab1, tab2, tab3 = st.tabs(["IMAGE VIEW", "ALGORITHM EXPLORER", "C SOURCE"]) |
|
|
| OP_MAP = { |
| "Compress Only": "compress_only", |
| "Grayscale": "grayscale", |
| "Negative": "negative", |
| "Sepia": "sepia", |
| "Brighten": "brighten", |
| "Mirror (Horizontal)": "mirror", |
| "Flip (Vertical)": "water", |
| "Rotate Left 90Β°": "rotate_left", |
| "Rotate Right 90Β°": "rotate_right", |
| "Blend / Union": "union", |
| } |
|
|
| with tab1: |
| |
| st.markdown("<div style='background-color: #FFFFFF; padding: 1.5rem; border-radius: 12px; border: 1px solid #E5E0D8; margin-bottom: 2rem; box-shadow: 0 4px 12px rgba(0,0,0,0.03);'>", unsafe_allow_html=True) |
| |
| col1, col2, col3 = st.columns([1, 1, 1]) |
| |
| arr1 = None |
| file_id = None |
| arr2 = None |
| |
| with col1: |
| st.markdown("<div style='color: #1A1A1A; font-weight: 600; margin-bottom: 0.5rem; font-size: 0.95rem;'>1. Input Source</div>", unsafe_allow_html=True) |
| input_options = ["Upload file"] |
| has_ppms = os.path.isdir(PPM_DIR) and len(list_ppms()) > 0 |
| if has_ppms: |
| input_options.append("Use Sample Images") |
| |
| input_mode = st.radio("Source", input_options, label_visibility="collapsed", horizontal=True) |
| |
| if input_mode == "Upload file": |
| up1 = st.file_uploader("Primary image", type=["ppm","png","jpg","jpeg"], key="u1", label_visibility="collapsed") |
| if up1: |
| file_id = f"{up1.name}_{up1.size}" |
| try: |
| arr1, _ = load_array(uploaded=up1) |
| except ValueError as e: |
| st.error(str(e)) |
| else: |
| sel = st.selectbox("Select PPM", list_ppms(), label_visibility="collapsed") |
| if sel: |
| file_id = f"repo_{sel}" |
| try: |
| arr1, _ = load_array(ppm_name=sel) |
| except ValueError as e: |
| st.error(str(e)) |
| |
| with col2: |
| st.markdown("<div style='color: #1A1A1A; font-weight: 600; margin-bottom: 0.5rem; font-size: 0.95rem;'>2. Operation</div>", unsafe_allow_html=True) |
| |
| st.markdown("<div style='height: 38px; margin-bottom: 0px;'></div>", unsafe_allow_html=True) |
| |
| operation = st.selectbox("Operation", list(OP_MAP.keys()), label_visibility="collapsed") |
| op_key = OP_MAP[operation] |
| |
| if op_key == "union": |
| st.markdown("<div style='font-size: 0.85rem; color: #777; margin-top: 0.5rem; margin-bottom: 0.2rem;'>Second image for blending:</div>", unsafe_allow_html=True) |
| if input_mode == "Upload file": |
| up2 = st.file_uploader("Second image", type=["ppm","png","jpg","jpeg"], key="u2", label_visibility="collapsed") |
| if up2: |
| try: |
| arr2, _ = load_array(uploaded=up2) |
| except ValueError as e: |
| st.error(str(e)) |
| else: |
| sel2 = st.selectbox("Second PPM", list_ppms(), key="sel2", label_visibility="collapsed") |
| if sel2: |
| try: |
| arr2, _ = load_array(ppm_name=sel2) |
| except ValueError as e: |
| st.error(str(e)) |
|
|
| with col3: |
| st.markdown("<div style='color: #1A1A1A; font-weight: 600; margin-bottom: 0.5rem; font-size: 0.95rem;'>3. Configuration</div>", unsafe_allow_html=True) |
| |
| st.markdown("<div style='height: 38px; margin-bottom: 0px;'></div>", unsafe_allow_html=True) |
| |
| threshold = st.slider("Quality vs Compression", 1, 500, 30, label_visibility="collapsed") |
| |
| if threshold <= 30: |
| st.markdown("<div style='color: #777; font-size: 0.8rem; margin-top: -10px;'>Mode: <span style='color: #10B981; font-weight: 600;'>Lossless</span></div>", unsafe_allow_html=True) |
| elif threshold <= 100: |
| st.markdown("<div style='color: #777; font-size: 0.8rem; margin-top: -10px;'>Mode: <span style='color: #F59E0B; font-weight: 600;'>Balanced</span></div>", unsafe_allow_html=True) |
| else: |
| st.markdown("<div style='color: #777; font-size: 0.8rem; margin-top: -10px;'>Mode: <span style='color: #EF4444; font-weight: 600;'>Lossy</span></div>", unsafe_allow_html=True) |
| |
| st.markdown("<hr style='margin-top: 1.5rem; margin-bottom: 1.5rem;'>", unsafe_allow_html=True) |
| |
| col_empty, col_btn = st.columns([4, 2]) |
| with col_btn: |
| run = st.button("β¨ Generate Result", disabled=(arr1 is None)) |
| |
| st.markdown("</div>", unsafe_allow_html=True) |
|
|
| |
| if file_id != st.session_state.get("last_file_id"): |
| st.session_state.pop("result", None) |
| st.session_state.pop("tree", None) |
| st.session_state.pop("op_done", None) |
| st.session_state["last_file_id"] = file_id |
|
|
| |
| if run and arr1 is not None: |
| st.session_state.pop("result", None) |
| st.session_state.pop("tree", None) |
| |
| with st.spinner("Processing image..."): |
| try: |
| if op_key == "compress_only": |
| padded, oh, ow = pad_to_square_pow2(arr1) |
| size = padded.shape[0] |
| tree = compress_image(padded, 0, 0, size, threshold) |
| out = np.zeros((size, size, 3), dtype=np.uint8) |
| decompress_image(tree, out, 0, 0, size) |
| computed = out[:oh, :ow] |
| elif op_key == "union": |
| if arr2 is None: |
| st.error("Please provide a second image for union.") |
| computed, tree = None, None |
| else: |
| computed, tree = process_image(arr1, "union", threshold, arr2, return_tree=True) |
| else: |
| computed, tree = process_image(arr1, op_key, threshold, return_tree=True) |
|
|
| if computed is not None: |
| st.session_state["result"] = computed |
| st.session_state["tree"] = tree |
| st.session_state["op_done"] = operation |
| st.session_state["thresh_done"] = threshold |
| except RecursionError: |
| st.error("RecursionError: image too large for this threshold. Try a higher threshold.") |
| except Exception as e: |
| st.error(f"Error: {e}") |
|
|
| result_arr = st.session_state.get("result") |
| tree = st.session_state.get("tree") |
| |
| |
| if arr1 is not None: |
| MAX_DIM = 2048 |
| h, w = arr1.shape[:2] |
| if h > MAX_DIM or w > MAX_DIM: |
| st.warning(f"Image is {w}Γ{h}. Images over {MAX_DIM}px may be slow or crash. Consider resizing first.") |
|
|
| rc1, rc2 = st.columns(2) |
| with rc1: |
| st.markdown("<div style='text-align: center; color: #888; margin-bottom: 1rem; font-weight: 600; letter-spacing: 0.05em; font-size: 0.85rem;'>ORIGINAL</div>", unsafe_allow_html=True) |
| st.image(arr_to_pil(arr1), use_container_width=True) |
| st.markdown(f"<div style='text-align: center; color: #777; font-size: 0.85rem; margin-top: 0.5rem;'>{arr1.shape[1]} Γ {arr1.shape[0]} px</div>", unsafe_allow_html=True) |
| |
| with rc2: |
| st.markdown("<div style='text-align: center; color: #888; margin-bottom: 1rem; font-weight: 600; letter-spacing: 0.05em; font-size: 0.85rem;'>PROCESSED</div>", unsafe_allow_html=True) |
| if result_arr is not None: |
| st.image(arr_to_pil(result_arr), use_container_width=True) |
| op_str = st.session_state.get("op_done", "") |
| th_str = st.session_state.get("thresh_done", "") |
| st.markdown(f"<div style='text-align: center; color: #777; font-size: 0.85rem; margin-top: 0.5rem;'>{op_str} (Threshold: {th_str})</div>", unsafe_allow_html=True) |
| else: |
| st.markdown(""" |
| <div style='background-color: #FFFFFF; border: 1px dashed #D1D1D1; border-radius: 12px; height: 300px; display: flex; align-items: center; justify-content: center; flex-direction: column;'> |
| <span style='font-size: 2rem; margin-bottom: 1rem;'>β¨</span> |
| <span style='color: #888; font-family: "Inter", sans-serif;'>Ready to process</span> |
| </div> |
| """, unsafe_allow_html=True) |
| |
| |
| if result_arr is not None and tree is not None: |
| total_nodes, leaves, max_d = count_nodes_and_leaves(tree) |
| total_pixels = arr1.shape[0] * arr1.shape[1] |
| ratio = total_pixels / total_nodes if total_nodes > 0 else 0 |
| |
| st.markdown(""" |
| <div class='claude-stats-container'> |
| <div class='claude-stat-card'> |
| <div class='claude-stat-label'>Total Nodes</div> |
| <div class='claude-stat-value'>{nodes}</div> |
| <div class='claude-stat-sub'>{ratio:.1f}x fewer nodes</div> |
| </div> |
| <div class='claude-stat-card'> |
| <div class='claude-stat-label'>Leaf Nodes</div> |
| <div class='claude-stat-value'>{leaves}</div> |
| </div> |
| <div class='claude-stat-card'> |
| <div class='claude-stat-label'>Max Depth</div> |
| <div class='claude-stat-value'>{max_d}</div> |
| </div> |
| <div class='claude-stat-card'> |
| <div class='claude-stat-label'>Total Pixels</div> |
| <div class='claude-stat-value'>{pixels}</div> |
| </div> |
| </div> |
| """.replace('{nodes}', f"{total_nodes:,}").replace('{ratio}', f"{ratio}").replace('{leaves}', f"{leaves:,}").replace('{max_d}', str(max_d)).replace('{pixels}', f"{total_pixels:,}"), unsafe_allow_html=True) |
| |
| st.markdown("<br>", unsafe_allow_html=True) |
| col_empty, col_dl = st.columns([4, 1]) |
| with col_dl: |
| img_pil = arr_to_pil(result_arr) |
| buf = io.BytesIO() |
| img_pil.save(buf, format="PNG") |
| st.download_button("Download Result", buf.getvalue(), "result.png", "image/png") |
| |
| else: |
| st.markdown(""" |
| <div style='background-color: #FFFFFF; border: 1px dashed #D1D1D1; border-radius: 12px; padding: 4rem; text-align: center; margin-top: 2rem;'> |
| <h3 style='color: #1A1A1A; font-weight: 500; margin-bottom: 0.5rem;'>No Image Selected</h3> |
| <p style='color: #666;'>Upload an image or select one from the repository to get started.</p> |
| </div> |
| """, unsafe_allow_html=True) |
|
|
| with tab2: |
| st.markdown("### 1. What is a Quadtree?") |
| c1, c2 = st.columns(2) |
| with c1: |
| st.code(""" |
| ββββββββββ¬βββββββββ |
| β β β |
| β TL β TR β |
| β β β |
| ββββββββββΌβββββββββ€ |
| β β β |
| β BL β BR β |
| β β β |
| ββββββββββ΄βββββββββ |
| """, language="text") |
| with c2: |
| st.markdown(""" |
| - **`red`, `green`, `blue`**: The average color of this region. |
| - **`area`**: The total pixels covered by this node. |
| - **Children**: If variance > threshold, the region splits into 4 sub-regions (`topLeft`, `topRight`, `bottomLeft`, `bottomRight`). |
| - **Leaf Node**: If variance <= threshold, children are null. The region is colored uniformly. |
| """) |
|
|
| st.markdown("---") |
| st.markdown("### 2. How Compression Works") |
| st.markdown("<div style='color: #777; margin-bottom: 1rem;'>See how threshold controls quality vs. compression</div>", unsafe_allow_html=True) |
| |
| @st.cache_data |
| def get_synthetic_image(): |
| y, x = np.mgrid[0:64, 0:64] |
| r = (x * 4) % 255 |
| g = (y * 4) % 255 |
| b = ((x + y) * 2) % 255 |
| noise = np.random.randint(0, 50, (64, 64, 3)) |
| img = np.stack([r, g, b], axis=-1) + noise |
| return np.clip(img, 0, 255).astype(np.uint8) |
|
|
| synth_img = get_synthetic_image() |
| sim_thresh = st.slider("Simulator Threshold", 1, 200, 50, key="sim_t") |
| |
| sc1, sc2 = st.columns(2) |
| with sc1: |
| st.image(arr_to_pil(synth_img), caption="Original (64x64)", use_container_width=True) |
| with sc2: |
| sim_tree = compress_image(synth_img, 0, 0, 64, sim_thresh) |
| sim_out = np.zeros((64, 64, 3), dtype=np.uint8) |
| decompress_image(sim_tree, sim_out, 0, 0, 64) |
| total_sim, leaves_sim, _ = count_nodes_and_leaves(sim_tree) |
| st.image(arr_to_pil(sim_out), caption=f"Compressed (Nodes: {total_sim})", use_container_width=True) |
|
|
| st.markdown("---") |
| st.markdown("### 3. Compression Algorithm Step-by-Step") |
| st.markdown(""" |
| <div class="step-row"><span class="step-num">STEP 1</span><span>Divide image into 4 quadrants</span></div> |
| <div class="step-row"><span class="step-num">STEP 2</span><span>Compute mean RGB + variance for each quadrant</span></div> |
| <div class="step-row"><span class="step-num">STEP 3</span><span>If variance β€ threshold: <b>LEAF</b> β store avg color, stop subdividing</span></div> |
| <div class="step-row"><span class="step-num">STEP 4</span><span>If variance > threshold: <b>RECURSE</b> into each quadrant with size/2</span></div> |
| <div class="step-row"><span class="step-num">STEP 5</span><span>Continue until size=1 pixel or variance β€ threshold</span></div> |
| """, unsafe_allow_html=True) |
|
|
| st.markdown("---") |
| st.markdown("### 4. Color Filters β How They Work on the Tree") |
| fc1, fc2, fc3, fc4 = st.columns(4) |
| with fc1: |
| with st.container(): |
| st.markdown("**Grayscale**") |
| st.code("L = 0.299R + 0.587G + 0.114B\nR'=L, G'=L, B'=L") |
| st.caption("Weights green channel most heavily") |
| with fc2: |
| with st.container(): |
| st.markdown("**Negative**") |
| st.code("R' = 255 - R\nG' = 255 - G\nB' = 255 - B") |
| st.caption("Inverts each channel β dark becomes light") |
| with fc3: |
| with st.container(): |
| st.markdown("**Sepia**") |
| st.code("R' = 0.393R + ...\nG' = ...\nB' = ...") |
| st.caption("Warm brownish tones by mixing channels") |
| with fc4: |
| with st.container(): |
| st.markdown("**Brighten**") |
| st.code("R' = min(255, R*1.3)\nG' = ...\nB' = ...") |
| st.caption("Scales all channels up") |
|
|
| st.markdown("---") |
| st.markdown("### 5. Spatial Transforms β Pointer Swaps") |
| st.markdown("<div style='color: #D97757; margin-bottom: 1rem;'><strong>No pixel data is ever copied. Only 4 pointer assignments per node.</strong></div>", unsafe_allow_html=True) |
| tc1, tc2 = st.columns(2) |
| with tc1: |
| st.code("""MIRROR (horizontal): |
| Before: TL | TR |
| ------- |
| BL | BR |
| |
| After: TR | TL |
| ------- |
| BR | BL""", language="text") |
| with tc2: |
| st.code("""ROTATE LEFT 90Β°: |
| Before: TL | TR |
| ------- |
| BL | BR |
| |
| After: TR | BR |
| ------- |
| TL | BL""", language="text") |
| |
| tc3, tc4 = st.columns(2) |
| with tc3: |
| st.code("""FLIP (vertical): |
| Before: TL | TR |
| ------- |
| BL | BR |
| |
| After: BL | BR |
| ------- |
| TL | TR""", language="text") |
| with tc4: |
| st.code("""ROTATE RIGHT 90Β°: |
| Before: TL | TR |
| ------- |
| BL | BR |
| |
| After: BL | TL |
| ------- |
| BR | TR""", language="text") |
|
|
| st.markdown("---") |
| st.markdown("### 6. Union / Blend β Three Cases") |
| st.markdown(""" |
| - **Case 1:** Both nodes are internal β recurse into all 4 child pairs. |
| - **Case 2:** t1 is a leaf, t2 has children β blend t1's solid color with each of t2's children. |
| - **Case 3:** t2 is a leaf, t1 has children β blend t2's solid color with each of t1's children. |
| |
| Averaging formula: `result.R = (t1.R + t2.R) / 2` |
| *Note: This produces a pixel-perfect 50/50 blend without ever decompressing either image to a pixel buffer.* |
| """) |
|
|
| st.markdown("---") |
| st.markdown("### 7. Complexity Analysis") |
| st.markdown(""" |
| | Operation | Time Complexity | Space Complexity | Notes | |
| |---|---|---|---| |
| | Compress | O(n log n) | O(n) | n = total pixels | |
| | Decompress | O(n) | O(n) | Linear tree traversal | |
| | Filter | O(k) | O(k) | k = tree nodes, k βͺ n | |
| | Rotate/Mirror | O(k) | O(1) | Only pointer swaps | |
| | Union | O(min(k1,k2)) | O(min(k1,k2)) | Bounded by smaller tree | |
| |
| <div style="border-left: 4px solid #D97757; padding-left: 1rem; margin-top: 1rem; color: #2D2D2D;"> |
| <strong>Key Takeaway:</strong> Filters and transforms run on the compressed tree β they're O(nodes) not O(pixels). At threshold=100, a 512Γ512 image (262K pixels) may have fewer than 5,000 nodes. |
| </div> |
| """, unsafe_allow_html=True) |
|
|
|
|
| with tab3: |
| st.markdown("### C Source vs Python") |
| st.markdown(""" |
| <div style="background-color: #FFFFFF; border: 1px solid #E5E0D8; padding: 1rem; margin-bottom: 1rem; border-radius: 8px;"> |
| π View the full C implementation on GitHub:<br> |
| <a href="https://github.com/Harshwardhan-Deshmukh03/Image-Manipulation-QuadTree.git" target="_blank" |
| style="color:#D97757;font-weight:600;text-decoration:none;"> |
| github.com/Harshwardhan-Deshmukh03/Image-Manipulation-QuadTree |
| </a> |
| </div> |
| """, unsafe_allow_html=True) |
| st.markdown(""" |
| | C File | Python Equivalent | |
| |---|---| |
| | `compress.c` | `compress_image()` | |
| | `filters.c` | `apply_grayscale()`, `apply_negative()`, etc. | |
| | `rotate.c` | `rotate_left()`, `get_mirror_image()`, etc. | |
| | `union.c` | `union_of_images()` | |
| | `decompress.c` | `decompress_image()` | |
| """) |
| |
| c_files = { |
| "main.c": "CLI entry point β parses flags, orchestrates the full pipeline", |
| "compress.c": "Quadtree construction from pixel matrix using variance threshold", |
| "decompress.c": "Quadtree β pixel matrix reconstruction", |
| "filters.c": "Color filters: grayscale, negative, sepia, brighten", |
| "rotate.c": "Spatial transforms: mirror, flip, rotate L/R/180", |
| "union.c": "Pixel-level blending of two Quadtrees", |
| "suppl.c": "PPM I/O, getMean(), tree serialization helpers", |
| "suppl.h": "All struct definitions: pixels, qtNode, qtInfo", |
| } |
|
|
| st.markdown("<br>", unsafe_allow_html=True) |
| for fname, desc in c_files.items(): |
| fpath = os.path.join(PPM_DIR, fname) |
| with st.expander(f"`{fname}` β {desc}"): |
| if os.path.isfile(fpath): |
| with open(fpath) as f: |
| st.code(f.read(), language="c") |
| else: |
| st.info(f"File not found at: {fpath}") |
|
|