| """ |
| 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: |
| |
| 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: |
| |
| 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) |
| |
| |
| for i in range(size): |
| img[i, :, 0] = int(255 * i / size) |
| img[:, i, 1] = int(255 * i / size) |
| |
| |
| 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) |
| |
| |
| 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 |
| |
| |
| downsampled = cv.resize(img, (new_w, new_h), interpolation=cv.INTER_AREA) |
| |
| |
| 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 |
| |
| |
| num_levels = 2 ** bits_per_pixel |
| |
| |
| 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.""" |
| |
| sampled = downsample_image(img, sampling_rate) |
| |
| |
| 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 |
| |
| |
| num_pixels = (h // sampling_rate) * (w // sampling_rate) |
| |
| |
| raw_size = num_pixels * c * bits_per_pixel / 8 |
| |
| |
| if compression_type == "png": |
| |
| size = raw_size * 0.7 |
| elif compression_type == "jpeg": |
| |
| 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.""" |
| |
| img_bgr = cv.cvtColor(img, cv.COLOR_RGB2BGR) |
| |
| |
| encode_param = [int(cv.IMWRITE_JPEG_QUALITY), quality] |
| _, buffer = cv.imencode('.jpg', img_bgr, encode_param) |
| |
| |
| 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.""" |
| |
| img_bgr = cv.cvtColor(img, cv.COLOR_RGB2BGR) |
| |
| |
| encode_param = [int(cv.IMWRITE_PNG_COMPRESSION), compression_level] |
| _, buffer = cv.imencode('.png', img_bgr, encode_param) |
| |
| |
| 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. |
| """) |
| |
| |
| sample_img = load_sample_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: |
| |
| 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) |
| |
| |
| 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 |
| |
| |
| 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)" |
| ) |
| |
| |
| 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") |
| |
| |
| processed_img = apply_sampling_and_quantization(img, sampling_rate, bits_per_pixel) |
| |
| |
| 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") |
| |
| |
| 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}%") |
| |
| |
| 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)}** |
| """) |
| |
| |
| 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. |
| """) |
| |
| |
| 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" |
| ) |
| |
| |
| 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") |
| |
| |
| if jpeg_quality < 30: |
| st.warning("**Low JPEG quality detected!** Look closely at the image to see blocking artifacts.") |
| |
| |
| 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") |
| |
| |
| st.markdown("---") |
| st.markdown(""" |
| <small> |
| Educational Demo for Image Analysis Courses | |
| Built with Streamlit | |
| <a href="https://github.com/ubern-image-analysis/sampling-quantization" target="_blank">View Source</a> |
| </small> |
| """, unsafe_allow_html=True) |
|
|
|
|
| if __name__ == "__main__": |
| main_loop() |
|
|