import streamlit as st
import numpy as np
from PIL import Image
import io
import sys
import os
# Bug 3 & 4 fixes - Top level imports, recursion limit
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"
)
# ── CSS ──────────────────────────────────────────────────────────────────────
st.markdown("""
""", unsafe_allow_html=True)
# ── Helpers ───────────────────────────────────────────────────────────────────
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 # total, leaves, max_d
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
# ── App Header ───────────────────────────────────────────────────────────────
st.markdown("
Image Manipulation Engine
", unsafe_allow_html=True)
# ── Main Area ────────────────────────────────────────────────────────────────
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:
# --- CONTROL PANEL ---
st.markdown("", unsafe_allow_html=True)
col1, col2, col3 = st.columns([1, 1, 1])
arr1 = None
file_id = None
arr2 = None
with col1:
st.markdown("
1. Input Source
", 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("
2. Operation
", unsafe_allow_html=True)
# Spacer to align with the radio button in col1
st.markdown("
", 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("
Second image for blending:
", 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("
3. Configuration
", unsafe_allow_html=True)
# Spacer to align with the radio button in col1
st.markdown("
", unsafe_allow_html=True)
threshold = st.slider("Quality vs Compression", 1, 500, 30, label_visibility="collapsed")
if threshold <= 30:
st.markdown("
Mode: Lossless
", unsafe_allow_html=True)
elif threshold <= 100:
st.markdown("
Mode: Balanced
", unsafe_allow_html=True)
else:
st.markdown("
Mode: Lossy
", unsafe_allow_html=True)
st.markdown("
", 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("
", unsafe_allow_html=True)
# State management
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
# --- IMAGE PROCESSING ---
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")
# --- RESULTS DISPLAY ---
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("ORIGINAL
", unsafe_allow_html=True)
st.image(arr_to_pil(arr1), use_container_width=True)
st.markdown(f"{arr1.shape[1]} × {arr1.shape[0]} px
", unsafe_allow_html=True)
with rc2:
st.markdown("PROCESSED
", 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"{op_str} (Threshold: {th_str})
", unsafe_allow_html=True)
else:
st.markdown("""
✨
Ready to process
""", unsafe_allow_html=True)
# QuadTree Stats
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("""
Total Nodes
{nodes}
{ratio:.1f}x fewer nodes
""".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("
", 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("""
No Image Selected
Upload an image or select one from the repository to get started.
""", 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("See how threshold controls quality vs. compression
", 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("""
STEP 1Divide image into 4 quadrants
STEP 2Compute mean RGB + variance for each quadrant
STEP 3If variance ≤ threshold: LEAF → store avg color, stop subdividing
STEP 4If variance > threshold: RECURSE into each quadrant with size/2
STEP 5Continue until size=1 pixel or variance ≤ threshold
""", 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("No pixel data is ever copied. Only 4 pointer assignments per node.
", 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 |
Key Takeaway: 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.
""", unsafe_allow_html=True)
with tab3:
st.markdown("### C Source vs Python")
st.markdown("""
""", 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("
", 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}")