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 +14 -0
- resenh/analysis.py +120 -0
- resenh/app.py +113 -0
- resenh/classical/__init__.py +55 -0
- resenh/classical/clahe.py +15 -0
- resenh/classical/color.py +48 -0
- resenh/classical/denoise.py +48 -0
- resenh/classical/filters.py +57 -0
- resenh/classical/illumination.py +43 -0
- resenh/classical/priors.py +18 -0
- resenh/cli.py +132 -0
- resenh/controller.py +98 -0
- resenh/io.py +99 -0
- resenh/metrics.py +135 -0
- resenh/models/__init__.py +0 -0
- resenh/models/retinexformer.py +253 -0
- resenh/models/runner.py +148 -0
- resenh/models/weights.py +82 -0
- resenh/pipeline.py +145 -0
- resenh/viz.py +46 -0
- resenh/web.py +123 -0
- resenh-0.1.0.dist-info/METADATA +161 -0
- resenh-0.1.0.dist-info/RECORD +26 -0
- resenh-0.1.0.dist-info/WHEEL +4 -0
- resenh-0.1.0.dist-info/entry_points.txt +2 -0
- resenh-0.1.0.dist-info/licenses/LICENSE +24 -0
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())
|