LEGENDFTW/image-filtering-explorer
0
1import sys2import os3sys.path.insert(0, os.path.dirname(__file__))4 5import streamlit as st6import numpy as np7from PIL import Image8import io9import json10import matplotlib11matplotlib.use("Agg")12import matplotlib.pyplot as plt13 14from processing import apply_filter, FILTER_DESCRIPTIONS, add_synthetic_noise15from metrics import compute_metrics16from utils import load_sample_image, overlay_noise_heatmap17 18# ── Page config ───────────────────────────────────────────────────────────────19st.set_page_config(20 page_title="Image Filtering & Denoising Explorer",21 layout="wide",22)23 24# ── Cached helpers ────────────────────────────────────────────────────────────25@st.cache_data(show_spinner=False)26def cached_apply_filter(img_bytes: bytes, filter_name: str, params_json: str):27 img = np.array(Image.open(io.BytesIO(img_bytes)))28 return apply_filter(img, filter_name, json.loads(params_json))29 30@st.cache_data(show_spinner=False)31def cached_add_noise(img_bytes: bytes, noise_type: str, level: int):32 img = np.array(Image.open(io.BytesIO(img_bytes)))33 return add_synthetic_noise(img, noise_type, level)34 35@st.cache_data(show_spinner=False)36def cached_metrics(input_bytes: bytes, filtered_bytes: bytes):37 inp = np.array(Image.open(io.BytesIO(input_bytes)))38 flt = np.array(Image.open(io.BytesIO(filtered_bytes)))39 return compute_metrics(inp, flt)40 41@st.cache_data(show_spinner=False)42def cached_heatmap(input_bytes: bytes, filtered_bytes: bytes):43 inp = np.array(Image.open(io.BytesIO(input_bytes)))44 flt = np.array(Image.open(io.BytesIO(filtered_bytes)))45 return overlay_noise_heatmap(inp, flt)46 47@st.cache_data(show_spinner=False)48def cached_histogram_image(input_bytes: bytes, filtered_bytes: bytes) -> bytes:49 inp = np.array(Image.open(io.BytesIO(input_bytes)))50 flt = np.array(Image.open(io.BytesIO(filtered_bytes)))51 fig, axes = plt.subplots(1, 2, figsize=(10, 3), facecolor="#1a1a1a")52 titles = ["Input pixel intensity", "Filtered pixel intensity"]53 colors = ["#e74c3c", "#2ecc71", "#3498db"]54 names = ["Red", "Green", "Blue"]55 for ax, arr, title in zip(axes, [inp, flt], titles):56 ax.set_facecolor("#1a1a1a")57 for c, (color, name) in enumerate(zip(colors, names)):58 hist, edges = np.histogram(arr[:, :, c].ravel(), bins=64, range=(0, 255))59 ax.bar(edges[:-1], hist, width=4, color=color, alpha=0.6, label=name)60 ax.set_title(title, color="white", fontsize=10)61 ax.tick_params(colors="gray")62 for spine in ax.spines.values():63 spine.set_color("#444")64 axes[0].legend(facecolor="#222", labelcolor="white", fontsize=8)65 fig.tight_layout()66 buf = io.BytesIO()67 fig.savefig(buf, format="png", facecolor="#1a1a1a", dpi=100)68 plt.close(fig)69 buf.seek(0)70 return buf.read()71 72def arr_to_bytes(arr: np.ndarray) -> bytes:73 buf = io.BytesIO()74 Image.fromarray(arr.astype(np.uint8)).save(buf, format="PNG")75 return buf.getvalue()76 77# ── CSS ───────────────────────────────────────────────────────────────────────78st.markdown("""79<style>80 .main-title { font-size:2.2rem; font-weight:700; margin-bottom:0.2rem; }81 .sub-title { font-size:1rem; color:#888; margin-bottom:1.5rem; }82 .metric-card { background:rgba(255,255,255,0.06); border:1px solid rgba(255,255,255,0.1);83 border-radius:10px; padding:14px 18px; margin:6px 0; }84 .metric-label{ font-size:0.78rem; color:#aaa; font-weight:600;85 letter-spacing:.05em; text-transform:uppercase; }86 .metric-value{ font-size:1.6rem; font-weight:700; }87 .filter-desc { background:rgba(255,255,255,0.06); border-radius:8px;88 padding:12px 16px; font-size:0.9rem; margin-bottom:1rem; }89 .info-box { border-left:4px solid #f4c430; border-radius:6px;90 padding:12px 16px; font-size:0.88rem; }91</style>92""", unsafe_allow_html=True)93 94# ── Header ────────────────────────────────────────────────────────────────────95st.markdown('<p class="main-title">Image Filtering & Denoising Explorer</p>', unsafe_allow_html=True)96st.markdown('<p class="sub-title">Interactively compare spatial filters — understand how kernel size, '97 'noise type, and algorithm choice affect image quality.</p>', unsafe_allow_html=True)98 99# ── Sidebar ───────────────────────────────────────────────────────────────────100with st.sidebar:101 st.header("Controls")102 103 st.subheader("1. Image source")104 source = st.radio("Choose input", ["Built-in sample", "Upload your own"], horizontal=True)105 106 img_array = None107 if source == "Built-in sample":108 sample_name = st.selectbox(109 "Sample image",110 ["checkerboard_noisy.png", "gradient_saltpepper.png", "circles_mixed_noise.png"],111 format_func=lambda x: x.replace("_", " ").replace(".png", "").title()112 )113 img_array = load_sample_image(sample_name)114 st.caption("These samples have synthetic noise added so you can see filtering effects clearly.")115 else:116 uploaded = st.file_uploader("Upload image (JPG / PNG)", type=["jpg", "jpeg", "png"])117 if uploaded:118 pil = Image.open(uploaded).convert("RGB")119 if max(pil.size) > 800:120 pil.thumbnail((800, 800), Image.LANCZOS)121 st.caption("Image resized to 800px max for performance.")122 img_array = np.array(pil)123 else:124 st.info("Upload an image to get started, or switch to a built-in sample.")125 126 st.subheader("2. Filter")127 filter_name = st.selectbox(128 "Algorithm",129 ["Gaussian Blur", "Median Filter", "Bilateral Filter",130 "Box (Mean) Filter", "Non-local Means"],131 )132 133 st.subheader("3. Parameters")134 params = {}135 if filter_name in ("Gaussian Blur", "Box (Mean) Filter"):136 params["ksize"] = st.slider("Kernel size", 3, 31, 7, step=2,137 help="Larger = stronger smoothing. Must be odd.")138 if filter_name == "Gaussian Blur":139 params["sigma"] = st.slider("Sigma", 0.5, 10.0, 1.5, step=0.5,140 help="Spread of the Gaussian. Larger = more blur.")141 elif filter_name == "Median Filter":142 params["ksize"] = st.slider("Kernel size", 3, 21, 5, step=2,143 help="Larger removes bigger noise clusters but loses fine detail.")144 elif filter_name == "Bilateral Filter":145 params["d"] = st.slider("Diameter (d)", 3, 25, 9, step=2)146 params["sigma_color"] = st.slider("Sigma color", 10, 200, 75, step=5)147 params["sigma_space"] = st.slider("Sigma space", 10, 200, 75, step=5)148 elif filter_name == "Non-local Means":149 params["h"] = st.slider("Filter strength (h)", 3, 30, 10)150 params["template_size"] = st.slider("Template patch size", 3, 11, 7, step=2)151 params["search_size"] = st.slider("Search window size", 11, 35, 21, step=2)152 153 st.subheader("4. Add extra noise (optional)")154 add_noise = st.checkbox("Add noise to input", value=False)155 noise_type, noise_level = None, 0156 if add_noise:157 noise_type = st.selectbox("Noise type", ["Gaussian", "Salt & Pepper", "Speckle"])158 noise_level = st.slider("Noise intensity", 5, 80, 25)159 160# ── Guard ─────────────────────────────────────────────────────────────────────161if img_array is None:162 st.markdown('<div class="info-box">Choose a built-in sample image or upload your own using the sidebar controls.</div>',163 unsafe_allow_html=True)164 st.stop()165 166# ── Compute everything once (all cached) ──────────────────────────────────────167base_bytes = arr_to_bytes(img_array)168 169if add_noise and noise_type:170 display_input = cached_add_noise(base_bytes, noise_type, noise_level)171else:172 display_input = img_array.copy()173 174input_bytes = arr_to_bytes(display_input)175params_json = json.dumps(params, sort_keys=True)176 177with st.spinner("Applying Non-local Means — this may take a few seconds..." if filter_name == "Non-local Means" else ""):178 try:179 filtered = cached_apply_filter(input_bytes, filter_name, params_json)180 except Exception as e:181 st.error(f"Filter error: {e}")182 st.stop()183 184filtered_bytes = arr_to_bytes(filtered)185metrics = cached_metrics(input_bytes, filtered_bytes)186heatmap = cached_heatmap(input_bytes, filtered_bytes)187histogram_png = cached_histogram_image(input_bytes, filtered_bytes)188 189diff = np.abs(display_input.astype(np.int32) - filtered.astype(np.int32))190diff_vis = np.clip(diff * 3, 0, 255).astype(np.uint8)191 192# ── Render results in a fragment so only this section reruns ──────────────────193@st.fragment194def render_results():195 tab1, tab2, tab3 = st.tabs(["Comparison", "Diagnostics", "Theory"])196 197 # ── Tab 1: Comparison ─────────────────────────────────────────────────────198 with tab1:199 st.markdown(f'<div class="filter-desc"><b>{filter_name}</b>: {FILTER_DESCRIPTIONS[filter_name]}</div>',200 unsafe_allow_html=True)201 col1, col2 = st.columns(2)202 with col1:203 st.image(display_input, caption="Input image", use_container_width=True)204 with col2:205 st.image(filtered, caption=f"After {filter_name}", use_container_width=True)206 with st.expander("Show difference image (amplified x3)"):207 st.image(diff_vis, caption="Removed detail / noise (amplified)", use_container_width=True)208 st.caption("Bright areas = pixels that changed the most.")209 210 # ── Tab 2: Diagnostics ────────────────────────────────────────────────────211 with tab2:212 m1, m2, m3, m4 = st.columns(4)213 with m1:214 st.markdown(f"""<div class="metric-card">215 <div class="metric-label">PSNR</div>216 <div class="metric-value">{metrics['psnr']:.1f} dB</div>217 <div style="font-size:.75rem;color:#aaa">Higher = less signal lost</div>218 </div>""", unsafe_allow_html=True)219 with m2:220 st.markdown(f"""<div class="metric-card">221 <div class="metric-label">SSIM</div>222 <div class="metric-value">{metrics['ssim']:.3f}</div>223 <div style="font-size:.75rem;color:#aaa">1.0 = identical structure</div>224 </div>""", unsafe_allow_html=True)225 with m3:226 st.markdown(f"""<div class="metric-card">227 <div class="metric-label">Mean change</div>228 <div class="metric-value">{metrics['mean_diff']:.2f}</div>229 <div style="font-size:.75rem;color:#aaa">Avg pixel change (0-255)</div>230 </div>""", unsafe_allow_html=True)231 with m4:232 st.markdown(f"""<div class="metric-card">233 <div class="metric-label">Noise reduction</div>234 <div class="metric-value">{metrics['noise_reduction']:.1f}%</div>235 <div style="font-size:.75rem;color:#aaa">Estimated noise removed</div>236 </div>""", unsafe_allow_html=True)237 238 st.divider()239 240 st.image(histogram_png,241 caption="Pixel intensity histograms (left: input, right: filtered)",242 use_container_width=True)243 st.caption("Histograms that are more concentrated after filtering indicate noise reduction.")244 245 st.subheader("Local noise heatmap")246 col_hm1, col_hm2 = st.columns([1, 2])247 with col_hm1:248 st.image(heatmap, caption="Noise activity heatmap", use_container_width=True)249 with col_hm2:250 st.markdown("""251**How to read this:**252 253- **Hot (red/yellow)** areas had the most noise removed — the filter worked hardest here.254- **Cool (blue/black)** areas changed little — either already clean or the filter preserved structure.255 256Use this to check whether noise removal is uniform (ideal) or selective.257 """)258 259 st.divider()260 psnr = metrics['psnr']261 ssim = metrics['ssim']262 interp = []263 if psnr > 35:264 interp.append("**PSNR > 35 dB** — very low signal distortion; filter is working gently.")265 elif psnr > 25:266 interp.append("**PSNR 25-35 dB** — moderate distortion; some fine detail is being lost.")267 else:268 interp.append("**PSNR < 25 dB** — significant distortion; filter may be too aggressive.")269 if ssim > 0.90:270 interp.append("**SSIM > 0.9** — structural content well preserved.")271 elif ssim > 0.75:272 interp.append("**SSIM 0.75-0.9** — noticeable structural changes; check edges.")273 else:274 interp.append("**SSIM < 0.75** — heavy structural loss; try reducing filter strength.")275 st.markdown("**Interpretation:**\n\n" + "\n\n".join(interp))276 277 # ── Tab 3: Theory ─────────────────────────────────────────────────────────278 with tab3:279 st.subheader("How spatial filters work")280 st.markdown("""281Spatial image filters operate by replacing each pixel with a function of its **neighbourhood**.282The key trade-off in denoising is:283 284> **Smoothing removes noise, but also blurs edges. The goal is to smooth noise while preserving structure.**285 286---287### Filter comparison288 289| Filter | Kernel type | Best noise | Preserves edges? | Speed |290|---|---|---|---|---|291| **Box (Mean)** | Uniform average | Gaussian | Poor | Fast |292| **Gaussian Blur** | Weighted average (bell curve) | Gaussian | Partial | Fast |293| **Median** | Non-linear median | Salt & Pepper | Good | Medium |294| **Bilateral** | Gaussian x intensity-weight | Gaussian | Very good | Medium |295| **Non-local Means** | Patch similarity | Any | Excellent | Slow |296 297---298### Key concepts299 300**Kernel size** controls the neighbourhood radius. Larger kernels smooth more aggressively but risk blurring fine detail and edges.301 302**Gaussian sigma** controls the shape of the bell curve. Small sigma = sharp, localised filter; large sigma = wide, slow-decaying influence.303 304**Bilateral sigma_color** adds an intensity gate: pixels that differ too much in colour are ignored even if spatially close. This lets bilateral filters smooth flat regions while keeping sharp edges.305 306**Non-local Means** compares small image patches. Similar-looking patches anywhere in the search window contribute to the estimate — very powerful for textured areas.307 308---309### When each filter fails310 311- **Gaussian / Box**: smears salt-and-pepper noise into surrounding pixels instead of removing them.312- **Median**: poor at Gaussian noise unless kernel is large (then blurs edges).313- **Bilateral**: can fail on fine textures (misidentifies texture variation as an edge to preserve).314- **Non-local Means**: very slow; may over-smooth unique regions with no similar patches.315 """)316 317render_results()