""" Interactive Image Sampling and Quantization Demo An educational tool for graduate-level image analysis courses """ import io import numpy as np import cv2 as cv import streamlit as st from PIL import Image from huggingface_hub import hf_hub_download @st.cache_resource def load_sample_image(): """Load a sample image for the demo. Falls back to generated image if download fails.""" try: # Try to download from HuggingFace image_path = hf_hub_download( repo_id="amithjkamath/exampleimages", filename="sample-image.jpg", repo_type="dataset", ) img = cv.imread(image_path) img = cv.cvtColor(img, cv.COLOR_BGR2RGB) except Exception: # Generate a sample image with varied content img = generate_sample_image() return img def generate_sample_image(size=512): """Generate a sample image with interesting features for demonstration.""" img = np.zeros((size, size, 3), dtype=np.uint8) # Create a gradient background for i in range(size): img[i, :, 0] = int(255 * i / size) # Red gradient img[:, i, 1] = int(255 * i / size) # Green gradient # Add some geometric shapes cv.circle(img, (size//4, size//4), size//8, (255, 255, 255), -1) cv.rectangle(img, (size//2, size//2), (3*size//4, 3*size//4), (255, 0, 0), -1) cv.circle(img, (3*size//4, size//4), size//12, (0, 255, 255), -1) # Add some text cv.putText(img, "Sample", (size//4, 3*size//4), cv.FONT_HERSHEY_SIMPLEX, 2, (255, 255, 255), 3) return img def downsample_image(img, sampling_rate): """ Downsample image by reducing spatial resolution. Args: img: Input image (RGB) sampling_rate: Factor by which to reduce resolution (1 = original, 2 = half, etc.) Returns: Downsampled image, upsampled back to original size for comparison """ if sampling_rate == 1: return img h, w = img.shape[:2] new_h, new_w = h // sampling_rate, w // sampling_rate # Downsample using area interpolation (better quality) downsampled = cv.resize(img, (new_w, new_h), interpolation=cv.INTER_AREA) # Upsample back to original size using nearest neighbor (shows pixelation) upsampled = cv.resize(downsampled, (w, h), interpolation=cv.INTER_NEAREST) return upsampled def quantize_image(img, bits_per_pixel): """ Quantize image by reducing the number of bits per pixel. Args: img: Input image (RGB) bits_per_pixel: Number of bits per pixel (1-8) Returns: Quantized image """ if bits_per_pixel == 8: return img # Calculate number of levels num_levels = 2 ** bits_per_pixel # Quantize by dividing into levels quantized = np.floor(img / (256.0 / num_levels)) * (256.0 / num_levels) quantized = np.clip(quantized, 0, 255).astype(np.uint8) return quantized def apply_sampling_and_quantization(img, sampling_rate, bits_per_pixel): """Apply both sampling and quantization to an image.""" # First downsample sampled = downsample_image(img, sampling_rate) # Then quantize result = quantize_image(sampled, bits_per_pixel) return result def calculate_file_size(img_shape, sampling_rate, bits_per_pixel, compression_type="none"): """ Calculate estimated file size. Args: img_shape: Shape of the original image (h, w, c) sampling_rate: Downsampling factor bits_per_pixel: Bits per pixel per channel compression_type: "none", "png", or "jpeg" Returns: File size in bytes """ h, w, c = img_shape # Calculate actual number of pixels after sampling num_pixels = (h // sampling_rate) * (w // sampling_rate) # Calculate raw size in bytes raw_size = num_pixels * c * bits_per_pixel / 8 # Apply compression estimate if compression_type == "png": # PNG typically achieves 60-80% of raw size for typical images size = raw_size * 0.7 elif compression_type == "jpeg": # JPEG can achieve much better compression (20-40% of raw) size = raw_size * 0.3 else: size = raw_size return int(size) def format_file_size(size_bytes): """Format file size in human-readable format.""" if size_bytes < 1024: return f"{size_bytes} B" elif size_bytes < 1024 * 1024: return f"{size_bytes / 1024:.2f} KB" else: return f"{size_bytes / (1024 * 1024):.2f} MB" def compress_image_jpeg(img, quality=50): """Compress image using JPEG and return the result.""" # Convert to BGR for OpenCV img_bgr = cv.cvtColor(img, cv.COLOR_RGB2BGR) # Encode as JPEG encode_param = [int(cv.IMWRITE_JPEG_QUALITY), quality] _, buffer = cv.imencode('.jpg', img_bgr, encode_param) # Decode back img_decoded = cv.imdecode(buffer, cv.IMREAD_COLOR) img_rgb = cv.cvtColor(img_decoded, cv.COLOR_BGR2RGB) return img_rgb, len(buffer) def compress_image_png(img, compression_level=6): """Compress image using PNG and return the result.""" # Convert to BGR for OpenCV img_bgr = cv.cvtColor(img, cv.COLOR_RGB2BGR) # Encode as PNG encode_param = [int(cv.IMWRITE_PNG_COMPRESSION), compression_level] _, buffer = cv.imencode('.png', img_bgr, encode_param) # Decode back img_decoded = cv.imdecode(buffer, cv.IMREAD_COLOR) img_rgb = cv.cvtColor(img_decoded, cv.COLOR_BGR2RGB) return img_rgb, len(buffer) def main_loop(): """Main application loop.""" st.set_page_config(layout="wide", page_title="Sampling & Quantization Demo") st.title("Interactive Image Sampling and Quantization Demo") st.markdown(""" Welcome! This interactive demo teaches fundamental concepts in digital image processing. Explore how **sampling** (spatial resolution) and **quantization** (bit depth) affect image quality and storage requirements. """) # Load sample image sample_img = load_sample_image() # Option to upload custom image st.sidebar.header("Image Input") uploaded_file = st.sidebar.file_uploader("Upload your own image (optional)", type=['png', 'jpg', 'jpeg']) if uploaded_file is not None: # Use uploaded image file_bytes = np.asarray(bytearray(uploaded_file.read()), dtype=np.uint8) img = cv.imdecode(file_bytes, cv.IMREAD_COLOR) img = cv.cvtColor(img, cv.COLOR_BGR2RGB) # Resize if too large max_size = 512 h, w = img.shape[:2] if max(h, w) > max_size: scale = max_size / max(h, w) new_w, new_h = int(w * scale), int(h * scale) img = cv.resize(img, (new_w, new_h), interpolation=cv.INTER_AREA) else: img = sample_img # Main controls st.sidebar.header("Controls") st.sidebar.subheader("Spatial Sampling") sampling_rate = st.sidebar.slider( "Sampling Grid Size (pixels)", min_value=1, max_value=16, value=1, step=1, help="Higher values = more pixelated image (fewer pixels stored)" ) st.sidebar.subheader("Quantization") bits_per_pixel = st.sidebar.slider( "Bits per Pixel per Channel", min_value=1, max_value=8, value=8, step=1, help="Lower values = fewer colors/gray levels (less storage per pixel)" ) # Calculate number of possible values num_levels = 2 ** bits_per_pixel st.sidebar.info(f"**{num_levels}** intensity levels per channel\n\n" f"**{num_levels**3:,}** total colors possible") # Process image processed_img = apply_sampling_and_quantization(img, sampling_rate, bits_per_pixel) # Display images st.markdown("---") st.markdown("## Visual Comparison") col1, col2 = st.columns(2) with col1: st.markdown("### Original Image") st.image(img, use_column_width=True) st.caption(f"Size: {img.shape[1]}x{img.shape[0]} pixels, 8 bits/channel") with col2: st.markdown("### Processed Image") st.image(processed_img, use_column_width=True) st.caption(f"Size: {img.shape[1]//sampling_rate}x{img.shape[0]//sampling_rate} pixels, " f"{bits_per_pixel} bits/channel") # File size analysis st.markdown("---") st.markdown("## Storage Analysis") col1, col2, col3 = st.columns(3) original_size = calculate_file_size(img.shape, 1, 8, "none") processed_size = calculate_file_size(img.shape, sampling_rate, bits_per_pixel, "none") reduction = (1 - processed_size / original_size) * 100 with col1: st.metric("Original (Uncompressed)", format_file_size(original_size)) with col2: st.metric("Processed (Uncompressed)", format_file_size(processed_size)) with col3: st.metric("Size Reduction", f"{reduction:.1f}%") # Detailed breakdown with st.expander("Size Calculation Details"): st.markdown(f""" **Original Image:** - Dimensions: {img.shape[1]} x {img.shape[0]} pixels - Channels: 3 (RGB) - Bits per pixel: 8 x 3 = 24 bits - Total bits: {img.shape[1]} x {img.shape[0]} x 24 = {img.shape[1] * img.shape[0] * 24:,} bits - **Uncompressed size: {format_file_size(original_size)}** **Processed Image:** - Dimensions: {img.shape[1]//sampling_rate} x {img.shape[0]//sampling_rate} pixels - Channels: 3 (RGB) - Bits per pixel: {bits_per_pixel} x 3 = {bits_per_pixel * 3} bits - Total bits: {img.shape[1]//sampling_rate} x {img.shape[0]//sampling_rate} x {bits_per_pixel * 3} = {(img.shape[1]//sampling_rate) * (img.shape[0]//sampling_rate) * bits_per_pixel * 3:,} bits - **Uncompressed size: {format_file_size(processed_size)}** """) # Compression comparison st.markdown("---") st.markdown("## Compression Methods Comparison") st.markdown(""" Now see how different compression algorithms affect the processed image. **PNG** uses lossless compression, while **JPEG** uses lossy compression. """) # JPEG compression col1, col2 = st.columns([1, 3]) with col1: st.subheader("JPEG Settings") jpeg_quality = st.slider( "JPEG Quality", min_value=1, max_value=100, value=50, help="Lower quality = more compression = smaller file = more artifacts" ) # Compress images jpeg_img, jpeg_size = compress_image_jpeg(processed_img, jpeg_quality) png_img, png_size = compress_image_png(processed_img, compression_level=6) with col2: st.markdown("### Compression Results") col_png, col_jpeg = st.columns(2) with col_png: st.markdown("**PNG (Lossless)**") st.image(png_img, use_column_width=True) st.metric("PNG File Size", format_file_size(png_size)) st.caption("Exact reconstruction, no quality loss") with col_jpeg: st.markdown("**JPEG (Lossy)**") st.image(jpeg_img, use_column_width=True) st.metric("JPEG File Size", format_file_size(jpeg_size)) compression_ratio = (1 - jpeg_size / png_size) * 100 st.caption(f"{compression_ratio:.1f}% smaller than PNG") # Show blocking artifacts if jpeg_quality < 30: st.warning("**Low JPEG quality detected!** Look closely at the image to see blocking artifacts.") # Educational section st.markdown("---") st.markdown("## Educational Insights") tab1, tab2, tab3 = st.tabs(["Sampling", "Quantization", "Compression"]) with tab1: st.markdown(""" ### Spatial Sampling **What is it?** - Sampling determines the spatial resolution of an image - A sampling rate of N means we keep every Nth pixel in each direction - This reduces the total number of pixels by a factor of N² **Key Concepts:** - **Nyquist-Shannon Sampling Theorem**: To avoid aliasing, sampling rate must be at least twice the highest frequency in the image - **Pixelation**: When sampling rate is too low, fine details are lost and edges become blocky - **Storage Impact**: Directly proportional to pixel count **Try it:** - Increase the sampling grid size slider above - Notice how the image becomes more pixelated - Watch the file size decrease as fewer pixels are stored """) if sampling_rate > 1: st.info(f"Current sampling reduces pixel count by {sampling_rate**2}x " f"({img.shape[0]*img.shape[1]:,} to {(img.shape[0]//sampling_rate)*(img.shape[1]//sampling_rate):,} pixels)") with tab2: st.markdown(""" ### Quantization (Bit Depth) **What is it?** - Quantization determines how many distinct values each pixel can have - With N bits per pixel per channel, we can represent 2^N different intensity levels - This affects color depth and tonal range **Key Concepts:** - **8 bits** = 256 levels per channel = 16.7 million colors (standard) - **4 bits** = 16 levels per channel = 4,096 colors - **1 bit** = 2 levels per channel = 8 colors (effectively binary) - **Posterization**: Visible bands in gradients when quantization is too coarse **Storage Impact:** - Each pixel requires: bits_per_channel x number_of_channels bits - For RGB: 3 x bits_per_pixel bits per pixel **Try it:** - Decrease the bits per pixel slider above - Notice color banding in smooth gradients - Watch file size decrease as fewer bits are used per pixel """) if bits_per_pixel < 8: st.info(f"Current quantization uses {bits_per_pixel * 3} bits per pixel " f"(vs 24 bits normally), saving {(1 - bits_per_pixel/8)*100:.0f}% per pixel") with tab3: st.markdown(""" ### Image Compression **PNG (Portable Network Graphics) - Lossless** - Uses DEFLATE compression algorithm (similar to ZIP) - Exploits spatial redundancy in images - Perfect reconstruction - no quality loss - Better for graphics, text, screenshots - Typically 50-80% of raw size - **JPEG (Joint Photographic Experts Group) - Lossy** - Uses Discrete Cosine Transform (DCT) on 8x8 blocks - Quantizes frequency components (loses information) - Much better compression ratios (10-40% of raw size) - Better for photographs with gradual color changes - **Blocking Artifacts**: Visible 8x8 blocks at low quality **Quality vs. Size Trade-off:** - High JPEG quality (90-100): Minimal artifacts, larger files - Medium quality (50-70): Good balance for photos - Low quality (1-30): Heavy artifacts, smallest files **Try it:** - Adjust the JPEG quality slider above - At low quality (<30), look for 8x8 blocking patterns - Compare file sizes: JPEG can be 5-10x smaller than PNG """) st.info(f"Current JPEG is {(png_size / jpeg_size):.1f}x smaller than PNG, " f"and {(processed_size / jpeg_size):.1f}x smaller than uncompressed") # Footer st.markdown("---") st.markdown(""" Educational Demo for Image Analysis Courses | Built with Streamlit | View Source """, unsafe_allow_html=True) if __name__ == "__main__": main_loop()