thoth-sphinx / src /enhance_img.py
beaunix's picture
upload sources files
2874635 verified
Raw
History Blame Contribute Delete
9.65 kB
#!/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()