import os import tempfile from pathlib import Path import gradio as gr import cv2 import numpy as np from PIL import Image, ImageCms # ========================================================= # Color profile assets (Gradio Space safe) # ========================================================= BASE_DIR = Path(__file__).resolve().parent ICC_DIR = BASE_DIR / "icc" ADOBE_ICC_PATH = ICC_DIR / "AdobeRGB1998.icc" # sRGB profile is always available via Pillow (no file needed) SRGB_PROFILE = ImageCms.createProfile("sRGB") SRGB_ICC_BYTES = ImageCms.ImageCmsProfile(SRGB_PROFILE).tobytes() def safe_open_profile(path: Path): """ Safely open ICC profile. AdobeRGB1998 can legitimately be small (e.g., ~560 bytes), so do NOT size-gate. """ try: if not path.exists(): print(f"⚠️ ICC missing: {path}") return None if not path.is_file(): print(f"⚠️ ICC is not a file: {path}") return None size = path.stat().st_size print(f"ℹ️ ICC found: {path} ({size} bytes)") prof = ImageCms.getOpenProfile(str(path)) return prof except Exception as e: print(f"⚠️ ICC open failed: {path} → {e}") return None ADOBE_PROFILE = safe_open_profile(ADOBE_ICC_PATH) def convert_and_tag(img: Image.Image, target_color_space: str): """ Converts pixel values to the selected target profile AND returns ICC bytes for embedding into the exported file. Incoming images are treated as sRGB by default (practical for AI/web images). """ if img.mode != "RGB": img = img.convert("RGB") if target_color_space == "sRGB (Stock Standard)": return img, SRGB_ICC_BYTES if target_color_space == "Adobe RGB (1998)": if ADOBE_PROFILE is None: print("⚠️ AdobeRGB requested but profile not loaded; falling back to sRGB tagging.") return img, SRGB_ICC_BYTES try: xform = ImageCms.buildTransformFromOpenProfiles( SRGB_PROFILE, ADOBE_PROFILE, "RGB", "RGB", renderingIntent=0 # perceptual ) out = ImageCms.applyTransform(img, xform) return out, ADOBE_PROFILE.tobytes() except Exception as e: print(f"⚠️ AdobeRGB transform failed; falling back to sRGB. Error: {e}") return img, SRGB_ICC_BYTES return img, SRGB_ICC_BYTES # ========================================================= # 1) THE PROCESSING ENGINE # ========================================================= def process_image(image, output_format, color_space, fix_lighting, add_grain): if image is None: return None, None # Convert PIL -> numpy img_array = np.array(image) # Stock safety: drop alpha if present if img_array.ndim == 3 and img_array.shape[2] == 4: img_array = cv2.cvtColor(img_array, cv2.COLOR_RGBA2RGB) # RGB -> BGR for OpenCV img_bgr = cv2.cvtColor(img_array, cv2.COLOR_RGB2BGR) # A) LIGHTING FIX (CLAHE) - gentle if fix_lighting: lab = cv2.cvtColor(img_bgr, cv2.COLOR_BGR2LAB) l, a, b = cv2.split(lab) clahe = cv2.createCLAHE(clipLimit=1.5, tileGridSize=(8, 8)) l_fixed = clahe.apply(l) lab_fixed = cv2.merge((l_fixed, a, b)) img_bgr = cv2.cvtColor(lab_fixed, cv2.COLOR_LAB2BGR) # B) TEXTURE FIX (Monochromatic Film Grain) if add_grain: h, w = img_bgr.shape[:2] # IMPORTANT: int16 prevents negative noise from wrapping under uint8 noise = np.random.normal(loc=0.0, scale=2.5, size=(h, w)).astype(np.int16) img_i16 = img_bgr.astype(np.int16) img_i16[:, :, 0] += noise img_i16[:, :, 1] += noise img_i16[:, :, 2] += noise img_bgr = np.clip(img_i16, 0, 255).astype(np.uint8) # Convert back to PIL RGB img_rgb = cv2.cvtColor(img_bgr, cv2.COLOR_BGR2RGB) final_pil = Image.fromarray(img_rgb).convert("RGB") # C) COLOR SPACE: convert + embed ICC final_pil, icc_bytes = convert_and_tag(final_pil, color_space) # D) EXPORT: create a real file for download suffix = ".jpg" if output_format == "JPEG" else ".png" tmp = tempfile.NamedTemporaryFile(delete=False, suffix=suffix) tmp_path = tmp.name tmp.close() if output_format == "JPEG": final_pil.save( tmp_path, format="JPEG", quality=100, subsampling=0, optimize=True, icc_profile=icc_bytes, ) else: final_pil.save( tmp_path, format="PNG", compress_level=6, optimize=True, icc_profile=icc_bytes, ) return final_pil, tmp_path # ========================================================= # 2) THE UI (Gradio) # ========================================================= css = """ #run-btn {background-color: #ff7c00 !important; color: white !important;} """ with gr.Blocks(title="StockFix AI", css=css) as app: gr.Markdown("## StockFix AI: De-Plasticizer") # Friendly status line if ADOBE_PROFILE is None: gr.Markdown( "⚠️ **Adobe RGB (1998) ICC not loaded.** " "Upload `icc/AdobeRGB1998.icc` to enable true Adobe RGB exports." ) else: gr.Markdown("✅ Adobe RGB (1998) profile loaded.") with gr.Row(): with gr.Column(): input_img = gr.Image(type="pil", label="Input AI Image") with gr.Group(): gr.Markdown("### 1. Fixes") chk_light = gr.Checkbox(label="Fix Flat Lighting (CLAHE)", value=True) chk_grain = gr.Checkbox(label="Add Film Grain (Monochromatic)", value=True) with gr.Group(): gr.Markdown("### 2. Color Profile") radio_color = gr.Radio( ["sRGB (Stock Standard)", "Adobe RGB (1998)"], label="Target Color Space", value="sRGB (Stock Standard)", ) with gr.Group(): gr.Markdown("### 3. Format") radio_fmt = gr.Radio(["JPEG", "PNG"], label="Output Format", value="JPEG") btn_run = gr.Button("Fix & Export", elem_id="run-btn") with gr.Column(): output_img = gr.Image(label="Preview", type="pil") output_file = gr.File(label="Download (PNG/JPEG)") btn_run.click( fn=process_image, inputs=[input_img, radio_fmt, radio_color, chk_light, chk_grain], outputs=[output_img, output_file], ) if __name__ == "__main__": app.launch()