Spaces:
Running on Zero
Running on Zero
| #!/usr/bin/env python3 | |
| # pyrefly: ignore [missing-import] | |
| import cv2 | |
| # pyrefly: ignore [missing-import] | |
| import numpy as np | |
| import argparse | |
| from pathlib import Path | |
| # Settings for the pipeline | |
| PRESETS = { | |
| "default": { | |
| "clahe_clip": 2.0, | |
| "clahe_grid": 8, | |
| "denoise_h": 6, | |
| "denoise_method": "nlm", # "gaussian" | "nlm" | |
| "unsharp_amount": 0.6, | |
| "unsharp_radius": 1.5, | |
| "gamma": 1.05, | |
| }, | |
| "aggressive": { | |
| "clahe_clip": 3.5, | |
| "clahe_grid": 8, | |
| "denoise_h": 10, | |
| "denoise_method": "nlm", | |
| "unsharp_amount": 1.0, | |
| "unsharp_radius": 2.0, | |
| "gamma": 0.90, | |
| }, | |
| "gentle": { | |
| "clahe_clip": 1.5, | |
| "clahe_grid": 16, | |
| "denoise_h": 3, | |
| "denoise_method": "nlm", | |
| "unsharp_amount": 0.3, | |
| "unsharp_radius": 1.0, | |
| "gamma": 1.0, | |
| }, | |
| "fast": { | |
| "clahe_clip": 2.0, | |
| "clahe_grid": 8, | |
| "denoise_h": 15, | |
| "denoise_method": "bilateral", | |
| "unsharp_amount": 0.6, | |
| "unsharp_radius": 1.5, | |
| "gamma": 1.05, | |
| } | |
| } | |
| # Pipeline steps | |
| # Constrast Adaptive Histogram Equalization (CLAHE) on L channel of LAB space | |
| def apply_clahe(img: np.ndarray, | |
| clip: float = 2.0, | |
| grid: int = 8) -> np.ndarray: | |
| """ | |
| CLAHE on L channel of LAB space. | |
| """ | |
| # My YOLO model was trained on BGR images, so convert to LAB space ( Rock Color from Ancient Temples ) | |
| lab = cv2.cvtColor(img, cv2.COLOR_BGR2LAB) | |
| l, a, b = cv2.split(lab) | |
| # Convert to LAB, apply CLAHE on L channel, then convert back to BGR | |
| clahe = cv2.createCLAHE(clipLimit=clip, | |
| tileGridSize=(grid, grid)) | |
| l_clahe = clahe.apply(l) | |
| merged = cv2.merge([l_clahe, a, b]) | |
| return cv2.cvtColor(merged, cv2.COLOR_LAB2BGR) | |
| def apply_denoising(img: np.ndarray, | |
| h: float = 6, | |
| method: str = "nlm" # gaussian | nlm | |
| ) -> np.ndarray: | |
| """ | |
| Non-local means denoising — keep borders better than GaussianBlur. | |
| h: strength of the filter. >10 erases fine details of the sign. | |
| """ | |
| if h <= 0: | |
| return img | |
| if method == "nlm": | |
| # Non-local means denoising — keep borders better than GaussianBlur. | |
| return cv2.fastNlMeansDenoisingColored( | |
| img, | |
| None, | |
| h=h, | |
| hColor=h, | |
| templateWindowSize=7, # search window size for similar patches px | |
| searchWindowSize=21 | |
| ) | |
| elif method == "gaussian": | |
| # Gaussian denoising — keep borders better than bilateral filter. | |
| return cv2.fastNlMeansDenoisingColored( | |
| img, | |
| None, | |
| h=h, | |
| hColor=h, | |
| templateWindowSize=7, | |
| searchWindowSize=21, | |
| sigmaColor=0, | |
| sigmaSpace=0 | |
| ) | |
| elif method == "bilateral": | |
| # Bilateral denoising — keep borders better than GaussianBlur. | |
| return cv2.bilateralFilter(img, d=9, sigmaColor=h, sigmaSpace=h) | |
| else: | |
| raise ValueError(f"Invalid denoising method: {method}") | |
| def apply_unsharp_mask(img: np.ndarray, # input image | |
| amount: float = 0.6, # 0.0 = no effect, 1.5 = very aggressive | |
| radius: float = 1.5 # use a larger radius for more aggressive effect | |
| ) -> np.ndarray: | |
| """ | |
| Unsharp mask — highlights the edges of the carving in stone. | |
| amount: 0.0 = no effect, 1.5 = very aggressive. | |
| Equation: sharpened = original + amount * (original - blurred) | |
| """ | |
| if amount <= 0: | |
| return img | |
| blurred = cv2.GaussianBlur(img, (0, 0), radius) | |
| sharpened = cv2.addWeighted(img, 1 + amount, blurred, -amount, 0) | |
| return sharpened | |
| def apply_gamma(img: np.ndarray, gamma: float = 1.05) -> np.ndarray: | |
| """ | |
| Gamma correction by lookup table — fast and without artifacts. | |
| gamma < 1.0 → brightens (useful for dark photos of tombs) | |
| gamma > 1.0 → darkens slightly (increases visual contrast) | |
| gamma = 1.0 → no change | |
| """ | |
| if gamma == 1.0: | |
| return img | |
| inv_gamma = 1.0 / gamma | |
| table = np.array([ | |
| ((i / 255.0) ** inv_gamma) * 255 | |
| for i in range(256) | |
| ], dtype=np.uint8) | |
| return cv2.LUT(img, table) | |
| # Main pipeline | |
| def enhance(img: np.ndarray, | |
| preset: str = "default", | |
| custom: dict | None = None) -> np.ndarray: | |
| """ | |
| Complete image enhancement pipeline. | |
| Args: | |
| img: imagen BGR (numpy array) — output de cv2.imread() | |
| preset: 'default' | 'aggressive' | 'gentle' | 'fast' | |
| custom: dict with custom parameters (overrides preset) | |
| Returns: | |
| enhanced BGR image (same size as input) | |
| """ | |
| if preset not in PRESETS: | |
| raise ValueError( | |
| f"Unknown preset: {preset!r}. " | |
| f"Choose from {sorted(PRESETS.keys())}." | |
| ) | |
| cfg = PRESETS.get(preset, PRESETS["default"]).copy() | |
| if custom: | |
| cfg.update(custom) | |
| # Step 1 — CLAHE | |
| out = apply_clahe(img, | |
| clip=cfg["clahe_clip"], | |
| grid=cfg["clahe_grid"]) | |
| # Step 2 — Denoising | |
| out = apply_denoising(out, | |
| h=cfg["denoise_h"], | |
| method=cfg["denoise_method"]) | |
| # Step 3 — Unsharp mask | |
| out = apply_unsharp_mask(out, | |
| amount=cfg["unsharp_amount"], | |
| radius=cfg["unsharp_radius"]) | |
| # Step 4 — Gamma | |
| out = apply_gamma(out, gamma=cfg["gamma"]) | |
| return out | |
| def compare_side_by_side(original: np.ndarray, | |
| enhanced: np.ndarray, | |
| label: str = "") -> np.ndarray: | |
| """Generate side-by-side comparison image original | enhanced for inspection.""" | |
| h = max(original.shape[0], enhanced.shape[0]) | |
| def pad_h(img, target_h): | |
| ph = target_h - img.shape[0] | |
| if ph > 0: | |
| return np.vstack([img, np.zeros((ph, img.shape[1], 3), | |
| dtype=np.uint8)]) | |
| return img | |
| orig_pad = pad_h(original, h) | |
| enh_pad = pad_h(enhanced, h) | |
| divider = np.full((h, 3, 3), 100, dtype=np.uint8) | |
| combined = np.hstack([orig_pad, divider, enh_pad]) | |
| # Labels | |
| cv2.putText(combined, "ORIGINAL", (10, 22), | |
| cv2.FONT_HERSHEY_SIMPLEX, 0.6, (200, 200, 200), 1) | |
| cv2.putText(combined, f"ENHANCED ({label})", | |
| (orig_pad.shape[1] + 13, 22), | |
| cv2.FONT_HERSHEY_SIMPLEX, 0.6, (100, 220, 100), 1) | |
| return combined | |
| # This script file will turn out to be app/utils/enhance_img.py | |
| # when this bakcend app is built in FastAPI | |
| # for now it is a standalone script to test the enhancement pipeline | |
| # It will become the dritical step 0 . after user upload an image from frontend | |
| # and before the image is sent to the model for inference | |
| # Entry point | |
| def main(): | |
| parser = argparse.ArgumentParser( | |
| description="SphinxEyes — Image preprocessor for inference" | |
| ) | |
| parser.add_argument("input", help="Input image") | |
| parser.add_argument("--output", type=str, default=None, | |
| help="Output path (default: <name>_enhanced.jpg)") | |
| parser.add_argument("--preset", type=str, default="default", | |
| choices=["default", "aggressive", "gentle", "fast"], | |
| help="Preset de mejora (default: default)") | |
| parser.add_argument("--show", action="store_true", | |
| help="Show original vs enhanced comparison") | |
| parser.add_argument("--compare-all", action="store_true", | |
| help="Show all presets side by side") | |
| args = parser.parse_args() | |
| img = cv2.imread(args.input) | |
| if img is None: | |
| print(f"ERROR: Could not open '{args.input}'") | |
| return | |
| if args.compare_all: | |
| # Compare all 3 presets | |
| results = [] | |
| for preset_name in ["gentle", "default", "aggressive", "fast"]: | |
| enh = enhance(img, preset=preset_name) | |
| results.append((preset_name, enh)) | |
| # Build comparison panel | |
| h = img.shape[0] | |
| div = np.full((h, 3, 3), 80, dtype=np.uint8) | |
| row = img.copy() | |
| cv2.putText(row, "ORIGINAL", (8, 20), | |
| cv2.FONT_HERSHEY_SIMPLEX, 0.55, (200,200,200), 1) | |
| for name, enh in results: | |
| cv2.putText(enh, name.upper(), (8, 20), | |
| cv2.FONT_HERSHEY_SIMPLEX, 0.55, (100,220,100), 1) | |
| row = np.hstack([row, div, enh]) | |
| cv2.imshow("SphinxEyes — Compare all presets (Q to quit)", row) | |
| cv2.waitKey(0) | |
| cv2.destroyAllWindows() | |
| return | |
| # Main pipeline | |
| enhanced = enhance(img, preset=args.preset) | |
| # Save | |
| if args.output: | |
| out_path = args.output | |
| else: | |
| p = Path(args.input) | |
| out_path = str(p.with_stem(p.stem + '_enhanced').with_suffix('.jpg')) | |
| cv2.imwrite(out_path, enhanced, [cv2.IMWRITE_JPEG_QUALITY, 95]) | |
| print(f" ✓ Saved: {out_path} (preset: {args.preset})") | |
| if args.show: | |
| comp = compare_side_by_side(img, enhanced, label=args.preset) | |
| cv2.imshow("Original vs Enhanced (Q to quit)", comp) | |
| cv2.waitKey(0) | |
| cv2.destroyAllWindows() | |
| if __name__ == "__main__": | |
| main() |