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.
- resenh-0.1.0/.gitignore +29 -0
- resenh-0.1.0/LICENSE +24 -0
- resenh-0.1.0/PKG-INFO +161 -0
- resenh-0.1.0/README.md +105 -0
- resenh-0.1.0/pyproject.toml +52 -0
- resenh-0.1.0/src/resenh/__init__.py +14 -0
- resenh-0.1.0/src/resenh/analysis.py +120 -0
- resenh-0.1.0/src/resenh/app.py +113 -0
- resenh-0.1.0/src/resenh/classical/__init__.py +55 -0
- resenh-0.1.0/src/resenh/classical/clahe.py +15 -0
- resenh-0.1.0/src/resenh/classical/color.py +48 -0
- resenh-0.1.0/src/resenh/classical/denoise.py +48 -0
- resenh-0.1.0/src/resenh/classical/filters.py +57 -0
- resenh-0.1.0/src/resenh/classical/illumination.py +43 -0
- resenh-0.1.0/src/resenh/classical/priors.py +18 -0
- resenh-0.1.0/src/resenh/cli.py +132 -0
- resenh-0.1.0/src/resenh/controller.py +98 -0
- resenh-0.1.0/src/resenh/io.py +99 -0
- resenh-0.1.0/src/resenh/metrics.py +135 -0
- resenh-0.1.0/src/resenh/models/__init__.py +0 -0
- resenh-0.1.0/src/resenh/models/retinexformer.py +253 -0
- resenh-0.1.0/src/resenh/models/runner.py +148 -0
- resenh-0.1.0/src/resenh/models/weights.py +82 -0
- resenh-0.1.0/src/resenh/pipeline.py +145 -0
- resenh-0.1.0/src/resenh/viz.py +46 -0
- resenh-0.1.0/src/resenh/web.py +123 -0
- resenh-0.1.0/tests/test_core.py +99 -0
- resenh-0.1.0/tests/test_import.py +5 -0
resenh-0.1.0/.gitignore
ADDED
|
@@ -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
|
+

|
|
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
|
+

|
|
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
|