LEGENDFTW/image-filtering-explorer
0
1import sys2import os3sys.path.insert(0, os.path.dirname(__file__))4import streamlit as st5import numpy as np6from PIL import Image7import io8import os9 10from processing import apply_filter, FILTER_DESCRIPTIONS11from metrics import compute_metrics, compute_noise_profile12from utils import load_sample_image, image_to_bytes, overlay_noise_heatmap13 14# ── Page config ──────────────────────────────────────────────────────────────15st.set_page_config(16 page_title="Image Filtering & Denoising Explorer",17 page_icon="🔍",18 layout="wide",19)20 21# ── Custom CSS ────────────────────────────────────────────────────────────────22st.markdown("""23<style>24 .main-title { font-size:2.2rem; font-weight:700; color:#1a1a2e; margin-bottom:0.2rem; }25 .sub-title { font-size:1rem; color:#555; margin-bottom:1.5rem; }26 .metric-card { background:#f0f4ff; border-radius:10px; padding:14px 18px; margin:6px 0; }27 .metric-label{ font-size:0.78rem; color:#666; font-weight:600; letter-spacing:.05em; text-transform:uppercase; }28 .metric-value{ font-size:1.6rem; font-weight:700; color:#2c3e7a; }29 .info-box { background:#fffbe6; border-left:4px solid #f4c430;30 border-radius:6px; padding:12px 16px; font-size:0.88rem; color:#444; }31 .filter-desc { background:#eef6ff; border-radius:8px; padding:12px 16px;32 font-size:0.9rem; color:#333; margin-bottom:1rem; }33 section[data-testid="stSidebar"] { background:#f7f9ff; }34</style>35""", unsafe_allow_html=True)36 37# ── Header ────────────────────────────────────────────────────────────────────38st.markdown('<p class="main-title">🔍 Image Filtering & Denoising Explorer</p>', unsafe_allow_html=True)39st.markdown('<p class="sub-title">Interactively compare spatial filters — understand how kernel size, '40 'noise type, and algorithm choice affect image quality.</p>', unsafe_allow_html=True)41 42# ── Sidebar ───────────────────────────────────────────────────────────────────43with st.sidebar:44 st.header("⚙️ Controls")45 46 # ── Image source ──────────────────────────────────────────────────────────47 st.subheader("1. Image source")48 source = st.radio("Choose input", ["Built-in sample", "Upload your own"], horizontal=True)49 50 img_array = None51 if source == "Built-in sample":52 sample_name = st.selectbox(53 "Sample image",54 ["checkerboard_noisy.png", "gradient_saltpepper.png", "circles_mixed_noise.png"],55 format_func=lambda x: x.replace("_", " ").replace(".png", "").title()56 )57 img_array = load_sample_image(sample_name)58 st.caption("📌 These samples have synthetic noise added so you can see filtering effects clearly.")59 else:60 uploaded = st.file_uploader("Upload image (JPG / PNG)", type=["jpg", "jpeg", "png"])61 if uploaded:62 pil = Image.open(uploaded).convert("RGB")63 # Resize large images for performance64 if max(pil.size) > 800:65 pil.thumbnail((800, 800), Image.LANCZOS)66 st.caption("⚡ Image resized to ≤800px for performance.")67 img_array = np.array(pil)68 else:69 st.info("Upload an image to get started, or switch to a built-in sample.")70 71 # ── Filter selection ──────────────────────────────────────────────────────72 st.subheader("2. Filter")73 filter_name = st.selectbox(74 "Algorithm",75 ["Gaussian Blur", "Median Filter", "Bilateral Filter",76 "Box (Mean) Filter", "Non-local Means"],77 )78 79 # ── Filter parameters ─────────────────────────────────────────────────────80 st.subheader("3. Parameters")81 82 params = {}83 if filter_name in ("Gaussian Blur", "Box (Mean) Filter"):84 params["ksize"] = st.slider("Kernel size", 3, 31, 7, step=2,85 help="Larger = stronger smoothing. Must be odd.")86 if filter_name == "Gaussian Blur":87 params["sigma"] = st.slider("σ (sigma)", 0.5, 10.0, 1.5, step=0.5,88 help="Spread of the Gaussian. Larger = more blur.")89 90 elif filter_name == "Median Filter":91 params["ksize"] = st.slider("Kernel size", 3, 21, 5, step=2,92 help="Larger removes bigger noise clusters but loses fine detail.")93 94 elif filter_name == "Bilateral Filter":95 params["d"] = st.slider("Diameter (d)", 3, 25, 9, step=2,96 help="Pixel neighbourhood diameter.")97 params["sigma_color"] = st.slider("σ color", 10, 200, 75, step=5,98 help="How much colour difference is tolerated. Higher = more colour averaging.")99 params["sigma_space"] = st.slider("σ space", 10, 200, 75, step=5,100 help="Spatial extent of the filter. Higher = farther pixels influence each other.")101 102 elif filter_name == "Non-local Means":103 params["h"] = st.slider("Filter strength (h)", 3, 30, 10,104 help="Higher = stronger denoising but risks blurring detail.")105 params["template_size"] = st.slider("Template patch size", 3, 11, 7, step=2,106 help="Size of the patch used for comparison.")107 params["search_size"] = st.slider("Search window size", 11, 35, 21, step=2,108 help="Area searched for similar patches. Larger = slower but better.")109 110 # ── Add synthetic noise option ─────────────────────────────────────────────111 st.subheader("4. Add extra noise (optional)")112 add_noise = st.checkbox("Add noise to input", value=False)113 noise_type, noise_level = None, 0114 if add_noise:115 noise_type = st.selectbox("Noise type", ["Gaussian", "Salt & Pepper", "Speckle"])116 noise_level = st.slider("Noise intensity", 5, 80, 25)117 118# ── Main panel ────────────────────────────────────────────────────────────────119if img_array is None:120 st.markdown("""121 <div class="info-box">122 👈 Choose a built-in sample image or upload your own using the sidebar controls.123 </div>124 """, unsafe_allow_html=True)125 st.stop()126 127# Apply synthetic noise if requested128from processing import add_synthetic_noise129display_input = img_array.copy()130if add_noise and noise_type:131 display_input = add_synthetic_noise(display_input, noise_type, noise_level)132 133# Apply filter134try:135 filtered = apply_filter(display_input, filter_name, params)136except Exception as e:137 st.error(f"Filter error: {e}")138 st.stop()139 140# ── Tab layout ────────────────────────────────────────────────────────────────141tab1, tab2, tab3 = st.tabs(["📷 Comparison", "📊 Diagnostics", "📚 Theory"])142 143# ────────────────────── TAB 1: Side-by-side ──────────────────────────────────144with tab1:145 st.markdown(f'<div class="filter-desc">🔬 <b>{filter_name}</b>: {FILTER_DESCRIPTIONS[filter_name]}</div>',146 unsafe_allow_html=True)147 148 col1, col2 = st.columns(2)149 with col1:150 st.image(display_input, caption="Input image", use_container_width=True)151 with col2:152 st.image(filtered, caption=f"After {filter_name}", use_container_width=True)153 154 # Difference image155 diff = np.abs(display_input.astype(np.int32) - filtered.astype(np.int32))156 diff_vis = np.clip(diff * 3, 0, 255).astype(np.uint8) # amplify for visibility157 with st.expander("🔎 Show difference image (amplified ×3)"):158 st.image(diff_vis, caption="Removed detail / noise (amplified)", use_container_width=True)159 st.caption("Bright areas = pixels that changed the most. This reveals where the filter is working hardest.")160 161# ────────────────────── TAB 2: Diagnostics ───────────────────────────────────162with tab2:163 metrics = compute_metrics(display_input, filtered)164 165 # Metric cards166 m1, m2, m3, m4 = st.columns(4)167 with m1:168 st.markdown(f"""<div class="metric-card">169 <div class="metric-label">PSNR</div>170 <div class="metric-value">{metrics['psnr']:.1f} dB</div>171 <div style="font-size:.75rem;color:#888">Higher = less signal lost</div>172 </div>""", unsafe_allow_html=True)173 with m2:174 st.markdown(f"""<div class="metric-card">175 <div class="metric-label">SSIM</div>176 <div class="metric-value">{metrics['ssim']:.3f}</div>177 <div style="font-size:.75rem;color:#888">1.0 = identical structure</div>178 </div>""", unsafe_allow_html=True)179 with m3:180 st.markdown(f"""<div class="metric-card">181 <div class="metric-label">Mean Δ</div>182 <div class="metric-value">{metrics['mean_diff']:.2f}</div>183 <div style="font-size:.75rem;color:#888">Avg pixel change (0-255)</div>184 </div>""", unsafe_allow_html=True)185 with m4:186 st.markdown(f"""<div class="metric-card">187 <div class="metric-label">Noise σ reduction</div>188 <div class="metric-value">{metrics['noise_reduction']:.1f}%</div>189 <div style="font-size:.75rem;color:#888">Estimated noise removed</div>190 </div>""", unsafe_allow_html=True)191 192 st.divider()193 194 # Histogram comparison195 col_h1, col_h2 = st.columns(2)196 noise_profile_in = compute_noise_profile(display_input)197 noise_profile_out = compute_noise_profile(filtered)198 199 import plotly.graph_objects as go200 from plotly.subplots import make_subplots201 202 # Pixel intensity histograms203 fig_hist = make_subplots(rows=1, cols=2,204 subplot_titles=("Input — pixel intensity", "Filtered — pixel intensity"))205 colors_rgb = ["#e74c3c", "#2ecc71", "#3498db"]206 channel_names = ["Red", "Green", "Blue"]207 for c, (col, name) in enumerate(zip(colors_rgb, channel_names)):208 for row, arr in [(1, display_input), (2, filtered)]: # plotly col209 hist, edges = np.histogram(arr[:, :, c].ravel(), bins=64, range=(0, 255))210 fig_hist.add_trace(211 go.Bar(x=edges[:-1], y=hist, name=name,212 marker_color=col, opacity=0.6,213 showlegend=(row == 1)),214 row=1, col=row215 )216 fig_hist.update_layout(height=300, margin=dict(t=40, b=10), barmode="overlay",217 legend_title="Channel")218 st.plotly_chart(fig_hist, use_container_width=True)219 st.caption("Histograms that are more concentrated / peaked after filtering indicate noise reduction. "220 "Very compressed histograms suggest over-smoothing.")221 222 # Noise heatmap223 st.subheader("Local noise heatmap")224 heatmap_img = overlay_noise_heatmap(display_input, filtered)225 col_hm1, col_hm2 = st.columns([1, 2])226 with col_hm1:227 st.image(heatmap_img, caption="Noise activity heatmap", use_container_width=True)228 with col_hm2:229 st.markdown("""230**How to read this:**231- 🔴 **Hot (red/yellow)** areas had the most noise removed — the filter worked hardest here.232- 🔵 **Cool (blue/black)** areas changed little — either already clean or the filter preserved structure.233 234Use this to check whether noise removal is **uniform** (ideal) or **selective** (suggests the filter is confused by edges or textures).235 """)236 237 # Metric interpretation238 st.divider()239 psnr = metrics['psnr']240 ssim = metrics['ssim']241 interp = []242 if psnr > 35:243 interp.append("✅ **PSNR > 35 dB** — very low signal distortion; filter is working gently.")244 elif psnr > 25:245 interp.append("⚠️ **PSNR 25–35 dB** — moderate distortion; some fine detail is being lost.")246 else:247 interp.append("🔴 **PSNR < 25 dB** — significant distortion; filter may be too aggressive.")248 if ssim > 0.90:249 interp.append("✅ **SSIM > 0.9** — structural content well preserved.")250 elif ssim > 0.75:251 interp.append("⚠️ **SSIM 0.75–0.9** — noticeable structural changes; check edges.")252 else:253 interp.append("🔴 **SSIM < 0.75** — heavy structural loss; try reducing filter strength.")254 255 st.markdown("**Interpretation:**\n\n" + "\n\n".join(interp))256 257# ────────────────────── TAB 3: Theory ────────────────────────────────────────258with tab3:259 st.subheader("📚 How spatial filters work")260 st.markdown("""261Spatial image filters operate by replacing each pixel with a function of its **neighbourhood**.262The key trade-off in denoising is:263 264> **Smoothing removes noise, but also blurs edges. The goal is to smooth noise while preserving structure.**265 266---267### Filter comparison268| Filter | Kernel type | Best noise | Preserves edges? | Speed |269|---|---|---|---|---|270| **Box (Mean)** | Uniform average | Gaussian | ❌ Poor | ⚡ Fast |271| **Gaussian Blur** | Weighted average (bell curve) | Gaussian | ⚠️ Partial | ⚡ Fast |272| **Median** | Non-linear median | Salt & Pepper | ✅ Good | 🐢 Medium |273| **Bilateral** | Gaussian × intensity-weight | Gaussian | ✅ Very good | 🐢 Medium |274| **Non-local Means (NLM)** | Patch similarity | Any | ✅ Excellent | 🐌 Slow |275 276---277### Key concepts278 279**Kernel size** controls the neighbourhood radius. Larger kernels smooth more aggressively but risk blurring fine detail and edges.280 281**Gaussian σ** controls the *shape* of the bell curve. Small σ = sharp, localised filter; large σ = wide, slow-decaying influence.282 283**Bilateral σ_color** adds an intensity gate: pixels that differ too much in colour are ignored even if they're close spatially. This is what lets bilateral filters smooth flat regions while keeping sharp edges.284 285**Non-local Means** takes this further: instead of comparing single pixels, it compares *patches* (small image regions). Similar-looking patches anywhere in the search window contribute to the estimate — making it very powerful for textured areas.286 287---288### When each filter fails289 290- **Gaussian / Box**: smears salt-and-pepper noise (single outlier pixels) into surrounding pixels instead of removing them.291- **Median**: poor at Gaussian noise unless kernel is large (then blurs edges).292- **Bilateral**: can fail on fine textures (misidentifies texture variation as an "edge" to preserve).293- **NLM**: very slow; may over-smooth unique regions with no similar patches.294 """)295 