resenh 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
resenh/__init__.py ADDED
@@ -0,0 +1,14 @@
1
+ """resenh: robust low-light image restoration and enhancement.
2
+
3
+ >>> import resenh
4
+ >>> result = resenh.enhance("night.jpg") # adaptive hybrid (classical + deep)
5
+ >>> resenh.save_image(result.image, "night_enhanced.png")
6
+ """
7
+
8
+ from resenh.io import ImageInputError, load_image, save_image
9
+ from resenh.pipeline import MODES, EnhanceResult, enhance
10
+
11
+ __version__ = "0.1.0"
12
+
13
+ __all__ = ["enhance", "EnhanceResult", "MODES", "load_image", "save_image", "ImageInputError",
14
+ "__version__"]
resenh/analysis.py ADDED
@@ -0,0 +1,120 @@
1
+ """Image analyzer: measures the input and maps the measurements to enhancement parameters.
2
+
3
+ This is what makes the pipeline adaptive (no fixed parameters): every classical stage and the
4
+ controller read their settings from :class:`Params`, computed per image from :class:`Stats`.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from dataclasses import asdict, dataclass
10
+
11
+ import cv2
12
+ import numpy as np
13
+
14
+ LUMA = np.array([0.299, 0.587, 0.114], dtype=np.float32)
15
+
16
+
17
+ def luma(img: np.ndarray) -> np.ndarray:
18
+ return img @ LUMA
19
+
20
+
21
+ def noise_sigma(gray: np.ndarray) -> float:
22
+ """Immerkaer (1996) fast noise estimate on a [0,1] gray image (returns sigma in [0,1] units).
23
+
24
+ Uses the Laplacian-difference kernel, which cancels image structure up to 2nd order.
25
+ """
26
+ h, w = gray.shape
27
+ if h < 3 or w < 3:
28
+ return 0.0
29
+ k = np.array([[1, -2, 1], [-2, 4, -2], [1, -2, 1]], dtype=np.float32)
30
+ conv = cv2.filter2D(gray.astype(np.float32), -1, k, borderType=cv2.BORDER_REFLECT)
31
+ s = np.abs(conv[1:-1, 1:-1]).sum()
32
+ return float(s * np.sqrt(0.5 * np.pi) / (6.0 * (w - 2) * (h - 2)))
33
+
34
+
35
+ @dataclass
36
+ class Stats:
37
+ mean_luma: float # global brightness
38
+ median_luma: float
39
+ dark_frac: float # fraction of pixels with luma < 0.1
40
+ highlight_frac: float # fraction of pixels with luma > 0.95 (lamps, signs)
41
+ noise_sigma: float # Immerkaer estimate on the raw input
42
+ rel_noise: float # noise relative to brightness (what gets amplified)
43
+ cast: tuple # per-channel mean / gray mean (colour cast)
44
+ cast_strength: float # max |channel ratio - 1|
45
+ nonuniformity: float # std/mean of a heavily blurred luma map (uneven lighting)
46
+ width: int
47
+ height: int
48
+
49
+ def to_dict(self):
50
+ return asdict(self)
51
+
52
+
53
+ @dataclass
54
+ class Params:
55
+ is_low_light: bool
56
+ target_mean: float # desired output mean luma
57
+ retinex_gamma: float # illumination compression; smaller = stronger lift
58
+ gain_floor: float # bounds max per-pixel gain (1 / floor)
59
+ clahe_clip: float
60
+ denoise_strength: float # 0 = off, 1 = strong
61
+ wb_strength: float # 0 = no white balance, 1 = full gray-world
62
+ highlight_protect: float # 0..1 how strongly to keep bright regions
63
+ dl_weight_hint: float # prior preference for the network output in the controller
64
+
65
+ def to_dict(self):
66
+ return asdict(self)
67
+
68
+
69
+ def analyze(img: np.ndarray) -> Stats:
70
+ y = luma(img)
71
+ h, w = y.shape
72
+ small = cv2.resize(y, (max(8, w // 8), max(8, h // 8)), interpolation=cv2.INTER_AREA)
73
+ blur = cv2.GaussianBlur(small, (0, 0), sigmaX=max(2.0, min(small.shape) / 8))
74
+ means = img.reshape(-1, 3).mean(axis=0)
75
+ gray_mean = float(means.mean()) + 1e-6
76
+ cast = tuple(float(m / gray_mean) for m in means)
77
+ sigma = noise_sigma(y)
78
+ mean_l = float(y.mean())
79
+ return Stats(
80
+ mean_luma=mean_l,
81
+ median_luma=float(np.median(y)),
82
+ dark_frac=float((y < 0.1).mean()),
83
+ highlight_frac=float((y > 0.95).mean()),
84
+ noise_sigma=sigma,
85
+ rel_noise=float(sigma / max(mean_l, 1e-3)),
86
+ cast=cast,
87
+ cast_strength=float(max(abs(c - 1.0) for c in cast)),
88
+ nonuniformity=float(blur.std() / (blur.mean() + 1e-3)),
89
+ width=w,
90
+ height=h,
91
+ )
92
+
93
+
94
+ def _lerp(a, b, t):
95
+ return a + (b - a) * float(np.clip(t, 0.0, 1.0))
96
+
97
+
98
+ def choose_params(s: Stats) -> Params:
99
+ """Map measurements to parameters. Thresholds were set on LOL train/val, never on test."""
100
+ is_low = s.mean_luma < 0.35 or s.dark_frac > 0.5
101
+ darkness = np.clip((0.35 - s.mean_luma) / 0.33, 0.0, 1.0) # 0 = normal, 1 = near black
102
+ gamma = _lerp(0.85, 0.45, darkness) if is_low else 0.95
103
+ gamma = min(0.95, gamma + 0.1 * np.clip(s.nonuniformity - 0.6, 0, 1)) # keep relief
104
+ denoise = np.clip((s.rel_noise - 0.02) / 0.10, 0.0, 1.0)
105
+ return Params(
106
+ is_low_light=bool(is_low),
107
+ target_mean=0.45 if is_low else max(s.mean_luma, 0.4),
108
+ retinex_gamma=float(gamma),
109
+ gain_floor=float(_lerp(0.08, 0.025, darkness)),
110
+ clahe_clip=float(_lerp(2.5, 1.5, denoise)), # less local contrast when noisy
111
+ denoise_strength=float(denoise),
112
+ wb_strength=float(np.clip((s.cast_strength - 0.05) / 0.25, 0.0, 0.8)),
113
+ # the spatial mask in protect_highlights localises the effect, so even a tiny lamp
114
+ # gets full protection (networks trained on LOL hallucinate colour in clipped sources)
115
+ # strongest for small clipped sources (lamps, signs); large clipped areas (sky,
116
+ # windows) are left to the network. Thresholds checked on val_b.
117
+ highlight_protect=float(np.clip((0.08 - s.highlight_frac) / 0.05, 0.0, 1.0))
118
+ if s.highlight_frac > 1e-5 else 0.0,
119
+ dl_weight_hint=float(1.0 if is_low else 0.3),
120
+ )
resenh/app.py ADDED
@@ -0,0 +1,113 @@
1
+ """Gradio web app: upload a low-light photo, see the enhancement and every intermediate stage."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ from pathlib import Path
7
+
8
+ import numpy as np
9
+
10
+ import resenh
11
+ from resenh.metrics import edge_preservation, gain_matched, noise_ratio
12
+ from resenh.viz import panels
13
+
14
+ try: # Hugging Face ZeroGPU: allocate a GPU only for the duration of a call
15
+ import spaces
16
+
17
+ _gpu = spaces.GPU(duration=20)
18
+ except Exception: # noqa: BLE001 - running locally / CPU / Docker
19
+ def _gpu(fn):
20
+ return fn
21
+
22
+ MODE_MAP = {
23
+ "Hybrid (adaptive, recommended)": "hybrid",
24
+ "Deep learning only": "dl",
25
+ "Classical only": "classical",
26
+ }
27
+
28
+ EXAMPLES_DIR = Path(os.environ.get("RESENH_EXAMPLES",
29
+ Path(__file__).resolve().parents[2] / "app" / "examples"))
30
+
31
+
32
+ @_gpu
33
+ def _run(img, mode, tta):
34
+ return resenh.enhance(img, mode=mode, tta=tta,
35
+ max_side=int(os.environ.get("RESENH_MAX_SIDE", 1280)))
36
+
37
+
38
+ def process(img, mode_label, tta):
39
+ import gradio as gr # noqa: PLC0415
40
+
41
+ if img is None:
42
+ raise gr.Error("Please upload an image first.")
43
+ try:
44
+ x = resenh.load_image(img)
45
+ res = _run(x, MODE_MAP[mode_label], bool(tta))
46
+ except resenh.ImageInputError as e:
47
+ raise gr.Error(str(e)) from e
48
+ out = res.image
49
+ ref = gain_matched(x, out)
50
+ s, p = res.stats, res.params
51
+ lines = [
52
+ f"### Result: `{res.mode}` mode" + (f" · model `{res.model}`" if res.model else ""),
53
+ f"**Time:** {res.timings['total']:.2f}s · **Size:** {s.width}×{s.height}",
54
+ "",
55
+ "**Analyzer (input measurements → adaptive parameters)**",
56
+ f"- brightness {s.mean_luma:.3f}, dark pixels {s.dark_frac:.0%}, "
57
+ f"highlights {s.highlight_frac:.1%}",
58
+ f"- noise σ {s.noise_sigma:.4f} (relative {s.rel_noise:.3f}), "
59
+ f"colour cast {s.cast_strength:.2f}, uneven lighting {s.nonuniformity:.2f}",
60
+ f"- low-light: **{p.is_low_light}** → Retinex γ {p.retinex_gamma:.2f}, "
61
+ f"denoise {p.denoise_strength:.2f}, CLAHE clip {p.clahe_clip:.1f}, "
62
+ f"WB {p.wb_strength:.2f}, highlight protect {p.highlight_protect:.2f}",
63
+ "",
64
+ "**Output quality (no reference available)**",
65
+ f"- noise vs. brightened input: ×{noise_ratio(out, ref):.2f} (lower = cleaner)",
66
+ f"- edge preservation (EPI): {edge_preservation(out, ref):.3f} (1 = all structure kept)",
67
+ ]
68
+ if res.decision:
69
+ d = res.decision
70
+ lines += ["", "**Quality controller**",
71
+ "- weights: " + ", ".join(f"{k} {v:.2f}" for k, v in d["weights"].items()),
72
+ "- guard penalties: " + ", ".join(f"{k} {v:.2f}"
73
+ for k, v in d["penalties"].items())]
74
+ lines += [f"- ⚠ {n}" for n in d["notes"]]
75
+ gallery = [((np.clip(im, 0, 1) * 255).astype(np.uint8), lab)
76
+ for im, lab in panels(res.intermediates, x, out)]
77
+ out_u8 = (np.clip(out, 0, 1) * 255 + 0.5).astype(np.uint8)
78
+ in_u8 = (np.clip(x, 0, 1) * 255 + 0.5).astype(np.uint8)
79
+ return (in_u8, out_u8), out_u8, gallery, "\n".join(lines)
80
+
81
+
82
+ def build():
83
+ import gradio as gr # noqa: PLC0415
84
+
85
+ examples = sorted(str(p) for p in EXAMPLES_DIR.glob("*")) if EXAMPLES_DIR.exists() else []
86
+ with gr.Blocks(title="resenh: low-light enhancement") as demo:
87
+ gr.Markdown(
88
+ "# resenh: robust low-light image enhancement\n"
89
+ "Adaptive hybrid of classical Retinex/CLAHE/edge-preserving denoising and a "
90
+ "fine-tuned Retinexformer, fused by a quality controller. "
91
+ "Upload a dark photo (JPEG/PNG)."
92
+ )
93
+ with gr.Row():
94
+ with gr.Column(scale=1):
95
+ inp = gr.Image(label="Low-light input", type="numpy", image_mode="RGB",
96
+ sources=["upload", "clipboard", "webcam"])
97
+ mode = gr.Radio(list(MODE_MAP), value=list(MODE_MAP)[0], label="Method")
98
+ tta = gr.Checkbox(label="High quality (×8 self-ensemble, slower)", value=False)
99
+ btn = gr.Button("Enhance", variant="primary")
100
+ if examples:
101
+ gr.Examples(examples, inputs=inp, label="Examples (unseen test images)")
102
+ with gr.Column(scale=2):
103
+ slider = gr.ImageSlider(label="Before / after", type="numpy")
104
+ out_img = gr.Image(label="Enhanced (download)", type="numpy", format="png")
105
+ report = gr.Markdown()
106
+ gallery = gr.Gallery(label="Intermediate stages", columns=4, height="auto")
107
+ btn.click(process, [inp, mode, tta], [slider, out_img, gallery, report],
108
+ concurrency_limit=2)
109
+ return demo
110
+
111
+
112
+ if __name__ == "__main__":
113
+ build().launch()
@@ -0,0 +1,55 @@
1
+ """Classical low-light enhancement branch (adaptive, parameters come from the analyzer)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import numpy as np
6
+
7
+ from resenh.analysis import Params, analyze, choose_params
8
+ from resenh.classical.clahe import clahe_luma
9
+ from resenh.classical.color import gray_world, match_exposure, protect_highlights
10
+ from resenh.classical.denoise import denoise, denoise_measured
11
+ from resenh.classical.illumination import estimate_illumination, reflectance, retinex_enhance
12
+
13
+ __all__ = ["enhance_classical"]
14
+
15
+
16
+ def enhance_classical(img: np.ndarray, params: Params | None = None,
17
+ denoise_method: str = "guided",
18
+ disable: frozenset[str] = frozenset()) -> tuple[np.ndarray, dict]:
19
+ """Run the classical branch. Returns (output, intermediates).
20
+
21
+ Stage order: denoise (before any gain, so noise is not amplified) -> illumination
22
+ estimation -> Retinex/LIME lift -> CLAHE on luma -> gray-world WB -> exposure match ->
23
+ highlight protection. ``disable`` names stages to skip (for ablation): any of
24
+ {"denoise", "retinex", "clahe", "wb", "exposure", "highlights"}.
25
+ """
26
+ if params is None:
27
+ params = choose_params(analyze(img))
28
+ inter: dict[str, np.ndarray] = {}
29
+ x = img
30
+ if "denoise" not in disable:
31
+ x = denoise(x, params.denoise_strength, denoise_method)
32
+ inter["denoised"] = x
33
+ illum = estimate_illumination(x)
34
+ inter["illumination"] = illum
35
+ inter["reflectance"] = reflectance(x, illum)
36
+ if "retinex" not in disable:
37
+ x, applied = retinex_enhance(x, illum, gamma=params.retinex_gamma,
38
+ floor=params.gain_floor)
39
+ inter["retinex"] = x
40
+ inter["gain_map"] = np.clip(1.0 / applied / 25.0, 0, 1)
41
+ if "denoise" not in disable:
42
+ # residual noise is amplified by the gain: re-estimate it and filter adaptively
43
+ x = denoise_measured(x)
44
+ inter["post_denoised"] = x
45
+ if "clahe" not in disable:
46
+ c = clahe_luma(x, params.clahe_clip)
47
+ x = (0.5 * x + 0.5 * c).astype(np.float32) # half-strength: CLAHE alone over-sharpens noise
48
+ inter["clahe"] = c
49
+ if "wb" not in disable:
50
+ x = gray_world(x, params.wb_strength)
51
+ if "exposure" not in disable:
52
+ x = match_exposure(x, params.target_mean)
53
+ if "highlights" not in disable:
54
+ x = protect_highlights(x, img, params.highlight_protect)
55
+ return np.clip(x, 0.0, 1.0).astype(np.float32), inter
@@ -0,0 +1,15 @@
1
+ """Contrast-limited adaptive histogram equalisation on the luma channel only (keeps hue)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import cv2
6
+ import numpy as np
7
+
8
+
9
+ def clahe_luma(img: np.ndarray, clip_limit: float = 2.0, tiles: int = 8) -> np.ndarray:
10
+ """Apply CLAHE to the L channel of Lab; ``img`` is RGB float in [0, 1]."""
11
+ u8 = (np.clip(img, 0, 1) * 255.0 + 0.5).astype(np.uint8)
12
+ lab = cv2.cvtColor(u8, cv2.COLOR_RGB2LAB)
13
+ clahe = cv2.createCLAHE(clipLimit=float(clip_limit), tileGridSize=(tiles, tiles))
14
+ lab[..., 0] = clahe.apply(lab[..., 0])
15
+ return cv2.cvtColor(lab, cv2.COLOR_LAB2RGB).astype(np.float32) / 255.0
@@ -0,0 +1,48 @@
1
+ """Colour and tone stages: gray-world white balance, exposure normalisation, highlight protection."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import numpy as np
6
+
7
+ from resenh.analysis import luma
8
+
9
+
10
+ def gray_world(img: np.ndarray, strength: float = 1.0) -> np.ndarray:
11
+ """Partial gray-world white balance (``strength`` 0 = off, 1 = full)."""
12
+ if strength <= 0:
13
+ return img
14
+ means = img.reshape(-1, 3).mean(axis=0) + 1e-6
15
+ gains = means.mean() / means
16
+ gains = 1.0 + strength * (gains - 1.0)
17
+ return np.clip(img * gains, 0.0, 1.0).astype(np.float32)
18
+
19
+
20
+ def match_exposure(img: np.ndarray, target_mean: float, max_gain: float = 4.0) -> np.ndarray:
21
+ """Scale so mean luma approaches ``target_mean`` with a soft shoulder (no hard clipping)."""
22
+ m = float(luma(img).mean())
23
+ if m <= 1e-4:
24
+ return img
25
+ g = float(np.clip(target_mean / m, 1.0 / max_gain, max_gain))
26
+ x = img * g
27
+ # filmic shoulder above 0.8 to avoid blown highlights
28
+ knee = 0.8
29
+ over = x > knee
30
+ x = np.where(over, knee + (1 - knee) * np.tanh((x - knee) / (1 - knee)), x)
31
+ return np.clip(x, 0.0, 1.0).astype(np.float32)
32
+
33
+
34
+ def protect_highlights(enhanced: np.ndarray, original: np.ndarray,
35
+ strength: float) -> np.ndarray:
36
+ """Blend back the original where it was already bright (lamps, signs), to stop blow-out."""
37
+ if strength <= 0:
38
+ return enhanced
39
+ import cv2 # noqa: PLC0415
40
+
41
+ y = luma(original)
42
+ # soft mask of already-bright sources, dilated so halos/colour fringes around them are
43
+ # also restored (networks trained on LOL hallucinate colour in saturated lamps)
44
+ m = np.clip((y - 0.88) / 0.1, 0.0, 1.0).astype(np.float32) # only (near-)clipped sources
45
+ k = max(3, int(round(min(y.shape) / 100)) | 1)
46
+ m = cv2.GaussianBlur(cv2.dilate(m, np.ones((k, k), np.uint8)), (0, 0), k / 2)
47
+ w = np.clip(m, 0, 1)[..., None] * strength
48
+ return (enhanced * (1 - w) + original * w).astype(np.float32)
@@ -0,0 +1,48 @@
1
+ """Edge-preserving denoising: guided filter (self-guided, per channel) or bilateral."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import cv2
6
+ import numpy as np
7
+
8
+ from resenh.classical.filters import guided_filter
9
+
10
+
11
+ def denoise(img: np.ndarray, strength: float, method: str = "guided") -> np.ndarray:
12
+ """Denoise an RGB float image. ``strength`` in [0, 1] (0 returns the input unchanged).
13
+
14
+ Guided filtering uses the luma as guide, so colour noise is smoothed along luma edges
15
+ (edges preserved); bilateral is offered for comparison/ablation.
16
+ """
17
+ if strength <= 1e-3:
18
+ return img
19
+ if method == "bilateral":
20
+ sigma_c = 0.02 + 0.10 * strength
21
+ d = 5 if strength < 0.5 else 9
22
+ return cv2.bilateralFilter(img.astype(np.float32), d, sigma_c, d).clip(0, 1)
23
+ guide = img @ np.array([0.299, 0.587, 0.114], dtype=np.float32)
24
+ r = 2 if strength < 0.5 else 3
25
+ eps = (0.01 + 0.04 * strength) ** 2
26
+ out = guided_filter(guide, img, r, eps)
27
+ return np.clip(out, 0.0, 1.0).astype(np.float32)
28
+
29
+
30
+ def denoise_measured(img: np.ndarray, k: float = 2.5) -> np.ndarray:
31
+ """Noise-adaptive guided filtering: eps tied to the *measured* noise variance.
32
+
33
+ Regions whose local variance is well above (k*sigma)^2 are edges and are kept; flatter
34
+ regions are averaged. Chroma gets a stronger pass than luma (colour noise is the most
35
+ visible artefact after brightening dark images).
36
+ """
37
+ from resenh.analysis import noise_sigma # noqa: PLC0415
38
+
39
+ y = img @ np.array([0.299, 0.587, 0.114], dtype=np.float32)
40
+ sigma = noise_sigma(y)
41
+ if sigma < 0.004:
42
+ return img
43
+ eps = (k * sigma) ** 2
44
+ r = 2 if sigma < 0.03 else 3
45
+ y_d = guided_filter(y, y, r, eps)
46
+ chroma = img - y[..., None]
47
+ chroma_d = guided_filter(y_d, chroma, r + 2, eps)
48
+ return np.clip(y_d[..., None] + chroma_d, 0.0, 1.0).astype(np.float32)
@@ -0,0 +1,57 @@
1
+ """Edge-preserving filters implemented with OpenCV box filters (no contrib dependency)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import cv2
6
+ import numpy as np
7
+
8
+
9
+ def box(x: np.ndarray, r: int) -> np.ndarray:
10
+ return cv2.boxFilter(x, -1, (2 * r + 1, 2 * r + 1), borderType=cv2.BORDER_REFLECT)
11
+
12
+
13
+ def guided_filter(guide: np.ndarray, src: np.ndarray, r: int, eps: float) -> np.ndarray:
14
+ """Gray-guide guided filter (He et al., TPAMI 2013).
15
+
16
+ ``guide`` is HxW float32; ``src`` is HxW or HxWxC float32. Works per channel of ``src``.
17
+ """
18
+ guide = guide.astype(np.float32)
19
+ src = src.astype(np.float32)
20
+ mean_i = box(guide, r)
21
+ var_i = box(guide * guide, r) - mean_i * mean_i
22
+ if src.ndim == 3:
23
+ mean_i3, var_i3, guide3 = mean_i[..., None], var_i[..., None], guide[..., None]
24
+ else:
25
+ mean_i3, var_i3, guide3 = mean_i, var_i, guide
26
+ mean_p = box(src, r)
27
+ cov_ip = box(guide3 * src if src.ndim == 3 else guide * src, r) - mean_i3 * mean_p
28
+ a = cov_ip / (var_i3 + eps)
29
+ b = mean_p - a * mean_i3
30
+ return box(a, r) * guide3 + box(b, r)
31
+
32
+
33
+ def fast_guided_filter(guide: np.ndarray, src: np.ndarray, r: int, eps: float,
34
+ scale: int = 4) -> np.ndarray:
35
+ """Subsampled guided filter (He & Sun 2015): same result shape, ~scale^2 faster."""
36
+ if scale <= 1 or min(guide.shape[:2]) < 8 * scale:
37
+ return guided_filter(guide, src, r, eps)
38
+ h, w = guide.shape[:2]
39
+ small = (max(1, w // scale), max(1, h // scale))
40
+ g_s = cv2.resize(guide, small, interpolation=cv2.INTER_AREA)
41
+ p_s = cv2.resize(src, small, interpolation=cv2.INTER_AREA)
42
+ rs = max(1, r // scale)
43
+ mean_i = box(g_s, rs)
44
+ var_i = box(g_s * g_s, rs) - mean_i * mean_i
45
+ if p_s.ndim == 3:
46
+ mean_i, var_i, g3 = mean_i[..., None], var_i[..., None], g_s[..., None]
47
+ else:
48
+ g3 = g_s
49
+ mean_p = box(p_s, rs)
50
+ a = (box(g3 * p_s, rs) - mean_i * mean_p) / (var_i + eps)
51
+ b = mean_p - a * mean_i
52
+ a_up = cv2.resize(box(a, rs), (w, h), interpolation=cv2.INTER_LINEAR)
53
+ b_up = cv2.resize(box(b, rs), (w, h), interpolation=cv2.INTER_LINEAR)
54
+ if src.ndim == 3 and a_up.ndim == 2:
55
+ a_up, b_up = a_up[..., None], b_up[..., None]
56
+ g = guide[..., None] if src.ndim == 3 else guide
57
+ return a_up * g + b_up
@@ -0,0 +1,43 @@
1
+ """Illumination estimation and Retinex-style decomposition.
2
+
3
+ ``estimate_illumination`` follows LIME (Guo et al., TIP 2017): the initial map is the per-pixel
4
+ max over RGB, refined with a structure-aware smoother (here a guided filter, which keeps strong
5
+ edges and flattens texture). ``retinex_enhance`` divides the image by the gamma-adjusted
6
+ illumination, so dark regions are lifted more than bright ones (non-uniform lighting handled
7
+ per pixel).
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import numpy as np
13
+
14
+ from resenh.classical.filters import fast_guided_filter
15
+
16
+
17
+ def estimate_illumination(img: np.ndarray, radius: int | None = None,
18
+ eps: float = 1e-3) -> np.ndarray:
19
+ """Return a smooth HxW illumination map in (0, 1] for an RGB float image in [0, 1]."""
20
+ t0 = img.max(axis=2)
21
+ h, w = t0.shape
22
+ r = radius or max(4, int(round(min(h, w) / 40)))
23
+ t = fast_guided_filter(t0, t0, r, eps)
24
+ return np.clip(t, 1e-3, 1.0).astype(np.float32)
25
+
26
+
27
+ def retinex_enhance(img: np.ndarray, illum: np.ndarray | None = None, gamma: float = 0.6,
28
+ floor: float = 0.04) -> tuple[np.ndarray, np.ndarray]:
29
+ """LIME-style enhancement. Returns (enhanced RGB, illumination actually applied).
30
+
31
+ ``gamma`` < 1 compresses the illumination map before division: smaller gamma = stronger
32
+ lift. ``floor`` bounds the gain at 1/floor to stop noise blow-up in near-black regions.
33
+ """
34
+ if illum is None:
35
+ illum = estimate_illumination(img)
36
+ t = np.maximum(illum, floor) ** gamma
37
+ out = img / t[..., None]
38
+ return np.clip(out, 0.0, 1.0).astype(np.float32), t.astype(np.float32)
39
+
40
+
41
+ def reflectance(img: np.ndarray, illum: np.ndarray, floor: float = 0.04) -> np.ndarray:
42
+ """Retinex reflectance R = I / L (clipped), for visualisation and priors."""
43
+ return np.clip(img / np.maximum(illum, floor)[..., None], 0.0, 1.0).astype(np.float32)
@@ -0,0 +1,18 @@
1
+ """Classical priors fed to the hybrid network (fixed, non-adaptive so train == test)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import numpy as np
6
+
7
+ from resenh.classical.clahe import clahe_luma
8
+ from resenh.classical.illumination import estimate_illumination, retinex_enhance
9
+
10
+ PRIOR_CHANNELS = 7 # illumination (1) + Retinex/LIME output (3) + CLAHE output (3)
11
+
12
+
13
+ def compute_priors(img: np.ndarray) -> np.ndarray:
14
+ """Return an HxWx7 float32 stack of classical priors for an RGB float image in [0, 1]."""
15
+ illum = estimate_illumination(img)
16
+ lime, _ = retinex_enhance(img, illum, gamma=0.6)
17
+ clahe = clahe_luma(lime, clip_limit=2.0)
18
+ return np.concatenate([illum[..., None], lime, clahe], axis=2).astype(np.float32)
resenh/cli.py ADDED
@@ -0,0 +1,132 @@
1
+ """resenh command-line interface.
2
+
3
+ resenh enhance night.jpg -o out.png [--mode hybrid] [--save-intermediates DIR]
4
+ resenh bench [--device cpu] [--size 1024]
5
+ resenh app [--port 7860]
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import argparse
11
+ import json
12
+ import sys
13
+ import time
14
+ from pathlib import Path
15
+
16
+ from resenh import __version__
17
+
18
+
19
+ def _cmd_enhance(a) -> int:
20
+ import resenh # noqa: PLC0415
21
+ from resenh.viz import panels # noqa: PLC0415
22
+
23
+ srcs = [Path(s) for s in a.inputs]
24
+ for src in srcs:
25
+ try:
26
+ res = resenh.enhance(src, mode=a.mode, model=a.model, device=a.device,
27
+ max_side=a.max_side, tta=a.tta)
28
+ except resenh.ImageInputError as e:
29
+ print(f"error: {src}: {e}", file=sys.stderr)
30
+ return 2
31
+ dst = Path(a.output) if (a.output and len(srcs) == 1) else (
32
+ Path(a.output or ".") / f"{src.stem}_resenh.png")
33
+ dst.parent.mkdir(parents=True, exist_ok=True)
34
+ resenh.save_image(res.image, dst)
35
+ print(f"{src} -> {dst} mode={res.mode} model={res.model} "
36
+ f"time={res.timings['total']:.2f}s")
37
+ if a.save_intermediates:
38
+ d = Path(a.save_intermediates) / src.stem
39
+ d.mkdir(parents=True, exist_ok=True)
40
+ for i, (img, label) in enumerate(panels(res.intermediates, resenh.load_image(src),
41
+ res.image)):
42
+ resenh.save_image(img, d / f"{i:02d}_{label.split(' ', 1)[-1][:40]}.png"
43
+ .replace("/", "-").replace(" ", "_"))
44
+ (d / "report.json").write_text(json.dumps({
45
+ "stats": res.stats.to_dict(), "params": res.params.to_dict(),
46
+ "decision": res.decision, "timings": res.timings}, indent=1, default=float))
47
+ return 0
48
+
49
+
50
+ def _cmd_bench(a) -> int:
51
+ import numpy as np # noqa: PLC0415
52
+
53
+ import resenh # noqa: PLC0415
54
+
55
+ rng = np.random.default_rng(0)
56
+ h = a.size * 3 // 4
57
+ img = (rng.random((h, a.size, 3)) * 0.15).astype(np.float32)
58
+ rows = []
59
+ for mode in a.modes:
60
+ resenh.enhance(img, mode=mode, device=a.device, max_side=a.max_side) # warm-up
61
+ ts = []
62
+ for _ in range(a.repeat):
63
+ t = time.perf_counter()
64
+ resenh.enhance(img, mode=mode, device=a.device, max_side=a.max_side)
65
+ ts.append(time.perf_counter() - t)
66
+ mp = img.shape[0] * img.shape[1] / 1e6
67
+ print(f"{mode:10s} {a.size}x{h} ({mp:.2f} MP) device={a.device}: "
68
+ f"{np.median(ts):.3f}s median, {np.median(ts) / mp * 1000:.0f} ms/MP")
69
+ from resenh.models.runner import pick_device # noqa: PLC0415
70
+
71
+ rows.append({"mode": mode, "device": pick_device(a.device) if mode != "classical" else "cpu",
72
+ "size": f"{a.size}x{h}", "mean_s": float(np.median(ts)),
73
+ "ms_per_mp": float(np.median(ts) / mp * 1000)})
74
+ if a.json:
75
+ import json # noqa: PLC0415
76
+ from pathlib import Path # noqa: PLC0415
77
+
78
+ f = Path(a.json)
79
+ old = json.loads(f.read_text()) if f.exists() else []
80
+ keep = {(r["mode"], r["device"], r["size"]) for r in rows}
81
+ old = [r for r in old if (r["mode"], r["device"], r["size"]) not in keep]
82
+ f.write_text(json.dumps(old + rows, indent=1))
83
+ return 0
84
+
85
+
86
+ def _cmd_app(a) -> int:
87
+ from resenh.app import build # noqa: PLC0415
88
+
89
+ build().launch(server_name=a.host, server_port=a.port, share=a.share)
90
+ return 0
91
+
92
+
93
+ def main(argv=None) -> int:
94
+ ap = argparse.ArgumentParser(prog="resenh", description="Robust low-light image enhancement")
95
+ ap.add_argument("--version", action="version", version=f"resenh {__version__}")
96
+ sub = ap.add_subparsers(dest="cmd")
97
+
98
+ e = sub.add_parser("enhance", help="enhance one or more images")
99
+ e.add_argument("inputs", nargs="+")
100
+ e.add_argument("-o", "--output", help="output file (single input) or directory")
101
+ e.add_argument("--mode", default="auto", choices=["auto", "classical", "dl", "hybrid"])
102
+ e.add_argument("--model", default=None)
103
+ e.add_argument("--device", default="auto")
104
+ e.add_argument("--max-side", type=int, default=1280)
105
+ e.add_argument("--tta", action="store_true")
106
+ e.add_argument("--save-intermediates", metavar="DIR")
107
+ e.set_defaults(func=_cmd_enhance)
108
+
109
+ b = sub.add_parser("bench", help="measure latency")
110
+ b.add_argument("--modes", nargs="+", default=["classical", "dl", "hybrid"])
111
+ b.add_argument("--device", default="auto")
112
+ b.add_argument("--size", type=int, default=1155, help="width; height = 3/4 (default ~1 MP)")
113
+ b.add_argument("--max-side", type=int, default=1280)
114
+ b.add_argument("--repeat", type=int, default=3)
115
+ b.add_argument("--json", metavar="FILE", help="append results to a JSON file")
116
+ b.set_defaults(func=_cmd_bench)
117
+
118
+ p = sub.add_parser("app", help="launch the Gradio web app")
119
+ p.add_argument("--host", default="0.0.0.0")
120
+ p.add_argument("--port", type=int, default=7860)
121
+ p.add_argument("--share", action="store_true")
122
+ p.set_defaults(func=_cmd_app)
123
+
124
+ a = ap.parse_args(argv)
125
+ if not getattr(a, "func", None):
126
+ ap.print_help()
127
+ return 0
128
+ return a.func(a)
129
+
130
+
131
+ if __name__ == "__main__":
132
+ raise SystemExit(main())