Download app.py from ubern-introimage/sampling-quantization: direct link, hf CLI and curl.
- Browser
- Download file 16.2 kB
-
https://huggingface.co/spaces/ubern-introimage/sampling-quantization/resolve/main/app.py
- Command line
-
hf download hf://spaces/ubern-introimage/sampling-quantization/app.py
-
curl -L -o app.py https://huggingface.co/spaces/ubern-introimage/sampling-quantization/resolve/main/app.py
16.2 kB
| """ | |
| 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 | |
| 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(""" | |
| <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() | |