""" Interactive Colorspace Exploration 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 import colorsys from pathlib import Path import matplotlib.pyplot as plt from huggingface_hub import hf_hub_download, list_repo_files # --- Utility Functions --- @st.cache_resource def load_image(image_name): """Load an image from Hugging Face Hub.""" try: # Download image from HF Hub dataset img_path = hf_hub_download( repo_id="amithjkamath/exampleimages", filename=image_name, repo_type="dataset" ) img = cv.imread(str(img_path)) if img is not None: return cv.cvtColor(img, cv.COLOR_BGR2RGB) except Exception as e: st.warning(f"Could not load image {image_name}: {e}") return None def get_available_images(exclude_colorblind_tests=False): """Get list of available images from Hugging Face Hub. Args: exclude_colorblind_tests: If True, exclude numbered test images (1-, 2-, etc.) """ try: files = list_repo_files( repo_id="amithjkamath/exampleimages", repo_type="dataset" ) # Filter for image files image_files = [f for f in files if f.lower().endswith(('.png', '.jpg', '.jpeg'))] image_files = sorted(image_files) if exclude_colorblind_tests: # Filter out colorblind test images (those starting with a digit) image_files = [f for f in image_files if not f[0].isdigit()] return image_files except Exception as e: st.warning(f"Could not fetch images from Hugging Face Hub: {e}") return [] def get_colorblind_test_images(): """Get only colorblind test images (numbered) from Hugging Face Hub.""" try: files = list_repo_files( repo_id="amithjkamath/exampleimages", repo_type="dataset" ) # Filter for image files that start with a digit image_files = [f for f in files if f.lower().endswith(('.png', '.jpg', '.jpeg')) and f[0].isdigit()] return sorted(image_files) except Exception as e: st.warning(f"Could not fetch colorblind test images: {e}") return [] def generate_gradient_image(size=256): """Generate a gradient test image.""" img = np.zeros((size, size, 3), dtype=np.uint8) for i in range(size): img[i, :] = [int(255 * i / size), int(128), int(255 * (1 - i / size))] return img # --- RGB Colorspace Tab --- def tab_rgb(): """RGB Colorspace exploration.""" st.header("🔴🟢🔵 RGB Colorspace") st.markdown( """ **RGB (Red, Green, Blue)** is an **additive** color model where colors are created by combining red, green, and blue light. It's the most common colorspace for displays and cameras. - **Range**: Each channel: 0-255 (8-bit) - **Use**: Displays, cameras, image storage - **Properties**: Device-dependent, not perceptually uniform """ ) col1, col2 = st.columns([1, 2]) with col1: st.subheader("Color Mixer") r = st.slider("🔴 Red", 0, 255, 128, key="rgb_r") g = st.slider("🟢 Green", 0, 255, 128, key="rgb_g") b = st.slider("🔵 Blue", 0, 255, 128, key="rgb_b") # Create color swatch color_rgb = np.ones((100, 100, 3), dtype=np.uint8) color_rgb[:, :] = [r, g, b] st.image(color_rgb, caption=f"RGB({r}, {g}, {b})", width=200) st.markdown( f""" **Hex**: #{r:02x}{g:02x}{b:02x} **Normalized**: ({r/255:.2f}, {g/255:.2f}, {b/255:.2f}) """ ) with col2: st.subheader("Image Analysis & Adjustment") # Select image (exclude colorblind tests) images = get_available_images(exclude_colorblind_tests=True) if images: selected_img = st.selectbox( "Select image:", images, index=images.index("lena.png") if "lena.png" in images else 0, key="rgb_img", ) img = load_image(selected_img) if img is not None: # Resize if too large h, w = img.shape[:2] if max(h, w) > 400: scale = 400 / max(h, w) img = cv.resize(img, None, fx=scale, fy=scale) # Show original and channels col_orig, col_r, col_g, col_b = st.columns(4) with col_orig: st.image(img, caption="Original", use_column_width=True) with col_r: r_channel = np.zeros_like(img) r_channel[:, :, 0] = img[:, :, 0] st.image(r_channel, caption="Red Channel", use_column_width=True) with col_g: g_channel = np.zeros_like(img) g_channel[:, :, 1] = img[:, :, 1] st.image(g_channel, caption="Green Channel", use_column_width=True) with col_b: b_channel = np.zeros_like(img) b_channel[:, :, 2] = img[:, :, 2] st.image(b_channel, caption="Blue Channel", use_column_width=True) # Interactive manipulation st.markdown("**Adjust RGB Components**") r_scale = st.slider("Red Scale", 0.0, 2.0, 1.0, key="rgb_r_scale") g_scale = st.slider("Green Scale", 0.0, 2.0, 1.0, key="rgb_g_scale") b_scale = st.slider("Blue Scale", 0.0, 2.0, 1.0, key="rgb_b_scale") # Apply transformations img_rgb_mod = img.copy().astype(np.float32) img_rgb_mod[:, :, 0] = np.clip(img_rgb_mod[:, :, 0] * r_scale, 0, 255) img_rgb_mod[:, :, 1] = np.clip(img_rgb_mod[:, :, 1] * g_scale, 0, 255) img_rgb_mod[:, :, 2] = np.clip(img_rgb_mod[:, :, 2] * b_scale, 0, 255) img_rgb_mod = img_rgb_mod.astype(np.uint8) col_before, col_after = st.columns(2) with col_before: st.image(img, caption="Original", use_column_width=True) with col_after: st.image(img_rgb_mod, caption="Modified", use_column_width=True) # Show grayscale channels st.markdown("**Individual Channel Intensities (Grayscale)**") col_r2, col_g2, col_b2 = st.columns(3) with col_r2: r_gray = np.stack([img[:, :, 0]] * 3, axis=-1) st.image(r_gray, caption="R intensity", use_column_width=True) with col_g2: g_gray = np.stack([img[:, :, 1]] * 3, axis=-1) st.image(g_gray, caption="G intensity", use_column_width=True) with col_b2: b_gray = np.stack([img[:, :, 2]] * 3, axis=-1) st.image(b_gray, caption="B intensity", use_column_width=True) st.markdown("---") st.markdown( """ ### 💡 Key Insights - **Additive Model**: White = R + G + B, Black = no light - **Primary Colors**: Red, Green, Blue - **Secondary Colors**: Cyan (G+B), Magenta (R+B), Yellow (R+G) - Each pixel requires 3 bytes (24 bits) for full color """ ) # --- HSV/HSI Colorspace Tab --- def tab_hsv(): """HSV/HSI Colorspace exploration.""" st.header("🌈 HSV/HSI Colorspace") st.markdown( """ **HSV** (Hue, Saturation, Value) and **HSI** (Hue, Saturation, Intensity) are cylindrical representations that separate color (hue) from brightness and saturation. - **Hue**: Color type (0-360°) - Red, Green, Blue, etc. - **Saturation**: Color purity (0-100%) - Gray to pure color - **Value/Intensity**: Brightness (0-100%) - Dark to bright """ ) col1, col2 = st.columns([1, 2]) with col1: st.subheader("HSV Color Picker") h = st.slider("🌈 Hue (degrees)", 0, 360, 180, key="hsv_h") s = st.slider("💧 Saturation (%)", 0, 100, 100, key="hsv_s") v = st.slider("☀️ Value/Brightness (%)", 0, 100, 100, key="hsv_v") # Convert HSV to RGB for display h_norm = h / 360.0 s_norm = s / 100.0 v_norm = v / 100.0 r, g, b = colorsys.hsv_to_rgb(h_norm, s_norm, v_norm) r, g, b = int(r * 255), int(g * 255), int(b * 255) # Create color swatch color_rgb = np.ones((100, 100, 3), dtype=np.uint8) color_rgb[:, :] = [r, g, b] st.image(color_rgb, caption=f"HSV({h}°, {s}%, {v}%)", width=200) st.markdown( f""" **RGB Equivalent**: ({r}, {g}, {b}) **Hex**: #{r:02x}{g:02x}{b:02x} """ ) # Hue visualization st.markdown("**Hue Circle**") hue_circle = np.zeros((100, 100, 3), dtype=np.uint8) for i in range(100): for j in range(100): dx, dy = i - 50, j - 50 angle = np.arctan2(dy, dx) hue = (angle + np.pi) / (2 * np.pi) * 360 r_h, g_h, b_h = colorsys.hsv_to_rgb(hue / 360, 1.0, 1.0) hue_circle[i, j] = [int(r_h * 255), int(g_h * 255), int(b_h * 255)] st.image(hue_circle, width=200) with col2: st.subheader("Image Analysis") images = get_available_images(exclude_colorblind_tests=True) if images: selected_img = st.selectbox( "Select image:", images, index=images.index("lena.png") if "lena.png" in images else 0, key="hsv_img", ) img = load_image(selected_img) if img is not None: h, w = img.shape[:2] if max(h, w) > 400: scale = 400 / max(h, w) img = cv.resize(img, None, fx=scale, fy=scale) # Convert to HSV img_hsv = cv.cvtColor(img, cv.COLOR_RGB2HSV) col_orig, col_h, col_s, col_v = st.columns(4) with col_orig: st.image(img, caption="Original", use_column_width=True) with col_h: # Visualize hue as color h_channel = img_hsv[:, :, 0] h_vis = cv.applyColorMap( (h_channel * 2).astype(np.uint8), cv.COLORMAP_HSV ) h_vis = cv.cvtColor(h_vis, cv.COLOR_BGR2RGB) st.image(h_vis, caption="Hue", use_column_width=True) with col_s: s_channel = img_hsv[:, :, 1] s_vis = np.stack([s_channel] * 3, axis=-1) st.image(s_vis, caption="Saturation", use_column_width=True) with col_v: v_channel = img_hsv[:, :, 2] v_vis = np.stack([v_channel] * 3, axis=-1) st.image(v_vis, caption="Value", use_column_width=True) # Interactive manipulation st.markdown("**Adjust HSV Components**") hue_shift = st.slider("Shift Hue", -180, 180, 0, key="hue_shift") sat_scale = st.slider( "Saturation Scale", 0.0, 2.0, 1.0, key="sat_scale" ) val_scale = st.slider("Value Scale", 0.0, 2.0, 1.0, key="val_scale") # Apply transformations img_hsv_mod = img_hsv.copy().astype(np.float32) img_hsv_mod[:, :, 0] = (img_hsv_mod[:, :, 0] + hue_shift / 2) % 180 img_hsv_mod[:, :, 1] = np.clip(img_hsv_mod[:, :, 1] * sat_scale, 0, 255) img_hsv_mod[:, :, 2] = np.clip(img_hsv_mod[:, :, 2] * val_scale, 0, 255) img_hsv_mod = img_hsv_mod.astype(np.uint8) img_modified = cv.cvtColor(img_hsv_mod, cv.COLOR_HSV2RGB) col_before, col_after = st.columns(2) with col_before: st.image(img, caption="Original", use_column_width=True) with col_after: st.image(img_modified, caption="Modified", use_column_width=True) st.markdown("---") st.markdown( """ ### 💡 Key Insights - **Perceptually Intuitive**: Separates "what color" from "how much" and "how bright" - **Hue**: Circular (0° and 360° are both red) - **Saturation**: 0% = gray, 100% = pure color - **Applications**: Color-based segmentation, color correction, artistic effects - **HSI vs HSV**: HSI uses intensity (average), HSV uses value (max channel) """ ) # --- CIE-LAB Colorspace Tab --- def tab_lab(): """CIE-LAB perceptual colorspace exploration.""" st.header("🔬 CIE-LAB Colorspace") st.markdown( """ **CIE-LAB** is a **perceptually uniform** colorspace designed to approximate human vision. Equal distances in LAB space correspond to roughly equal perceived color differences. - **L**: Lightness (0-100) - Black to white - **a**: Green (-128) to Red (+127) - **b**: Blue (-128) to Yellow (+127) - **Standard**: CIE 1976 (L*a*b*) """ ) col1, col2 = st.columns([1, 2]) with col1: st.subheader("LAB Controls") L = st.slider("L* (Lightness)", 0, 100, 50, key="lab_l") a = st.slider("a* (Green ← → Red)", -128, 127, 0, key="lab_a") b = st.slider("b* (Blue ← → Yellow)", -128, 127, 0, key="lab_b") # Convert LAB to RGB for display lab_color = np.array([[[L, a, b]]], dtype=np.uint8) rgb_color = cv.cvtColor(lab_color, cv.COLOR_LAB2RGB) r, g, b_rgb = rgb_color[0, 0] color_rgb = np.ones((100, 100, 3), dtype=np.uint8) color_rgb[:, :] = [r, g, b_rgb] st.image(color_rgb, caption=f"LAB({L}, {a}, {b})", width=200) st.markdown( f""" **RGB**: ({r}, {g}, {b_rgb}) **Properties**: - Perceptually uniform - Device-independent - Used in color science """ ) with col2: st.subheader("Image Analysis & Adjustment") images = get_available_images(exclude_colorblind_tests=True) if images: selected_img = st.selectbox( "Select image:", images, index=images.index("lena.png") if "lena.png" in images else 0, key="lab_img", ) img = load_image(selected_img) if img is not None: h, w = img.shape[:2] if max(h, w) > 400: scale = 400 / max(h, w) img = cv.resize(img, None, fx=scale, fy=scale) # Convert to LAB img_lab = cv.cvtColor(img, cv.COLOR_RGB2LAB) col_orig, col_l, col_a, col_b = st.columns(4) with col_orig: st.image(img, caption="Original", use_column_width=True) with col_l: l_channel = img_lab[:, :, 0] l_vis = np.stack([l_channel] * 3, axis=-1) st.image(l_vis, caption="L* (Lightness)", use_column_width=True) with col_a: a_channel = img_lab[:, :, 1] # Normalize to 0-255 for display a_vis = np.stack([a_channel] * 3, axis=-1) st.image(a_vis, caption="a* (G→R)", use_column_width=True) with col_b: b_channel = img_lab[:, :, 2] b_vis = np.stack([b_channel] * 3, axis=-1) st.image(b_vis, caption="b* (B→Y)", use_column_width=True) # Interactive manipulation st.markdown("**Adjust LAB Components**") l_scale = st.slider("Lightness Scale", 0.0, 2.0, 1.0, key="lab_l_scale") a_scale = st.slider("a* Scale", 0.0, 2.0, 1.0, key="lab_a_scale") b_scale_val = st.slider( "b* Scale", 0.0, 2.0, 1.0, key="lab_b_scale_val" ) # Apply transformations img_lab_mod = img_lab.copy().astype(np.float32) img_lab_mod[:, :, 0] = np.clip(img_lab_mod[:, :, 0] * l_scale, 0, 255) img_lab_mod[:, :, 1] = np.clip( (img_lab_mod[:, :, 1] - 128) * a_scale + 128, 0, 255 ) img_lab_mod[:, :, 2] = np.clip( (img_lab_mod[:, :, 2] - 128) * b_scale_val + 128, 0, 255 ) img_lab_mod = img_lab_mod.astype(np.uint8) img_modified = cv.cvtColor(img_lab_mod, cv.COLOR_LAB2RGB) col_before, col_after = st.columns(2) with col_before: st.image(img, caption="Original", use_column_width=True) with col_after: st.image(img_modified, caption="Modified", use_column_width=True) # Color difference demonstration st.markdown("**Color Difference (ΔE)**") st.markdown( """ LAB enables calculating perceptual color difference. The Euclidean distance in LAB space approximates perceived difference: ΔE = √((L₁-L₂)² + (a₁-a₂)² + (b₁-b₂)²) - ΔE < 1: Not perceptible - ΔE 1-2: Perceptible with close observation - ΔE 2-10: Perceptible at a glance - ΔE > 10: Very different colors """ ) st.markdown("---") st.markdown( """ ### 💡 Key Insights - **Perceptually Uniform**: Equal distances = equal perceived differences - **Device Independent**: Based on human visual system, not display technology - **Applications**: Color matching, quality control, color difference calculation - **Opponent Colors**: a* (red-green), b* (blue-yellow) match human color perception - **Better than RGB**: For color comparison, color correction, and scientific analysis """ ) # --- CMYK Colorspace Tab --- def tab_cmyk(): """CMYK subtractive color model exploration.""" st.header("🖨️ CMYK Colorspace (Subtractive)") st.markdown( """ **CMYK** (Cyan, Magenta, Yellow, Key/Black) is a **subtractive** color model used in printing. Colors are created by absorbing (subtracting) light from white paper. - **C**: Cyan (absorbs red) - **M**: Magenta (absorbs green) - **Y**: Yellow (absorbs blue) - **K**: Key/Black (absorbs all light) """ ) col1, col2 = st.columns([1, 2]) with col1: st.subheader("CMYK Mixer") c = st.slider("💠 Cyan (%)", 0, 100, 0, key="cmyk_c") m = st.slider("💮 Magenta (%)", 0, 100, 0, key="cmyk_m") y = st.slider("💛 Yellow (%)", 0, 100, 0, key="cmyk_y") k = st.slider("⚫ Key/Black (%)", 0, 100, 0, key="cmyk_k") # Convert CMYK to RGB (simplified) # Formula: RGB = 255 * (1 - C/100) * (1 - K/100) c_norm, m_norm, y_norm, k_norm = c / 100, m / 100, y / 100, k / 100 r = int(255 * (1 - c_norm) * (1 - k_norm)) g = int(255 * (1 - m_norm) * (1 - k_norm)) b = int(255 * (1 - y_norm) * (1 - k_norm)) color_rgb = np.ones((100, 100, 3), dtype=np.uint8) color_rgb[:, :] = [r, g, b] st.image(color_rgb, caption=f"CMYK({c}, {m}, {y}, {k})", width=200) st.markdown( f""" **RGB Equivalent**: ({r}, {g}, {b}) **Note**: This is a simplified conversion. Real printing involves complex color profiles. """ ) st.markdown( """ ### Subtractive vs Additive **Subtractive (CMYK)**: - Used in printing - Starts with white paper - Inks absorb light - C+M+Y = Black (ideally) **Additive (RGB)**: - Used in displays - Starts with black screen - Light emits colors - R+G+B = White """ ) with col2: st.subheader("Image Analysis & Adjustment") images = get_available_images(exclude_colorblind_tests=True) if images: selected_img = st.selectbox( "Select image:", images, index=images.index("lena.png") if "lena.png" in images else 0, key="cmyk_img", ) img = load_image(selected_img) if img is not None: h, w = img.shape[:2] if max(h, w) > 400: scale = 400 / max(h, w) img = cv.resize(img, None, fx=scale, fy=scale) # Convert RGB to CMYK img_float = img.astype(np.float32) / 255.0 # Calculate K (black) K = 1 - np.max(img_float, axis=2) # Calculate CMY C = (1 - img_float[:, :, 0] - K) / (1 - K + 1e-10) M = (1 - img_float[:, :, 1] - K) / (1 - K + 1e-10) Y = (1 - img_float[:, :, 2] - K) / (1 - K + 1e-10) # Clip values C = np.clip(C, 0, 1) M = np.clip(M, 0, 1) Y = np.clip(Y, 0, 1) K = np.clip(K, 0, 1) # Display col_orig, col_c, col_m, col_y, col_k = st.columns(5) with col_orig: st.image(img, caption="Original (RGB)", use_column_width=True) with col_c: # Show cyan channel (inverted for visualization) c_vis = (1 - C) * 255 c_rgb = np.stack( [c_vis, np.ones_like(C) * 255, np.ones_like(C) * 255], axis=-1 ).astype(np.uint8) st.image(c_rgb, caption="Cyan Plate", use_column_width=True) with col_m: m_vis = (1 - M) * 255 m_rgb = np.stack( [np.ones_like(M) * 255, m_vis, np.ones_like(M) * 255], axis=-1 ).astype(np.uint8) st.image(m_rgb, caption="Magenta Plate", use_column_width=True) with col_y: y_vis = (1 - Y) * 255 y_rgb = np.stack( [np.ones_like(Y) * 255, np.ones_like(Y) * 255, y_vis], axis=-1 ).astype(np.uint8) st.image(y_rgb, caption="Yellow Plate", use_column_width=True) with col_k: k_vis = (1 - K) * 255 k_rgb = np.stack([k_vis] * 3, axis=-1).astype(np.uint8) st.image(k_rgb, caption="Black Plate", use_column_width=True) # Interactive manipulation st.markdown("**Adjust CMYK Components**") c_scale = st.slider("Cyan Scale", 0.0, 2.0, 1.0, key="cmyk_c_scale") m_scale = st.slider("Magenta Scale", 0.0, 2.0, 1.0, key="cmyk_m_scale") y_scale = st.slider("Yellow Scale", 0.0, 2.0, 1.0, key="cmyk_y_scale") k_scale = st.slider("Key Scale", 0.0, 2.0, 1.0, key="cmyk_k_scale") # Apply transformations C_mod = np.clip(C * c_scale, 0, 1) M_mod = np.clip(M * m_scale, 0, 1) Y_mod = np.clip(Y * y_scale, 0, 1) K_mod = np.clip(K * k_scale, 0, 1) # Convert back to RGB img_modified = np.zeros_like(img, dtype=np.float32) img_modified[:, :, 0] = 255 * (1 - C_mod) * (1 - K_mod) img_modified[:, :, 1] = 255 * (1 - M_mod) * (1 - K_mod) img_modified[:, :, 2] = 255 * (1 - Y_mod) * (1 - K_mod) img_modified = np.clip(img_modified, 0, 255).astype(np.uint8) col_before, col_after = st.columns(2) with col_before: st.image(img, caption="Original", use_column_width=True) with col_after: st.image(img_modified, caption="Modified", use_column_width=True) # Show separation plates st.markdown("**Individual Separation Plates (Grayscale)**") col_c2, col_m2, col_y2, col_k2 = st.columns(4) with col_c2: c_gray = (C * 255).astype(np.uint8) c_gray_vis = np.stack([c_gray] * 3, axis=-1) st.image(c_gray_vis, caption="C values", use_column_width=True) with col_m2: m_gray = (M * 255).astype(np.uint8) m_gray_vis = np.stack([m_gray] * 3, axis=-1) st.image(m_gray_vis, caption="M values", use_column_width=True) with col_y2: y_gray = (Y * 255).astype(np.uint8) y_gray_vis = np.stack([y_gray] * 3, axis=-1) st.image(y_gray_vis, caption="Y values", use_column_width=True) with col_k2: k_gray = (K * 255).astype(np.uint8) k_gray_vis = np.stack([k_gray] * 3, axis=-1) st.image(k_gray_vis, caption="K values", use_column_width=True) st.markdown("---") st.markdown( """ ### 💡 Key Insights - **Subtractive Model**: Inks absorb light, not emit it - **Why K (Black)?**: CMY mix creates muddy brown, not pure black. Saves ink too! - **Printing Process**: Four-color process (4-color printing) - **Color Gamut**: CMYK has smaller gamut than RGB (some RGB colors can't be printed) - **Screen vs Print**: What you see (RGB) ≠ What you get (CMYK) """ ) # --- YCbCr Colorspace Tab --- def tab_ycbcr(): """YCbCr compression-oriented colorspace exploration.""" st.header("📼 YCbCr Colorspace (Compression)") st.markdown( """ **YCbCr** is used in JPEG compression and video encoding. It separates luminance (Y) from chrominance (Cb, Cr), exploiting human visual system's lower sensitivity to color detail. - **Y**: Luma (brightness/luminance) - 0 to 255 - **Cb**: Blue-difference chroma - -128 to 127 - **Cr**: Red-difference chroma - -128 to 127 """ ) col1, col2 = st.columns([1, 2]) with col1: st.subheader("Why YCbCr?") st.markdown( """ **Human Visual System**: - Very sensitive to brightness (luminance) - Less sensitive to color (chrominance) **Compression Strategy**: - Keep full resolution Y channel - Subsample Cb, Cr channels - Typical: 4:2:0 subsampling - Saves 50% data with minimal perceptual loss """ ) st.markdown("**Common Subsampling Schemes**") subsampling = st.radio( "Select subsampling:", ["4:4:4 (No subsampling)", "4:2:2 (Horizontal 2x)", "4:2:0 (Both 2x)"], key="ycbcr_subsample", ) st.markdown( f""" **{subsampling}**: - **4:4:4**: Full color resolution (no compression) - **4:2:2**: Half horizontal color resolution (common in video) - **4:2:0**: Half resolution in both dimensions (JPEG, H.264) """ ) with col2: st.subheader("Channel Analysis & Subsampling") images = get_available_images(exclude_colorblind_tests=True) if images: selected_img = st.selectbox( "Select image:", images, index=images.index("cameraman.png") if "cameraman.png" in images else 0, key="ycbcr_img", ) img = load_image(selected_img) if img is not None: h_orig, w_orig = img.shape[:2] if max(h_orig, w_orig) > 400: # Calculate scale and target dimensions scale = 400 / max(h_orig, w_orig) h_target = int(h_orig * scale) w_target = int(w_orig * scale) img = cv.resize(img, (w_target, h_target)) # Get actual dimensions after resizing h, w = img.shape[:2] # Convert to YCrCb (OpenCV's version) img_ycrcb = cv.cvtColor(img, cv.COLOR_RGB2YCrCb) # Show channels col_orig, col_y, col_cb, col_cr = st.columns(4) with col_orig: st.image(img, caption="Original", use_column_width=True) with col_y: y_channel = img_ycrcb[:, :, 0] y_vis = np.stack([y_channel] * 3, axis=-1) st.image(y_vis, caption="Y (Luma)", use_column_width=True) with col_cr: cr_channel = img_ycrcb[:, :, 1] cr_vis = np.stack([cr_channel] * 3, axis=-1) st.image(cr_vis, caption="Cr (Red-diff)", use_column_width=True) with col_cb: cb_channel = img_ycrcb[:, :, 2] cb_vis = np.stack([cb_channel] * 3, axis=-1) st.image(cb_vis, caption="Cb (Blue-diff)", use_column_width=True) # Demonstrate subsampling st.markdown("**Chroma Subsampling Effects**") # Simulate subsampling img_ycrcb_sub = img_ycrcb.copy() if "4:2:2" in subsampling: # Horizontal 2x subsampling cb_sub = img_ycrcb[:, ::2, 2] cr_sub = img_ycrcb[:, ::2, 1] # Upsample back to full width (keep full height) cb_sub = cv.resize(cb_sub, (w, h), interpolation=cv.INTER_NEAREST) cr_sub = cv.resize(cr_sub, (w, h), interpolation=cv.INTER_NEAREST) img_ycrcb_sub[:, :, 1] = cr_sub img_ycrcb_sub[:, :, 2] = cb_sub elif "4:2:0" in subsampling: # 2x2 subsampling (both horizontal and vertical) cb_sub = img_ycrcb[::2, ::2, 2] cr_sub = img_ycrcb[::2, ::2, 1] # Upsample back to full resolution cb_sub = cv.resize(cb_sub, (w, h), interpolation=cv.INTER_NEAREST) cr_sub = cv.resize(cr_sub, (w, h), interpolation=cv.INTER_NEAREST) img_ycrcb_sub[:, :, 1] = cr_sub img_ycrcb_sub[:, :, 2] = cb_sub img_reconstructed = cv.cvtColor(img_ycrcb_sub, cv.COLOR_YCrCb2RGB) col_before, col_after = st.columns(2) with col_before: st.image(img, caption="Original (4:4:4)", use_column_width=True) st.caption(f"Size: {h*w*3} bytes") with col_after: st.image( img_reconstructed, caption=f"Subsampled ({subsampling})", use_column_width=True, ) if "4:2:2" in subsampling: size_factor = 2 / 3 elif "4:2:0" in subsampling: size_factor = 0.5 else: size_factor = 1.0 st.caption( f"Size: {int(h*w*3*size_factor)} bytes ({size_factor*100:.0f}%)" ) st.markdown("---") st.markdown( """ ### 💡 Key Insights - **Luminance-Chrominance Separation**: Matches human visual perception - **Compression Efficiency**: Subsample chroma without visible quality loss - **JPEG Standard**: Uses YCbCr with 4:2:0 subsampling + DCT compression - **Video Codecs**: H.264, H.265 all use YCbCr - **Bandwidth Savings**: 4:2:0 saves 50% bandwidth compared to 4:4:4 - **Trade-off**: Some color detail lost, but usually imperceptible """ ) # --- Gamma Correction & White Balance Tab --- def tab_gamma(): """Gamma correction exploration.""" st.header("⚙️ Gamma Correction") st.markdown( """ **Gamma correction** compensates for non-linear relationship between pixel values and displayed brightness. Displays are not linear! - **Gamma < 1**: Brightens midtones (gamma expansion) - **Gamma = 1**: Linear (no correction) - **Gamma > 1**: Darkens midtones (gamma compression) - **Standard gamma**: 2.2 (sRGB), 2.4 (Rec. 709) """ ) col1, col2 = st.columns([1, 2]) with col1: gamma = st.slider("Gamma value", 0.1, 3.0, 1.0, 0.1, key="gamma_val") # Show gamma curve st.markdown("**Gamma Curve**") x = np.linspace(0, 1, 100) y = x**gamma fig, ax = plt.subplots(figsize=(5, 4)) ax.plot(x, y, "b-", linewidth=2, label=f"γ = {gamma}") ax.plot(x, x, "k--", linewidth=1, label="γ = 1 (linear)") ax.set_xlabel("Input") ax.set_ylabel("Output") ax.set_title("Gamma Curve") ax.legend() ax.grid(True, alpha=0.3) ax.set_xlim(0, 1) ax.set_ylim(0, 1) st.pyplot(fig) plt.close() if gamma < 1: st.info("**Gamma < 1**: Brightens image, reveals shadow detail") elif gamma > 1: st.info("**Gamma > 1**: Darkens image, enhances contrast") else: st.info("**Gamma = 1**: Linear, no correction") with col2: images = get_available_images(exclude_colorblind_tests=True) if images: selected_img = st.selectbox("Select image:", images, key="gamma_img") img = load_image(selected_img) if img is not None: h, w = img.shape[:2] if max(h, w) > 500: scale = 500 / max(h, w) img = cv.resize(img, None, fx=scale, fy=scale) # Apply gamma correction img_norm = img.astype(np.float32) / 255.0 img_gamma = np.power(img_norm, gamma) img_gamma = (img_gamma * 255).clip(0, 255).astype(np.uint8) col_before, col_after = st.columns(2) with col_before: st.image(img, caption="Original", use_column_width=True) with col_after: st.image( img_gamma, caption=f"Gamma = {gamma}", use_column_width=True, ) # Show histogram st.markdown("**Intensity Histograms**") fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(10, 3)) # Original histogram img_gray = cv.cvtColor(img, cv.COLOR_RGB2GRAY) ax1.hist( img_gray.ravel(), bins=256, range=(0, 256), color="gray", alpha=0.7, ) ax1.set_title("Original") ax1.set_xlabel("Intensity") ax1.set_ylabel("Frequency") ax1.set_xlim(0, 255) # Gamma corrected histogram img_gamma_gray = cv.cvtColor(img_gamma, cv.COLOR_RGB2GRAY) ax2.hist( img_gamma_gray.ravel(), bins=256, range=(0, 256), color="gray", alpha=0.7, ) ax2.set_title(f"Gamma = {gamma}") ax2.set_xlabel("Intensity") ax2.set_ylabel("Frequency") ax2.set_xlim(0, 255) plt.tight_layout() st.pyplot(fig) plt.close() st.markdown("---") st.markdown( """ ### 💡 Key Insights - Compensates for non-linear display response - Standard sRGB: γ ≈ 2.2 - Brightens midtones while preserving black and white - Essential for consistent appearance across devices """ ) def tab_white_balance(): """White balance exploration.""" st.header("⚪ White Balance") st.markdown( """ **White balance** corrects color casts caused by different lighting conditions. The goal is to make white objects appear white regardless of the light source. - **Daylight**: ~6500K (neutral) - **Incandescent**: ~3000K (warm/orange) - **Fluorescent**: ~4000K (cool/blue) - **Shade**: ~7500K (very blue) """ ) images = get_available_images(exclude_colorblind_tests=True) if images: selected_img = st.selectbox( "Select image:", images, index=images.index("lena.png") if "lena.png" in images else 0, key="wb_img", ) img = load_image(selected_img) if img is not None: h, w = img.shape[:2] if max(h, w) > 500: scale = 500 / max(h, w) img = cv.resize(img, None, fx=scale, fy=scale) col1, col2 = st.columns([1, 2]) with col1: st.subheader("Manual Adjustment") temp_preset = st.selectbox( "Temperature Preset:", [ "Custom", "Daylight (6500K)", "Incandescent (3000K)", "Fluorescent (4000K)", "Shade (7500K)", ], ) if temp_preset == "Incandescent (3000K)": r_scale, g_scale, b_scale = 1.0, 0.7, 0.5 elif temp_preset == "Fluorescent (4000K)": r_scale, g_scale, b_scale = 0.9, 1.0, 1.1 elif temp_preset == "Shade (7500K)": r_scale, g_scale, b_scale = 0.8, 0.9, 1.2 elif temp_preset == "Daylight (6500K)": r_scale, g_scale, b_scale = 1.0, 1.0, 1.0 else: r_scale = st.slider("Red channel", 0.5, 1.5, 1.0, 0.05, key="wb_r") g_scale = st.slider( "Green channel", 0.5, 1.5, 1.0, 0.05, key="wb_g" ) b_scale = st.slider("Blue channel", 0.5, 1.5, 1.0, 0.05, key="wb_b") # Gray World assumption if st.button("Auto White Balance (Gray World)"): r_mean = img[:, :, 0].mean() g_mean = img[:, :, 1].mean() b_mean = img[:, :, 2].mean() avg_mean = (r_mean + g_mean + b_mean) / 3 r_scale = avg_mean / r_mean g_scale = avg_mean / g_mean b_scale = avg_mean / b_mean st.success( f"Auto WB: R={r_scale:.2f}, G={g_scale:.2f}, B={b_scale:.2f}" ) st.markdown( f""" **Current Adjustment**: - Red: ×{r_scale:.2f} - Green: ×{g_scale:.2f} - Blue: ×{b_scale:.2f} """ ) with col2: # Apply white balance img_wb = img.astype(np.float32) img_wb[:, :, 0] = img_wb[:, :, 0] * r_scale img_wb[:, :, 1] = img_wb[:, :, 1] * g_scale img_wb[:, :, 2] = img_wb[:, :, 2] * b_scale img_wb = np.clip(img_wb, 0, 255).astype(np.uint8) col_before, col_after = st.columns(2) with col_before: st.image(img, caption="Original", use_column_width=True) with col_after: st.image(img_wb, caption="White Balanced", use_column_width=True) # Show RGB histograms st.markdown("**RGB Channel Histograms**") fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(10, 3)) colors = ["red", "green", "blue"] for i, color in enumerate(colors): ax1.hist( img[:, :, i].ravel(), bins=256, range=(0, 256), color=color, alpha=0.5, label=color.upper(), ) ax2.hist( img_wb[:, :, i].ravel(), bins=256, range=(0, 256), color=color, alpha=0.5, label=color.upper(), ) ax1.set_title("Original") ax1.set_xlabel("Intensity") ax1.set_ylabel("Frequency") ax1.legend() ax1.set_xlim(0, 255) ax2.set_title("White Balanced") ax2.set_xlabel("Intensity") ax2.set_ylabel("Frequency") ax2.legend() ax2.set_xlim(0, 255) plt.tight_layout() st.pyplot(fig) plt.close() st.markdown("---") st.markdown( """ ### 💡 Key Insights - Corrects color temperature of light source - Human vision adapts automatically; cameras need adjustment - **Gray World Assumption**: Average scene color should be gray - **Applications**: Photography, video production, color consistency """ ) # --- Color Blindness Simulation Tab --- def tab_colorblind(): """Color blindness simulation.""" st.header("👁️ Color Blindness Simulation") st.markdown( """ **Color vision deficiency** (color blindness) affects ~8% of men and ~0.5% of women. Understanding how color-blind users perceive images is crucial for accessible design. - **Protanopia**: Missing L (red) cones (~1% of men) - **Deuteranopia**: Missing M (green) cones (~1% of men) - **Tritanopia**: Missing S (blue) cones (~0.001%) - **Monochromacy**: No color vision (very rare) """ ) col1, col2 = st.columns([1, 3]) with col1: st.subheader("Simulation Type") cb_type = st.radio( "Color vision deficiency:", [ "Normal Vision", "Protanopia (No Red)", "Deuteranopia (No Green)", "Tritanopia (No Blue)", "Monochromacy (Grayscale)", ], key="cb_type", ) st.markdown( """ ### Cone Types Human vision has three cone types: - **L-cones**: Long wavelength (red) - **M-cones**: Medium wavelength (green) - **S-cones**: Short wavelength (blue) Color blindness results from missing or defective cones. """ ) # Show colorblind test numbers st.markdown("**Ishihara Test**") st.markdown("Try the colorblind test images in the images/ folder!") with col2: # Select image - use appropriate functions st.subheader("Image Selection") img_category = st.radio( "Image type:", ["Color Blindness Tests", "Regular Images"], horizontal=True ) if img_category == "Color Blindness Tests": colorblind_tests = get_colorblind_test_images() if colorblind_tests: selected_img = st.selectbox( "Select test image:", colorblind_tests, key="cb_test_img" ) else: selected_img = None else: regular_images = get_available_images(exclude_colorblind_tests=True) if regular_images: selected_img = st.selectbox( "Select image:", regular_images, index=( regular_images.index("lena.png") if "lena.png" in regular_images else 0 ), key="cb_regular_img", ) else: selected_img = None img = load_image(selected_img) if img is not None: h, w = img.shape[:2] if max(h, w) > 600: scale = 600 / max(h, w) img = cv.resize(img, None, fx=scale, fy=scale) # Simulate color blindness img_cb = simulate_colorblindness(img, cb_type) col_before, col_after = st.columns(2) with col_before: st.image(img, caption="Normal Vision", use_column_width=True) with col_after: st.image(img_cb, caption=cb_type, use_column_width=True) # Show difference st.markdown("**Difference Map** (What's lost in color-blind vision)") diff = cv.absdiff(img, img_cb) diff = cv.applyColorMap( (diff.mean(axis=2) * 3).astype(np.uint8), cv.COLORMAP_JET ) diff = cv.cvtColor(diff, cv.COLOR_BGR2RGB) st.image(diff, caption="Difference Heatmap", use_column_width=True) st.markdown("---") st.markdown( """ ### 💡 Design Guidelines for Accessibility 1. **Don't rely solely on color**: Use text labels, patterns, or shapes 2. **Sufficient contrast**: Ensure high contrast between foreground/background 3. **Test your designs**: Use simulators to check accessibility 4. **Common problematic pairs**: - Red/Green (most common issue) - Blue/Purple - Light Green/Yellow 5. **Safe color combinations**: - Blue/Orange - Blue/Yellow - Black/White (always safe) ### Statistics - **Protanopia + Deuteranopia**: ~8% of men, ~0.5% of women (red-green colorblind) - **Tritanopia**: Very rare (~0.001%) - **Total affected**: ~300 million people worldwide """ ) def simulate_colorblindness(img, cb_type): """ Simulate various types of color blindness using standard transformation matrices. Based on http://www.daltonize.org/ and research by Brettel, Viénot, and Mollon. """ if cb_type == "Normal Vision": return img # Convert to float img_float = img.astype(np.float32) / 255.0 # Reshape for matrix multiplication pixels = img_float.reshape(-1, 3) if cb_type == "Protanopia (No Red)": # Missing L-cones (red) transform = np.array( [ [0.56667, 0.43333, 0.00000], [0.55833, 0.44167, 0.00000], [0.00000, 0.24167, 0.75833], ] ) elif cb_type == "Deuteranopia (No Green)": # Missing M-cones (green) transform = np.array([[0.625, 0.375, 0.0], [0.7, 0.3, 0.0], [0.0, 0.3, 0.7]]) elif cb_type == "Tritanopia (No Blue)": # Missing S-cones (blue) transform = np.array( [[0.95, 0.05, 0.0], [0.0, 0.43333, 0.56667], [0.0, 0.475, 0.525]] ) elif cb_type == "Monochromacy (Grayscale)": # No color vision transform = np.array( [[0.299, 0.587, 0.114], [0.299, 0.587, 0.114], [0.299, 0.587, 0.114]] ) else: return img # Apply transformation pixels_cb = pixels @ transform.T # Clip and convert back pixels_cb = np.clip(pixels_cb, 0, 1) img_cb = (pixels_cb.reshape(img_float.shape) * 255).astype(np.uint8) return img_cb # --- Main App --- def main(): """Main application.""" st.set_page_config(layout="wide", page_title="Colorspace Explorer", page_icon="🎨") st.title("🎨 Interactive Colorspace Exploration") st.markdown( """ Welcome to the **Colorspace Explorer**! This educational tool helps you understand different colorspaces and color models used in image processing and computer vision. Use the tabs below to explore different topics. """ ) # Create tabs tabs = st.tabs( [ "RGB", "HSV/HSI", "CIE-LAB", "CMYK", "YCbCr", "Gamma Correction", "White Balance", "Color Blindness", ] ) with tabs[0]: tab_rgb() with tabs[1]: tab_hsv() with tabs[2]: tab_lab() with tabs[3]: tab_cmyk() with tabs[4]: tab_ycbcr() with tabs[5]: tab_gamma() with tabs[6]: tab_white_balance() with tabs[7]: tab_colorblind() # Footer st.markdown("---") st.markdown( """ Educational Demo for Image Analysis Courses | Built with Streamlit | © 2026 | View Source """, unsafe_allow_html=True, ) if __name__ == "__main__": main()