resenh 0.1.0__tar.gz

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.
@@ -0,0 +1,29 @@
1
+ # secrets
2
+ .env
3
+ kaggle.json
4
+ *.token
5
+
6
+ # local-only files
7
+ CLAUDE.md
8
+ Computer Vision Hackathon 2026.pdf
9
+ .remember/
10
+
11
+ # python
12
+ .venv/
13
+ __pycache__/
14
+ *.py[cod]
15
+ *.egg-info/
16
+ build/
17
+ dist/
18
+ .pytest_cache/
19
+ .ruff_cache/
20
+
21
+ # data, weights, runs
22
+ data/
23
+ weights/
24
+ runs/
25
+ outputs/
26
+ *.pth
27
+ *.pt
28
+ *.safetensors
29
+ *.onnx
resenh-0.1.0/LICENSE ADDED
@@ -0,0 +1,24 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Sharadh Naidu
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
22
+
23
+ Third-party: the Retinexformer architecture (src/resenh/models/retinexformer.py) is
24
+ adapted from https://github.com/caiyuanhao1998/Retinexformer (MIT, Cai et al., ICCV 2023).
resenh-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,161 @@
1
+ Metadata-Version: 2.5
2
+ Name: resenh
3
+ Version: 0.1.0
4
+ Summary: Robust low-light image restoration and enhancement: classical, deep and adaptive hybrid pipelines.
5
+ Project-URL: Homepage, https://resenh.sharadh.blog
6
+ Project-URL: Source, https://github.com/SharadhNaidu/resen
7
+ Project-URL: Issues, https://github.com/SharadhNaidu/resen/issues
8
+ Project-URL: Weights, https://huggingface.co/SharadhNaiduTrains/resenh
9
+ Author-email: Sharadh Naidu <sharadhanaidu@rvu.edu.in>
10
+ License-Expression: MIT
11
+ License-File: LICENSE
12
+ Keywords: computer-vision,image-enhancement,image-restoration,low-light,retinex
13
+ Classifier: Operating System :: OS Independent
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Topic :: Scientific/Engineering :: Image Processing
16
+ Requires-Python: >=3.10
17
+ Requires-Dist: numpy>=1.24
18
+ Requires-Dist: opencv-python-headless>=4.8
19
+ Requires-Dist: pillow>=10.0
20
+ Provides-Extra: app
21
+ Requires-Dist: einops>=0.7; extra == 'app'
22
+ Requires-Dist: gradio>=5.0; extra == 'app'
23
+ Requires-Dist: huggingface-hub>=0.23; extra == 'app'
24
+ Requires-Dist: torch>=2.2; extra == 'app'
25
+ Provides-Extra: dev
26
+ Requires-Dist: pytest>=8.0; extra == 'dev'
27
+ Requires-Dist: ruff>=0.5; extra == 'dev'
28
+ Provides-Extra: dl
29
+ Requires-Dist: einops>=0.7; extra == 'dl'
30
+ Requires-Dist: huggingface-hub>=0.23; extra == 'dl'
31
+ Requires-Dist: torch>=2.2; extra == 'dl'
32
+ Provides-Extra: eval
33
+ Requires-Dist: einops>=0.7; extra == 'eval'
34
+ Requires-Dist: huggingface-hub>=0.23; extra == 'eval'
35
+ Requires-Dist: lpips>=0.1.4; extra == 'eval'
36
+ Requires-Dist: pyiqa>=0.1.13; extra == 'eval'
37
+ Requires-Dist: scipy>=1.11; extra == 'eval'
38
+ Requires-Dist: torch>=2.2; extra == 'eval'
39
+ Provides-Extra: full
40
+ Requires-Dist: einops>=0.7; extra == 'full'
41
+ Requires-Dist: huggingface-hub>=0.23; extra == 'full'
42
+ Requires-Dist: timm>=1.0; extra == 'full'
43
+ Requires-Dist: torch>=2.2; extra == 'full'
44
+ Requires-Dist: transformers>=4.44; extra == 'full'
45
+ Provides-Extra: train
46
+ Requires-Dist: einops>=0.7; extra == 'train'
47
+ Requires-Dist: huggingface-hub>=0.23; extra == 'train'
48
+ Requires-Dist: imagehash>=4.3; extra == 'train'
49
+ Requires-Dist: lpips>=0.1.4; extra == 'train'
50
+ Requires-Dist: pyiqa>=0.1.13; extra == 'train'
51
+ Requires-Dist: pyyaml>=6.0; extra == 'train'
52
+ Requires-Dist: scipy>=1.11; extra == 'train'
53
+ Requires-Dist: torch>=2.2; extra == 'train'
54
+ Requires-Dist: tqdm>=4.66; extra == 'train'
55
+ Description-Content-Type: text/markdown
56
+
57
+ # resenh: robust low-light image restoration and enhancement
58
+
59
+ `resenh` brightens dark, noisy, colour-shifted photos without blowing out lamps, amplifying
60
+ noise or inventing colours. It combines three things:
61
+
62
+ - **A classical branch:** illumination estimation, Retinex, CLAHE, edge-preserving denoising
63
+ and colour correction, with every parameter adapted per image.
64
+ - **A deep network:** Retinexformer (ICCV 2023) fine-tuned with the GT-mean loss, taking the
65
+ classical results as extra inputs.
66
+ - **A quality controller:** checks every candidate output for over-exposure, colour shift,
67
+ detail loss and noise amplification, and fuses them.
68
+
69
+ ![architecture](https://raw.githubusercontent.com/SharadhNaidu/resen/main/docs/architecture.svg)
70
+
71
+ **Live demo (runs in your browser):** https://resenh.sharadh.blog
72
+
73
+ ## Install
74
+
75
+ ```bash
76
+ pip install "resenh[dl]" # classical + deep hybrid (PyTorch)
77
+ pip install resenh # classical only (numpy + OpenCV, no PyTorch)
78
+ pip install "resenh[app]" # + Gradio web app
79
+ pip install "resenh[eval]" # + LPIPS / NIQE metrics
80
+ ```
81
+
82
+ From source: `git clone https://github.com/SharadhNaidu/resen && cd resen && pip install -e ".[dl,app,eval,dev]"`.
83
+ Weights are downloaded and checksum-verified on first use (cached in `~/.cache/resenh`).
84
+
85
+ ## Use
86
+
87
+ ```python
88
+ import resenh
89
+
90
+ res = resenh.enhance("night.jpg") # adaptive hybrid (default)
91
+ resenh.save_image(res.image, "night_enhanced.png")
92
+ res.intermediates["illumination"] # every stage is inspectable
93
+ res.decision # controller weights and guard scores
94
+ res.stats, res.params # analyzer measurements → chosen parameters
95
+
96
+ resenh.enhance("night.jpg", mode="classical") # no deep learning
97
+ resenh.enhance("night.jpg", mode="dl") # network only
98
+ ```
99
+
100
+ ```bash
101
+ resenh enhance night.jpg -o out.png --save-intermediates stages/
102
+ resenh bench --device cpu # latency at ~1 MP
103
+ resenh app # web UI at http://localhost:7860
104
+ ```
105
+
106
+ **One command with Docker:** `docker build -t resenh . && docker run -p 7860:7860 resenh`
107
+
108
+ ## Results
109
+ Full tables with all metrics and 95% bootstrap CIs are in
110
+ [`eval_P1_T01.md`](https://github.com/SharadhNaidu/resen/blob/main/eval_P1_T01.md). The ablation is in [`docs/ablation.md`](https://github.com/SharadhNaidu/resen/blob/main/docs/ablation.md)
111
+ and the failure analysis in [`docs/failures.md`](https://github.com/SharadhNaidu/resen/blob/main/docs/failures.md).
112
+
113
+ **Protocol.**
114
+ - Plain RGB PSNR. No GT-mean correction and no test-time brightness knobs.
115
+ - Checkpoints are fixed in advance (`docs/devlog/protocol-decision.md`); none is selected on
116
+ the test set.
117
+ - Train and test on the same benchmark only. The LOL-v1/v2 overlap is documented.
118
+ - Our harness reproduces the official Retinexformer checkpoints exactly: 25.15 / 22.79 /
119
+ 25.67 dB, matching the published audit.
120
+
121
+ ## Reproduce
122
+
123
+ ```bash
124
+ pip install -e ".[train,dev]"
125
+ python -m kaggle datasets download -d ohmahler91/lolv1-and-lolv2 -p data/raw/lol --unzip
126
+ python -m kaggle datasets download -d bjzh123/lsrw123 -p data/raw/lsrw --unzip
127
+ python -m kaggle datasets download -d mijancse12/onedrive-1-6-13-2026 -p data/raw/unpaired --unzip
128
+ python training/data_prep.py # verify counts, scene-grouped val splits, dedup
129
+ # official Retinexformer weights → weights/retinexformer/ (see src/resenh/models/weights.py)
130
+ bash training/queue.sh # fine-tune per benchmark + ablations
131
+ python eval/run_eval.py --methods classical "dl:retinexformer-{bench}" ...
132
+ python eval/make_report.py # writes eval_P1_T01.md, docs/ablation.md
133
+ pytest
134
+ ```
135
+
136
+ ## Project layout
137
+
138
+ | Path | What |
139
+ |---|---|
140
+ | `src/resenh/` | the package: `io`, `analysis`, `classical/`, `models/`, `controller`, `pipeline`, `metrics`, `viz`, `cli`, `app` |
141
+ | `training/` | data preparation, training script and GPU queue |
142
+ | `eval/` | evaluation harness and report generator |
143
+ | `docs/` | design document, architecture diagram, ablation, failure analysis, development log |
144
+ | `app/` | Space entry point and example images |
145
+
146
+ ## Credits
147
+ - Retinexformer: Cai et al., ICCV 2023 (MIT), [repo](https://github.com/caiyuanhao1998/Retinexformer).
148
+ - GT-mean loss: Liao et al., ICCV 2025.
149
+ - LIME: Guo et al., TIP 2017.
150
+ - Guided filter: He et al., TPAMI 2013.
151
+ - Evaluation audit: "What Does LOL Measure?", arXiv 2610.38121.
152
+ - Datasets: LOL (Wei et al. 2018), LOL-v2 (Yang et al. 2021), LSRW (Hai et al. 2023),
153
+ DICM, LIME, MEF, NPE and VV.
154
+
155
+ ## Limitations and responsible use
156
+ See the [design document §8](https://github.com/SharadhNaidu/resen/blob/main/docs/design.md#8-limitations-and-ethics). In short, enhanced
157
+ images can contain hallucinated detail and **must not be used as forensic evidence**. The
158
+ training data is small and mostly indoor.
159
+
160
+ ## License
161
+ MIT
resenh-0.1.0/README.md ADDED
@@ -0,0 +1,105 @@
1
+ # resenh: robust low-light image restoration and enhancement
2
+
3
+ `resenh` brightens dark, noisy, colour-shifted photos without blowing out lamps, amplifying
4
+ noise or inventing colours. It combines three things:
5
+
6
+ - **A classical branch:** illumination estimation, Retinex, CLAHE, edge-preserving denoising
7
+ and colour correction, with every parameter adapted per image.
8
+ - **A deep network:** Retinexformer (ICCV 2023) fine-tuned with the GT-mean loss, taking the
9
+ classical results as extra inputs.
10
+ - **A quality controller:** checks every candidate output for over-exposure, colour shift,
11
+ detail loss and noise amplification, and fuses them.
12
+
13
+ ![architecture](https://raw.githubusercontent.com/SharadhNaidu/resen/main/docs/architecture.svg)
14
+
15
+ **Live demo (runs in your browser):** https://resenh.sharadh.blog
16
+
17
+ ## Install
18
+
19
+ ```bash
20
+ pip install "resenh[dl]" # classical + deep hybrid (PyTorch)
21
+ pip install resenh # classical only (numpy + OpenCV, no PyTorch)
22
+ pip install "resenh[app]" # + Gradio web app
23
+ pip install "resenh[eval]" # + LPIPS / NIQE metrics
24
+ ```
25
+
26
+ From source: `git clone https://github.com/SharadhNaidu/resen && cd resen && pip install -e ".[dl,app,eval,dev]"`.
27
+ Weights are downloaded and checksum-verified on first use (cached in `~/.cache/resenh`).
28
+
29
+ ## Use
30
+
31
+ ```python
32
+ import resenh
33
+
34
+ res = resenh.enhance("night.jpg") # adaptive hybrid (default)
35
+ resenh.save_image(res.image, "night_enhanced.png")
36
+ res.intermediates["illumination"] # every stage is inspectable
37
+ res.decision # controller weights and guard scores
38
+ res.stats, res.params # analyzer measurements → chosen parameters
39
+
40
+ resenh.enhance("night.jpg", mode="classical") # no deep learning
41
+ resenh.enhance("night.jpg", mode="dl") # network only
42
+ ```
43
+
44
+ ```bash
45
+ resenh enhance night.jpg -o out.png --save-intermediates stages/
46
+ resenh bench --device cpu # latency at ~1 MP
47
+ resenh app # web UI at http://localhost:7860
48
+ ```
49
+
50
+ **One command with Docker:** `docker build -t resenh . && docker run -p 7860:7860 resenh`
51
+
52
+ ## Results
53
+ Full tables with all metrics and 95% bootstrap CIs are in
54
+ [`eval_P1_T01.md`](https://github.com/SharadhNaidu/resen/blob/main/eval_P1_T01.md). The ablation is in [`docs/ablation.md`](https://github.com/SharadhNaidu/resen/blob/main/docs/ablation.md)
55
+ and the failure analysis in [`docs/failures.md`](https://github.com/SharadhNaidu/resen/blob/main/docs/failures.md).
56
+
57
+ **Protocol.**
58
+ - Plain RGB PSNR. No GT-mean correction and no test-time brightness knobs.
59
+ - Checkpoints are fixed in advance (`docs/devlog/protocol-decision.md`); none is selected on
60
+ the test set.
61
+ - Train and test on the same benchmark only. The LOL-v1/v2 overlap is documented.
62
+ - Our harness reproduces the official Retinexformer checkpoints exactly: 25.15 / 22.79 /
63
+ 25.67 dB, matching the published audit.
64
+
65
+ ## Reproduce
66
+
67
+ ```bash
68
+ pip install -e ".[train,dev]"
69
+ python -m kaggle datasets download -d ohmahler91/lolv1-and-lolv2 -p data/raw/lol --unzip
70
+ python -m kaggle datasets download -d bjzh123/lsrw123 -p data/raw/lsrw --unzip
71
+ python -m kaggle datasets download -d mijancse12/onedrive-1-6-13-2026 -p data/raw/unpaired --unzip
72
+ python training/data_prep.py # verify counts, scene-grouped val splits, dedup
73
+ # official Retinexformer weights → weights/retinexformer/ (see src/resenh/models/weights.py)
74
+ bash training/queue.sh # fine-tune per benchmark + ablations
75
+ python eval/run_eval.py --methods classical "dl:retinexformer-{bench}" ...
76
+ python eval/make_report.py # writes eval_P1_T01.md, docs/ablation.md
77
+ pytest
78
+ ```
79
+
80
+ ## Project layout
81
+
82
+ | Path | What |
83
+ |---|---|
84
+ | `src/resenh/` | the package: `io`, `analysis`, `classical/`, `models/`, `controller`, `pipeline`, `metrics`, `viz`, `cli`, `app` |
85
+ | `training/` | data preparation, training script and GPU queue |
86
+ | `eval/` | evaluation harness and report generator |
87
+ | `docs/` | design document, architecture diagram, ablation, failure analysis, development log |
88
+ | `app/` | Space entry point and example images |
89
+
90
+ ## Credits
91
+ - Retinexformer: Cai et al., ICCV 2023 (MIT), [repo](https://github.com/caiyuanhao1998/Retinexformer).
92
+ - GT-mean loss: Liao et al., ICCV 2025.
93
+ - LIME: Guo et al., TIP 2017.
94
+ - Guided filter: He et al., TPAMI 2013.
95
+ - Evaluation audit: "What Does LOL Measure?", arXiv 2610.38121.
96
+ - Datasets: LOL (Wei et al. 2018), LOL-v2 (Yang et al. 2021), LSRW (Hai et al. 2023),
97
+ DICM, LIME, MEF, NPE and VV.
98
+
99
+ ## Limitations and responsible use
100
+ See the [design document §8](https://github.com/SharadhNaidu/resen/blob/main/docs/design.md#8-limitations-and-ethics). In short, enhanced
101
+ images can contain hallucinated detail and **must not be used as forensic evidence**. The
102
+ training data is small and mostly indoor.
103
+
104
+ ## License
105
+ MIT
@@ -0,0 +1,52 @@
1
+ [build-system]
2
+ requires = ["hatchling>=1.24"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "resenh"
7
+ version = "0.1.0"
8
+ description = "Robust low-light image restoration and enhancement: classical, deep and adaptive hybrid pipelines."
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+ license = "MIT"
12
+ authors = [{ name = "Sharadh Naidu", email = "sharadhanaidu@rvu.edu.in" }]
13
+ keywords = ["low-light", "image-enhancement", "retinex", "computer-vision", "image-restoration"]
14
+ classifiers = [
15
+ "Programming Language :: Python :: 3",
16
+ "Topic :: Scientific/Engineering :: Image Processing",
17
+ "Operating System :: OS Independent",
18
+ ]
19
+ dependencies = [
20
+ "numpy>=1.24",
21
+ "opencv-python-headless>=4.8",
22
+ "pillow>=10.0",
23
+ ]
24
+
25
+ [project.optional-dependencies]
26
+ dl = ["torch>=2.2", "einops>=0.7", "huggingface_hub>=0.23"]
27
+ full = ["resenh[dl]", "transformers>=4.44", "timm>=1.0"]
28
+ eval = ["resenh[dl]", "lpips>=0.1.4", "pyiqa>=0.1.13", "scipy>=1.11"]
29
+ app = ["resenh[dl]", "gradio>=5.0"]
30
+ train = ["resenh[eval]", "pyyaml>=6.0", "imagehash>=4.3", "tqdm>=4.66"]
31
+ dev = ["pytest>=8.0", "ruff>=0.5"]
32
+
33
+ [project.scripts]
34
+ resenh = "resenh.cli:main"
35
+
36
+ [project.urls]
37
+ Homepage = "https://resenh.sharadh.blog"
38
+ Source = "https://github.com/SharadhNaidu/resen"
39
+ Issues = "https://github.com/SharadhNaidu/resen/issues"
40
+ Weights = "https://huggingface.co/SharadhNaiduTrains/resenh"
41
+
42
+ [tool.hatch.build.targets.wheel]
43
+ packages = ["src/resenh"]
44
+
45
+ [tool.pytest.ini_options]
46
+ testpaths = ["tests"]
47
+
48
+ [tool.ruff]
49
+ line-length = 100
50
+
51
+ [tool.hatch.build.targets.sdist]
52
+ include = ["src/resenh", "tests", "README.md", "LICENSE", "pyproject.toml"]
@@ -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__"]
@@ -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
+ )
@@ -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