Spaces:
Runtime error
Runtime error
| """ | |
| 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 --- | |
| 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( | |
| """ | |
| <small> | |
| Educational Demo for Image Analysis Courses | | |
| Built with Streamlit | | |
| © 2026 | | |
| <a href="https://github.com/ubern-image-analysis/colorspaces" target="_blank">View Source</a> | |
| </small> | |
| """, | |
| unsafe_allow_html=True, | |
| ) | |
| if __name__ == "__main__": | |
| main() | |