Added code files
Browse files- src/app.py +560 -0
- src/quadtree_engine.py +336 -0
- src/test_engine.py +27 -0
src/app.py
ADDED
|
@@ -0,0 +1,560 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import streamlit as st
|
| 2 |
+
import numpy as np
|
| 3 |
+
from PIL import Image
|
| 4 |
+
import io
|
| 5 |
+
import sys
|
| 6 |
+
import os
|
| 7 |
+
|
| 8 |
+
# Bug 3 & 4 fixes - Top level imports, recursion limit
|
| 9 |
+
sys.setrecursionlimit(10000)
|
| 10 |
+
|
| 11 |
+
sys.path.insert(0, os.path.dirname(__file__))
|
| 12 |
+
from quadtree_engine import (
|
| 13 |
+
read_ppm_bytes, process_image, arr_to_pil, compress_image, decompress_image, pad_to_square_pow2
|
| 14 |
+
)
|
| 15 |
+
|
| 16 |
+
st.set_page_config(
|
| 17 |
+
page_title="QuadTree Image Engine",
|
| 18 |
+
page_icon="π²",
|
| 19 |
+
layout="wide",
|
| 20 |
+
initial_sidebar_state="expanded"
|
| 21 |
+
)
|
| 22 |
+
|
| 23 |
+
# ββ CSS ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 24 |
+
st.markdown("""
|
| 25 |
+
<style>
|
| 26 |
+
@import url('https://fonts.googleapis.com/css2?family=DM+Mono:wght@400;500;700&family=IBM+Plex+Sans:wght@400;500;600&display=swap');
|
| 27 |
+
|
| 28 |
+
html, body, [data-testid="stAppViewContainer"], .stApp {
|
| 29 |
+
background: #0d0d0d !important;
|
| 30 |
+
color: #f0f0f0 !important;
|
| 31 |
+
font-family: 'IBM Plex Sans', sans-serif !important;
|
| 32 |
+
}
|
| 33 |
+
|
| 34 |
+
header[data-testid="stHeader"] {
|
| 35 |
+
display: none !important;
|
| 36 |
+
}
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
h1, h2, h3, h4, h5, h6, [data-testid="stMarkdownContainer"] h1, [data-testid="stMarkdownContainer"] h2, [data-testid="stMarkdownContainer"] h3 {
|
| 40 |
+
font-family: 'DM Mono', monospace !important;
|
| 41 |
+
}
|
| 42 |
+
|
| 43 |
+
[data-testid="stSidebar"] {
|
| 44 |
+
background: #141414 !important;
|
| 45 |
+
border-right: 1px solid #2a2a2a !important;
|
| 46 |
+
}
|
| 47 |
+
|
| 48 |
+
.stButton > button {
|
| 49 |
+
background-color: #00ff87 !important;
|
| 50 |
+
color: #0d0d0d !important;
|
| 51 |
+
border: none !important;
|
| 52 |
+
border-radius: 4px !important;
|
| 53 |
+
font-family: 'DM Mono', monospace !important;
|
| 54 |
+
font-weight: 700 !important;
|
| 55 |
+
width: 100%;
|
| 56 |
+
margin-top: 1rem;
|
| 57 |
+
}
|
| 58 |
+
|
| 59 |
+
.stButton > button:hover {
|
| 60 |
+
background-color: #00cc6a !important;
|
| 61 |
+
color: #000000 !important;
|
| 62 |
+
}
|
| 63 |
+
|
| 64 |
+
.stDownloadButton > button {
|
| 65 |
+
background-color: transparent !important;
|
| 66 |
+
color: #00ff87 !important;
|
| 67 |
+
border: 1px solid #00ff87 !important;
|
| 68 |
+
border-radius: 4px !important;
|
| 69 |
+
font-family: 'DM Mono', monospace !important;
|
| 70 |
+
font-weight: 700 !important;
|
| 71 |
+
}
|
| 72 |
+
|
| 73 |
+
.stDownloadButton > button:hover {
|
| 74 |
+
background-color: #00ff87 !important;
|
| 75 |
+
color: #0d0d0d !important;
|
| 76 |
+
}
|
| 77 |
+
|
| 78 |
+
.accent-text { color: #00ff87; }
|
| 79 |
+
.warning-text { color: #ffb700; }
|
| 80 |
+
.error-text { color: #ff4444; }
|
| 81 |
+
|
| 82 |
+
.step-row {
|
| 83 |
+
display: flex;
|
| 84 |
+
align-items: center;
|
| 85 |
+
margin-bottom: 0.5rem;
|
| 86 |
+
background-color: #141414;
|
| 87 |
+
border: 1px solid #2a2a2a;
|
| 88 |
+
padding: 0.75rem;
|
| 89 |
+
}
|
| 90 |
+
.step-num {
|
| 91 |
+
color: #00ff87;
|
| 92 |
+
font-family: 'DM Mono', monospace;
|
| 93 |
+
font-weight: 700;
|
| 94 |
+
margin-right: 1rem;
|
| 95 |
+
min-width: 60px;
|
| 96 |
+
}
|
| 97 |
+
|
| 98 |
+
.code-block-custom {
|
| 99 |
+
background-color: #141414;
|
| 100 |
+
border: 1px solid #2a2a2a;
|
| 101 |
+
padding: 1rem;
|
| 102 |
+
font-family: 'DM Mono', monospace;
|
| 103 |
+
color: #f0f0f0;
|
| 104 |
+
}
|
| 105 |
+
</style>
|
| 106 |
+
""", unsafe_allow_html=True)
|
| 107 |
+
|
| 108 |
+
# ββ Helpers βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 109 |
+
|
| 110 |
+
PPM_DIR = os.path.join(os.path.dirname(__file__), "c_project_files")
|
| 111 |
+
|
| 112 |
+
def list_ppms():
|
| 113 |
+
result = []
|
| 114 |
+
if not os.path.isdir(PPM_DIR):
|
| 115 |
+
return result
|
| 116 |
+
for f in sorted(os.listdir(PPM_DIR)):
|
| 117 |
+
if not f.endswith(".ppm"):
|
| 118 |
+
continue
|
| 119 |
+
fpath = os.path.join(PPM_DIR, f)
|
| 120 |
+
try:
|
| 121 |
+
with open(fpath, "rb") as fh:
|
| 122 |
+
header = fh.read(3)
|
| 123 |
+
if header.startswith(b'P6'):
|
| 124 |
+
result.append(f)
|
| 125 |
+
except Exception:
|
| 126 |
+
pass
|
| 127 |
+
return result
|
| 128 |
+
|
| 129 |
+
def load_array(uploaded=None, ppm_name=None):
|
| 130 |
+
if uploaded is not None:
|
| 131 |
+
uploaded.seek(0)
|
| 132 |
+
raw = uploaded.read()
|
| 133 |
+
elif ppm_name:
|
| 134 |
+
path = os.path.join(PPM_DIR, ppm_name)
|
| 135 |
+
with open(path, "rb") as f:
|
| 136 |
+
raw = f.read()
|
| 137 |
+
else:
|
| 138 |
+
return None, None
|
| 139 |
+
|
| 140 |
+
try:
|
| 141 |
+
arr = np.array(Image.open(io.BytesIO(raw)).convert("RGB"), dtype=np.uint8)
|
| 142 |
+
return arr, raw
|
| 143 |
+
except Exception:
|
| 144 |
+
pass
|
| 145 |
+
|
| 146 |
+
try:
|
| 147 |
+
arr = read_ppm_bytes(raw)
|
| 148 |
+
return arr, raw
|
| 149 |
+
except Exception as e:
|
| 150 |
+
fname = uploaded.name if uploaded else ppm_name
|
| 151 |
+
raise ValueError(f"Cannot read '{fname}' as an image.\nDetail: {e}")
|
| 152 |
+
|
| 153 |
+
def count_nodes_and_leaves(node, depth=0):
|
| 154 |
+
if node is None:
|
| 155 |
+
return 0, 0, 0 # total, leaves, max_d
|
| 156 |
+
if node.is_leaf():
|
| 157 |
+
return 1, 1, depth
|
| 158 |
+
counts = [count_nodes_and_leaves(c, depth+1) for c in [node.topLeft, node.topRight, node.bottomLeft, node.bottomRight]]
|
| 159 |
+
total = 1 + sum(c[0] for c in counts)
|
| 160 |
+
leaves = sum(c[1] for c in counts)
|
| 161 |
+
max_d = max(c[2] for c in counts)
|
| 162 |
+
return total, leaves, max_d
|
| 163 |
+
|
| 164 |
+
# ββ Sidebar ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 165 |
+
|
| 166 |
+
with st.sidebar:
|
| 167 |
+
st.markdown("### β¬ INPUT")
|
| 168 |
+
|
| 169 |
+
input_options = ["Upload file"]
|
| 170 |
+
has_ppms = os.path.isdir(PPM_DIR) and len(list_ppms()) > 0
|
| 171 |
+
if has_ppms:
|
| 172 |
+
input_options.append("Use repo PPM")
|
| 173 |
+
|
| 174 |
+
input_mode = st.radio("Source", input_options, label_visibility="collapsed")
|
| 175 |
+
|
| 176 |
+
arr1 = None
|
| 177 |
+
file_id = None
|
| 178 |
+
|
| 179 |
+
if input_mode == "Upload file":
|
| 180 |
+
up1 = st.file_uploader("Primary image", type=["ppm","png","jpg","jpeg"], key="u1")
|
| 181 |
+
if up1:
|
| 182 |
+
file_id = f"{up1.name}_{up1.size}"
|
| 183 |
+
try:
|
| 184 |
+
arr1, _ = load_array(uploaded=up1)
|
| 185 |
+
except ValueError as e:
|
| 186 |
+
st.error(str(e))
|
| 187 |
+
else:
|
| 188 |
+
sel = st.selectbox("Select PPM", list_ppms())
|
| 189 |
+
if sel:
|
| 190 |
+
file_id = f"repo_{sel}"
|
| 191 |
+
try:
|
| 192 |
+
arr1, _ = load_array(ppm_name=sel)
|
| 193 |
+
except ValueError as e:
|
| 194 |
+
st.error(str(e))
|
| 195 |
+
|
| 196 |
+
# Bug 5: Image Size Guard
|
| 197 |
+
if arr1 is not None:
|
| 198 |
+
MAX_DIM = 2048
|
| 199 |
+
h, w = arr1.shape[:2]
|
| 200 |
+
if h > MAX_DIM or w > MAX_DIM:
|
| 201 |
+
st.markdown(f"<div style='color: #ffb700; font-size: 0.85rem; margin-bottom: 1rem; border: 1px solid #ffb700; padding: 0.5rem;'>β οΈ Image is {w}Γ{h}. Images over {MAX_DIM}px may be slow or crash. Consider resizing first.</div>", unsafe_allow_html=True)
|
| 202 |
+
|
| 203 |
+
# Bug 6: Stale Result Flash (immediately wipe on new file)
|
| 204 |
+
if file_id != st.session_state.get("last_file_id"):
|
| 205 |
+
st.session_state.pop("result", None)
|
| 206 |
+
st.session_state.pop("tree", None)
|
| 207 |
+
st.session_state.pop("op_done", None)
|
| 208 |
+
st.session_state["last_file_id"] = file_id
|
| 209 |
+
|
| 210 |
+
st.markdown("---")
|
| 211 |
+
st.markdown("### β OPERATION")
|
| 212 |
+
|
| 213 |
+
OP_MAP = {
|
| 214 |
+
"Compress Only": "compress_only",
|
| 215 |
+
"Grayscale": "grayscale",
|
| 216 |
+
"Negative": "negative",
|
| 217 |
+
"Sepia": "sepia",
|
| 218 |
+
"Brighten": "brighten",
|
| 219 |
+
"Mirror (Horizontal)": "mirror",
|
| 220 |
+
"Flip (Vertical)": "water",
|
| 221 |
+
"Rotate Left 90Β°": "rotate_left",
|
| 222 |
+
"Rotate Right 90Β°": "rotate_right",
|
| 223 |
+
"Blend / Union": "union",
|
| 224 |
+
}
|
| 225 |
+
|
| 226 |
+
operation = st.selectbox("Operation", list(OP_MAP.keys()), label_visibility="collapsed")
|
| 227 |
+
op_key = OP_MAP[operation]
|
| 228 |
+
|
| 229 |
+
arr2 = None
|
| 230 |
+
if op_key == "union":
|
| 231 |
+
st.markdown("<div style='font-family: \"DM Mono\", monospace; font-size: 0.85rem;'>Second image for blending:</div>", unsafe_allow_html=True)
|
| 232 |
+
if input_mode == "Upload file":
|
| 233 |
+
up2 = st.file_uploader("Second image", type=["ppm","png","jpg","jpeg"], key="u2")
|
| 234 |
+
if up2:
|
| 235 |
+
try:
|
| 236 |
+
arr2, _ = load_array(uploaded=up2)
|
| 237 |
+
except ValueError as e:
|
| 238 |
+
st.error(str(e))
|
| 239 |
+
else:
|
| 240 |
+
sel2 = st.selectbox("Second PPM", list_ppms(), key="sel2")
|
| 241 |
+
if sel2:
|
| 242 |
+
try:
|
| 243 |
+
arr2, _ = load_array(ppm_name=sel2)
|
| 244 |
+
except ValueError as e:
|
| 245 |
+
st.error(str(e))
|
| 246 |
+
|
| 247 |
+
st.markdown("---")
|
| 248 |
+
st.markdown("### β THRESHOLD")
|
| 249 |
+
threshold = st.slider("Quality vs Compression", 1, 500, 30, label_visibility="collapsed")
|
| 250 |
+
|
| 251 |
+
if threshold <= 30:
|
| 252 |
+
st.markdown("<div style='color: #00ff87; font-family: \"DM Mono\", monospace; font-weight: bold;'>β LOSSLESS</div>", unsafe_allow_html=True)
|
| 253 |
+
elif threshold <= 100:
|
| 254 |
+
st.markdown("<div style='color: #ffb700; font-family: \"DM Mono\", monospace; font-weight: bold;'>β BALANCED</div>", unsafe_allow_html=True)
|
| 255 |
+
else:
|
| 256 |
+
st.markdown("<div style='color: #ff4444; font-family: \"DM Mono\", monospace; font-weight: bold;'>β LOSSY</div>", unsafe_allow_html=True)
|
| 257 |
+
|
| 258 |
+
run = st.button("βΆ RUN", disabled=(arr1 is None))
|
| 259 |
+
|
| 260 |
+
st.markdown("<br>", unsafe_allow_html=True)
|
| 261 |
+
download_placeholder = st.empty()
|
| 262 |
+
|
| 263 |
+
|
| 264 |
+
# ββ Main Area ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 265 |
+
|
| 266 |
+
tab1, tab2, tab3 = st.tabs(["IMAGE VIEW", "ALGORITHM EXPLORER", "C SOURCE"])
|
| 267 |
+
|
| 268 |
+
with tab1:
|
| 269 |
+
if run and arr1 is not None:
|
| 270 |
+
st.session_state.pop("result", None)
|
| 271 |
+
st.session_state.pop("tree", None)
|
| 272 |
+
|
| 273 |
+
with st.spinner("Processing..."):
|
| 274 |
+
try:
|
| 275 |
+
if op_key == "compress_only":
|
| 276 |
+
padded, oh, ow = pad_to_square_pow2(arr1)
|
| 277 |
+
size = padded.shape[0]
|
| 278 |
+
tree = compress_image(padded, 0, 0, size, threshold)
|
| 279 |
+
out = np.zeros((size, size, 3), dtype=np.uint8)
|
| 280 |
+
decompress_image(tree, out, 0, 0, size)
|
| 281 |
+
computed = out[:oh, :ow]
|
| 282 |
+
elif op_key == "union":
|
| 283 |
+
if arr2 is None:
|
| 284 |
+
st.error("Please provide a second image for union.")
|
| 285 |
+
computed, tree = None, None
|
| 286 |
+
else:
|
| 287 |
+
computed, tree = process_image(arr1, "union", threshold, arr2, return_tree=True)
|
| 288 |
+
else:
|
| 289 |
+
computed, tree = process_image(arr1, op_key, threshold, return_tree=True)
|
| 290 |
+
|
| 291 |
+
if computed is not None:
|
| 292 |
+
# Bug 1 Fix: Store result and tree together, avoid rebuilding
|
| 293 |
+
st.session_state["result"] = computed
|
| 294 |
+
st.session_state["tree"] = tree
|
| 295 |
+
st.session_state["op_done"] = operation
|
| 296 |
+
st.session_state["thresh_done"] = threshold
|
| 297 |
+
except RecursionError:
|
| 298 |
+
st.error("RecursionError: image too large for this threshold. Try a higher threshold.")
|
| 299 |
+
except Exception as e:
|
| 300 |
+
st.error(f"Error: {e}")
|
| 301 |
+
|
| 302 |
+
result_arr = st.session_state.get("result")
|
| 303 |
+
tree = st.session_state.get("tree")
|
| 304 |
+
|
| 305 |
+
if arr1 is not None:
|
| 306 |
+
with st.container():
|
| 307 |
+
c1, c2 = st.columns(2)
|
| 308 |
+
with c1:
|
| 309 |
+
st.markdown("<div style='font-family: \"DM Mono\", monospace; margin-bottom: 0.5rem; color: #555;'>BEFORE</div>", unsafe_allow_html=True)
|
| 310 |
+
st.image(arr_to_pil(arr1), use_container_width=True)
|
| 311 |
+
st.markdown(f"<div style='font-family: \"DM Mono\", monospace; color: #555; text-align: center;'>{arr1.shape[1]} Γ {arr1.shape[0]}</div>", unsafe_allow_html=True)
|
| 312 |
+
with c2:
|
| 313 |
+
st.markdown("<div style='font-family: \"DM Mono\", monospace; margin-bottom: 0.5rem; color: #555;'>AFTER</div>", unsafe_allow_html=True)
|
| 314 |
+
if result_arr is not None:
|
| 315 |
+
st.image(arr_to_pil(result_arr), use_container_width=True)
|
| 316 |
+
op_str = st.session_state.get("op_done", "")
|
| 317 |
+
th_str = st.session_state.get("thresh_done", "")
|
| 318 |
+
st.markdown(f"<div style='font-family: \"DM Mono\", monospace; color: #555; text-align: center;'>{op_str} Β· t={th_str}</div>", unsafe_allow_html=True)
|
| 319 |
+
else:
|
| 320 |
+
st.markdown("<div style='border: 1px solid #2a2a2a; height: 300px; display: flex; align-items: center; justify-content: center; color: #555; font-family: \"DM Mono\", monospace; background-color: #141414;'>[ RESULT IMAGE ]</div>", unsafe_allow_html=True)
|
| 321 |
+
|
| 322 |
+
if result_arr is not None and tree is not None:
|
| 323 |
+
total_nodes, leaves, max_d = count_nodes_and_leaves(tree)
|
| 324 |
+
total_pixels = arr1.shape[0] * arr1.shape[1]
|
| 325 |
+
ratio = total_pixels / total_nodes if total_nodes > 0 else 0
|
| 326 |
+
|
| 327 |
+
st.markdown("<div style='font-family: \"DM Mono\", monospace; color: #555; margin-top: 2rem; margin-bottom: 0.5rem;'>QUADTREE STATS</div>", unsafe_allow_html=True)
|
| 328 |
+
|
| 329 |
+
st.markdown(f"""
|
| 330 |
+
<div style='display: flex; border: 1px solid #2a2a2a; background-color: #141414;'>
|
| 331 |
+
<div style='flex: 1; border-right: 1px solid #2a2a2a; padding: 1rem; text-align: center;'>
|
| 332 |
+
<div style='color: #555; font-family: "DM Mono", monospace; font-size: 0.8rem; margin-bottom: 0.5rem;'>TOTAL NODES</div>
|
| 333 |
+
<div style='color: #00ff87; font-family: "DM Mono", monospace; font-size: 1.5rem; font-weight: 700;'>{total_nodes:,}</div>
|
| 334 |
+
<div style='color: #00ff87; font-size: 0.75rem; font-family: "DM Mono", monospace; margin-top: 4px;'>{ratio:.1f}Γ fewer nodes</div>
|
| 335 |
+
</div>
|
| 336 |
+
<div style='flex: 1; border-right: 1px solid #2a2a2a; padding: 1rem; text-align: center;'>
|
| 337 |
+
<div style='color: #555; font-family: "DM Mono", monospace; font-size: 0.8rem; margin-bottom: 0.5rem;'>LEAF NODES</div>
|
| 338 |
+
<div style='color: #00ff87; font-family: "DM Mono", monospace; font-size: 1.5rem; font-weight: 700;'>{leaves:,}</div>
|
| 339 |
+
</div>
|
| 340 |
+
<div style='flex: 1; border-right: 1px solid #2a2a2a; padding: 1rem; text-align: center;'>
|
| 341 |
+
<div style='color: #555; font-family: "DM Mono", monospace; font-size: 0.8rem; margin-bottom: 0.5rem;'>MAX DEPTH</div>
|
| 342 |
+
<div style='color: #00ff87; font-family: "DM Mono", monospace; font-size: 1.5rem; font-weight: 700;'>{max_d}</div>
|
| 343 |
+
</div>
|
| 344 |
+
<div style='flex: 1; border-right: 1px solid #2a2a2a; padding: 1rem; text-align: center;'>
|
| 345 |
+
<div style='color: #555; font-family: "DM Mono", monospace; font-size: 0.8rem; margin-bottom: 0.5rem;'>TOTAL PIXELS</div>
|
| 346 |
+
<div style='color: #00ff87; font-family: "DM Mono", monospace; font-size: 1.5rem; font-weight: 700;'>{total_pixels:,}</div>
|
| 347 |
+
</div>
|
| 348 |
+
<div style='flex: 1; padding: 1rem; text-align: center;'>
|
| 349 |
+
<div style='color: #555; font-family: "DM Mono", monospace; font-size: 0.8rem; margin-bottom: 0.5rem;'>THRESHOLD</div>
|
| 350 |
+
<div style='color: #00ff87; font-family: "DM Mono", monospace; font-size: 1.5rem; font-weight: 700;'>{st.session_state.get('thresh_done')}</div>
|
| 351 |
+
</div>
|
| 352 |
+
</div>
|
| 353 |
+
""", unsafe_allow_html=True)
|
| 354 |
+
|
| 355 |
+
with download_placeholder:
|
| 356 |
+
img_pil = arr_to_pil(result_arr)
|
| 357 |
+
buf = io.BytesIO()
|
| 358 |
+
img_pil.save(buf, format="PNG")
|
| 359 |
+
st.download_button("β DOWNLOAD RESULT", buf.getvalue(), "result.png", "image/png")
|
| 360 |
+
|
| 361 |
+
else:
|
| 362 |
+
st.markdown("<div style='border: 1px solid #2a2a2a; padding: 6rem; text-align: center; color: #555; font-family: \"DM Mono\", monospace; background-color: #141414;'>[ AWAITING INPUT ]</div>", unsafe_allow_html=True)
|
| 363 |
+
|
| 364 |
+
|
| 365 |
+
with tab2:
|
| 366 |
+
st.markdown("### 1. What is a Quadtree?")
|
| 367 |
+
c1, c2 = st.columns(2)
|
| 368 |
+
with c1:
|
| 369 |
+
st.code("""
|
| 370 |
+
ββββββββββ¬βββββββββ
|
| 371 |
+
β β β
|
| 372 |
+
β TL β TR β
|
| 373 |
+
β β β
|
| 374 |
+
ββββββββββΌβββββββββ€
|
| 375 |
+
β β β
|
| 376 |
+
β BL β BR β
|
| 377 |
+
β β β
|
| 378 |
+
ββββββββββ΄βββββββββ
|
| 379 |
+
""", language="text")
|
| 380 |
+
with c2:
|
| 381 |
+
st.markdown("""
|
| 382 |
+
- **`red`, `green`, `blue`**: The average color of this region.
|
| 383 |
+
- **`area`**: The total pixels covered by this node.
|
| 384 |
+
- **Children**: If variance > threshold, the region splits into 4 sub-regions (`topLeft`, `topRight`, `bottomLeft`, `bottomRight`).
|
| 385 |
+
- **Leaf Node**: If variance <= threshold, children are null. The region is colored uniformly.
|
| 386 |
+
""")
|
| 387 |
+
|
| 388 |
+
st.markdown("---")
|
| 389 |
+
st.markdown("### 2. How Compression Works")
|
| 390 |
+
st.markdown("<div style='color: #555; margin-bottom: 1rem;'>See how threshold controls quality vs. compression</div>", unsafe_allow_html=True)
|
| 391 |
+
|
| 392 |
+
@st.cache_data
|
| 393 |
+
def get_synthetic_image():
|
| 394 |
+
y, x = np.mgrid[0:64, 0:64]
|
| 395 |
+
r = (x * 4) % 255
|
| 396 |
+
g = (y * 4) % 255
|
| 397 |
+
b = ((x + y) * 2) % 255
|
| 398 |
+
noise = np.random.randint(0, 50, (64, 64, 3))
|
| 399 |
+
img = np.stack([r, g, b], axis=-1) + noise
|
| 400 |
+
return np.clip(img, 0, 255).astype(np.uint8)
|
| 401 |
+
|
| 402 |
+
synth_img = get_synthetic_image()
|
| 403 |
+
sim_thresh = st.slider("Simulator Threshold", 1, 200, 50, key="sim_t")
|
| 404 |
+
|
| 405 |
+
sc1, sc2 = st.columns(2)
|
| 406 |
+
with sc1:
|
| 407 |
+
st.image(arr_to_pil(synth_img), caption="Original (64x64)", use_container_width=True)
|
| 408 |
+
with sc2:
|
| 409 |
+
sim_tree = compress_image(synth_img, 0, 0, 64, sim_thresh)
|
| 410 |
+
sim_out = np.zeros((64, 64, 3), dtype=np.uint8)
|
| 411 |
+
decompress_image(sim_tree, sim_out, 0, 0, 64)
|
| 412 |
+
total_sim, leaves_sim, _ = count_nodes_and_leaves(sim_tree)
|
| 413 |
+
st.image(arr_to_pil(sim_out), caption=f"Compressed (Nodes: {total_sim})", use_container_width=True)
|
| 414 |
+
|
| 415 |
+
st.markdown("---")
|
| 416 |
+
st.markdown("### 3. Compression Algorithm Step-by-Step")
|
| 417 |
+
st.markdown("""
|
| 418 |
+
<div class="step-row"><span class="step-num">STEP 1</span><span>Divide image into 4 quadrants</span></div>
|
| 419 |
+
<div class="step-row"><span class="step-num">STEP 2</span><span>Compute mean RGB + variance for each quadrant</span></div>
|
| 420 |
+
<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>
|
| 421 |
+
<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>
|
| 422 |
+
<div class="step-row"><span class="step-num">STEP 5</span><span>Continue until size=1 pixel or variance β€ threshold</span></div>
|
| 423 |
+
""", unsafe_allow_html=True)
|
| 424 |
+
|
| 425 |
+
st.markdown("---")
|
| 426 |
+
st.markdown("### 4. Color Filters β How They Work on the Tree")
|
| 427 |
+
fc1, fc2, fc3, fc4 = st.columns(4)
|
| 428 |
+
with fc1:
|
| 429 |
+
with st.container(border=True):
|
| 430 |
+
st.markdown("**Grayscale**")
|
| 431 |
+
st.code("L = 0.299R + 0.587G + 0.114B\nR'=L, G'=L, B'=L")
|
| 432 |
+
st.caption("Weights green channel most heavily (human eye is most sensitive to green)")
|
| 433 |
+
with fc2:
|
| 434 |
+
with st.container(border=True):
|
| 435 |
+
st.markdown("**Negative**")
|
| 436 |
+
st.code("R' = 255 - R\nG' = 255 - G\nB' = 255 - B")
|
| 437 |
+
st.caption("Inverts each channel β dark becomes light, colors become complementary")
|
| 438 |
+
with fc3:
|
| 439 |
+
with st.container(border=True):
|
| 440 |
+
st.markdown("**Sepia**")
|
| 441 |
+
st.code("R' = 0.393R + 0.769G + 0.189B\nG' = ...\nB' = ...")
|
| 442 |
+
st.caption("Warm brownish tones by mixing channels β mimics aged photographic paper")
|
| 443 |
+
with fc4:
|
| 444 |
+
with st.container(border=True):
|
| 445 |
+
st.markdown("**Brighten**")
|
| 446 |
+
st.code("R' = min(255, R*1.3)\nG' = min(255, G*1.3)\nB' = min(255, B*1.3)")
|
| 447 |
+
st.caption("Scales all channels up β clips at 255 to avoid overflow")
|
| 448 |
+
|
| 449 |
+
st.markdown("---")
|
| 450 |
+
st.markdown("### 5. Spatial Transforms β Pointer Swaps")
|
| 451 |
+
st.markdown("<div class='accent-text'><strong>No pixel data is ever copied. Only 4 pointer assignments per node.</strong></div><br>", unsafe_allow_html=True)
|
| 452 |
+
tc1, tc2 = st.columns(2)
|
| 453 |
+
with tc1:
|
| 454 |
+
st.code("""MIRROR (horizontal):
|
| 455 |
+
Before: TL | TR
|
| 456 |
+
-------
|
| 457 |
+
BL | BR
|
| 458 |
+
|
| 459 |
+
After: TR | TL
|
| 460 |
+
-------
|
| 461 |
+
BR | BL""", language="text")
|
| 462 |
+
with tc2:
|
| 463 |
+
st.code("""ROTATE LEFT 90Β°:
|
| 464 |
+
Before: TL | TR
|
| 465 |
+
-------
|
| 466 |
+
BL | BR
|
| 467 |
+
|
| 468 |
+
After: TR | BR
|
| 469 |
+
-------
|
| 470 |
+
TL | BL""", language="text")
|
| 471 |
+
|
| 472 |
+
tc3, tc4 = st.columns(2)
|
| 473 |
+
with tc3:
|
| 474 |
+
st.code("""FLIP (vertical):
|
| 475 |
+
Before: TL | TR
|
| 476 |
+
-------
|
| 477 |
+
BL | BR
|
| 478 |
+
|
| 479 |
+
After: BL | BR
|
| 480 |
+
-------
|
| 481 |
+
TL | TR""", language="text")
|
| 482 |
+
with tc4:
|
| 483 |
+
st.code("""ROTATE RIGHT 90Β°:
|
| 484 |
+
Before: TL | TR
|
| 485 |
+
-------
|
| 486 |
+
BL | BR
|
| 487 |
+
|
| 488 |
+
After: BL | TL
|
| 489 |
+
-------
|
| 490 |
+
BR | TR""", language="text")
|
| 491 |
+
|
| 492 |
+
st.markdown("---")
|
| 493 |
+
st.markdown("### 6. Union / Blend β Three Cases")
|
| 494 |
+
st.markdown("""
|
| 495 |
+
- **Case 1:** Both nodes are internal β recurse into all 4 child pairs.
|
| 496 |
+
- **Case 2:** t1 is a leaf, t2 has children β blend t1's solid color with each of t2's children.
|
| 497 |
+
- **Case 3:** t2 is a leaf, t1 has children β blend t2's solid color with each of t1's children.
|
| 498 |
+
|
| 499 |
+
Averaging formula: `result.R = (t1.R + t2.R) / 2`
|
| 500 |
+
*Note: This produces a pixel-perfect 50/50 blend without ever decompressing either image to a pixel buffer.*
|
| 501 |
+
""")
|
| 502 |
+
|
| 503 |
+
st.markdown("---")
|
| 504 |
+
st.markdown("### 7. Complexity Analysis")
|
| 505 |
+
st.markdown("""
|
| 506 |
+
| Operation | Time Complexity | Space Complexity | Notes |
|
| 507 |
+
|---|---|---|---|
|
| 508 |
+
| Compress | O(n log n) | O(n) | n = total pixels |
|
| 509 |
+
| Decompress | O(n) | O(n) | Linear tree traversal |
|
| 510 |
+
| Filter | O(k) | O(k) | k = tree nodes, k βͺ n |
|
| 511 |
+
| Rotate/Mirror | O(k) | O(1) | Only pointer swaps |
|
| 512 |
+
| Union | O(min(k1,k2)) | O(min(k1,k2)) | Bounded by smaller tree |
|
| 513 |
+
|
| 514 |
+
<div style="border-left: 4px solid #00ff87; padding-left: 1rem; margin-top: 1rem; color: #f0f0f0;">
|
| 515 |
+
<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.
|
| 516 |
+
</div>
|
| 517 |
+
""", unsafe_allow_html=True)
|
| 518 |
+
|
| 519 |
+
|
| 520 |
+
with tab3:
|
| 521 |
+
st.markdown("### C Source vs Python")
|
| 522 |
+
st.markdown("""
|
| 523 |
+
<div style="background-color: #141414; border: 1px solid #2a2a2a; padding: 1rem; margin-bottom: 1rem;">
|
| 524 |
+
π View the full C implementation on GitHub:<br>
|
| 525 |
+
<a href="https://github.com/Harshwardhan-Deshmukh03/Image-Manipulation-QuadTree.git" target="_blank"
|
| 526 |
+
style="color:#00ff87;font-weight:600;text-decoration:none;">
|
| 527 |
+
github.com/Harshwardhan-Deshmukh03/Image-Manipulation-QuadTree
|
| 528 |
+
</a>
|
| 529 |
+
</div>
|
| 530 |
+
""", unsafe_allow_html=True)
|
| 531 |
+
st.markdown("""
|
| 532 |
+
| C File | Python Equivalent |
|
| 533 |
+
|---|---|
|
| 534 |
+
| `compress.c` | `compress_image()` |
|
| 535 |
+
| `filters.c` | `apply_grayscale()`, `apply_negative()`, etc. |
|
| 536 |
+
| `rotate.c` | `rotate_left()`, `get_mirror_image()`, etc. |
|
| 537 |
+
| `union.c` | `union_of_images()` |
|
| 538 |
+
| `decompress.c` | `decompress_image()` |
|
| 539 |
+
""")
|
| 540 |
+
|
| 541 |
+
c_files = {
|
| 542 |
+
"main.c": "CLI entry point β parses flags, orchestrates the full pipeline",
|
| 543 |
+
"compress.c": "Quadtree construction from pixel matrix using variance threshold",
|
| 544 |
+
"decompress.c": "Quadtree β pixel matrix reconstruction",
|
| 545 |
+
"filters.c": "Color filters: grayscale, negative, sepia, brighten",
|
| 546 |
+
"rotate.c": "Spatial transforms: mirror, flip, rotate L/R/180",
|
| 547 |
+
"union.c": "Pixel-level blending of two Quadtrees",
|
| 548 |
+
"suppl.c": "PPM I/O, getMean(), tree serialization helpers",
|
| 549 |
+
"suppl.h": "All struct definitions: pixels, qtNode, qtInfo",
|
| 550 |
+
}
|
| 551 |
+
|
| 552 |
+
st.markdown("<br>", unsafe_allow_html=True)
|
| 553 |
+
for fname, desc in c_files.items():
|
| 554 |
+
fpath = os.path.join(PPM_DIR, fname)
|
| 555 |
+
with st.expander(f"`{fname}` β {desc}"):
|
| 556 |
+
if os.path.isfile(fpath):
|
| 557 |
+
with open(fpath) as f:
|
| 558 |
+
st.code(f.read(), language="c")
|
| 559 |
+
else:
|
| 560 |
+
st.info(f"File not found at: {fpath}")
|
src/quadtree_engine.py
ADDED
|
@@ -0,0 +1,336 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Quadtree Image Engine - Python implementation of the C quadtree image manipulation.
|
| 3 |
+
Mirrors the logic from compress.c, decompress.c, filters.c, rotate.c, union.c
|
| 4 |
+
"""
|
| 5 |
+
import numpy as np
|
| 6 |
+
from dataclasses import dataclass, field
|
| 7 |
+
from typing import Optional
|
| 8 |
+
from PIL import Image
|
| 9 |
+
import io
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
@dataclass
|
| 13 |
+
class QtNode:
|
| 14 |
+
red: int = 0
|
| 15 |
+
green: int = 0
|
| 16 |
+
blue: int = 0
|
| 17 |
+
area: int = 0
|
| 18 |
+
topLeft: Optional['QtNode'] = field(default=None, repr=False)
|
| 19 |
+
topRight: Optional['QtNode'] = field(default=None, repr=False)
|
| 20 |
+
bottomLeft: Optional['QtNode'] = field(default=None, repr=False)
|
| 21 |
+
bottomRight: Optional['QtNode'] = field(default=None, repr=False)
|
| 22 |
+
|
| 23 |
+
def is_leaf(self):
|
| 24 |
+
return (self.topLeft is None and self.topRight is None and
|
| 25 |
+
self.bottomLeft is None and self.bottomRight is None)
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
def get_mean(matrix: np.ndarray, x: int, y: int, size: int):
|
| 29 |
+
"""Compute average color and variance score for a region. Mirrors getMean() in suppl.c"""
|
| 30 |
+
region = matrix[y:y+size, x:x+size]
|
| 31 |
+
red = int(np.mean(region[:, :, 0]))
|
| 32 |
+
green = int(np.mean(region[:, :, 1]))
|
| 33 |
+
blue = int(np.mean(region[:, :, 2]))
|
| 34 |
+
variance = (
|
| 35 |
+
np.mean((region[:, :, 0].astype(int) - red)**2) +
|
| 36 |
+
np.mean((region[:, :, 1].astype(int) - green)**2) +
|
| 37 |
+
np.mean((region[:, :, 2].astype(int) - blue)**2)
|
| 38 |
+
) / 3
|
| 39 |
+
return red, green, blue, int(variance)
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
def compress_image(matrix: np.ndarray, x: int, y: int, size: int, threshold: int) -> QtNode:
|
| 43 |
+
"""Build quadtree. Mirrors compressImage() in compress.c"""
|
| 44 |
+
red, green, blue, score = get_mean(matrix, x, y, size)
|
| 45 |
+
node = QtNode(red=red, green=green, blue=blue, area=size * size)
|
| 46 |
+
if size > 1 and score > threshold:
|
| 47 |
+
half = size // 2
|
| 48 |
+
node.topLeft = compress_image(matrix, x, y, half, threshold)
|
| 49 |
+
node.topRight = compress_image(matrix, x + half, y, half, threshold)
|
| 50 |
+
node.bottomRight = compress_image(matrix, x + half, y + half, half, threshold)
|
| 51 |
+
node.bottomLeft = compress_image(matrix, x, y + half, half, threshold)
|
| 52 |
+
return node
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
def decompress_image(node: QtNode, matrix: np.ndarray, x: int, y: int, size: int):
|
| 56 |
+
"""Reconstruct pixel matrix from quadtree. Mirrors decompressImage() in decompress.c"""
|
| 57 |
+
if node.is_leaf():
|
| 58 |
+
matrix[y:y+size, x:x+size, 0] = node.red
|
| 59 |
+
matrix[y:y+size, x:x+size, 1] = node.green
|
| 60 |
+
matrix[y:y+size, x:x+size, 2] = node.blue
|
| 61 |
+
else:
|
| 62 |
+
half = size // 2
|
| 63 |
+
decompress_image(node.topLeft, matrix, x, y, half)
|
| 64 |
+
decompress_image(node.topRight, matrix, x + half, y, half)
|
| 65 |
+
decompress_image(node.bottomRight, matrix, x + half, y + half, half)
|
| 66 |
+
decompress_image(node.bottomLeft, matrix, x, y + half, half)
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
# ββ Filters (mirrors filters.c) ββββββββββββββββββββββββββββββββββββββββββββββ
|
| 70 |
+
|
| 71 |
+
def apply_grayscale(node: QtNode) -> QtNode:
|
| 72 |
+
"""True luminance-weighted grayscale.
|
| 73 |
+
Compute single luma value and assign to all 3 channels so the
|
| 74 |
+
output is actually grey (not green-tinted).
|
| 75 |
+
luma = 0.299R + 0.587G + 0.114B (BT.601 standard)
|
| 76 |
+
"""
|
| 77 |
+
res = QtNode(area=node.area)
|
| 78 |
+
if not node.is_leaf():
|
| 79 |
+
res.topLeft = apply_grayscale(node.topLeft)
|
| 80 |
+
res.topRight = apply_grayscale(node.topRight)
|
| 81 |
+
res.bottomLeft = apply_grayscale(node.bottomLeft)
|
| 82 |
+
res.bottomRight = apply_grayscale(node.bottomRight)
|
| 83 |
+
luma = int(0.299 * node.red + 0.587 * node.green + 0.114 * node.blue)
|
| 84 |
+
res.red = luma
|
| 85 |
+
res.green = luma
|
| 86 |
+
res.blue = luma
|
| 87 |
+
return res
|
| 88 |
+
|
| 89 |
+
|
| 90 |
+
def apply_negative(node: QtNode) -> QtNode:
|
| 91 |
+
"""Invert channels: 255 - value. Mirrors negativeImage() in filters.c"""
|
| 92 |
+
res = QtNode(area=node.area)
|
| 93 |
+
if not node.is_leaf():
|
| 94 |
+
res.topLeft = apply_negative(node.topLeft)
|
| 95 |
+
res.topRight = apply_negative(node.topRight)
|
| 96 |
+
res.bottomLeft = apply_negative(node.bottomLeft)
|
| 97 |
+
res.bottomRight = apply_negative(node.bottomRight)
|
| 98 |
+
res.red = 255 - node.red
|
| 99 |
+
res.green = 255 - node.green
|
| 100 |
+
res.blue = 255 - node.blue
|
| 101 |
+
return res
|
| 102 |
+
|
| 103 |
+
|
| 104 |
+
def apply_sepia(node: QtNode) -> QtNode:
|
| 105 |
+
"""Cinematic warm sepia tone. Mirrors sepia() in filters.c"""
|
| 106 |
+
res = QtNode(area=node.area)
|
| 107 |
+
if not node.is_leaf():
|
| 108 |
+
res.topLeft = apply_sepia(node.topLeft)
|
| 109 |
+
res.topRight = apply_sepia(node.topRight)
|
| 110 |
+
res.bottomLeft = apply_sepia(node.bottomLeft)
|
| 111 |
+
res.bottomRight = apply_sepia(node.bottomRight)
|
| 112 |
+
r, g, b = node.red, node.green, node.blue
|
| 113 |
+
res.red = min(255, int(0.393 * r + 0.769 * g + 0.189 * b))
|
| 114 |
+
res.green = min(255, int(0.272 * r + 0.534 * g + 0.131 * b))
|
| 115 |
+
res.blue = min(255, int(0.349 * r + 0.686 * g + 0.168 * b))
|
| 116 |
+
return res
|
| 117 |
+
|
| 118 |
+
|
| 119 |
+
def apply_brighten(node: QtNode, factor: float = 1.3) -> QtNode:
|
| 120 |
+
"""Brighten by scaling channels. Mirrors brighten() in filters.c"""
|
| 121 |
+
res = QtNode(area=node.area)
|
| 122 |
+
if not node.is_leaf():
|
| 123 |
+
res.topLeft = apply_brighten(node.topLeft, factor)
|
| 124 |
+
res.topRight = apply_brighten(node.topRight, factor)
|
| 125 |
+
res.bottomLeft = apply_brighten(node.bottomLeft, factor)
|
| 126 |
+
res.bottomRight = apply_brighten(node.bottomRight, factor)
|
| 127 |
+
res.red = min(255, int(node.red * factor))
|
| 128 |
+
res.green = min(255, int(node.green * factor))
|
| 129 |
+
res.blue = min(255, int(node.blue * factor))
|
| 130 |
+
return res
|
| 131 |
+
|
| 132 |
+
|
| 133 |
+
# ββ Spatial Transforms (mirrors rotate.c) ββββββββββββββββββββββββββββββββββββ
|
| 134 |
+
|
| 135 |
+
def get_water_image(node: QtNode):
|
| 136 |
+
"""Vertical flip (top-bottom). Mirrors getWaterImage() in rotate.c"""
|
| 137 |
+
if not node.is_leaf():
|
| 138 |
+
get_water_image(node.topLeft)
|
| 139 |
+
get_water_image(node.topRight)
|
| 140 |
+
get_water_image(node.bottomLeft)
|
| 141 |
+
get_water_image(node.bottomRight)
|
| 142 |
+
node.topLeft, node.bottomLeft = node.bottomLeft, node.topLeft
|
| 143 |
+
node.topRight, node.bottomRight = node.bottomRight, node.topRight
|
| 144 |
+
|
| 145 |
+
|
| 146 |
+
def get_mirror_image(node: QtNode):
|
| 147 |
+
"""Horizontal mirror (left-right). Mirrors getMirrorImage() in rotate.c"""
|
| 148 |
+
if not node.is_leaf():
|
| 149 |
+
get_mirror_image(node.topLeft)
|
| 150 |
+
get_mirror_image(node.topRight)
|
| 151 |
+
get_mirror_image(node.bottomLeft)
|
| 152 |
+
get_mirror_image(node.bottomRight)
|
| 153 |
+
node.topLeft, node.topRight = node.topRight, node.topLeft
|
| 154 |
+
node.bottomLeft, node.bottomRight = node.bottomRight, node.bottomLeft
|
| 155 |
+
|
| 156 |
+
|
| 157 |
+
def rotate_left(node: QtNode):
|
| 158 |
+
"""90Β° counter-clockwise. Mirrors rotateLeft() in rotate.c"""
|
| 159 |
+
if not node.is_leaf():
|
| 160 |
+
rotate_left(node.topLeft)
|
| 161 |
+
rotate_left(node.topRight)
|
| 162 |
+
rotate_left(node.bottomLeft)
|
| 163 |
+
rotate_left(node.bottomRight)
|
| 164 |
+
tl, tr, bl, br = node.topLeft, node.topRight, node.bottomLeft, node.bottomRight
|
| 165 |
+
node.topLeft = tr
|
| 166 |
+
node.topRight = br
|
| 167 |
+
node.bottomLeft = tl
|
| 168 |
+
node.bottomRight = bl
|
| 169 |
+
|
| 170 |
+
|
| 171 |
+
def rotate_right(node: QtNode):
|
| 172 |
+
"""90Β° clockwise. Mirrors rotateRight() in rotate.c"""
|
| 173 |
+
if not node.is_leaf():
|
| 174 |
+
rotate_right(node.topLeft)
|
| 175 |
+
rotate_right(node.topRight)
|
| 176 |
+
rotate_right(node.bottomLeft)
|
| 177 |
+
rotate_right(node.bottomRight)
|
| 178 |
+
tl, tr, bl, br = node.topLeft, node.topRight, node.bottomLeft, node.bottomRight
|
| 179 |
+
node.topLeft = bl
|
| 180 |
+
node.topRight = tl
|
| 181 |
+
node.bottomLeft = br
|
| 182 |
+
node.bottomRight = tr
|
| 183 |
+
|
| 184 |
+
|
| 185 |
+
# ββ Union / Blend (mirrors union.c) ββββββββββββββββββββββββββββββββββββββββββ
|
| 186 |
+
|
| 187 |
+
def union_of_images(t1: QtNode, t2: QtNode) -> QtNode:
|
| 188 |
+
"""Average-blend two quadtrees. Mirrors unionOfImages() in union.c"""
|
| 189 |
+
res = QtNode(area=min(t1.area, t2.area))
|
| 190 |
+
res.red = (t1.red + t2.red) // 2
|
| 191 |
+
res.green = (t1.green + t2.green) // 2
|
| 192 |
+
res.blue = (t1.blue + t2.blue) // 2
|
| 193 |
+
if not t1.is_leaf() and not t2.is_leaf():
|
| 194 |
+
res.topLeft = union_of_images(t1.topLeft, t2.topLeft)
|
| 195 |
+
res.topRight = union_of_images(t1.topRight, t2.topRight)
|
| 196 |
+
res.bottomLeft = union_of_images(t1.bottomLeft, t2.bottomLeft)
|
| 197 |
+
res.bottomRight = union_of_images(t1.bottomRight, t2.bottomRight)
|
| 198 |
+
elif t1.is_leaf() and not t2.is_leaf():
|
| 199 |
+
res.topLeft = union_of_images(t1, t2.topLeft)
|
| 200 |
+
res.topRight = union_of_images(t1, t2.topRight)
|
| 201 |
+
res.bottomLeft = union_of_images(t1, t2.bottomLeft)
|
| 202 |
+
res.bottomRight = union_of_images(t1, t2.bottomRight)
|
| 203 |
+
elif not t1.is_leaf() and t2.is_leaf():
|
| 204 |
+
res.topLeft = union_of_images(t1.topLeft, t2)
|
| 205 |
+
res.topRight = union_of_images(t1.topRight, t2)
|
| 206 |
+
res.bottomLeft = union_of_images(t1.bottomLeft, t2)
|
| 207 |
+
res.bottomRight = union_of_images(t1.bottomRight, t2)
|
| 208 |
+
return res
|
| 209 |
+
|
| 210 |
+
|
| 211 |
+
# ββ I/O Helpers βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 212 |
+
|
| 213 |
+
def read_ppm_bytes(data: bytes):
|
| 214 |
+
"""
|
| 215 |
+
Parse P6 binary PPM data robustly.
|
| 216 |
+
Returns numpy array (H, W, 3) uint8.
|
| 217 |
+
"""
|
| 218 |
+
# First try Pillow (handles PNG/JPG/PPM automatically)
|
| 219 |
+
try:
|
| 220 |
+
img = Image.open(io.BytesIO(data)).convert("RGB")
|
| 221 |
+
return np.array(img, dtype=np.uint8)
|
| 222 |
+
except Exception:
|
| 223 |
+
pass
|
| 224 |
+
|
| 225 |
+
# Manual P6 binary PPM parser β reads header byte-by-byte
|
| 226 |
+
pos = 0
|
| 227 |
+
def read_token():
|
| 228 |
+
nonlocal pos
|
| 229 |
+
# Skip whitespace and comments
|
| 230 |
+
while pos < len(data):
|
| 231 |
+
c = data[pos:pos+1]
|
| 232 |
+
if c == b'#':
|
| 233 |
+
while pos < len(data) and data[pos:pos+1] != b'\n':
|
| 234 |
+
pos += 1
|
| 235 |
+
elif c in (b' ', b'\t', b'\n', b'\r'):
|
| 236 |
+
pos += 1
|
| 237 |
+
else:
|
| 238 |
+
break
|
| 239 |
+
start = pos
|
| 240 |
+
while pos < len(data) and data[pos:pos+1] not in (b' ', b'\t', b'\n', b'\r'):
|
| 241 |
+
pos += 1
|
| 242 |
+
return data[start:pos].decode('ascii')
|
| 243 |
+
|
| 244 |
+
magic = read_token()
|
| 245 |
+
if magic != 'P6':
|
| 246 |
+
raise ValueError(f"Not a P6 PPM file (got: {magic!r})")
|
| 247 |
+
w = int(read_token())
|
| 248 |
+
h = int(read_token())
|
| 249 |
+
_maxval = int(read_token())
|
| 250 |
+
# skip exactly one whitespace byte after maxval
|
| 251 |
+
pos += 1
|
| 252 |
+
raw = data[pos:pos + h * w * 3]
|
| 253 |
+
arr = np.frombuffer(raw, dtype=np.uint8).reshape(h, w, 3)
|
| 254 |
+
return arr.copy() # make writable
|
| 255 |
+
|
| 256 |
+
|
| 257 |
+
def arr_to_pil(arr: np.ndarray) -> Image.Image:
|
| 258 |
+
return Image.fromarray(arr.astype(np.uint8), 'RGB')
|
| 259 |
+
|
| 260 |
+
|
| 261 |
+
def next_power_of_two(n: int) -> int:
|
| 262 |
+
p = 1
|
| 263 |
+
while p < n:
|
| 264 |
+
p <<= 1
|
| 265 |
+
return p
|
| 266 |
+
|
| 267 |
+
|
| 268 |
+
def pad_to_square_pow2(arr: np.ndarray):
|
| 269 |
+
"""Pad image to square power-of-2 as required by the quadtree."""
|
| 270 |
+
h, w = arr.shape[:2]
|
| 271 |
+
size = next_power_of_two(max(h, w))
|
| 272 |
+
padded = np.zeros((size, size, 3), dtype=np.uint8)
|
| 273 |
+
padded[:h, :w] = arr
|
| 274 |
+
return padded, h, w
|
| 275 |
+
|
| 276 |
+
|
| 277 |
+
def process_image(matrix: np.ndarray, operation: str, threshold: int,
|
| 278 |
+
matrix2: Optional[np.ndarray] = None, return_tree: bool = False) -> np.ndarray:
|
| 279 |
+
"""
|
| 280 |
+
Full pipeline: pad β compress β transform β decompress β crop.
|
| 281 |
+
Returns the result as a numpy RGB array, and optionally the tree.
|
| 282 |
+
"""
|
| 283 |
+
padded, oh, ow = pad_to_square_pow2(matrix)
|
| 284 |
+
size = padded.shape[0]
|
| 285 |
+
|
| 286 |
+
tree = compress_image(padded, 0, 0, size, threshold)
|
| 287 |
+
|
| 288 |
+
if operation == "grayscale":
|
| 289 |
+
tree = apply_grayscale(tree)
|
| 290 |
+
elif operation == "negative":
|
| 291 |
+
tree = apply_negative(tree)
|
| 292 |
+
elif operation == "sepia":
|
| 293 |
+
tree = apply_sepia(tree)
|
| 294 |
+
elif operation == "brighten":
|
| 295 |
+
tree = apply_brighten(tree)
|
| 296 |
+
elif operation == "mirror":
|
| 297 |
+
get_mirror_image(tree)
|
| 298 |
+
elif operation == "water":
|
| 299 |
+
get_water_image(tree)
|
| 300 |
+
elif operation == "rotate_left":
|
| 301 |
+
rotate_left(tree)
|
| 302 |
+
elif operation == "rotate_right":
|
| 303 |
+
rotate_right(tree)
|
| 304 |
+
elif operation == "union" and matrix2 is not None:
|
| 305 |
+
padded2, oh2, ow2 = pad_to_square_pow2(matrix2)
|
| 306 |
+
s2 = padded2.shape[0]
|
| 307 |
+
s_common = max(size, s2)
|
| 308 |
+
p1 = np.zeros((s_common, s_common, 3), dtype=np.uint8)
|
| 309 |
+
p1[:padded.shape[0], :padded.shape[1]] = padded
|
| 310 |
+
p2 = np.zeros((s_common, s_common, 3), dtype=np.uint8)
|
| 311 |
+
p2[:padded2.shape[0], :padded2.shape[1]] = padded2
|
| 312 |
+
t1 = compress_image(p1, 0, 0, s_common, threshold)
|
| 313 |
+
t2 = compress_image(p2, 0, 0, s_common, threshold)
|
| 314 |
+
tree = union_of_images(t1, t2)
|
| 315 |
+
size = s_common
|
| 316 |
+
# For union, we want the bounding box that encompasses both original images
|
| 317 |
+
oh = max(oh, oh2)
|
| 318 |
+
ow = max(ow, ow2)
|
| 319 |
+
|
| 320 |
+
out = np.zeros((size, size, 3), dtype=np.uint8)
|
| 321 |
+
decompress_image(tree, out, 0, 0, size)
|
| 322 |
+
|
| 323 |
+
if operation == "mirror":
|
| 324 |
+
res = out[:oh, size - ow : size]
|
| 325 |
+
elif operation == "water":
|
| 326 |
+
res = out[size - oh : size, :ow]
|
| 327 |
+
elif operation == "rotate_left":
|
| 328 |
+
res = out[size - ow : size, :oh]
|
| 329 |
+
elif operation == "rotate_right":
|
| 330 |
+
res = out[:ow, size - oh : size]
|
| 331 |
+
else:
|
| 332 |
+
res = out[:oh, :ow]
|
| 333 |
+
|
| 334 |
+
if return_tree:
|
| 335 |
+
return res, tree
|
| 336 |
+
return res
|
src/test_engine.py
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import numpy as np
|
| 2 |
+
from quadtree_engine import process_image
|
| 3 |
+
|
| 4 |
+
# Create a mock "Mona Lisa" image: 1000x671
|
| 5 |
+
arr = np.zeros((1000, 671, 3), dtype=np.uint8)
|
| 6 |
+
# Draw a white rectangle to represent the face in the center
|
| 7 |
+
arr[200:400, 200:471] = 255
|
| 8 |
+
|
| 9 |
+
# Apply "water"
|
| 10 |
+
res = process_image(arr, "water", 30)
|
| 11 |
+
|
| 12 |
+
print(f"Original shape: {arr.shape}")
|
| 13 |
+
print(f"Result shape: {res.shape}")
|
| 14 |
+
|
| 15 |
+
# Check where the white pixels are in the result
|
| 16 |
+
white_pixels = np.where(res[:, :, 0] == 255)
|
| 17 |
+
if len(white_pixels[0]) > 0:
|
| 18 |
+
min_y, max_y = np.min(white_pixels[0]), np.max(white_pixels[0])
|
| 19 |
+
min_x, max_x = np.min(white_pixels[1]), np.max(white_pixels[1])
|
| 20 |
+
print(f"Face is at y: {min_y}-{max_y}, x: {min_x}-{max_x}")
|
| 21 |
+
else:
|
| 22 |
+
print("No white pixels found!")
|
| 23 |
+
|
| 24 |
+
# Let's also check if there are non-zero pixels outside the expected region
|
| 25 |
+
non_zero = np.where(res > 0)
|
| 26 |
+
if len(non_zero[0]) > 0:
|
| 27 |
+
print(f"All Non-zero y: {np.min(non_zero[0])}-{np.max(non_zero[0])}, x: {np.min(non_zero[1])}-{np.max(non_zero[1])}")
|