Add StarDenoise source code
Browse files- StarDenoise/README.md +300 -0
- StarDenoise/README_1080P.md +111 -0
- StarDenoise/benchmark_1080p.py +191 -0
- StarDenoise/denoise.py +262 -0
- StarDenoise/download_assets.py +53 -0
- StarDenoise/evaluate.py +37 -0
- StarDenoise/image_io.py +121 -0
- StarDenoise/model.py +117 -0
- StarDenoise/requirements.txt +7 -0
- StarDenoise/tests/__init__.py +1 -0
- StarDenoise/tests/test_core.py +54 -0
- StarDenoise/third_party/AstroNoiseNet_LICENSE.txt +21 -0
- StarDenoise/third_party/CosmicClarity_LICENSE.txt +21 -0
- StarDenoise/third_party/upstream_denoise.py +1225 -0
StarDenoise/README.md
ADDED
|
@@ -0,0 +1,300 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# StarDenoise:天文图像 AI 降噪 Demo
|
| 2 |
+
|
| 3 |
+
这是一个可直接运行的 PyTorch 天文图像降噪 demo。复现对象是 **SetiAstro Cosmic Clarity Denoise AI 3.6** 的网络与官方预训练权重,并将原项目中面向桌面 GUI 的推理逻辑整理成适合工程集成的命令行模块。
|
| 4 |
+
|
| 5 |
+
本目录已经包含:
|
| 6 |
+
|
| 7 |
+
- PyTorch 模型结构与官方 AI 3.6 权重;
|
| 8 |
+
- 从 AstroNoiseNet 官方仓库下载的 M51 短曝光/长曝光测试对;
|
| 9 |
+
- JPG、PNG、TIFF、单色或 RGB FITS 的推理支持;
|
| 10 |
+
- 自动 GPU/CPU 选择、重叠分块、边缘融合、降噪强度与模式控制;
|
| 11 |
+
- FITS 线性数据的自动拉伸、网络推理、反拉伸,以及 FITS header 保留;
|
| 12 |
+
- 下载校验脚本、简单的参考图 PSNR/MAE 评估和单元测试。
|
| 13 |
+
- 三张 ESO 1920×1080 在线图片的完整测试、对比图与指标,见 [`README_1080P.md`](README_1080P.md)。
|
| 14 |
+
|
| 15 |
+
> 注意:“星图降噪”与“去星”“星点缩小”“反卷积”不同。降噪要压制随机/结构噪声,同时尽量保持星点 PSF、亮度、星云微弱结构和色彩;它不能替代 bias/dark/flat 校准、配准和叠加。
|
| 16 |
+
|
| 17 |
+
## 1. 快速运行
|
| 18 |
+
|
| 19 |
+
工作目录:
|
| 20 |
+
|
| 21 |
+
```powershell
|
| 22 |
+
cd D:\Workman\MyProject\ImageStack\AstroColorProcess\StarDenoise
|
| 23 |
+
conda activate study
|
| 24 |
+
```
|
| 25 |
+
|
| 26 |
+
环境依赖已经在 `study` 中安装过;在新机器上可执行:
|
| 27 |
+
|
| 28 |
+
```powershell
|
| 29 |
+
# 根据显卡/CUDA 选择 PyTorch 官方安装命令,然后:
|
| 30 |
+
python -m pip install -r requirements.txt
|
| 31 |
+
```
|
| 32 |
+
|
| 33 |
+
当前已验证环境是 Python 3.10.20、PyTorch 2.13.0+cpu。由于本次网络下载 CUDA wheel 过慢,先安装 CPU 版保证 demo 即开即用;代码本身已支持 CUDA。要启用本机 RTX 4060,可在网络条件合适时按 [PyTorch 官方安装选择器](https://pytorch.org/get-started/locally/)重装 CUDA wheel,例如:
|
| 34 |
+
|
| 35 |
+
```powershell
|
| 36 |
+
python -m pip install --upgrade --force-reinstall torch torchvision `
|
| 37 |
+
--index-url https://download.pytorch.org/whl/cu126
|
| 38 |
+
```
|
| 39 |
+
|
| 40 |
+
运行内置 M51 样例:
|
| 41 |
+
|
| 42 |
+
```powershell
|
| 43 |
+
python denoise.py samples\M51_short.jpg outputs\M51_short_denoised.jpg `
|
| 44 |
+
--device auto --strength 0.9 --mode full --tile-size 256 --overlap 64
|
| 45 |
+
```
|
| 46 |
+
|
| 47 |
+
运行自己的 FITS:
|
| 48 |
+
|
| 49 |
+
```powershell
|
| 50 |
+
python denoise.py D:\path\input.fits outputs\input_denoised.fits `
|
| 51 |
+
--device cuda --strength 0.8 --mode luminance
|
| 52 |
+
```
|
| 53 |
+
|
| 54 |
+
常用参数:
|
| 55 |
+
|
| 56 |
+
| 参数 | 默认值 | 说明 |
|
| 57 |
+
|---|---:|---|
|
| 58 |
+
| `--device` | `auto` | 自动选 CUDA,否则 CPU;也可指定 `cuda:0` |
|
| 59 |
+
| `--strength` | `0.9` | 神经网络亮度降噪结果与原图的混合比例,范围 0~1 |
|
| 60 |
+
| `--color-strength` | 同 `strength` | `full` 模式下的色度降噪强度 |
|
| 61 |
+
| `--mode` | `full` | `luminance` 只处理亮度;`full` 亮度 AI + 引导色度滤波;`separate` 三通道各自 AI |
|
| 62 |
+
| `--tile-size` | `256` | 官方模型训练/推理块大小;一般无需修改 |
|
| 63 |
+
| `--overlap` | `64` | tile 重叠宽度,减小接缝 |
|
| 64 |
+
| `--batch-size` | `1` | 8 GB 显存建议 1~2;显存足够再增加 |
|
| 65 |
+
| `--precision` | `fp16` | CUDA 上用半精度;CPU 会自动使用 FP32 |
|
| 66 |
+
| `--linear-stretch` | `auto` | 线性天文图自动 MTF 拉伸后推理并反拉伸,可设 `always/never` |
|
| 67 |
+
|
| 68 |
+
比较短曝光输入、我们的结果与仓库给出的长曝光参考:
|
| 69 |
+
|
| 70 |
+
```powershell
|
| 71 |
+
python evaluate.py samples\M51_long_reference.jpg samples\M51_short.jpg
|
| 72 |
+
python evaluate.py samples\M51_long_reference.jpg outputs\M51_short_denoised.jpg
|
| 73 |
+
```
|
| 74 |
+
|
| 75 |
+
长曝光参考并不是严格像素级 ground truth(曝光、配准、亮度都可能存在差异),因此该指标只能用于 demo 的相对观察,不能当成论文级结论。更可靠的验证见“训练与评估建议”。
|
| 76 |
+
|
| 77 |
+
本机实测结果(CPU、400×400、FP32、strength=0.9、full):
|
| 78 |
+
|
| 79 |
+
| 项目 | 相对 M51 长曝光参考 |
|
| 80 |
+
|---|---:|
|
| 81 |
+
| 短曝光输入 | PSNR 30.588 dB;MAE 0.022448 |
|
| 82 |
+
| 本 demo 输出 | **PSNR 35.322 dB;MAE 0.013325** |
|
| 83 |
+
| 推理时间 | 3.414 秒 |
|
| 84 |
+
|
| 85 |
+
官方权重严格加载成功,4 个单元测试(结构/参数量、颜色空间往返、零强度恒等、FITS channel-first 往返)全部通过。
|
| 86 |
+
|
| 87 |
+
如需重新下载资产:
|
| 88 |
+
|
| 89 |
+
```powershell
|
| 90 |
+
python download_assets.py
|
| 91 |
+
```
|
| 92 |
+
|
| 93 |
+
权重下载后会检查官方公布的 SHA-256:`f860e234dc6a3ab1acacbea399c235179c86cb8b287f2ab9010bc18c8af08671`。
|
| 94 |
+
|
| 95 |
+
## 2. 天文图像噪声与降噪原理
|
| 96 |
+
|
| 97 |
+
天文图像的信号极弱,观测值可简化为:
|
| 98 |
+
|
| 99 |
+
```text
|
| 100 |
+
观测 = 天体光子 + 天空背景 + 暗电流 + 偏置 + 读出噪声 + 固定图样 + 离群点
|
| 101 |
+
```
|
| 102 |
+
|
| 103 |
+
主要成分如下:
|
| 104 |
+
|
| 105 |
+
- **光子散粒噪声**:光子到达服从泊松统计,标准差约为 `sqrt(N)`;它是信号相关噪声,不是固定方差高斯噪声。
|
| 106 |
+
- **天空背景噪声**:月光、光污染和大气辉光也产生光子与散粒噪声。
|
| 107 |
+
- **读出噪声**:传感器读出电路在每个子曝光中引入,常近似高斯分布。
|
| 108 |
+
- **暗电流及热像素**:热产生电子;其平均图样可由 dark frame 校正,但暗电流自身仍有随机性。
|
| 109 |
+
- **偏置、PRNU、渐晕和尘埃**:分别通过 bias、flat 等校准处理;校准残差会表现为结构噪声。
|
| 110 |
+
- **宇宙射线、卫星、坏点**:属于稀疏离群污染,通常应在叠加/专门剔除阶段处理,而非交给普通降噪器“猜”。
|
| 111 |
+
|
| 112 |
+
[Astropy CCD reduction guide](https://www.astropy.org/ccd-reduction-and-photometry-guide/v/dev/notebooks/01-01-astronomical-CCD-image-components.html) 对 bias、dark、read noise、天空背景和 flat correction 有系统说明;NASA 的[传感器噪声与平方根定律说明](https://science.nasa.gov/wp-content/uploads/2023/09/Astrophotography_Guide.pdf)解释了泊松计数的 `sqrt(N)` 关系。2026 年的 [Denoising the Deep Sky](https://arxiv.org/abs/2601.23276) 进一步把光子散粒、PRNU、暗电流、读出效应、宇宙射线和热像素统一到物理 CCD 噪声合成管线中。
|
| 113 |
+
|
| 114 |
+
传统流程首先做校准、配准和叠加。对独立随机噪声,平均 `N` 帧后噪声标准差大致降低到 `1/sqrt(N)`;深度模型学习的是一个条件先验:根据大量“短曝光/带噪输入 → 长曝光堆栈/低噪目标”样本,预测给定结构更可能对应真实星点和星云,哪些更可能是噪声。
|
| 115 |
+
|
| 116 |
+
常见网络有两种输出形式:
|
| 117 |
+
|
| 118 |
+
1. 直接输出干净图 `x_hat = f(y)`;
|
| 119 |
+
2. 预测噪声残差 `n_hat = f(y)`,再计算 `x_hat = y - n_hat`。
|
| 120 |
+
|
| 121 |
+
天文降噪比普通照片更怕“看起来漂亮但不真实”:很小的点状结构既可能是恒星,也可能是热像素/噪声;过强感知损失或 GAN 还可能生成不存在的结构。因此不仅要看 PSNR/SSIM,还要检查星点检出率、孔径测光偏差、PSF/FWHM、背景 NMAD、假阳性和弱结构保真度。
|
| 122 |
+
|
| 123 |
+
## 3. 模型调研与选型
|
| 124 |
+
|
| 125 |
+
调研日期:2026-08-31。这里把“学术主流”和“深空摄影社区实际可用”分开考虑。
|
| 126 |
+
|
| 127 |
+
| 工作 | 领域匹配 | 开源/权重 | 规模与部署 | 结论 |
|
| 128 |
+
|---|---|---|---|---|
|
| 129 |
+
| [DnCNN](https://github.com/cszn/DnCNN) | 通用 AWGN | 官方代码和 PyTorch 权重;经典论文,社区最成熟 | 约 0.56M 参数,极轻 | 很适合基线,但高斯噪声假设与天文泊松/读噪/固定图样不完全匹配 |
|
| 130 |
+
| [SCUNet](https://github.com/cszn/SCUNet) | 通用真实图盲降噪 | 官方 PyTorch;Swin + Conv,研究认可度高 | 约 17.95M 参数、FP32 约 72 MB,速度/显存成本较高 | 通用盲降噪强,但不是天文数据训练,弱星点有域偏移风险 |
|
| 131 |
+
| [Astro U-net](https://github.com/Sponka/Astro_U-net) | HST WFC3 科学图像 | 论文、代码和补充数据开放 | U-Net;论文报告 SNR 平均提高 1.63×、恒星恢复率 95.9%、平均 flux error 2.26% | 科学依据强,但针对特定 HST 波段;直接用于消费级 RGB 深空摄影不够即插即用 |
|
| 132 |
+
| [AstroNoiseNet](https://github.com/Steffenhir/AstroNoiseNet) | 真实短/长曝光深空摄影 | MIT,代码开放;PRIDNet + 判别器 | 较复杂,仓库没有便于独立部署的现成 PyTorch checkpoint | 数据构造思路很好;本 demo 的 M51 测试对来自该仓库 |
|
| 133 |
+
| [GraXpert](https://github.com/Steffenhir/GraXpert) | 深空摄影 | GPL-3.0 应用开放,发布 ONNX 模型 | 产品成熟、约 300+ GitHub stars、支持 CLI/PixInsight/Siril 工作流 | 实际认可度最高之一,但模型以 ONNX 应用形式分发,独立 PyTorch 复现和再训练不如本方案直接 |
|
| 134 |
+
| [Cosmic Clarity AI 3.6](https://github.com/setiastro/cosmicclarity) | 深空摄影 | **MIT,官方 PyTorch `.pth` + ONNX** | **2,945,283 参数;纯 FP32 参数约 11.24 MiB;官方 checkpoint 33.8 MB** | **本 demo 选择:领域匹配、体积、开放程度和部署成本最均衡** |
|
| 135 |
+
| [Denoising the Deep Sky](https://github.com/ShuhongLL/Denoising-Deep-Sky) | 科研级 raw CCD | 代码、MuSCAT 数据、PyTorch 训练流程开放 | 物理噪声模型 + U-Net/PMN-U-Net,数据约 124 GB | 2026 年的新工作,科学训练路线很强,但还不够“轻量即用”,适合下一阶段自训 |
|
| 136 |
+
|
| 137 |
+
选择 Cosmic Clarity 的理由:
|
| 138 |
+
|
| 139 |
+
- 是针对实际深空摄影训练的模型,不是用自然图像 AWGN 权重硬套;
|
| 140 |
+
- 官方直接发布 PyTorch checkpoint 和网络源代码,许可证为 MIT;
|
| 141 |
+
- 约 2.94M 参数,显著小于 SCUNet,256 tile 可在 8 GB 显卡上轻松运行;
|
| 142 |
+
- 结构是全分辨率残差 CNN,含 dilation 和多层 skip concatenation,适合保存点源与小尺度纹理;
|
| 143 |
+
- 上游工具已形成社区使用,并有 Siril 相关集成;
|
| 144 |
+
- 相比只发布 ONNX 的应用模型,更容易审阅、修改、继续训练和嵌入 Python 工程。
|
| 145 |
+
|
| 146 |
+
局限也要明确:该模型没有正式 model card 公布训练集规模、相机分布和严格 benchmark;MIT 是仓库整体许可证,权重发布页没有更细的单独数据许可声明。它适合“工程 demo / 深空摄影后期”,若目标是可发表的测光或形态学分析,必须在自己的仪器和目标分布上重新评估或训练。
|
| 147 |
+
|
| 148 |
+
## 4. 本复现与上游的对应关系
|
| 149 |
+
|
| 150 |
+
网络完全对应官方 AI 3.6 PyTorch 定义:
|
| 151 |
+
|
| 152 |
+
- 输入/输出:3 通道,范围 `[0,1]`;单色图复制为三通道,取输出第一通道;
|
| 153 |
+
- 编码通道:`3 → 16 → 32 → 64 → 128 → 256`;
|
| 154 |
+
- 64 和 256 通道入口使用 dilation=2;
|
| 155 |
+
- 每级包含两层 3×3 卷积的 residual block;
|
| 156 |
+
- 解码端逐级拼接 encoder 特征,最终 sigmoid 输出;
|
| 157 |
+
- 模型不做 pooling,空间尺寸始终不变。
|
| 158 |
+
|
| 159 |
+
本 demo 保留上游的 256 tile、64 overlap、MTF 线性拉伸/反拉伸、Y/Cb/Cr 亮度处理和引导色度降噪;工程化改动包括:
|
| 160 |
+
|
| 161 |
+
- Hann 权重融合取代简单覆盖/平均,减少 tile 接缝;
|
| 162 |
+
- `torch.inference_mode()`、CUDA FP16 和可调 batch;
|
| 163 |
+
- `torch.load(..., weights_only=True)` 限制反序列化范围;
|
| 164 |
+
- 独立、可测试的 I/O,支持 FITS header 与原始动态范围回写;
|
| 165 |
+
- CLI 参数和 SHA-256 校验。
|
| 166 |
+
|
| 167 |
+
官方来源和许可证副本保存在 `third_party/`。测试样例来自 AstroNoiseNet 的 `examples/M51_short.jpg` 与 `examples/M51_long.jpg`,均由本项目的下载脚本给出可复现 URL。
|
| 168 |
+
|
| 169 |
+
## 5. 推荐处理顺序
|
| 170 |
+
|
| 171 |
+
对消费级深空摄影数据,建议:
|
| 172 |
+
|
| 173 |
+
```text
|
| 174 |
+
raw 子帧
|
| 175 |
+
→ bias/dark/flat 校准
|
| 176 |
+
→ 星点配准
|
| 177 |
+
→ 异常值剔除与叠加
|
| 178 |
+
→ 背景/梯度校正、颜色校准
|
| 179 |
+
→ 在线性数据上温和降噪(或按训练域选择拉伸后降噪)
|
| 180 |
+
→ 反卷积/星点处理
|
| 181 |
+
→ 非线性拉伸与最终调色
|
| 182 |
+
```
|
| 183 |
+
|
| 184 |
+
若模型明确在 stretched 图上训练(Cosmic Clarity 属于这一类工程工具),本 demo 的 `auto` 会在线性 FITS 中位数很低时先做可逆 MTF 拉伸。强度建议从 `0.6~0.8` 开始,放大 100% 检查弱星与星云边缘;不要只看缩略图。
|
| 185 |
+
|
| 186 |
+
## 6. 如何获取自己的训练数据
|
| 187 |
+
|
| 188 |
+
### 路线 A:自己的相机,最推荐
|
| 189 |
+
|
| 190 |
+
对每个目标保存校准后的单帧,并生成高 SNR reference:
|
| 191 |
+
|
| 192 |
+
1. 同一目标、相同相机/增益/温度/滤镜拍摄 20~100+ 个 dithered subframes;
|
| 193 |
+
2. 每个子帧完成 bias/dark/flat 校准;
|
| 194 |
+
3. 亚像素配准到同一坐标;
|
| 195 |
+
4. 从其中一帧或少量帧构造 noisy input;其余帧用 sigma-clipping/加权平均生成 clean target;
|
| 196 |
+
5. 避免把 input 本身同时放进 target stack,否则 target 噪声与输入相关,会高估效果;
|
| 197 |
+
6. 不同观测目标划分 train/val/test,不能把同一张大图随机 patch 后分到不同集合;
|
| 198 |
+
7. 保存 32-bit float linear FITS,以及 gain、read noise、曝光、温度、滤镜等 metadata。
|
| 199 |
+
|
| 200 |
+
如果没有足够干净 target,可用 **Noise2Noise**:把同一视场奇数帧和偶数帧分别叠加成两张独立噪声图作为 pair。两边天体信号一致、随机噪声独立,使用 L1/L2 训练仍能学习期望的干净信号。也可用 Noise2Void/Self2Self 做单图自监督,但在密集星场要谨慎设计 blind-spot,避免把点源当噪声。
|
| 201 |
+
|
| 202 |
+
### 路线 B:公开科学数据
|
| 203 |
+
|
| 204 |
+
- [MuSCAT Raw Image dataset](https://huggingface.co/datasets/ToferFish/MuSCAT-RawImage):约 124 GB,CC BY-NC 4.0;包含 MuSCAT-3/4 的 raw、overscan-corrected `OS`、calibrated、stacked `MEAN`、mask、bias/dark/flat。可构造 `OS → CALIB` 或 `synthetic → MEAN`,是当前最完整的物理 CCD 降噪数据之一。非商业限制必须注意。
|
| 205 |
+
- [Denoising the Deep Sky 官方代码](https://github.com/ShuhongLL/Denoising-Deep-Sky):包含 FITS→NPY、数据划分、U-Net/PMN-U-Net 训练、PSF 注入和 PSNR/SSIM/NMAD/FLUX-SNR 评估脚本。
|
| 206 |
+
- [MAST](https://archive.stsci.edu/):HST 等任务的公开观测档案。可按 Astro U-net 方法选择同一目标/滤镜的短曝光与深曝光或 drizzle 产品;务必读取仪器校准与 data-use 说明。
|
| 207 |
+
- [Astro U-net 论文与代码](https://github.com/Sponka/Astro_U-net):适合复现 HST WFC3 F555W/F606W 的科学图像训练设计。
|
| 208 |
+
- [AstroNoiseNet](https://github.com/Steffenhir/AstroNoiseNet):仓库展示真实短/长积分 pair 与数据预处理思路,但没有发布完整训练数据;可借鉴采集协议。
|
| 209 |
+
|
| 210 |
+
公开数据和自己相机的数据不要直接混合后随机切分。不同相机的 PSF、像元、Bayer/mono、增益、读噪、滤镜和后处理差异很大,应先按 instrument 做域划分和归一化,并保留独立的跨仪器测试集。
|
| 211 |
+
|
| 212 |
+
## 7. 自训练架构建议
|
| 213 |
+
|
| 214 |
+
### 最低风险、最快落地
|
| 215 |
+
|
| 216 |
+
直接继续训练本 demo 的 `CosmicClarityDenoiseCNN`:
|
| 217 |
+
|
| 218 |
+
- 2.95M 参数,8 GB RTX 4060 可用 `256×256` crop、batch 2~4、AMP;
|
| 219 |
+
- 单色科学 FITS 最好把首尾层改为 1 通道,不要无意义复制 RGB;
|
| 220 |
+
- 输入预测 clean image,或者改为 residual noise prediction 并加全局 skip;
|
| 221 |
+
- 在输入中追加 noise-level/gain/read-noise map,提升不同相机和曝光参数的泛化。
|
| 222 |
+
|
| 223 |
+
### 追求更高上限
|
| 224 |
+
|
| 225 |
+
使用小型 **Residual U-Net / NAFNet-small**,4 层尺度、base width 32,约 8~20M 参数。下采样扩大感受野,skip 保留星点定位;用 tile inference 控制显存。科研 raw CCD 可参考 PMN-U-Net,把物理噪声参数或固定图样作为条件输入。
|
| 226 |
+
|
| 227 |
+
不建议第一版就用扩散模型或 GAN:推理���、训练成本高,而且感知上“锐利”的结果可能改变光度或生成假结构。天文图像首先应优化可测量的真实性。
|
| 228 |
+
|
| 229 |
+
建议训练配置:
|
| 230 |
+
|
| 231 |
+
| 项目 | 建议 |
|
| 232 |
+
|---|---|
|
| 233 |
+
| 数据域 | 线性、校准后、未锐化 FITS;每个 filter/instrument 记录 metadata |
|
| 234 |
+
| crop | 256×256;至少一部分 patch 强制含星点/星云,另一部分纯背景 |
|
| 235 |
+
| augmentation | 90° 旋转、翻转;Bayer raw 不能任意旋转/翻转后破坏 CFA 排列 |
|
| 236 |
+
| optimizer | AdamW,初始 LR `1e-4`,cosine decay,50~150 epochs |
|
| 237 |
+
| primary loss | Charbonnier 或 L1,比纯 MSE 更抗离群点 |
|
| 238 |
+
| structure loss | `0.1~0.2 × (1-SSIM)` + 小权重 gradient loss |
|
| 239 |
+
| astronomy loss | aperture-flux L1、PSF/FWHM loss 或 source-mask 加权;背景与源区分别统计 |
|
| 240 |
+
| validation | 按目标/夜晚/仪器分组,绝不能 patch 泄漏 |
|
| 241 |
+
| checkpoint | 以 flux bias + 背景 NMAD + SSIM 综合选模,而非只看训练 loss |
|
| 242 |
+
|
| 243 |
+
物理噪声合成可在干净 stack 上加入:
|
| 244 |
+
|
| 245 |
+
```text
|
| 246 |
+
electrons = clean_ADU × gain
|
| 247 |
+
shot ~ Poisson(electrons)
|
| 248 |
+
dark ~ Poisson(dark_current × exposure)
|
| 249 |
+
read ~ Normal(0, read_noise_e)
|
| 250 |
+
PRNU = signal × pixel_response_map
|
| 251 |
+
outliers = hot pixels + cosmic ray events
|
| 252 |
+
noisy_ADU = (shot + dark + read + PRNU + outliers) / gain
|
| 253 |
+
```
|
| 254 |
+
|
| 255 |
+
参数应从真实 bias/dark/flat 与相机 specification 估计,不要只用固定 `Gaussian(σ=25)`。合成数据用于扩充覆盖,最终仍应以真实独立短/长曝光 pair 微调和验证。
|
| 256 |
+
|
| 257 |
+
## 8. 科学验证清单
|
| 258 |
+
|
| 259 |
+
在把模型用于正式处理前,至少做以下检查:
|
| 260 |
+
|
| 261 |
+
1. 背景:均值/中位数偏移、标准差、MAD/NMAD、空间相关噪声功率谱;
|
| 262 |
+
2. 星点:检测 completeness、false positive、centroid 偏差、FWHM/椭率变化;
|
| 263 |
+
3. 测光:多个亮度区间的 aperture flux ratio 与 magnitude bias;
|
| 264 |
+
4. 扩展源:表面亮度、颜色、弱结构差值图;
|
| 265 |
+
5. 泛化:未见过的目标、夜晚、相机、滤镜、增益和曝光;
|
| 266 |
+
6. 伪影:星周黑环、彩斑、tile seam、背景塑料感、被抹掉的弱星;
|
| 267 |
+
7. 与传统基线比较:stacking、BM3D/小波、DnCNN/SCUNet,以及不降噪输入。
|
| 268 |
+
|
| 269 |
+
论文级评估可参考 Astro U-net 的星点恢复率与 flux error,以及 Denoising the Deep Sky 的 PSNR、SSIM、NMAD、source injection 与 FLUX-SNR。保存原始图、输出图、模型版本、权重 hash、参数和环境版本,保证可追溯。
|
| 270 |
+
|
| 271 |
+
## 9. 目录结构与许可证
|
| 272 |
+
|
| 273 |
+
```text
|
| 274 |
+
StarDenoise/
|
| 275 |
+
├── denoise.py # CLI 与推理管线
|
| 276 |
+
├── model.py # AI 3.6 PyTorch 网络
|
| 277 |
+
├── image_io.py # JPG/PNG/TIFF/FITS I/O
|
| 278 |
+
├── evaluate.py # 样例全参考指标
|
| 279 |
+
├── download_assets.py # 官方资产下载与 hash 校验
|
| 280 |
+
├── models/ # 官方 checkpoint
|
| 281 |
+
├── samples/ # 官方 M51 短/长曝光样例
|
| 282 |
+
├── outputs/ # demo 输出
|
| 283 |
+
├── tests/ # 单元测试
|
| 284 |
+
└── third_party/ # 上游许可证/审阅源码
|
| 285 |
+
```
|
| 286 |
+
|
| 287 |
+
Cosmic Clarity 和 AstroNoiseNet 均使用 MIT License;上游许可证副本位于 `third_party/`。本复现应保留这些版权与许可声明。测试样例的使用遵循其来源仓库条款;用于商业产品前,仍建议向权重作者确认模型训练数据与权重的商业授权范围。
|
| 288 |
+
|
| 289 |
+
## 10. 主要来源
|
| 290 |
+
|
| 291 |
+
- [Cosmic Clarity 官方仓库(MIT、PyTorch 源码)](https://github.com/setiastro/cosmicclarity)
|
| 292 |
+
- [Cosmic Clarity 官方 Release(AI 3.6 PTH/ONNX 与 SHA-256)](https://github.com/setiastro/cosmicclarity/releases/tag/Linux)
|
| 293 |
+
- [AstroNoiseNet 官方仓库与 M51 样例](https://github.com/Steffenhir/AstroNoiseNet)
|
| 294 |
+
- [GraXpert 官方仓库与 CLI](https://github.com/Steffenhir/GraXpert)
|
| 295 |
+
- [Learning to denoise astronomical images with U-nets(MNRAS)](https://academic.oup.com/mnras/article/503/3/3204/5989737)
|
| 296 |
+
- [Denoising the Deep Sky(论文)](https://arxiv.org/abs/2601.23276)
|
| 297 |
+
- [Denoising the Deep Sky(代码与数据说明)](https://github.com/ShuhongLL/Denoising-Deep-Sky)
|
| 298 |
+
- [DnCNN 官方仓库](https://github.com/cszn/DnCNN)
|
| 299 |
+
- [SCUNet 官方仓库](https://github.com/cszn/SCUNet)
|
| 300 |
+
- [Astropy CCD reduction guide](https://www.astropy.org/ccd-reduction-and-photometry-guide/v/dev/notebooks/01-01-astronomical-CCD-image-components.html)
|
StarDenoise/README_1080P.md
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# 1080P 在线天文图片测试报告
|
| 2 |
+
|
| 3 |
+
测试日期:2026-09-01。目标是验证 `StarDenoise` 在真正的 1920×1080 图像上能否完整运行,并观察不同天文内容对降噪强度的敏感性。
|
| 4 |
+
|
| 5 |
+
## 测试图片与授权
|
| 6 |
+
|
| 7 |
+
三张图都从欧洲南方天文台(ESO)官方下载,文件经 Pillow 验证为 `1920×1080 RGB`。
|
| 8 |
+
|
| 9 |
+
| 测试图 | ESO 页面 | 下载文件 | 署名 |
|
| 10 |
+
|---|---|---|---|
|
| 11 |
+
| Eagle / Omega / Sh2-54 三大星云全景 | [vbg_011](https://www.eso.org/public/products/virtualbackgrounds/vbg_011/) | `inputs_1080p/references/vbg_011_nebulae.jpg` | ESO |
|
| 12 |
+
| M17 恒星形成区 | [vbg_012](https://www.eso.org/public/products/virtualbackgrounds/vbg_012/) | `inputs_1080p/references/vbg_012_messier17.jpg` | ESO |
|
| 13 |
+
| 猎户座星云 | [vbg_013](https://www.eso.org/public/products/virtualbackgrounds/vbg_013/) | `inputs_1080p/references/vbg_013_orion.jpg` | ESO/Igor Chekalin |
|
| 14 |
+
|
| 15 |
+
除非页面另有说明,ESO 公共图片采用 [Creative Commons Attribution 4.0](https://www.eso.org/public/outreach/copyright/);再发布图片或本报告中的对比图时必须保留上表完整署名。图中的 ESO 水印来自官方原图,本测试没有移除。
|
| 16 |
+
|
| 17 |
+
## 为什么添加可控噪声
|
| 18 |
+
|
| 19 |
+
ESO 发布图片已经经过校准、叠加和后期处理,本身接近低噪成片。如果直接对低噪图运行模型,只能看到轻微平滑,无法客观判断去噪正确性。
|
| 20 |
+
|
| 21 |
+
因此测试保留下载图作为 reference,并用固定随机种子生成真实感更接近传感器的测试输入:
|
| 22 |
+
|
| 23 |
+
```text
|
| 24 |
+
shot noise = Poisson(reference × 350) / 350
|
| 25 |
+
read noise = Normal(0, 0.010)
|
| 26 |
+
row pattern = 每行每通道 Normal(0, 0.003)
|
| 27 |
+
noisy = clip(shot + read + row pattern, 0, 1)
|
| 28 |
+
```
|
| 29 |
+
|
| 30 |
+
生成的 `inputs_1080p/noisy/*.png` 仍然是 1920×1080。脚本保存 noisy PNG 后会重新读取该文件,再送入模型;输出 PNG 也重新读取后才计算指标,所以报告数字与交付文件完全一致。
|
| 31 |
+
|
| 32 |
+
## 统一测试参数
|
| 33 |
+
|
| 34 |
+
```text
|
| 35 |
+
model Cosmic Clarity AI 3.6 PyTorch
|
| 36 |
+
device CPU / torch 2.13.0+cpu
|
| 37 |
+
resolution 1920×1080 RGB
|
| 38 |
+
tile 256×256
|
| 39 |
+
overlap 64
|
| 40 |
+
mode full(亮度 AI + 引导色度滤波)
|
| 41 |
+
strength 0.85
|
| 42 |
+
color-strength 0.75
|
| 43 |
+
precision FP32
|
| 44 |
+
linear-stretch never(输入已经拉伸)
|
| 45 |
+
```
|
| 46 |
+
|
| 47 |
+
运行:
|
| 48 |
+
|
| 49 |
+
```powershell
|
| 50 |
+
cd D:\Workman\MyProject\ImageStack\AstroColorProcess\StarDenoise
|
| 51 |
+
conda activate study
|
| 52 |
+
python benchmark_1080p.py
|
| 53 |
+
```
|
| 54 |
+
|
| 55 |
+
## 严格落盘结果
|
| 56 |
+
|
| 57 |
+
| 图片 | CPU 耗时 | 输入 PSNR | 输出 PSNR | 输入 SSIM | 输出 SSIM | 输入 MAE | 输出 MAE | 结论 |
|
| 58 |
+
|---|---:|---:|---:|---:|---:|---:|---:|---|
|
| 59 |
+
| 三大星云全景 | 52.980 s | 30.603 | 27.274 | 0.89247 | 0.78516 | 0.022849 | 0.030590 | **0.85 过强,真实密集星点被平滑** |
|
| 60 |
+
| M17 | 57.588 s | 30.724 | 30.781 | 0.75422 | 0.88554 | 0.021921 | 0.017849 | PSNR基本持平,结构和绝对误差改善 |
|
| 61 |
+
| 猎户座星云 | 59.533 s | 28.392 | **34.168** | 0.50262 | **0.91295** | 0.029182 | **0.011197** | **明显有效** |
|
| 62 |
+
|
| 63 |
+
平均 CPU 时间约 56.7 秒/张;输出尺寸全部保持 1920×1080,未发现 tile seam。
|
| 64 |
+
|
| 65 |
+
## 密集星野的低强度复测
|
| 66 |
+
|
| 67 |
+
三大星云全景包含极密集星点和大量细碎高频结构。统一的 `strength=0.85` 会把一部分真实结构识别成噪声,因此使用同一个 `denoise.py` 精确重跑:
|
| 68 |
+
|
| 69 |
+
```powershell
|
| 70 |
+
python denoise.py `
|
| 71 |
+
inputs_1080p\noisy\vbg_011_nebulae_noisy.png `
|
| 72 |
+
outputs_1080p\vbg_011_nebulae_denoised_strength030.png `
|
| 73 |
+
--device cpu --strength 0.30 --color-strength 0.25 --mode full `
|
| 74 |
+
--tile-size 256 --overlap 64 --precision fp32 --linear-stretch never
|
| 75 |
+
```
|
| 76 |
+
|
| 77 |
+
| 三大星云全景 | PSNR | SSIM | MAE | CPU 时间 |
|
| 78 |
+
|---|---:|---:|---:|---:|
|
| 79 |
+
| 带噪输入 | 30.603 | 0.89247 | 0.022849 | — |
|
| 80 |
+
| strength=0.85 | 27.274 | 0.78516 | 0.030590 | 52.980 s |
|
| 81 |
+
| **strength=0.30** | **32.300** | **0.92579** | **0.018252** | 51.126 s |
|
| 82 |
+
|
| 83 |
+
这说明失败原因不是分辨率或 tile 推理错误,而是降噪强度与内容不匹配。网络计算量基本与 strength 无关,所以低强度不会显著加速;它只是把更多原始细节混回输出。
|
| 84 |
+
|
| 85 |
+
## 对比图
|
| 86 |
+
|
| 87 |
+
每张图从左到右都是:带噪输入、模型输出、ESO reference。
|
| 88 |
+
|
| 89 |
+
### 三大星云全景——调优后 strength=0.30
|
| 90 |
+
|
| 91 |
+

|
| 92 |
+
|
| 93 |
+
### M17——strength=0.85
|
| 94 |
+
|
| 95 |
+

|
| 96 |
+
|
| 97 |
+
### 猎户座星云——strength=0.85
|
| 98 |
+
|
| 99 |
+

|
| 100 |
+
|
| 101 |
+
## 结论
|
| 102 |
+
|
| 103 |
+
1. 当前代码可以稳定处理 1920×1080 RGB 图片,输出尺寸正确且无明显分块接缝。
|
| 104 |
+
2. 对猎户座这种大面积弥散结构,`0.8~0.9` 强度效果很好。
|
| 105 |
+
3. 对 M17 这类星点与星云混合画面,强降噪会改善背景/结构一致性,但 PSNR不一定大幅增加,建议从 `0.5~0.7` 开始。
|
| 106 |
+
4. 对极密集星野,建议 `0.25~0.40`;本次 `0.30` 明显优于 `0.85`。
|
| 107 |
+
5. 仅以缩略图判断容易忽略被删除的弱星。产品化时应加入星点密度/高频能量检测,自动限制 strength,并提供 100% 局部预览。
|
| 108 |
+
6. 本测试是受控合成噪声 benchmark,不等价于相机 raw/FITS 物理验证。正式使用仍应测试真实短曝光与长曝光 stack pair、星点 flux 和 FWHM。
|
| 109 |
+
|
| 110 |
+
完整机器可读数据位于 `outputs_1080p/benchmark_results.json`,测试脚本为 `benchmark_1080p.py`。
|
| 111 |
+
|
StarDenoise/benchmark_1080p.py
ADDED
|
@@ -0,0 +1,191 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Reproducible 1080p benchmark using three CC BY 4.0 ESO images.
|
| 2 |
+
|
| 3 |
+
The ESO publication images are already processed and comparatively clean, so this
|
| 4 |
+
script creates deterministic Poisson + Gaussian sensor-noise inputs. The original
|
| 5 |
+
downloads remain untouched and act as full-reference targets.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
from __future__ import annotations
|
| 9 |
+
|
| 10 |
+
import json
|
| 11 |
+
import math
|
| 12 |
+
import time
|
| 13 |
+
from pathlib import Path
|
| 14 |
+
|
| 15 |
+
import cv2
|
| 16 |
+
import numpy as np
|
| 17 |
+
import torch
|
| 18 |
+
from PIL import Image, ImageDraw
|
| 19 |
+
|
| 20 |
+
from denoise import denoise_array
|
| 21 |
+
from model import load_model
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
ROOT = Path(__file__).resolve().parent
|
| 25 |
+
REFERENCE_DIR = ROOT / "inputs_1080p" / "references"
|
| 26 |
+
NOISY_DIR = ROOT / "inputs_1080p" / "noisy"
|
| 27 |
+
OUTPUT_DIR = ROOT / "outputs_1080p"
|
| 28 |
+
COMPARISON_DIR = OUTPUT_DIR / "comparisons"
|
| 29 |
+
WEIGHTS = ROOT / "models" / "deep_denoise_cnn_AI3_6.pth"
|
| 30 |
+
|
| 31 |
+
IMAGES = {
|
| 32 |
+
"vbg_011_nebulae": {
|
| 33 |
+
"filename": "vbg_011_nebulae.jpg",
|
| 34 |
+
"title": "Eagle / Omega / Sh2-54 nebulae",
|
| 35 |
+
"source": "https://www.eso.org/public/products/virtualbackgrounds/vbg_011/",
|
| 36 |
+
"credit": "ESO",
|
| 37 |
+
},
|
| 38 |
+
"vbg_012_messier17": {
|
| 39 |
+
"filename": "vbg_012_messier17.jpg",
|
| 40 |
+
"title": "Messier 17",
|
| 41 |
+
"source": "https://www.eso.org/public/products/virtualbackgrounds/vbg_012/",
|
| 42 |
+
"credit": "ESO",
|
| 43 |
+
},
|
| 44 |
+
"vbg_013_orion": {
|
| 45 |
+
"filename": "vbg_013_orion.jpg",
|
| 46 |
+
"title": "Orion Nebula",
|
| 47 |
+
"source": "https://www.eso.org/public/products/virtualbackgrounds/vbg_013/",
|
| 48 |
+
"credit": "ESO/Igor Chekalin",
|
| 49 |
+
},
|
| 50 |
+
}
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
def load_rgb(path: Path) -> np.ndarray:
|
| 54 |
+
with Image.open(path) as image:
|
| 55 |
+
return np.asarray(image.convert("RGB"), dtype=np.float32) / 255.0
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
def save_rgb(path: Path, image: np.ndarray) -> None:
|
| 59 |
+
path.parent.mkdir(parents=True, exist_ok=True)
|
| 60 |
+
array = np.clip(image * 255.0 + 0.5, 0, 255).astype(np.uint8)
|
| 61 |
+
Image.fromarray(array, "RGB").save(path)
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
def add_sensor_noise(clean: np.ndarray, seed: int) -> np.ndarray:
|
| 65 |
+
"""Add signal-dependent shot noise plus channel-wise read noise.
|
| 66 |
+
|
| 67 |
+
``peak_electrons`` controls the Poisson component. The values are deliberately
|
| 68 |
+
moderate: visible at 100% but not so strong that the test becomes artificial.
|
| 69 |
+
"""
|
| 70 |
+
rng = np.random.default_rng(seed)
|
| 71 |
+
peak_electrons = 350.0
|
| 72 |
+
shot = rng.poisson(np.clip(clean, 0, 1) * peak_electrons).astype(np.float32) / peak_electrons
|
| 73 |
+
read = rng.normal(0.0, 0.010, clean.shape).astype(np.float32)
|
| 74 |
+
# A small row-correlated component approximates residual banding/read pattern.
|
| 75 |
+
row_pattern = rng.normal(0.0, 0.003, (clean.shape[0], 1, 3)).astype(np.float32)
|
| 76 |
+
return np.clip(shot + read + row_pattern, 0.0, 1.0)
|
| 77 |
+
|
| 78 |
+
|
| 79 |
+
def psnr(reference: np.ndarray, candidate: np.ndarray) -> float:
|
| 80 |
+
mse = float(np.mean((reference - candidate) ** 2))
|
| 81 |
+
return math.inf if mse == 0 else 10.0 * math.log10(1.0 / mse)
|
| 82 |
+
|
| 83 |
+
|
| 84 |
+
def ssim(reference: np.ndarray, candidate: np.ndarray) -> float:
|
| 85 |
+
"""Standard local SSIM averaged over RGB channels."""
|
| 86 |
+
c1, c2 = 0.01**2, 0.03**2
|
| 87 |
+
scores = []
|
| 88 |
+
for channel in range(3):
|
| 89 |
+
x, y = reference[..., channel], candidate[..., channel]
|
| 90 |
+
mu_x = cv2.GaussianBlur(x, (11, 11), 1.5)
|
| 91 |
+
mu_y = cv2.GaussianBlur(y, (11, 11), 1.5)
|
| 92 |
+
sigma_x = cv2.GaussianBlur(x * x, (11, 11), 1.5) - mu_x * mu_x
|
| 93 |
+
sigma_y = cv2.GaussianBlur(y * y, (11, 11), 1.5) - mu_y * mu_y
|
| 94 |
+
sigma_xy = cv2.GaussianBlur(x * y, (11, 11), 1.5) - mu_x * mu_y
|
| 95 |
+
numerator = (2 * mu_x * mu_y + c1) * (2 * sigma_xy + c2)
|
| 96 |
+
denominator = (mu_x * mu_x + mu_y * mu_y + c1) * (sigma_x + sigma_y + c2)
|
| 97 |
+
scores.append(float(np.mean(numerator / np.maximum(denominator, 1e-12))))
|
| 98 |
+
return float(np.mean(scores))
|
| 99 |
+
|
| 100 |
+
|
| 101 |
+
def mae(reference: np.ndarray, candidate: np.ndarray) -> float:
|
| 102 |
+
return float(np.mean(np.abs(reference - candidate)))
|
| 103 |
+
|
| 104 |
+
|
| 105 |
+
def make_comparison(path: Path, noisy: np.ndarray, denoised: np.ndarray, reference: np.ndarray, title: str) -> None:
|
| 106 |
+
panel_size = (640, 360)
|
| 107 |
+
labels = ("NOISY INPUT", "DENOISED", "ESO REFERENCE")
|
| 108 |
+
images = (noisy, denoised, reference)
|
| 109 |
+
canvas = Image.new("RGB", (1920, 410), "#111111")
|
| 110 |
+
draw = ImageDraw.Draw(canvas)
|
| 111 |
+
draw.text((12, 8), title, fill="white")
|
| 112 |
+
for index, (label, array) in enumerate(zip(labels, images)):
|
| 113 |
+
panel = Image.fromarray(np.clip(array * 255.0 + 0.5, 0, 255).astype(np.uint8), "RGB")
|
| 114 |
+
panel = panel.resize(panel_size, Image.Resampling.LANCZOS)
|
| 115 |
+
x = index * panel_size[0]
|
| 116 |
+
canvas.paste(panel, (x, 50))
|
| 117 |
+
draw.text((x + 12, 30), label, fill="white")
|
| 118 |
+
path.parent.mkdir(parents=True, exist_ok=True)
|
| 119 |
+
canvas.save(path, quality=94)
|
| 120 |
+
|
| 121 |
+
|
| 122 |
+
def main() -> None:
|
| 123 |
+
NOISY_DIR.mkdir(parents=True, exist_ok=True)
|
| 124 |
+
OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
|
| 125 |
+
COMPARISON_DIR.mkdir(parents=True, exist_ok=True)
|
| 126 |
+
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
|
| 127 |
+
model = load_model(WEIGHTS, device)
|
| 128 |
+
results = []
|
| 129 |
+
|
| 130 |
+
for seed, (key, metadata) in enumerate(IMAGES.items(), start=20260901):
|
| 131 |
+
reference_path = REFERENCE_DIR / metadata["filename"]
|
| 132 |
+
reference = load_rgb(reference_path)
|
| 133 |
+
if reference.shape[:2] != (1080, 1920):
|
| 134 |
+
raise ValueError(f"Expected 1920x1080: {reference_path} -> {reference.shape}")
|
| 135 |
+
noisy = add_sensor_noise(reference, seed)
|
| 136 |
+
noisy_path = NOISY_DIR / f"{key}_noisy.png"
|
| 137 |
+
save_rgb(noisy_path, noisy)
|
| 138 |
+
# Benchmark the exact 8-bit file that a user can pass to denoise.py.
|
| 139 |
+
noisy = load_rgb(noisy_path)
|
| 140 |
+
|
| 141 |
+
started = time.perf_counter()
|
| 142 |
+
denoised = denoise_array(
|
| 143 |
+
noisy,
|
| 144 |
+
model,
|
| 145 |
+
device,
|
| 146 |
+
strength=0.85,
|
| 147 |
+
color_strength=0.75,
|
| 148 |
+
mode="full",
|
| 149 |
+
tile_size=256,
|
| 150 |
+
overlap=64,
|
| 151 |
+
batch_size=1,
|
| 152 |
+
half_precision=device.type == "cuda",
|
| 153 |
+
linear_stretch="never",
|
| 154 |
+
)
|
| 155 |
+
elapsed = time.perf_counter() - started
|
| 156 |
+
output_path = OUTPUT_DIR / f"{key}_denoised.png"
|
| 157 |
+
save_rgb(output_path, denoised)
|
| 158 |
+
denoised = load_rgb(output_path)
|
| 159 |
+
make_comparison(
|
| 160 |
+
COMPARISON_DIR / f"{key}_comparison.jpg",
|
| 161 |
+
noisy,
|
| 162 |
+
denoised,
|
| 163 |
+
reference,
|
| 164 |
+
metadata["title"],
|
| 165 |
+
)
|
| 166 |
+
|
| 167 |
+
result = {
|
| 168 |
+
"id": key,
|
| 169 |
+
**metadata,
|
| 170 |
+
"resolution": "1920x1080",
|
| 171 |
+
"device": str(device),
|
| 172 |
+
"elapsed_seconds": round(elapsed, 3),
|
| 173 |
+
"noisy_psnr_db": round(psnr(reference, noisy), 3),
|
| 174 |
+
"denoised_psnr_db": round(psnr(reference, denoised), 3),
|
| 175 |
+
"noisy_ssim": round(ssim(reference, noisy), 5),
|
| 176 |
+
"denoised_ssim": round(ssim(reference, denoised), 5),
|
| 177 |
+
"noisy_mae": round(mae(reference, noisy), 6),
|
| 178 |
+
"denoised_mae": round(mae(reference, denoised), 6),
|
| 179 |
+
"input": str(noisy_path.relative_to(ROOT)),
|
| 180 |
+
"output": str(output_path.relative_to(ROOT)),
|
| 181 |
+
}
|
| 182 |
+
results.append(result)
|
| 183 |
+
print(json.dumps(result, ensure_ascii=False))
|
| 184 |
+
|
| 185 |
+
(OUTPUT_DIR / "benchmark_results.json").write_text(
|
| 186 |
+
json.dumps(results, indent=2, ensure_ascii=False), encoding="utf-8"
|
| 187 |
+
)
|
| 188 |
+
|
| 189 |
+
|
| 190 |
+
if __name__ == "__main__":
|
| 191 |
+
main()
|
StarDenoise/denoise.py
ADDED
|
@@ -0,0 +1,262 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Command-line inference demo for Cosmic Clarity AI 3.6."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import argparse
|
| 6 |
+
import contextlib
|
| 7 |
+
import math
|
| 8 |
+
import time
|
| 9 |
+
from pathlib import Path
|
| 10 |
+
from typing import Iterator
|
| 11 |
+
|
| 12 |
+
import cv2
|
| 13 |
+
import numpy as np
|
| 14 |
+
import torch
|
| 15 |
+
|
| 16 |
+
from image_io import load_image, save_image
|
| 17 |
+
from model import load_model, model_statistics
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
ROOT = Path(__file__).resolve().parent
|
| 21 |
+
DEFAULT_WEIGHTS = ROOT / "models" / "deep_denoise_cnn_AI3_6.pth"
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
def rgb_to_ycbcr(image: np.ndarray) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
|
| 25 |
+
matrix = np.array(
|
| 26 |
+
[[0.299, 0.587, 0.114], [-0.168736, -0.331264, 0.5], [0.5, -0.418688, -0.081312]],
|
| 27 |
+
dtype=np.float32,
|
| 28 |
+
)
|
| 29 |
+
converted = image @ matrix.T
|
| 30 |
+
return converted[..., 0], converted[..., 1] + 0.5, converted[..., 2] + 0.5
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
def ycbcr_to_rgb(y: np.ndarray, cb: np.ndarray, cr: np.ndarray) -> np.ndarray:
|
| 34 |
+
converted = np.stack((y, cb - 0.5, cr - 0.5), axis=-1)
|
| 35 |
+
matrix = np.array(
|
| 36 |
+
[[1.0, 0.0, 1.402], [1.0, -0.344136, -0.714136], [1.0, 1.772, 0.0]],
|
| 37 |
+
dtype=np.float32,
|
| 38 |
+
)
|
| 39 |
+
return np.clip(converted @ matrix.T, 0.0, 1.0)
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
def _mtf_stretch(image: np.ndarray, target_median: float = 0.25) -> tuple[np.ndarray, float, list[float]]:
|
| 43 |
+
original_min = float(image.min())
|
| 44 |
+
stretched = image - original_min
|
| 45 |
+
medians = [float(np.median(stretched[..., channel])) for channel in range(3)]
|
| 46 |
+
for channel, median in enumerate(medians):
|
| 47 |
+
if median == 0:
|
| 48 |
+
continue
|
| 49 |
+
x = stretched[..., channel]
|
| 50 |
+
denominator = median * (target_median + x - 1.0) - target_median * x
|
| 51 |
+
stretched[..., channel] = ((median - 1.0) * target_median * x) / np.where(
|
| 52 |
+
np.abs(denominator) < 1e-12, 1e-12, denominator
|
| 53 |
+
)
|
| 54 |
+
return np.clip(stretched, 0.0, 1.0), original_min, medians
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
def _mtf_unstretch(image: np.ndarray, original_min: float, original_medians: list[float]) -> np.ndarray:
|
| 58 |
+
result = image.copy()
|
| 59 |
+
current_medians = [float(np.median(result[..., channel])) for channel in range(3)]
|
| 60 |
+
for channel, current in enumerate(current_medians):
|
| 61 |
+
target = original_medians[channel]
|
| 62 |
+
if current == 0 or target == 0:
|
| 63 |
+
continue
|
| 64 |
+
x = result[..., channel]
|
| 65 |
+
denominator = current * (target + x - 1.0) - target * x
|
| 66 |
+
result[..., channel] = ((current - 1.0) * target * x) / np.where(
|
| 67 |
+
np.abs(denominator) < 1e-12, 1e-12, denominator
|
| 68 |
+
)
|
| 69 |
+
return np.clip(result + original_min, 0.0, 1.0)
|
| 70 |
+
|
| 71 |
+
|
| 72 |
+
def _guided_filter(guide: np.ndarray, source: np.ndarray, radius: int, epsilon: float) -> np.ndarray:
|
| 73 |
+
kernel = (2 * radius + 1, 2 * radius + 1)
|
| 74 |
+
kwargs = {"ddepth": -1, "ksize": kernel, "borderType": cv2.BORDER_REFLECT}
|
| 75 |
+
mean_i = cv2.boxFilter(guide, **kwargs)
|
| 76 |
+
mean_p = cv2.boxFilter(source, **kwargs)
|
| 77 |
+
corr_i = cv2.boxFilter(guide * guide, **kwargs)
|
| 78 |
+
corr_ip = cv2.boxFilter(guide * source, **kwargs)
|
| 79 |
+
variance_i = corr_i - mean_i * mean_i
|
| 80 |
+
covariance_ip = corr_ip - mean_i * mean_p
|
| 81 |
+
a = covariance_ip / (variance_i + epsilon)
|
| 82 |
+
b = mean_p - a * mean_i
|
| 83 |
+
return cv2.boxFilter(a, **kwargs) * guide + cv2.boxFilter(b, **kwargs)
|
| 84 |
+
|
| 85 |
+
|
| 86 |
+
def denoise_chroma(y: np.ndarray, cb: np.ndarray, cr: np.ndarray, strength: float) -> tuple[np.ndarray, np.ndarray]:
|
| 87 |
+
effective = float(np.clip(strength * 2.0, 0.0, 1.0))
|
| 88 |
+
if effective == 0:
|
| 89 |
+
return cb, cr
|
| 90 |
+
radius = 2 + int(round(10 * effective))
|
| 91 |
+
epsilon = (0.001 + 0.05 * effective) ** 2
|
| 92 |
+
cb_filtered = _guided_filter(y, cb, radius, epsilon)
|
| 93 |
+
cr_filtered = _guided_filter(y, cr, radius, epsilon)
|
| 94 |
+
return (
|
| 95 |
+
(1.0 - effective) * cb + effective * cb_filtered,
|
| 96 |
+
(1.0 - effective) * cr + effective * cr_filtered,
|
| 97 |
+
)
|
| 98 |
+
|
| 99 |
+
|
| 100 |
+
def _positions(length: int, tile: int, overlap: int) -> list[int]:
|
| 101 |
+
if length <= tile:
|
| 102 |
+
return [0]
|
| 103 |
+
stride = tile - overlap
|
| 104 |
+
result = list(range(0, length - tile + 1, stride))
|
| 105 |
+
if result[-1] != length - tile:
|
| 106 |
+
result.append(length - tile)
|
| 107 |
+
return result
|
| 108 |
+
|
| 109 |
+
|
| 110 |
+
def _batches(items: list[tuple[int, int, np.ndarray]], size: int) -> Iterator[list[tuple[int, int, np.ndarray]]]:
|
| 111 |
+
for start in range(0, len(items), size):
|
| 112 |
+
yield items[start : start + size]
|
| 113 |
+
|
| 114 |
+
|
| 115 |
+
def infer_channel(
|
| 116 |
+
channel: np.ndarray,
|
| 117 |
+
model: torch.nn.Module,
|
| 118 |
+
device: torch.device,
|
| 119 |
+
tile_size: int,
|
| 120 |
+
overlap: int,
|
| 121 |
+
batch_size: int,
|
| 122 |
+
half_precision: bool,
|
| 123 |
+
) -> np.ndarray:
|
| 124 |
+
if overlap >= tile_size:
|
| 125 |
+
raise ValueError("overlap must be smaller than tile-size")
|
| 126 |
+
height, width = channel.shape
|
| 127 |
+
pad_y, pad_x = max(0, tile_size - height), max(0, tile_size - width)
|
| 128 |
+
padded = np.pad(channel, ((0, pad_y), (0, pad_x)), mode="reflect")
|
| 129 |
+
ph, pw = padded.shape
|
| 130 |
+
tiles = [
|
| 131 |
+
(y, x, padded[y : y + tile_size, x : x + tile_size])
|
| 132 |
+
for y in _positions(ph, tile_size, overlap)
|
| 133 |
+
for x in _positions(pw, tile_size, overlap)
|
| 134 |
+
]
|
| 135 |
+
# A Hann window suppresses tile seams; the floor guarantees edge coverage.
|
| 136 |
+
window_1d = np.hanning(tile_size).astype(np.float32)
|
| 137 |
+
window = np.maximum(np.outer(window_1d, window_1d), 1e-3)
|
| 138 |
+
accumulated = np.zeros_like(padded, dtype=np.float32)
|
| 139 |
+
weights = np.zeros_like(padded, dtype=np.float32)
|
| 140 |
+
|
| 141 |
+
use_amp = half_precision and device.type == "cuda"
|
| 142 |
+
for batch in _batches(tiles, batch_size):
|
| 143 |
+
array = np.stack([item[2] for item in batch])
|
| 144 |
+
tensor = torch.from_numpy(array[:, None]).repeat(1, 3, 1, 1).to(device)
|
| 145 |
+
amp = torch.autocast(device_type="cuda", dtype=torch.float16) if use_amp else contextlib.nullcontext()
|
| 146 |
+
with torch.inference_mode(), amp:
|
| 147 |
+
prediction = model(tensor)[:, 0].float().cpu().numpy()
|
| 148 |
+
for (y, x, _), tile_prediction in zip(batch, prediction):
|
| 149 |
+
accumulated[y : y + tile_size, x : x + tile_size] += tile_prediction * window
|
| 150 |
+
weights[y : y + tile_size, x : x + tile_size] += window
|
| 151 |
+
return (accumulated / np.maximum(weights, 1e-8))[:height, :width]
|
| 152 |
+
|
| 153 |
+
|
| 154 |
+
def denoise_array(
|
| 155 |
+
pixels: np.ndarray,
|
| 156 |
+
model: torch.nn.Module,
|
| 157 |
+
device: torch.device,
|
| 158 |
+
strength: float = 0.9,
|
| 159 |
+
color_strength: float | None = None,
|
| 160 |
+
mode: str = "full",
|
| 161 |
+
tile_size: int = 256,
|
| 162 |
+
overlap: int = 64,
|
| 163 |
+
batch_size: int = 1,
|
| 164 |
+
half_precision: bool = True,
|
| 165 |
+
linear_stretch: str = "auto",
|
| 166 |
+
) -> np.ndarray:
|
| 167 |
+
if not 0.0 <= strength <= 1.0:
|
| 168 |
+
raise ValueError("strength must be in [0, 1]")
|
| 169 |
+
if strength == 0.0:
|
| 170 |
+
return pixels.copy()
|
| 171 |
+
mono = pixels.shape[-1] == 1
|
| 172 |
+
rgb = np.repeat(pixels, 3, axis=-1) if mono else pixels[..., :3].copy()
|
| 173 |
+
|
| 174 |
+
should_stretch = linear_stretch == "always" or (
|
| 175 |
+
linear_stretch == "auto" and float(np.median(rgb - rgb.min())) < 0.05
|
| 176 |
+
)
|
| 177 |
+
stretch_state = None
|
| 178 |
+
if should_stretch:
|
| 179 |
+
rgb, minimum, medians = _mtf_stretch(rgb)
|
| 180 |
+
stretch_state = (minimum, medians)
|
| 181 |
+
|
| 182 |
+
def run(channel: np.ndarray) -> np.ndarray:
|
| 183 |
+
return infer_channel(channel, model, device, tile_size, overlap, batch_size, half_precision)
|
| 184 |
+
|
| 185 |
+
if mono:
|
| 186 |
+
denoised = ((1.0 - strength) * rgb[..., 0] + strength * run(rgb[..., 0]))[..., None]
|
| 187 |
+
result = np.repeat(denoised, 3, axis=-1)
|
| 188 |
+
elif mode == "separate":
|
| 189 |
+
result = np.stack(
|
| 190 |
+
[(1.0 - strength) * rgb[..., c] + strength * run(rgb[..., c]) for c in range(3)],
|
| 191 |
+
axis=-1,
|
| 192 |
+
)
|
| 193 |
+
elif mode in ("luminance", "full"):
|
| 194 |
+
y, cb, cr = rgb_to_ycbcr(rgb)
|
| 195 |
+
y = (1.0 - strength) * y + strength * run(y)
|
| 196 |
+
if mode == "full":
|
| 197 |
+
cb, cr = denoise_chroma(y, cb, cr, strength if color_strength is None else color_strength)
|
| 198 |
+
result = ycbcr_to_rgb(y, cb, cr)
|
| 199 |
+
else:
|
| 200 |
+
raise ValueError(f"Unknown mode: {mode}")
|
| 201 |
+
|
| 202 |
+
if stretch_state is not None:
|
| 203 |
+
result = _mtf_unstretch(result, *stretch_state)
|
| 204 |
+
return result[..., :1] if mono else result
|
| 205 |
+
|
| 206 |
+
|
| 207 |
+
def choose_device(requested: str) -> torch.device:
|
| 208 |
+
if requested == "auto":
|
| 209 |
+
return torch.device("cuda" if torch.cuda.is_available() else "cpu")
|
| 210 |
+
device = torch.device(requested)
|
| 211 |
+
if device.type == "cuda" and not torch.cuda.is_available():
|
| 212 |
+
raise RuntimeError("CUDA was requested but is not available")
|
| 213 |
+
return device
|
| 214 |
+
|
| 215 |
+
|
| 216 |
+
def build_parser() -> argparse.ArgumentParser:
|
| 217 |
+
parser = argparse.ArgumentParser(description="Astronomy-specific Cosmic Clarity AI 3.6 denoising demo")
|
| 218 |
+
parser.add_argument("input", type=Path, help="Input JPG/PNG/TIFF/FITS")
|
| 219 |
+
parser.add_argument("output", type=Path, help="Output path; extension selects format")
|
| 220 |
+
parser.add_argument("--weights", type=Path, default=DEFAULT_WEIGHTS)
|
| 221 |
+
parser.add_argument("--device", default="auto", help="auto, cpu, cuda, or cuda:N")
|
| 222 |
+
parser.add_argument("--strength", type=float, default=0.9)
|
| 223 |
+
parser.add_argument("--color-strength", type=float, default=None)
|
| 224 |
+
parser.add_argument("--mode", choices=("luminance", "full", "separate"), default="full")
|
| 225 |
+
parser.add_argument("--tile-size", type=int, default=256)
|
| 226 |
+
parser.add_argument("--overlap", type=int, default=64)
|
| 227 |
+
parser.add_argument("--batch-size", type=int, default=1)
|
| 228 |
+
parser.add_argument("--precision", choices=("fp16", "fp32"), default="fp16")
|
| 229 |
+
parser.add_argument("--linear-stretch", choices=("auto", "always", "never"), default="auto")
|
| 230 |
+
return parser
|
| 231 |
+
|
| 232 |
+
|
| 233 |
+
def main() -> None:
|
| 234 |
+
args = build_parser().parse_args()
|
| 235 |
+
device = choose_device(args.device)
|
| 236 |
+
model = load_model(args.weights, device)
|
| 237 |
+
record = load_image(args.input)
|
| 238 |
+
started = time.perf_counter()
|
| 239 |
+
output = denoise_array(
|
| 240 |
+
record.pixels,
|
| 241 |
+
model,
|
| 242 |
+
device,
|
| 243 |
+
strength=args.strength,
|
| 244 |
+
color_strength=args.color_strength,
|
| 245 |
+
mode=args.mode,
|
| 246 |
+
tile_size=args.tile_size,
|
| 247 |
+
overlap=args.overlap,
|
| 248 |
+
batch_size=args.batch_size,
|
| 249 |
+
half_precision=args.precision == "fp16",
|
| 250 |
+
linear_stretch=args.linear_stretch,
|
| 251 |
+
)
|
| 252 |
+
save_image(args.output, record, output)
|
| 253 |
+
elapsed = time.perf_counter() - started
|
| 254 |
+
statistics = model_statistics(model)
|
| 255 |
+
print(f"Model: Cosmic Clarity AI 3.6 ({statistics['parameters']:,} parameters)")
|
| 256 |
+
print(f"Device: {device}; input: {record.pixels.shape}; elapsed: {elapsed:.3f}s")
|
| 257 |
+
print(f"Saved: {args.output.resolve()}")
|
| 258 |
+
|
| 259 |
+
|
| 260 |
+
if __name__ == "__main__":
|
| 261 |
+
main()
|
| 262 |
+
|
StarDenoise/download_assets.py
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Re-download official model/sample assets with integrity verification."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import hashlib
|
| 6 |
+
import shutil
|
| 7 |
+
import urllib.request
|
| 8 |
+
from pathlib import Path
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
ROOT = Path(__file__).resolve().parent
|
| 12 |
+
ASSETS = {
|
| 13 |
+
ROOT / "models" / "deep_denoise_cnn_AI3_6.pth": (
|
| 14 |
+
"https://github.com/setiastro/cosmicclarity/releases/download/Linux/deep_denoise_cnn_AI3_6.pth",
|
| 15 |
+
"f860e234dc6a3ab1acacbea399c235179c86cb8b287f2ab9010bc18c8af08671",
|
| 16 |
+
),
|
| 17 |
+
ROOT / "samples" / "M51_short.jpg": (
|
| 18 |
+
"https://raw.githubusercontent.com/Steffenhir/AstroNoiseNet/main/examples/M51_short.jpg",
|
| 19 |
+
None,
|
| 20 |
+
),
|
| 21 |
+
ROOT / "samples" / "M51_long_reference.jpg": (
|
| 22 |
+
"https://raw.githubusercontent.com/Steffenhir/AstroNoiseNet/main/examples/M51_long.jpg",
|
| 23 |
+
None,
|
| 24 |
+
),
|
| 25 |
+
}
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
def sha256(path: Path) -> str:
|
| 29 |
+
digest = hashlib.sha256()
|
| 30 |
+
with path.open("rb") as stream:
|
| 31 |
+
for block in iter(lambda: stream.read(1024 * 1024), b""):
|
| 32 |
+
digest.update(block)
|
| 33 |
+
return digest.hexdigest()
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
def main() -> None:
|
| 37 |
+
for destination, (url, expected_hash) in ASSETS.items():
|
| 38 |
+
destination.parent.mkdir(parents=True, exist_ok=True)
|
| 39 |
+
temporary = destination.with_suffix(destination.suffix + ".download")
|
| 40 |
+
print(f"Downloading {url}")
|
| 41 |
+
with urllib.request.urlopen(url) as response, temporary.open("wb") as output:
|
| 42 |
+
shutil.copyfileobj(response, output)
|
| 43 |
+
actual_hash = sha256(temporary)
|
| 44 |
+
if expected_hash and actual_hash != expected_hash:
|
| 45 |
+
temporary.unlink(missing_ok=True)
|
| 46 |
+
raise RuntimeError(f"SHA-256 mismatch for {destination.name}: {actual_hash}")
|
| 47 |
+
temporary.replace(destination)
|
| 48 |
+
print(f"Saved {destination} (sha256={actual_hash})")
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
if __name__ == "__main__":
|
| 52 |
+
main()
|
| 53 |
+
|
StarDenoise/evaluate.py
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Simple full-reference metrics for the bundled short/long M51 pair."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import argparse
|
| 6 |
+
import math
|
| 7 |
+
from pathlib import Path
|
| 8 |
+
|
| 9 |
+
import numpy as np
|
| 10 |
+
from PIL import Image
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
def load_rgb(path: Path) -> np.ndarray:
|
| 14 |
+
with Image.open(path) as image:
|
| 15 |
+
return np.asarray(image.convert("RGB"), dtype=np.float32) / 255.0
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
def psnr(reference: np.ndarray, candidate: np.ndarray) -> float:
|
| 19 |
+
mse = float(np.mean((reference - candidate) ** 2))
|
| 20 |
+
return math.inf if mse == 0 else 10.0 * math.log10(1.0 / mse)
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
def main() -> None:
|
| 24 |
+
parser = argparse.ArgumentParser()
|
| 25 |
+
parser.add_argument("reference", type=Path)
|
| 26 |
+
parser.add_argument("candidate", type=Path)
|
| 27 |
+
args = parser.parse_args()
|
| 28 |
+
reference, candidate = load_rgb(args.reference), load_rgb(args.candidate)
|
| 29 |
+
if reference.shape != candidate.shape:
|
| 30 |
+
raise ValueError(f"Shape mismatch: {reference.shape} vs {candidate.shape}")
|
| 31 |
+
print(f"PSNR: {psnr(reference, candidate):.3f} dB")
|
| 32 |
+
print(f"MAE: {np.mean(np.abs(reference - candidate)):.6f}")
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
if __name__ == "__main__":
|
| 36 |
+
main()
|
| 37 |
+
|
StarDenoise/image_io.py
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Image I/O with FITS metadata and dynamic-range preservation."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
from dataclasses import dataclass
|
| 6 |
+
from pathlib import Path
|
| 7 |
+
from typing import Any
|
| 8 |
+
|
| 9 |
+
import numpy as np
|
| 10 |
+
import tifffile
|
| 11 |
+
from astropy.io import fits
|
| 12 |
+
from PIL import Image
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
FITS_EXTENSIONS = {".fit", ".fits", ".fts"}
|
| 16 |
+
TIFF_EXTENSIONS = {".tif", ".tiff"}
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
@dataclass
|
| 20 |
+
class ImageRecord:
|
| 21 |
+
pixels: np.ndarray # HWC float32, normalized to [0, 1]
|
| 22 |
+
offset: float
|
| 23 |
+
scale: float
|
| 24 |
+
original_dtype: np.dtype
|
| 25 |
+
original_layout: str
|
| 26 |
+
header: Any = None
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
def _to_hwc(array: np.ndarray) -> tuple[np.ndarray, str]:
|
| 30 |
+
if array.ndim == 2:
|
| 31 |
+
return array[..., None], "hw"
|
| 32 |
+
if array.ndim != 3:
|
| 33 |
+
raise ValueError(f"Only mono/RGB 2-D images are supported, got shape {array.shape}")
|
| 34 |
+
if array.shape[-1] in (1, 3, 4):
|
| 35 |
+
return array[..., :3], "hwc"
|
| 36 |
+
if array.shape[0] in (1, 3, 4):
|
| 37 |
+
return np.moveaxis(array[:3], 0, -1), "chw"
|
| 38 |
+
raise ValueError(f"Cannot determine channel axis for shape {array.shape}")
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
def _normalize(array: np.ndarray) -> tuple[np.ndarray, float, float]:
|
| 42 |
+
finite = np.isfinite(array)
|
| 43 |
+
if not finite.any():
|
| 44 |
+
raise ValueError("Image contains no finite pixels")
|
| 45 |
+
clean = np.nan_to_num(array, copy=True)
|
| 46 |
+
if np.issubdtype(array.dtype, np.integer):
|
| 47 |
+
info = np.iinfo(array.dtype)
|
| 48 |
+
offset = float(max(0, info.min))
|
| 49 |
+
scale = float(info.max - offset)
|
| 50 |
+
else:
|
| 51 |
+
minimum = float(np.min(clean[finite]))
|
| 52 |
+
maximum = float(np.max(clean[finite]))
|
| 53 |
+
if minimum >= 0.0 and maximum <= 1.0:
|
| 54 |
+
offset, scale = 0.0, 1.0
|
| 55 |
+
else:
|
| 56 |
+
offset, scale = minimum, maximum - minimum
|
| 57 |
+
if scale <= 0:
|
| 58 |
+
scale = 1.0
|
| 59 |
+
normalized = np.clip((clean.astype(np.float32) - offset) / scale, 0.0, 1.0)
|
| 60 |
+
return normalized, offset, scale
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
def load_image(path: str | Path) -> ImageRecord:
|
| 64 |
+
path = Path(path)
|
| 65 |
+
extension = path.suffix.lower()
|
| 66 |
+
header = None
|
| 67 |
+
if extension in FITS_EXTENSIONS:
|
| 68 |
+
with fits.open(path, memmap=False) as hdul:
|
| 69 |
+
index = next((i for i, hdu in enumerate(hdul) if hdu.data is not None), None)
|
| 70 |
+
if index is None:
|
| 71 |
+
raise ValueError(f"No image HDU in {path}")
|
| 72 |
+
array = np.asarray(hdul[index].data)
|
| 73 |
+
header = hdul[index].header.copy()
|
| 74 |
+
elif extension in TIFF_EXTENSIONS:
|
| 75 |
+
array = np.asarray(tifffile.imread(path))
|
| 76 |
+
else:
|
| 77 |
+
with Image.open(path) as image:
|
| 78 |
+
if image.mode not in ("L", "I;16", "I", "F"):
|
| 79 |
+
image = image.convert("RGB")
|
| 80 |
+
array = np.asarray(image)
|
| 81 |
+
|
| 82 |
+
original_dtype = array.dtype
|
| 83 |
+
hwc, layout = _to_hwc(array)
|
| 84 |
+
pixels, offset, scale = _normalize(hwc)
|
| 85 |
+
return ImageRecord(pixels, offset, scale, original_dtype, layout, header)
|
| 86 |
+
|
| 87 |
+
|
| 88 |
+
def _restore_layout(array: np.ndarray, layout: str) -> np.ndarray:
|
| 89 |
+
if layout == "hw":
|
| 90 |
+
return array[..., 0]
|
| 91 |
+
if layout == "chw":
|
| 92 |
+
return np.moveaxis(array, -1, 0)
|
| 93 |
+
return array
|
| 94 |
+
|
| 95 |
+
|
| 96 |
+
def _restore_range(record: ImageRecord, pixels: np.ndarray) -> np.ndarray:
|
| 97 |
+
restored = np.clip(pixels, 0.0, 1.0) * record.scale + record.offset
|
| 98 |
+
if np.issubdtype(record.original_dtype, np.integer):
|
| 99 |
+
info = np.iinfo(record.original_dtype)
|
| 100 |
+
restored = np.clip(np.rint(restored), info.min, info.max).astype(record.original_dtype)
|
| 101 |
+
else:
|
| 102 |
+
restored = restored.astype(record.original_dtype)
|
| 103 |
+
return _restore_layout(restored, record.original_layout)
|
| 104 |
+
|
| 105 |
+
|
| 106 |
+
def save_image(path: str | Path, record: ImageRecord, pixels: np.ndarray) -> None:
|
| 107 |
+
path = Path(path)
|
| 108 |
+
path.parent.mkdir(parents=True, exist_ok=True)
|
| 109 |
+
extension = path.suffix.lower()
|
| 110 |
+
restored = _restore_range(record, pixels)
|
| 111 |
+
if extension in FITS_EXTENSIONS:
|
| 112 |
+
fits.PrimaryHDU(restored, header=record.header).writeto(path, overwrite=True)
|
| 113 |
+
elif extension in TIFF_EXTENSIONS:
|
| 114 |
+
tifffile.imwrite(path, restored)
|
| 115 |
+
else:
|
| 116 |
+
# JPEG/PNG demo output is display-oriented. Preserve mono when applicable.
|
| 117 |
+
display = np.clip(pixels * 255.0 + 0.5, 0, 255).astype(np.uint8)
|
| 118 |
+
if record.original_layout == "hw":
|
| 119 |
+
display = display[..., 0]
|
| 120 |
+
Image.fromarray(display).save(path, quality=95)
|
| 121 |
+
|
StarDenoise/model.py
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""PyTorch reproduction of the SetiAstro Cosmic Clarity AI 3.6 denoiser.
|
| 2 |
+
|
| 3 |
+
The architecture follows SetiAstroCosmicClarity_denoise.py from the upstream
|
| 4 |
+
MIT-licensed project. It is intentionally kept independent of the original
|
| 5 |
+
GUI so it can be embedded in a larger image-processing pipeline.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
from __future__ import annotations
|
| 9 |
+
|
| 10 |
+
from pathlib import Path
|
| 11 |
+
from typing import Any
|
| 12 |
+
|
| 13 |
+
import torch
|
| 14 |
+
from torch import nn
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
class ResidualBlock(nn.Module):
|
| 18 |
+
def __init__(self, channels: int) -> None:
|
| 19 |
+
super().__init__()
|
| 20 |
+
self.conv1 = nn.Conv2d(channels, channels, 3, padding=1)
|
| 21 |
+
self.relu = nn.ReLU(inplace=False)
|
| 22 |
+
self.conv2 = nn.Conv2d(channels, channels, 3, padding=1)
|
| 23 |
+
|
| 24 |
+
def forward(self, x: torch.Tensor) -> torch.Tensor:
|
| 25 |
+
return self.relu(self.conv2(self.relu(self.conv1(x))) + x)
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
class CosmicClarityDenoiseCNN(nn.Module):
|
| 29 |
+
"""Full-resolution residual encoder/decoder used by AI 3.6.
|
| 30 |
+
|
| 31 |
+
Despite the encoder/decoder names, the network does not spatially
|
| 32 |
+
downsample. Dilated convolutions and skip concatenations preserve faint
|
| 33 |
+
stars and small-scale structure while increasing the receptive field.
|
| 34 |
+
"""
|
| 35 |
+
|
| 36 |
+
def __init__(self) -> None:
|
| 37 |
+
super().__init__()
|
| 38 |
+
self.encoder1 = self._encoder(3, 16)
|
| 39 |
+
self.encoder2 = self._encoder(16, 32)
|
| 40 |
+
self.encoder3 = self._encoder(32, 64, dilation=2)
|
| 41 |
+
self.encoder4 = self._encoder(64, 128)
|
| 42 |
+
self.encoder5 = self._encoder(128, 256, dilation=2)
|
| 43 |
+
|
| 44 |
+
self.decoder5 = self._decoder(256 + 128, 128)
|
| 45 |
+
self.decoder4 = self._decoder(128 + 64, 64)
|
| 46 |
+
self.decoder3 = self._decoder(64 + 32, 32)
|
| 47 |
+
self.decoder2 = self._decoder(32 + 16, 16)
|
| 48 |
+
self.decoder1 = nn.Sequential(nn.Conv2d(16, 3, 3, padding=1), nn.Sigmoid())
|
| 49 |
+
|
| 50 |
+
@staticmethod
|
| 51 |
+
def _encoder(in_channels: int, out_channels: int, dilation: int = 1) -> nn.Sequential:
|
| 52 |
+
return nn.Sequential(
|
| 53 |
+
nn.Conv2d(
|
| 54 |
+
in_channels,
|
| 55 |
+
out_channels,
|
| 56 |
+
3,
|
| 57 |
+
padding=dilation,
|
| 58 |
+
dilation=dilation,
|
| 59 |
+
),
|
| 60 |
+
nn.ReLU(inplace=False),
|
| 61 |
+
ResidualBlock(out_channels),
|
| 62 |
+
)
|
| 63 |
+
|
| 64 |
+
@staticmethod
|
| 65 |
+
def _decoder(in_channels: int, out_channels: int) -> nn.Sequential:
|
| 66 |
+
return nn.Sequential(
|
| 67 |
+
nn.Conv2d(in_channels, out_channels, 3, padding=1),
|
| 68 |
+
nn.ReLU(inplace=False),
|
| 69 |
+
ResidualBlock(out_channels),
|
| 70 |
+
)
|
| 71 |
+
|
| 72 |
+
def forward(self, x: torch.Tensor) -> torch.Tensor:
|
| 73 |
+
e1 = self.encoder1(x)
|
| 74 |
+
e2 = self.encoder2(e1)
|
| 75 |
+
e3 = self.encoder3(e2)
|
| 76 |
+
e4 = self.encoder4(e3)
|
| 77 |
+
e5 = self.encoder5(e4)
|
| 78 |
+
d5 = self.decoder5(torch.cat((e5, e4), dim=1))
|
| 79 |
+
d4 = self.decoder4(torch.cat((d5, e3), dim=1))
|
| 80 |
+
d3 = self.decoder3(torch.cat((d4, e2), dim=1))
|
| 81 |
+
d2 = self.decoder2(torch.cat((d3, e1), dim=1))
|
| 82 |
+
return self.decoder1(d2)
|
| 83 |
+
|
| 84 |
+
|
| 85 |
+
def _safe_torch_load(path: Path, device: torch.device) -> Any:
|
| 86 |
+
"""Load a trusted upstream checkpoint while preferring restricted unpickling."""
|
| 87 |
+
try:
|
| 88 |
+
return torch.load(path, map_location=device, weights_only=True)
|
| 89 |
+
except TypeError: # torch < 2.0 compatibility
|
| 90 |
+
return torch.load(path, map_location=device)
|
| 91 |
+
|
| 92 |
+
|
| 93 |
+
def load_model(weights: str | Path, device: torch.device) -> CosmicClarityDenoiseCNN:
|
| 94 |
+
weights_path = Path(weights)
|
| 95 |
+
if not weights_path.is_file():
|
| 96 |
+
raise FileNotFoundError(
|
| 97 |
+
f"Model weights not found: {weights_path}. Run download_assets.py first."
|
| 98 |
+
)
|
| 99 |
+
checkpoint = _safe_torch_load(weights_path, device)
|
| 100 |
+
if not isinstance(checkpoint, dict):
|
| 101 |
+
raise ValueError("Unexpected checkpoint format: expected a mapping")
|
| 102 |
+
state_dict = checkpoint.get("model_state_dict", checkpoint)
|
| 103 |
+
model = CosmicClarityDenoiseCNN().to(device)
|
| 104 |
+
model.load_state_dict(state_dict, strict=True)
|
| 105 |
+
model.eval()
|
| 106 |
+
return model
|
| 107 |
+
|
| 108 |
+
|
| 109 |
+
def model_statistics(model: nn.Module) -> dict[str, float | int]:
|
| 110 |
+
parameters = sum(parameter.numel() for parameter in model.parameters())
|
| 111 |
+
trainable = sum(parameter.numel() for parameter in model.parameters() if parameter.requires_grad)
|
| 112 |
+
return {
|
| 113 |
+
"parameters": parameters,
|
| 114 |
+
"trainable_parameters": trainable,
|
| 115 |
+
"fp32_size_mib": parameters * 4 / 1024**2,
|
| 116 |
+
}
|
| 117 |
+
|
StarDenoise/requirements.txt
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
numpy>=1.24
|
| 2 |
+
Pillow>=10.0
|
| 3 |
+
astropy>=5.3
|
| 4 |
+
tifffile>=2023.7
|
| 5 |
+
opencv-python>=4.8
|
| 6 |
+
torch>=2.1
|
| 7 |
+
|
StarDenoise/tests/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
|
StarDenoise/tests/test_core.py
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import sys
|
| 4 |
+
import tempfile
|
| 5 |
+
import unittest
|
| 6 |
+
from pathlib import Path
|
| 7 |
+
|
| 8 |
+
import numpy as np
|
| 9 |
+
import torch
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
ROOT = Path(__file__).resolve().parents[1]
|
| 13 |
+
sys.path.insert(0, str(ROOT))
|
| 14 |
+
|
| 15 |
+
from denoise import denoise_array, rgb_to_ycbcr, ycbcr_to_rgb # noqa: E402
|
| 16 |
+
from image_io import load_image, save_image # noqa: E402
|
| 17 |
+
from model import CosmicClarityDenoiseCNN, model_statistics # noqa: E402
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
class CoreTests(unittest.TestCase):
|
| 21 |
+
def test_model_shape_and_parameter_count(self) -> None:
|
| 22 |
+
model = CosmicClarityDenoiseCNN()
|
| 23 |
+
output = model(torch.rand(1, 3, 16, 16))
|
| 24 |
+
self.assertEqual(tuple(output.shape), (1, 3, 16, 16))
|
| 25 |
+
self.assertEqual(model_statistics(model)["parameters"], 2_945_283)
|
| 26 |
+
|
| 27 |
+
def test_ycbcr_roundtrip(self) -> None:
|
| 28 |
+
image = np.random.default_rng(3).random((20, 21, 3), dtype=np.float32)
|
| 29 |
+
reconstructed = ycbcr_to_rgb(*rgb_to_ycbcr(image))
|
| 30 |
+
np.testing.assert_allclose(image, reconstructed, atol=3e-5)
|
| 31 |
+
|
| 32 |
+
def test_zero_strength_is_identity(self) -> None:
|
| 33 |
+
image = np.random.default_rng(4).random((9, 10, 3), dtype=np.float32)
|
| 34 |
+
result = denoise_array(image, torch.nn.Identity(), torch.device("cpu"), strength=0.0)
|
| 35 |
+
np.testing.assert_array_equal(image, result)
|
| 36 |
+
|
| 37 |
+
def test_fits_roundtrip_preserves_channel_layout(self) -> None:
|
| 38 |
+
from astropy.io import fits
|
| 39 |
+
|
| 40 |
+
original = np.arange(3 * 8 * 9, dtype=np.uint16).reshape(3, 8, 9)
|
| 41 |
+
with tempfile.TemporaryDirectory() as directory:
|
| 42 |
+
source = Path(directory) / "source.fits"
|
| 43 |
+
output = Path(directory) / "output.fits"
|
| 44 |
+
fits.PrimaryHDU(original).writeto(source)
|
| 45 |
+
record = load_image(source)
|
| 46 |
+
save_image(output, record, record.pixels)
|
| 47 |
+
with fits.open(output) as hdul:
|
| 48 |
+
restored = hdul[0].data
|
| 49 |
+
self.assertEqual(restored.shape, original.shape)
|
| 50 |
+
np.testing.assert_array_equal(restored, original)
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
if __name__ == "__main__":
|
| 54 |
+
unittest.main()
|
StarDenoise/third_party/AstroNoiseNet_LICENSE.txt
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
MIT License
|
| 2 |
+
|
| 3 |
+
Copyright (c) 2022 Steffenhir
|
| 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.
|
StarDenoise/third_party/CosmicClarity_LICENSE.txt
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
MIT License
|
| 2 |
+
|
| 3 |
+
Copyright (c) 2024 Franklin marek
|
| 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.
|
StarDenoise/third_party/upstream_denoise.py
ADDED
|
@@ -0,0 +1,1225 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import warnings
|
| 2 |
+
from xisf import XISF
|
| 3 |
+
import os
|
| 4 |
+
import sys
|
| 5 |
+
import torch
|
| 6 |
+
import numpy as np
|
| 7 |
+
import torch.nn as nn
|
| 8 |
+
import lz4.block
|
| 9 |
+
import zstandard
|
| 10 |
+
import base64
|
| 11 |
+
import ast
|
| 12 |
+
import platform
|
| 13 |
+
import tifffile as tiff
|
| 14 |
+
from astropy.io import fits
|
| 15 |
+
from PIL import Image
|
| 16 |
+
import tkinter as tk
|
| 17 |
+
from tkinter import simpledialog, messagebox
|
| 18 |
+
from tkinter import ttk
|
| 19 |
+
import argparse # For command-line argument parsing
|
| 20 |
+
import onnxruntime as ort
|
| 21 |
+
import cv2
|
| 22 |
+
|
| 23 |
+
sys.stdout.reconfigure(encoding='utf-8')
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
#torch.cuda.is_available = lambda: False
|
| 29 |
+
|
| 30 |
+
# Suppress model loading warnings
|
| 31 |
+
warnings.filterwarnings("ignore")
|
| 32 |
+
|
| 33 |
+
# Detect broken fp16/mixed-precision support
|
| 34 |
+
def has_broken_fp16():
|
| 35 |
+
try:
|
| 36 |
+
cc = torch.cuda.get_device_capability()
|
| 37 |
+
return cc[0] < 8 # For example, <8 means <Turing (RTX 20xx)
|
| 38 |
+
except:
|
| 39 |
+
return True
|
| 40 |
+
|
| 41 |
+
DISABLE_MIXED_PRECISION = has_broken_fp16()
|
| 42 |
+
if DISABLE_MIXED_PRECISION:
|
| 43 |
+
print("[INFO] Mixed precision disabled due to unsupported GPU capability.")
|
| 44 |
+
|
| 45 |
+
# Define the ResidualBlock
|
| 46 |
+
class ResidualBlock(nn.Module):
|
| 47 |
+
def __init__(self, channels):
|
| 48 |
+
super(ResidualBlock, self).__init__()
|
| 49 |
+
self.conv1 = nn.Conv2d(channels, channels, kernel_size=3, padding=1)
|
| 50 |
+
self.relu = nn.ReLU()
|
| 51 |
+
self.conv2 = nn.Conv2d(channels, channels, kernel_size=3, padding=1)
|
| 52 |
+
|
| 53 |
+
def forward(self, x):
|
| 54 |
+
residual = x
|
| 55 |
+
out = self.conv1(x)
|
| 56 |
+
out = self.relu(out)
|
| 57 |
+
out = self.conv2(out)
|
| 58 |
+
out += residual
|
| 59 |
+
out = self.relu(out)
|
| 60 |
+
return out
|
| 61 |
+
|
| 62 |
+
# Updated DenoiseCNN with Residual Blocks
|
| 63 |
+
class DenoiseCNN(nn.Module):
|
| 64 |
+
def __init__(self):
|
| 65 |
+
super(DenoiseCNN, self).__init__()
|
| 66 |
+
|
| 67 |
+
# Encoder (down-sampling path)
|
| 68 |
+
self.encoder1 = nn.Sequential(
|
| 69 |
+
nn.Conv2d(3, 16, kernel_size=3, padding=1), # 1st layer (3 -> 16 feature maps)
|
| 70 |
+
nn.ReLU(),
|
| 71 |
+
ResidualBlock(16) # Replaced Conv2d + ReLU with ResidualBlock
|
| 72 |
+
)
|
| 73 |
+
self.encoder2 = nn.Sequential(
|
| 74 |
+
nn.Conv2d(16, 32, kernel_size=3, padding=1), # 2nd layer (16 -> 32 feature maps)
|
| 75 |
+
nn.ReLU(),
|
| 76 |
+
ResidualBlock(32) # Replaced Conv2d + ReLU with ResidualBlock
|
| 77 |
+
)
|
| 78 |
+
self.encoder3 = nn.Sequential(
|
| 79 |
+
nn.Conv2d(32, 64, kernel_size=3, padding=2, dilation=2), # 3rd layer (32 -> 64) with dilation
|
| 80 |
+
nn.ReLU(),
|
| 81 |
+
ResidualBlock(64) # Replaced Conv2d + ReLU with ResidualBlock
|
| 82 |
+
)
|
| 83 |
+
self.encoder4 = nn.Sequential(
|
| 84 |
+
nn.Conv2d(64, 128, kernel_size=3, padding=1), # 4th layer (64 -> 128 feature maps)
|
| 85 |
+
nn.ReLU(),
|
| 86 |
+
ResidualBlock(128) # Replaced Conv2d + ReLU with ResidualBlock
|
| 87 |
+
)
|
| 88 |
+
self.encoder5 = nn.Sequential(
|
| 89 |
+
nn.Conv2d(128, 256, kernel_size=3, padding=2, dilation=2), # 5th layer (128 -> 256) with dilation
|
| 90 |
+
nn.ReLU(),
|
| 91 |
+
ResidualBlock(256) # Replaced Conv2d + ReLU with ResidualBlock
|
| 92 |
+
)
|
| 93 |
+
|
| 94 |
+
# Decoder (up-sampling path with skip connections)
|
| 95 |
+
self.decoder5 = nn.Sequential(
|
| 96 |
+
nn.Conv2d(256 + 128, 128, kernel_size=3, padding=1), # 256 + 128 feature maps from encoder4
|
| 97 |
+
nn.ReLU(),
|
| 98 |
+
ResidualBlock(128) # Replaced Conv2d + ReLU with ResidualBlock
|
| 99 |
+
)
|
| 100 |
+
self.decoder4 = nn.Sequential(
|
| 101 |
+
nn.Conv2d(128 + 64, 64, kernel_size=3, padding=1), # 128 + 64 feature maps from encoder3
|
| 102 |
+
nn.ReLU(),
|
| 103 |
+
ResidualBlock(64) # Replaced Conv2d + ReLU with ResidualBlock
|
| 104 |
+
)
|
| 105 |
+
self.decoder3 = nn.Sequential(
|
| 106 |
+
nn.Conv2d(64 + 32, 32, kernel_size=3, padding=1), # 64 + 32 feature maps from encoder2
|
| 107 |
+
nn.ReLU(),
|
| 108 |
+
ResidualBlock(32) # Replaced Conv2d + ReLU with ResidualBlock
|
| 109 |
+
)
|
| 110 |
+
self.decoder2 = nn.Sequential(
|
| 111 |
+
nn.Conv2d(32 + 16, 16, kernel_size=3, padding=1), # 32 + 16 feature maps from encoder1
|
| 112 |
+
nn.ReLU(),
|
| 113 |
+
ResidualBlock(16) # Replaced Conv2d + ReLU with ResidualBlock
|
| 114 |
+
)
|
| 115 |
+
self.decoder1 = nn.Sequential(
|
| 116 |
+
nn.Conv2d(16, 3, kernel_size=3, padding=1), # Output layer (16 -> 3 channels for RGB output)
|
| 117 |
+
nn.Sigmoid() # Ensure output values are between 0 and 1
|
| 118 |
+
)
|
| 119 |
+
|
| 120 |
+
def forward(self, x):
|
| 121 |
+
# Encoder
|
| 122 |
+
e1 = self.encoder1(x) # First encoding block
|
| 123 |
+
e2 = self.encoder2(e1) # Second encoding block
|
| 124 |
+
e3 = self.encoder3(e2) # Third encoding block
|
| 125 |
+
e4 = self.encoder4(e3) # Fourth encoding block
|
| 126 |
+
e5 = self.encoder5(e4) # Fifth encoding block
|
| 127 |
+
|
| 128 |
+
# Decoder with skip connections
|
| 129 |
+
d5 = self.decoder5(torch.cat([e5, e4], dim=1)) # Concatenate with encoder4 output
|
| 130 |
+
d4 = self.decoder4(torch.cat([d5, e3], dim=1)) # Concatenate with encoder3 output
|
| 131 |
+
d3 = self.decoder3(torch.cat([d4, e2], dim=1)) # Concatenate with encoder2 output
|
| 132 |
+
d2 = self.decoder2(torch.cat([d3, e1], dim=1)) # Concatenate with encoder1 output
|
| 133 |
+
d1 = self.decoder1(d2) # Final output layer
|
| 134 |
+
|
| 135 |
+
return d1
|
| 136 |
+
|
| 137 |
+
|
| 138 |
+
# Get the directory of the executable or the script location
|
| 139 |
+
# Get the directory of the executable or the script location
|
| 140 |
+
exe_dir = os.path.dirname(sys.executable) if getattr(sys, 'frozen', False) else os.path.dirname(__file__)
|
| 141 |
+
|
| 142 |
+
cached_models = None # Cache to avoid reloading models unnecessarily
|
| 143 |
+
|
| 144 |
+
def load_models(exe_dir, use_gpu=True):
|
| 145 |
+
"""
|
| 146 |
+
Load only the mono denoise model.
|
| 147 |
+
- PyTorch (CUDA/CPU) if available
|
| 148 |
+
- Otherwise ONNX (DirectML) if available and GPU requested
|
| 149 |
+
"""
|
| 150 |
+
global cached_models
|
| 151 |
+
if cached_models:
|
| 152 |
+
return cached_models
|
| 153 |
+
|
| 154 |
+
# Decide backend (PyTorch preferred; ONNX/DML fallback)
|
| 155 |
+
if torch.cuda.is_available() and use_gpu:
|
| 156 |
+
device = torch.device("cuda"); is_onnx = False
|
| 157 |
+
elif "DmlExecutionProvider" in ort.get_available_providers() and use_gpu:
|
| 158 |
+
device = "DirectML"; is_onnx = True
|
| 159 |
+
else:
|
| 160 |
+
device = torch.device("cpu"); is_onnx = False
|
| 161 |
+
|
| 162 |
+
# --- Mono model only ---
|
| 163 |
+
if not is_onnx:
|
| 164 |
+
mono = DenoiseCNN().to(device)
|
| 165 |
+
ckpt = torch.load(os.path.join(exe_dir, "deep_denoise_cnn_AI3_6.pth"), map_location=device)
|
| 166 |
+
mono.load_state_dict(ckpt.get("model_state_dict", ckpt))
|
| 167 |
+
mono.eval()
|
| 168 |
+
mono_model = mono
|
| 169 |
+
else:
|
| 170 |
+
mono_model = ort.InferenceSession(
|
| 171 |
+
os.path.join(exe_dir, "deep_denoise_cnn_AI3_6.onnx"),
|
| 172 |
+
providers=["DmlExecutionProvider"]
|
| 173 |
+
)
|
| 174 |
+
|
| 175 |
+
cached_models = {
|
| 176 |
+
"device": device,
|
| 177 |
+
"is_onnx": is_onnx,
|
| 178 |
+
"mono_model": mono_model
|
| 179 |
+
}
|
| 180 |
+
|
| 181 |
+
backend_str = "ONNX/DML" if is_onnx else (device.type if isinstance(device, torch.device) else str(device))
|
| 182 |
+
print(f"Loaded mono model → backend={backend_str}")
|
| 183 |
+
return cached_models
|
| 184 |
+
|
| 185 |
+
|
| 186 |
+
|
| 187 |
+
|
| 188 |
+
# Function to extract luminance (Y channel) directly using a matrix for 32-bit float precision
|
| 189 |
+
def extract_luminance(image):
|
| 190 |
+
# Ensure the image is a NumPy array in 32-bit float format
|
| 191 |
+
if isinstance(image, Image.Image):
|
| 192 |
+
image = np.array(image).astype(np.float32) / 255.0 # Ensure it's in 32-bit float format
|
| 193 |
+
|
| 194 |
+
# Check if the image is grayscale (single channel), and convert it to 3-channel RGB if so
|
| 195 |
+
if image.shape[-1] == 1:
|
| 196 |
+
image = np.concatenate([image] * 3, axis=-1) # Duplicate the single channel to create RGB
|
| 197 |
+
|
| 198 |
+
# Conversion matrix for RGB to YCbCr (ITU-R BT.601 standard)
|
| 199 |
+
conversion_matrix = np.array([[0.299, 0.587, 0.114],
|
| 200 |
+
[-0.168736, -0.331264, 0.5],
|
| 201 |
+
[0.5, -0.418688, -0.081312]], dtype=np.float32)
|
| 202 |
+
|
| 203 |
+
# Apply the RGB to YCbCr conversion matrix
|
| 204 |
+
ycbcr_image = np.dot(image, conversion_matrix.T)
|
| 205 |
+
|
| 206 |
+
# Split the channels: Y is luminance, Cb and Cr are chrominance
|
| 207 |
+
y_channel = ycbcr_image[:, :, 0]
|
| 208 |
+
cb_channel = ycbcr_image[:, :, 1] + 0.5 # Offset back to [0, 1] range
|
| 209 |
+
cr_channel = ycbcr_image[:, :, 2] + 0.5 # Offset back to [0, 1] range
|
| 210 |
+
|
| 211 |
+
# Return the Y (luminance) channel in 32-bit float and the Cb, Cr channels for later use
|
| 212 |
+
return y_channel, cb_channel, cr_channel
|
| 213 |
+
|
| 214 |
+
def merge_luminance(y_channel, cb_channel, cr_channel):
|
| 215 |
+
# Ensure all channels are 32-bit float and in the range [0, 1]
|
| 216 |
+
y_channel = np.clip(y_channel, 0, 1).astype(np.float32)
|
| 217 |
+
cb_channel = np.clip(cb_channel, 0, 1).astype(np.float32) - 0.5 # Adjust Cb to [-0.5, 0.5]
|
| 218 |
+
cr_channel = np.clip(cr_channel, 0, 1).astype(np.float32) - 0.5 # Adjust Cr to [-0.5, 0.5]
|
| 219 |
+
|
| 220 |
+
# Convert YCbCr to RGB in 32-bit float format
|
| 221 |
+
rgb_image = ycbcr_to_rgb(y_channel, cb_channel, cr_channel)
|
| 222 |
+
|
| 223 |
+
return rgb_image
|
| 224 |
+
|
| 225 |
+
|
| 226 |
+
# Helper function to convert YCbCr (32-bit float) to RGB
|
| 227 |
+
def ycbcr_to_rgb(y_channel, cb_channel, cr_channel):
|
| 228 |
+
# Combine Y, Cb, Cr channels into one array for matrix multiplication
|
| 229 |
+
ycbcr_image = np.stack([y_channel, cb_channel, cr_channel], axis=-1)
|
| 230 |
+
|
| 231 |
+
# Conversion matrix for YCbCr to RGB (ITU-R BT.601 standard)
|
| 232 |
+
conversion_matrix = np.array([[1.0, 0.0, 1.402],
|
| 233 |
+
[1.0, -0.344136, -0.714136],
|
| 234 |
+
[1.0, 1.772, 0.0]], dtype=np.float32)
|
| 235 |
+
|
| 236 |
+
# Apply the YCbCr to RGB conversion matrix in 32-bit float
|
| 237 |
+
rgb_image = np.dot(ycbcr_image, conversion_matrix.T)
|
| 238 |
+
|
| 239 |
+
# Clip to ensure the RGB values stay within [0, 1] range
|
| 240 |
+
rgb_image = np.clip(rgb_image, 0.0, 1.0)
|
| 241 |
+
|
| 242 |
+
return rgb_image
|
| 243 |
+
|
| 244 |
+
def _guided_filter(guide: np.ndarray, src: np.ndarray, radius: int, eps: float) -> np.ndarray:
|
| 245 |
+
"""
|
| 246 |
+
Fast guided filter using boxFilter (edge-preserving, very fast).
|
| 247 |
+
guide and src are HxW float32 in [0,1].
|
| 248 |
+
radius is the neighborhood radius; ksize=(2*radius+1).
|
| 249 |
+
eps is the regularization term.
|
| 250 |
+
"""
|
| 251 |
+
r = max(1, int(radius))
|
| 252 |
+
ksize = (2*r + 1, 2*r + 1)
|
| 253 |
+
|
| 254 |
+
mean_I = cv2.boxFilter(guide, ddepth=-1, ksize=ksize, borderType=cv2.BORDER_REFLECT)
|
| 255 |
+
mean_p = cv2.boxFilter(src, ddepth=-1, ksize=ksize, borderType=cv2.BORDER_REFLECT)
|
| 256 |
+
mean_Ip = cv2.boxFilter(guide * src, ddepth=-1, ksize=ksize, borderType=cv2.BORDER_REFLECT)
|
| 257 |
+
cov_Ip = mean_Ip - mean_I * mean_p
|
| 258 |
+
|
| 259 |
+
mean_II = cv2.boxFilter(guide * guide, ddepth=-1, ksize=ksize, borderType=cv2.BORDER_REFLECT)
|
| 260 |
+
var_I = mean_II - mean_I * mean_I
|
| 261 |
+
|
| 262 |
+
a = cov_Ip / (var_I + eps)
|
| 263 |
+
b = mean_p - a * mean_I
|
| 264 |
+
|
| 265 |
+
mean_a = cv2.boxFilter(a, ddepth=-1, ksize=ksize, borderType=cv2.BORDER_REFLECT)
|
| 266 |
+
mean_b = cv2.boxFilter(b, ddepth=-1, ksize=ksize, borderType=cv2.BORDER_REFLECT)
|
| 267 |
+
|
| 268 |
+
q = mean_a * guide + mean_b
|
| 269 |
+
return q
|
| 270 |
+
|
| 271 |
+
|
| 272 |
+
|
| 273 |
+
def denoise_chroma(cb: np.ndarray,
|
| 274 |
+
cr: np.ndarray,
|
| 275 |
+
strength: float,
|
| 276 |
+
method: str = "guided",
|
| 277 |
+
strength_scale: float = 2.0,
|
| 278 |
+
guide_y: np.ndarray | None = None):
|
| 279 |
+
"""
|
| 280 |
+
Fast chroma-only denoise for Cb/Cr in [0,1] float32.
|
| 281 |
+
method: 'guided' (default), 'gaussian', 'bilateral'
|
| 282 |
+
strength_scale: lets chroma smoothing go up to ~2× your slider.
|
| 283 |
+
guide_y: optional luminance guide (Y in [0,1]); required for 'guided' to be best.
|
| 284 |
+
"""
|
| 285 |
+
eff = float(np.clip(strength * strength_scale, 0.0, 1.0))
|
| 286 |
+
if eff <= 0.0:
|
| 287 |
+
return cb, cr
|
| 288 |
+
|
| 289 |
+
cb = cb.astype(np.float32, copy=False)
|
| 290 |
+
cr = cr.astype(np.float32, copy=False)
|
| 291 |
+
|
| 292 |
+
if method == "guided":
|
| 293 |
+
# Need a guide; if not provided, fall back to Gaussian
|
| 294 |
+
if guide_y is not None:
|
| 295 |
+
# radius & eps scale with strength; tuned for strong chroma smoothing but edge-safe
|
| 296 |
+
radius = 2 + int(round(10 * eff)) # ~2..12 (ksize ~5..25)
|
| 297 |
+
eps = (0.001 + 0.05 * eff) ** 2 # small regularization
|
| 298 |
+
cb_f = _guided_filter(guide_y, cb, radius, eps)
|
| 299 |
+
cr_f = _guided_filter(guide_y, cr, radius, eps)
|
| 300 |
+
else:
|
| 301 |
+
method = "gaussian" # no guide provided → fast fallback
|
| 302 |
+
|
| 303 |
+
if method == "gaussian":
|
| 304 |
+
k = 1 + 2 * int(round(8 * eff)) # 1,3,5,..,17
|
| 305 |
+
sigma = max(0.15, 2.4 * eff)
|
| 306 |
+
cb_f = cv2.GaussianBlur(cb, (k, k), sigmaX=sigma, sigmaY=sigma, borderType=cv2.BORDER_REFLECT)
|
| 307 |
+
cr_f = cv2.GaussianBlur(cr, (k, k), sigmaX=sigma, sigmaY=sigma, borderType=cv2.BORDER_REFLECT)
|
| 308 |
+
|
| 309 |
+
if method == "bilateral":
|
| 310 |
+
# Bilateral is decent but slower than Gaussian; guided is preferred for speed/quality.
|
| 311 |
+
d = 5 + 2 * int(round(6 * eff)) # 5..17
|
| 312 |
+
sigmaC = 25.0 * (0.5 + 3.0 * eff) # ~12.5..100
|
| 313 |
+
sigmaS = 3.0 * (0.5 + 6.0 * eff) # ~1.5..21
|
| 314 |
+
cb_f = cv2.bilateralFilter(cb, d=d, sigmaColor=sigmaC, sigmaSpace=sigmaS)
|
| 315 |
+
cr_f = cv2.bilateralFilter(cr, d=d, sigmaColor=sigmaC, sigmaSpace=sigmaS)
|
| 316 |
+
|
| 317 |
+
# Blend (maskless)
|
| 318 |
+
w = eff
|
| 319 |
+
cb_out = (1.0 - w) * cb + w * cb_f
|
| 320 |
+
cr_out = (1.0 - w) * cr + w * cr_f
|
| 321 |
+
return cb_out, cr_out
|
| 322 |
+
|
| 323 |
+
|
| 324 |
+
# Function to split an image into chunks with overlap
|
| 325 |
+
def split_image_into_chunks_with_overlap(image, chunk_size, overlap):
|
| 326 |
+
height, width = image.shape[:2]
|
| 327 |
+
chunks = []
|
| 328 |
+
step_size = chunk_size - overlap # Define how much to step over (overlapping area)
|
| 329 |
+
|
| 330 |
+
for i in range(0, height, step_size):
|
| 331 |
+
for j in range(0, width, step_size):
|
| 332 |
+
end_i = min(i + chunk_size, height)
|
| 333 |
+
end_j = min(j + chunk_size, width)
|
| 334 |
+
chunk = image[i:end_i, j:end_j]
|
| 335 |
+
chunks.append((chunk, i, j)) # Return chunk and its position
|
| 336 |
+
return chunks
|
| 337 |
+
|
| 338 |
+
# Soft blending weights for the overlap area
|
| 339 |
+
def generate_blend_weights(chunk_size, overlap):
|
| 340 |
+
ramp = np.linspace(0, 1, overlap)
|
| 341 |
+
flat = np.ones(chunk_size - 2 * overlap)
|
| 342 |
+
blend_vector = np.concatenate([ramp, flat, ramp[::-1]]) # Create smooth transition
|
| 343 |
+
blend_matrix = np.outer(blend_vector, blend_vector) # 2D blending weights
|
| 344 |
+
return blend_matrix
|
| 345 |
+
|
| 346 |
+
# Function to stitch overlapping chunks back together with soft blending while ignoring borders for all chunks
|
| 347 |
+
def stitch_chunks_ignore_border(chunks, image_shape, chunk_size, overlap, border_size=16):
|
| 348 |
+
print(f"Number of chunks: {len(chunks)}")
|
| 349 |
+
print(f"First few chunks: {[type(c) if not isinstance(c, tuple) else len(c) for c in chunks[:3]]}")
|
| 350 |
+
|
| 351 |
+
stitched_image = np.zeros(image_shape, dtype=np.float32)
|
| 352 |
+
weight_map = np.zeros(image_shape, dtype=np.float32) # Track blending weights
|
| 353 |
+
|
| 354 |
+
for chunk, i, j in chunks:
|
| 355 |
+
actual_chunk_h, actual_chunk_w = chunk.shape[:2]
|
| 356 |
+
|
| 357 |
+
# Calculate the boundaries for the current chunk, ignoring the border
|
| 358 |
+
border_h = min(border_size, actual_chunk_h // 2)
|
| 359 |
+
border_w = min(border_size, actual_chunk_w // 2)
|
| 360 |
+
|
| 361 |
+
# Always ignore the border for all chunks, including edges
|
| 362 |
+
inner_chunk = chunk[border_h:actual_chunk_h-border_h, border_w:actual_chunk_w-border_w]
|
| 363 |
+
stitched_image[i+border_h:i+actual_chunk_h-border_h, j+border_w:j+actual_chunk_w-border_w] += inner_chunk
|
| 364 |
+
weight_map[i+border_h:i+actual_chunk_h-border_h, j+border_w:j+actual_chunk_w-border_w] += 1
|
| 365 |
+
|
| 366 |
+
# Normalize by weight_map to blend overlapping areas
|
| 367 |
+
stitched_image /= np.maximum(weight_map, 1) # Avoid division by zero
|
| 368 |
+
return stitched_image
|
| 369 |
+
|
| 370 |
+
# Function to blend two images (before and after)
|
| 371 |
+
def blend_images(before, after, amount):
|
| 372 |
+
return (1 - amount) * before + amount * after
|
| 373 |
+
|
| 374 |
+
# Function to get user input for denoise strength (Interactive)
|
| 375 |
+
def get_user_input():
|
| 376 |
+
# Define global variables to store the user input
|
| 377 |
+
global use_gpu, denoise_strength, denoise_mode, color_denoise_strength
|
| 378 |
+
|
| 379 |
+
def on_submit():
|
| 380 |
+
# Update globals from UI
|
| 381 |
+
global use_gpu, denoise_strength, denoise_mode, separate_channels, color_denoise_strength
|
| 382 |
+
use_gpu = gpu_var.get() == "Yes"
|
| 383 |
+
denoise_strength = denoise_strength_slider.get()
|
| 384 |
+
# if the user didn't move the color slider, it still has a value; we take it as-is
|
| 385 |
+
color_denoise_strength = color_strength_slider.get()
|
| 386 |
+
denoise_mode = denoise_mode_var.get().lower()
|
| 387 |
+
separate_channels = sep_var.get()
|
| 388 |
+
root.quit()
|
| 389 |
+
|
| 390 |
+
root = tk.Tk()
|
| 391 |
+
root.title("Cosmic Clarity Denoise Tool")
|
| 392 |
+
|
| 393 |
+
# GPU selection
|
| 394 |
+
ttk.Label(root, text="Use GPU Acceleration:").pack(pady=5)
|
| 395 |
+
gpu_var = tk.StringVar(value="Yes")
|
| 396 |
+
ttk.OptionMenu(root, gpu_var, "Yes", "Yes", "No").pack()
|
| 397 |
+
|
| 398 |
+
# Luminance denoise strength
|
| 399 |
+
ttk.Label(root, text="Luminance Denoise Strength (0–1):").pack(pady=5)
|
| 400 |
+
denoise_strength_slider = tk.Scale(root, from_=0, to=1, resolution=0.01, orient="horizontal")
|
| 401 |
+
denoise_strength_slider.set(0.9)
|
| 402 |
+
denoise_strength_slider.pack()
|
| 403 |
+
|
| 404 |
+
# Color denoise strength (separate)
|
| 405 |
+
ttk.Label(root, text="Color (Chroma) Denoise Strength (0–1):").pack(pady=5)
|
| 406 |
+
color_strength_slider = tk.Scale(root, from_=0, to=1, resolution=0.01, orient="horizontal")
|
| 407 |
+
color_strength_slider.set(0.9) # default to same as luminance
|
| 408 |
+
color_strength_slider.pack()
|
| 409 |
+
|
| 410 |
+
# Denoise mode
|
| 411 |
+
ttk.Label(root, text="Denoise Mode:").pack(pady=5)
|
| 412 |
+
denoise_mode_var = tk.StringVar(value="full")
|
| 413 |
+
ttk.OptionMenu(root, denoise_mode_var, "full", "luminance", "full", "separate").pack()
|
| 414 |
+
|
| 415 |
+
# Separate channels
|
| 416 |
+
ttk.Label(root, text="Process each RGB channel separately:").pack(pady=5)
|
| 417 |
+
sep_var = tk.BooleanVar(value=False)
|
| 418 |
+
ttk.Checkbutton(root, variable=sep_var, text="Separate channels").pack()
|
| 419 |
+
|
| 420 |
+
# Submit
|
| 421 |
+
ttk.Button(root, text="Submit", command=on_submit).pack(pady=20)
|
| 422 |
+
|
| 423 |
+
root.mainloop()
|
| 424 |
+
root.destroy()
|
| 425 |
+
|
| 426 |
+
return use_gpu, denoise_strength, denoise_mode, separate_channels, color_denoise_strength
|
| 427 |
+
|
| 428 |
+
|
| 429 |
+
# Function to show progress during chunk processing
|
| 430 |
+
def show_progress(current, total):
|
| 431 |
+
progress_percentage = (current / total) * 100
|
| 432 |
+
print(f"Progress: {progress_percentage:.2f}% ({current}/{total} chunks processed)", end='\r', flush=True)
|
| 433 |
+
|
| 434 |
+
# Function to replace the 5-pixel border from the original image into the processed image
|
| 435 |
+
def replace_border(original_image, processed_image, border_size=16):
|
| 436 |
+
# Ensure the dimensions of both images match
|
| 437 |
+
if original_image.shape != processed_image.shape:
|
| 438 |
+
raise ValueError("Original image and processed image must have the same dimensions.")
|
| 439 |
+
|
| 440 |
+
# Replace the top border
|
| 441 |
+
processed_image[:border_size, :] = original_image[:border_size, :]
|
| 442 |
+
|
| 443 |
+
# Replace the bottom border
|
| 444 |
+
processed_image[-border_size:, :] = original_image[-border_size:, :]
|
| 445 |
+
|
| 446 |
+
# Replace the left border
|
| 447 |
+
processed_image[:, :border_size] = original_image[:, :border_size]
|
| 448 |
+
|
| 449 |
+
# Replace the right border
|
| 450 |
+
processed_image[:, -border_size:] = original_image[:, -border_size:]
|
| 451 |
+
|
| 452 |
+
return processed_image
|
| 453 |
+
|
| 454 |
+
|
| 455 |
+
|
| 456 |
+
# Function to stretch an image
|
| 457 |
+
def stretch_image(image):
|
| 458 |
+
"""
|
| 459 |
+
Perform a linear stretch on the image with unlinked channels for all images.
|
| 460 |
+
"""
|
| 461 |
+
original_min = np.min(image)
|
| 462 |
+
stretched_image = image - original_min # Shift image so that the min is 0
|
| 463 |
+
|
| 464 |
+
# Capture the original medians before any further processing
|
| 465 |
+
original_medians = []
|
| 466 |
+
for c in range(3): # Assume 3-channel input
|
| 467 |
+
channel_median = np.median(stretched_image[..., c])
|
| 468 |
+
original_medians.append(channel_median)
|
| 469 |
+
|
| 470 |
+
# Define the target median for stretching
|
| 471 |
+
target_median = 0.25
|
| 472 |
+
|
| 473 |
+
# Apply the stretch for each channel
|
| 474 |
+
for c in range(3):
|
| 475 |
+
channel_median = original_medians[c]
|
| 476 |
+
if channel_median != 0:
|
| 477 |
+
stretched_image[..., c] = ((channel_median - 1) * target_median * stretched_image[..., c]) / (
|
| 478 |
+
channel_median * (target_median + stretched_image[..., c] - 1) - target_median * stretched_image[..., c]
|
| 479 |
+
)
|
| 480 |
+
|
| 481 |
+
# Clip stretched image to [0, 1] range
|
| 482 |
+
stretched_image = np.clip(stretched_image, 0, 1)
|
| 483 |
+
|
| 484 |
+
# Return the stretched image, original min, and original medians
|
| 485 |
+
return stretched_image, original_min, original_medians
|
| 486 |
+
|
| 487 |
+
|
| 488 |
+
|
| 489 |
+
# Function to unstretch an image
|
| 490 |
+
def unstretch_image(image, original_medians, original_min):
|
| 491 |
+
"""
|
| 492 |
+
Undo the stretch to return the image to the original linear state with unlinked channels.
|
| 493 |
+
Handles both single-channel and 3-channel images.
|
| 494 |
+
"""
|
| 495 |
+
was_single_channel = False # Local flag to check if the image was originally mono
|
| 496 |
+
|
| 497 |
+
|
| 498 |
+
|
| 499 |
+
# Check if the image is single-channel
|
| 500 |
+
if image.ndim == 3 and image.shape[2] == 1:
|
| 501 |
+
was_single_channel = True # Mark the image as originally single-channel
|
| 502 |
+
image = np.repeat(image, 3, axis=2) # Convert to 3-channel by duplicating
|
| 503 |
+
|
| 504 |
+
elif image.ndim == 2: # True single-channel case
|
| 505 |
+
was_single_channel = True
|
| 506 |
+
image = np.stack([image] * 3, axis=-1) # Convert to 3-channel by duplicating
|
| 507 |
+
|
| 508 |
+
# Process each channel independently
|
| 509 |
+
for c in range(3): # Assume 3-channel input at this point
|
| 510 |
+
channel_median = np.median(image[..., c])
|
| 511 |
+
if channel_median != 0 and original_medians[c] != 0:
|
| 512 |
+
|
| 513 |
+
image[..., c] = ((channel_median - 1) * original_medians[c] * image[..., c]) / (
|
| 514 |
+
channel_median * (original_medians[c] + image[..., c] - 1) - original_medians[c] * image[..., c]
|
| 515 |
+
)
|
| 516 |
+
|
| 517 |
+
else:
|
| 518 |
+
print(f"Channel {c} - Skipping unstretch due to zero median.")
|
| 519 |
+
|
| 520 |
+
image += original_min # Add back the original minimum
|
| 521 |
+
image = np.clip(image, 0, 1) # Clip to [0, 1] range
|
| 522 |
+
|
| 523 |
+
# If the image was originally single-channel, convert back to single-channel
|
| 524 |
+
if was_single_channel:
|
| 525 |
+
image = np.mean(image, axis=2, keepdims=True) # Convert back to single-channel
|
| 526 |
+
|
| 527 |
+
|
| 528 |
+
|
| 529 |
+
return image
|
| 530 |
+
|
| 531 |
+
|
| 532 |
+
|
| 533 |
+
|
| 534 |
+
|
| 535 |
+
|
| 536 |
+
|
| 537 |
+
|
| 538 |
+
|
| 539 |
+
|
| 540 |
+
# Function to add a border of median value around the image
|
| 541 |
+
def add_border(image, border_size=16):
|
| 542 |
+
if image.ndim == 2: # mono
|
| 543 |
+
med = np.median(image)
|
| 544 |
+
return np.pad(image,
|
| 545 |
+
((border_size, border_size), (border_size, border_size)),
|
| 546 |
+
mode="constant",
|
| 547 |
+
constant_values=med)
|
| 548 |
+
|
| 549 |
+
elif image.ndim == 3 and image.shape[2] == 3: # RGB
|
| 550 |
+
meds = np.median(image, axis=(0, 1)).astype(image.dtype) # (3,)
|
| 551 |
+
padded = [np.pad(image[..., c],
|
| 552 |
+
((border_size, border_size), (border_size, border_size)),
|
| 553 |
+
mode="constant",
|
| 554 |
+
constant_values=float(meds[c]))
|
| 555 |
+
for c in range(3)]
|
| 556 |
+
return np.stack(padded, axis=-1)
|
| 557 |
+
else:
|
| 558 |
+
raise ValueError("add_border expects mono or RGB image.")
|
| 559 |
+
|
| 560 |
+
# Function to remove the border added around the image
|
| 561 |
+
def remove_border(image, border_size=5):
|
| 562 |
+
if len(image.shape) == 2:
|
| 563 |
+
return image[border_size:-border_size, border_size:-border_size]
|
| 564 |
+
else:
|
| 565 |
+
return image[border_size:-border_size, border_size:-border_size, :]
|
| 566 |
+
|
| 567 |
+
# Function to denoise the image
|
| 568 |
+
def denoise_image(image_path: str,
|
| 569 |
+
denoise_strength: float,
|
| 570 |
+
models: dict,
|
| 571 |
+
denoise_mode: str = 'luminance',
|
| 572 |
+
separate_channels: bool = False,
|
| 573 |
+
color_denoise_strength: float | None = None):
|
| 574 |
+
"""
|
| 575 |
+
Denoises the input image using the specified model and mode.
|
| 576 |
+
|
| 577 |
+
Args:
|
| 578 |
+
image_path (str): Path to the input image.
|
| 579 |
+
denoise_strength (float): Strength of the denoising effect.
|
| 580 |
+
device (torch.device or str): Device for PyTorch or ONNX inference.
|
| 581 |
+
model: PyTorch model or ONNX session for denoising.
|
| 582 |
+
denoise_mode (str): 'luminance' or 'full' denoising mode.
|
| 583 |
+
is_onnx (bool): Whether to use ONNX for inference.
|
| 584 |
+
|
| 585 |
+
Returns:
|
| 586 |
+
tuple: Denoised image, original header, bit depth, file extension, is_mono, and metadata.
|
| 587 |
+
"""
|
| 588 |
+
device = models["device"]
|
| 589 |
+
is_onnx = models["is_onnx"]
|
| 590 |
+
mono_model = models["mono_model"]
|
| 591 |
+
|
| 592 |
+
|
| 593 |
+
# Get file extension
|
| 594 |
+
file_extension = os.path.splitext(image_path)[1].lower()
|
| 595 |
+
if file_extension not in ['.png', '.tif', '.tiff', '.fit', '.fits', '.xisf', 'jpg', 'jpeg']:
|
| 596 |
+
print(f"Ignoring non-image file: {image_path}")
|
| 597 |
+
return None, None, None, None, None, None, None
|
| 598 |
+
|
| 599 |
+
original_header = None
|
| 600 |
+
image = None
|
| 601 |
+
bit_depth = "32-bit float" # Default bit depth
|
| 602 |
+
is_mono = False
|
| 603 |
+
file_meta, image_meta = None, None
|
| 604 |
+
stretch_needed = None
|
| 605 |
+
|
| 606 |
+
try:
|
| 607 |
+
# Load the image based on its extension
|
| 608 |
+
if file_extension in ['.tif', '.tiff']:
|
| 609 |
+
image = tiff.imread(image_path)
|
| 610 |
+
print(f"Loaded TIFF image with dtype: {image.dtype}")
|
| 611 |
+
if image.dtype == np.uint16:
|
| 612 |
+
image = image.astype(np.float32) / 65535.0
|
| 613 |
+
bit_depth = "16-bit"
|
| 614 |
+
|
| 615 |
+
elif image.dtype == np.uint8:
|
| 616 |
+
image = image.astype(np.float32) / 255.0
|
| 617 |
+
bit_depth = "8-bit"
|
| 618 |
+
elif image.dtype == np.uint32:
|
| 619 |
+
image = image.astype(np.float32) / 4294967295.0
|
| 620 |
+
bit_depth = "32-bit unsigned"
|
| 621 |
+
else:
|
| 622 |
+
image.dtype == np.float32
|
| 623 |
+
bit_depth = "32-bit floating point" # If dtype is already float
|
| 624 |
+
|
| 625 |
+
print(f"Final bit depth set to: {bit_depth}")
|
| 626 |
+
|
| 627 |
+
# Handle alpha channels and grayscale TIFFs
|
| 628 |
+
if image.ndim == 3 and image.shape[-1] == 4:
|
| 629 |
+
print("Detected alpha channel in TIFF. Removing it.")
|
| 630 |
+
image = image[:, :, :3]
|
| 631 |
+
if image.ndim == 2:
|
| 632 |
+
image = np.stack([image] * 3, axis=-1) # Convert to RGB
|
| 633 |
+
is_mono = True
|
| 634 |
+
else:
|
| 635 |
+
is_mono = False
|
| 636 |
+
|
| 637 |
+
elif file_extension in ['.fits', '.fit']:
|
| 638 |
+
# Load the FITS image
|
| 639 |
+
with fits.open(image_path) as hdul:
|
| 640 |
+
image_data = hdul[0].data
|
| 641 |
+
original_header = hdul[0].header # Capture the FITS header
|
| 642 |
+
|
| 643 |
+
# Ensure the image data uses the native byte order
|
| 644 |
+
if image_data.dtype.byteorder not in ('=', '|'): # Check if byte order is non-native
|
| 645 |
+
image_data = image_data.astype(image_data.dtype.newbyteorder('=')) # Convert to native byte order
|
| 646 |
+
|
| 647 |
+
# Determine the bit depth based on the data type in the FITS file
|
| 648 |
+
if image_data.dtype == np.uint16:
|
| 649 |
+
bit_depth = "16-bit"
|
| 650 |
+
print("Identified 16bit FITS image.")
|
| 651 |
+
elif image_data.dtype == np.float32:
|
| 652 |
+
bit_depth = "32-bit floating point"
|
| 653 |
+
print("Identified 32bit floating point FITS image.")
|
| 654 |
+
elif image_data.dtype == np.uint32:
|
| 655 |
+
bit_depth = "32-bit unsigned"
|
| 656 |
+
print("Identified 32bit unsigned FITS image.")
|
| 657 |
+
|
| 658 |
+
# Handle 3D FITS data (e.g., RGB or multi-layered data)
|
| 659 |
+
if image_data.ndim == 3:
|
| 660 |
+
print(f"Detected 3D FITS data with shape: {image_data.shape}")
|
| 661 |
+
|
| 662 |
+
if image_data.shape[0] == 3:
|
| 663 |
+
# Assume RGB in (3, H, W) format
|
| 664 |
+
image = np.transpose(image_data, (1, 2, 0)) # Convert to (H, W, 3)
|
| 665 |
+
|
| 666 |
+
if bit_depth == "16-bit":
|
| 667 |
+
image = image.astype(np.float32) / 65535.0
|
| 668 |
+
elif bit_depth == "32-bit unsigned":
|
| 669 |
+
bzero = original_header.get('BZERO', 0)
|
| 670 |
+
bscale = original_header.get('BSCALE', 1)
|
| 671 |
+
image = image.astype(np.float32) * bscale + bzero
|
| 672 |
+
image_min = image.min()
|
| 673 |
+
image_max = image.max()
|
| 674 |
+
image = (image - image_min) / (image_max - image_min)
|
| 675 |
+
print(f"Image range after applying BZERO and BSCALE (RGB): min={image_min}, max={image_max}")
|
| 676 |
+
# No need to normalize float32
|
| 677 |
+
is_mono = False
|
| 678 |
+
|
| 679 |
+
elif image_data.shape[0] == 1:
|
| 680 |
+
# Single-channel mono FITS in (1, H, W) format
|
| 681 |
+
image = image_data[0] # Drop channel dim, now (H, W)
|
| 682 |
+
image = np.stack([image] * 3, axis=-1) # Convert to RGB shape (H, W, 3)
|
| 683 |
+
is_mono = True
|
| 684 |
+
|
| 685 |
+
elif image_data.shape[2] == 1:
|
| 686 |
+
# Shape (H, W, 1) — mono with trailing singleton dim
|
| 687 |
+
image = image_data[:, :, 0]
|
| 688 |
+
image = np.stack([image] * 3, axis=-1)
|
| 689 |
+
is_mono = True
|
| 690 |
+
|
| 691 |
+
else:
|
| 692 |
+
raise ValueError(f"Unsupported 3D FITS shape: {image_data.shape}")
|
| 693 |
+
|
| 694 |
+
# Handle 2D FITS data (grayscale)
|
| 695 |
+
elif image_data.ndim == 2:
|
| 696 |
+
if bit_depth == "16-bit":
|
| 697 |
+
image = image_data.astype(np.float32) / 65535.0 # Normalize to [0, 1] for 16-bit
|
| 698 |
+
elif bit_depth == "32-bit unsigned":
|
| 699 |
+
# Apply BSCALE and BZERO if present
|
| 700 |
+
bzero = original_header.get('BZERO', 0) # Default to 0 if not present
|
| 701 |
+
bscale = original_header.get('BSCALE', 1) # Default to 1 if not present
|
| 702 |
+
|
| 703 |
+
# Convert to float and apply the scaling and offset
|
| 704 |
+
image = image_data.astype(np.float32) * bscale + bzero
|
| 705 |
+
|
| 706 |
+
# Normalize based on the actual data range
|
| 707 |
+
image_min = image.min() # Get the min value after applying BZERO
|
| 708 |
+
image_max = image.max() # Get the max value after applying BZERO
|
| 709 |
+
|
| 710 |
+
# Normalize the image data to the range [0, 1]
|
| 711 |
+
image = (image - image_min) / (image_max - image_min)
|
| 712 |
+
print(f"Image range after applying BZERO and BSCALE (2D case): min={image_min}, max={image_max}")
|
| 713 |
+
|
| 714 |
+
|
| 715 |
+
|
| 716 |
+
|
| 717 |
+
elif bit_depth == "32-bit floating point":
|
| 718 |
+
image = image_data # No normalization needed for 32-bit float
|
| 719 |
+
is_mono = True
|
| 720 |
+
image = np.stack([image] * 3, axis=-1) # Convert to 3-channel for consistency
|
| 721 |
+
else:
|
| 722 |
+
raise ValueError("Unsupported FITS format!")
|
| 723 |
+
|
| 724 |
+
|
| 725 |
+
# Check if file extension is '.xisf'
|
| 726 |
+
elif file_extension == '.xisf':
|
| 727 |
+
# Load XISF file
|
| 728 |
+
xisf = XISF(image_path)
|
| 729 |
+
image = xisf.read_image(0) # Assuming the image data is in the first image block
|
| 730 |
+
image_meta = xisf.get_images_metadata() # List of metadata blocks for each image
|
| 731 |
+
file_meta = xisf.get_file_metadata() # File-level metadata
|
| 732 |
+
|
| 733 |
+
# Determine bit depth
|
| 734 |
+
if image.dtype == np.uint16:
|
| 735 |
+
image = image.astype(np.float32) / 65535.0
|
| 736 |
+
bit_depth = "16-bit"
|
| 737 |
+
elif image.dtype == np.uint8:
|
| 738 |
+
image = image.astype(np.float32) / 255.0
|
| 739 |
+
bit_depth = "8-bit"
|
| 740 |
+
elif image.dtype == np.uint32:
|
| 741 |
+
image = image.astype(np.float32) / 4294967295.0
|
| 742 |
+
bit_depth = "32-bit unsigned"
|
| 743 |
+
elif image.dtype == np.float32:
|
| 744 |
+
bit_depth = "32-bit floating point"
|
| 745 |
+
|
| 746 |
+
# Check if image is mono (2D array) and stack into 3 channels if mono
|
| 747 |
+
if len(image.shape) == 2 or (len(image.shape) == 3 and image.shape[2] == 1):
|
| 748 |
+
image = np.stack([image.squeeze()] * 3, axis=-1) # Convert mono to RGB by duplicating channels
|
| 749 |
+
is_mono = True
|
| 750 |
+
else:
|
| 751 |
+
is_mono = False
|
| 752 |
+
|
| 753 |
+
print(f"Loaded XISF image with bit depth: {bit_depth}, Mono: {is_mono}")
|
| 754 |
+
|
| 755 |
+
|
| 756 |
+
else: # Assume 8-bit PNG
|
| 757 |
+
image = np.array(Image.open(image_path).convert('RGB')).astype(np.float32) / 255.0
|
| 758 |
+
bit_depth = "8-bit"
|
| 759 |
+
print(f"Loaded {bit_depth} PNG image.")
|
| 760 |
+
|
| 761 |
+
if image is None:
|
| 762 |
+
raise ValueError(f"Failed to load image from: {image_path}")
|
| 763 |
+
|
| 764 |
+
# Add a border around the image with the median value
|
| 765 |
+
stretch_needed = np.median(image - np.min(image)) < 0.05 # decision first
|
| 766 |
+
|
| 767 |
+
if stretch_needed:
|
| 768 |
+
print("normalizing linear data")
|
| 769 |
+
stretched_core, original_min, original_medians = stretch_image(image)
|
| 770 |
+
else:
|
| 771 |
+
stretched_core = image.astype(np.float32, copy=False)
|
| 772 |
+
original_min = np.min(image)
|
| 773 |
+
original_medians = [np.median(image[..., c]) for c in range(3)] \
|
| 774 |
+
if image.ndim == 3 else [np.median(image)]
|
| 775 |
+
|
| 776 |
+
# pad AFTER stretch, per-channel median
|
| 777 |
+
stretched_image = add_border(stretched_core, border_size=16)
|
| 778 |
+
|
| 779 |
+
# **Apply TV Denoise on the full image unconditionally**
|
| 780 |
+
#tv_weight = 0.005 # Fixed weight for TV denoising
|
| 781 |
+
#print(f"Applying Total Variation Denoising (TVDenoise) on the full image with fixed weight: {tv_weight}")
|
| 782 |
+
#denoised_full = np.zeros_like(stretched_image)
|
| 783 |
+
#for c in range(stretched_image.shape[2]):
|
| 784 |
+
# denoised_full[:, :, c] = denoise_tv_chambolle(stretched_image[:, :, c], weight=tv_weight, channel_axis=-1)
|
| 785 |
+
#stretched_image = denoised_full
|
| 786 |
+
#print("TV Denoising applied.")
|
| 787 |
+
|
| 788 |
+
# Process mono or color images
|
| 789 |
+
if is_mono:
|
| 790 |
+
# use mono network on channel 0
|
| 791 |
+
mono_net = models["mono_model"]
|
| 792 |
+
den_r = denoise_channel(stretched_image[...,0], models["device"], mono_net, True, models["is_onnx"])
|
| 793 |
+
den = denoise_strength * den_r + (1-denoise_strength)*stretched_image[...,0]
|
| 794 |
+
denoised_image = den[...,None]
|
| 795 |
+
|
| 796 |
+
else:
|
| 797 |
+
# 1) separate‐channels override
|
| 798 |
+
if separate_channels:
|
| 799 |
+
out_ch = []
|
| 800 |
+
for c in range(3):
|
| 801 |
+
dch = denoise_channel(
|
| 802 |
+
stretched_image[...,c],
|
| 803 |
+
models["device"],
|
| 804 |
+
models["mono_model"],
|
| 805 |
+
True, models["is_onnx"]
|
| 806 |
+
)
|
| 807 |
+
blended = blend_images(stretched_image[...,c], dch, denoise_strength)
|
| 808 |
+
out_ch.append(blended)
|
| 809 |
+
denoised_image = np.stack(out_ch, axis=-1)
|
| 810 |
+
|
| 811 |
+
# 2) luminance only
|
| 812 |
+
elif denoise_mode == 'luminance':
|
| 813 |
+
y, cb, cr = extract_luminance(stretched_image)
|
| 814 |
+
den_y = denoise_channel(
|
| 815 |
+
y,
|
| 816 |
+
models["device"],
|
| 817 |
+
models["mono_model"],
|
| 818 |
+
True, models["is_onnx"]
|
| 819 |
+
)
|
| 820 |
+
y2 = blend_images(y, den_y, denoise_strength)
|
| 821 |
+
denoised_image = merge_luminance(y2, cb, cr)
|
| 822 |
+
|
| 823 |
+
|
| 824 |
+
else:
|
| 825 |
+
# --- FULL mode: L via NN (AI3_5), chroma via traditional denoise, then recombine ---
|
| 826 |
+
# Split to YCbCr
|
| 827 |
+
y, cb, cr = extract_luminance(stretched_image)
|
| 828 |
+
|
| 829 |
+
# Denoise L with mono NN
|
| 830 |
+
# Denoise L with mono NN
|
| 831 |
+
den_y = denoise_channel(
|
| 832 |
+
y,
|
| 833 |
+
models["device"],
|
| 834 |
+
models["mono_model"],
|
| 835 |
+
True, models["is_onnx"]
|
| 836 |
+
)
|
| 837 |
+
y2 = blend_images(y, den_y, denoise_strength)
|
| 838 |
+
|
| 839 |
+
colorstrength = color_denoise_strength if color_denoise_strength is not None else denoise_strength
|
| 840 |
+
cb2, cr2 = denoise_chroma(cb, cr,
|
| 841 |
+
strength=colorstrength,
|
| 842 |
+
method="guided",
|
| 843 |
+
guide_y=y)
|
| 844 |
+
|
| 845 |
+
# Recombine to RGB
|
| 846 |
+
denoised_image = merge_luminance(y2, cb2, cr2)
|
| 847 |
+
|
| 848 |
+
|
| 849 |
+
# Unstretch the image
|
| 850 |
+
if stretch_needed:
|
| 851 |
+
print("de-normalizing linear data")
|
| 852 |
+
denoised_image = unstretch_image(denoised_image, original_medians, original_min)
|
| 853 |
+
|
| 854 |
+
|
| 855 |
+
# Remove the border added around the image
|
| 856 |
+
|
| 857 |
+
denoised_image = remove_border(denoised_image, border_size=16)
|
| 858 |
+
|
| 859 |
+
return denoised_image, original_header, bit_depth, file_extension, is_mono, file_meta, image_meta
|
| 860 |
+
|
| 861 |
+
except Exception as e:
|
| 862 |
+
print(f"Error reading image {image_path}: {e}")
|
| 863 |
+
return None, None, None, None, None, None, None
|
| 864 |
+
|
| 865 |
+
|
| 866 |
+
|
| 867 |
+
# Function to denoise a single channel
|
| 868 |
+
def denoise_channel(channel, device, model, is_mono=False, is_onnx=False):
|
| 869 |
+
"""
|
| 870 |
+
Denoises a single image channel using the specified model.
|
| 871 |
+
|
| 872 |
+
Args:
|
| 873 |
+
channel (numpy.ndarray): The input image channel to denoise.
|
| 874 |
+
device (torch.device or str): Device for PyTorch or ONNX inference.
|
| 875 |
+
model: PyTorch model or ONNX inference session.
|
| 876 |
+
is_mono (bool): Whether the input is monochrome.
|
| 877 |
+
is_onnx (bool): Whether to use ONNX for inference.
|
| 878 |
+
|
| 879 |
+
Returns:
|
| 880 |
+
numpy.ndarray: The denoised image channel.
|
| 881 |
+
"""
|
| 882 |
+
chunk_size = 256
|
| 883 |
+
overlap = 64
|
| 884 |
+
chunks = split_image_into_chunks_with_overlap(channel, chunk_size=chunk_size, overlap=overlap)
|
| 885 |
+
|
| 886 |
+
# Debug: validate chunk structure
|
| 887 |
+
for idx, entry in enumerate(chunks[:3]):
|
| 888 |
+
print(f"Chunk {idx} type: {type(entry)}, length: {len(entry) if isinstance(entry, tuple) else 'N/A'}")
|
| 889 |
+
|
| 890 |
+
denoised_chunks = []
|
| 891 |
+
|
| 892 |
+
for idx, entry in enumerate(chunks):
|
| 893 |
+
try:
|
| 894 |
+
chunk, i, j = entry
|
| 895 |
+
except Exception as e:
|
| 896 |
+
print(f"❌ Failed to unpack chunk {idx}: {entry} ({e})")
|
| 897 |
+
continue
|
| 898 |
+
|
| 899 |
+
original_chunk_shape = chunk.shape
|
| 900 |
+
if is_onnx:
|
| 901 |
+
# Prepare ONNX input: Expand to 3 channels
|
| 902 |
+
chunk_input = chunk[np.newaxis, np.newaxis, :, :].astype(np.float32) # Shape: (1, 1, H, W)
|
| 903 |
+
chunk_input = np.tile(chunk_input, (1, 3, 1, 1)) # Shape: (1, 3, H, W)
|
| 904 |
+
|
| 905 |
+
# Pad the chunk to 256x256 if necessary
|
| 906 |
+
if chunk_input.shape[2] != chunk_size or chunk_input.shape[3] != chunk_size:
|
| 907 |
+
padded_chunk = np.zeros((1, 3, chunk_size, chunk_size), dtype=np.float32)
|
| 908 |
+
padded_chunk[:, :, :chunk_input.shape[2], :chunk_input.shape[3]] = chunk_input
|
| 909 |
+
chunk_input = padded_chunk
|
| 910 |
+
|
| 911 |
+
# ONNX inference
|
| 912 |
+
input_name = model.get_inputs()[0].name
|
| 913 |
+
try:
|
| 914 |
+
denoised_chunk = model.run(None, {input_name: chunk_input})[0]
|
| 915 |
+
denoised_chunk = denoised_chunk[0, 0, :original_chunk_shape[0], :original_chunk_shape[1]]
|
| 916 |
+
except Exception as e:
|
| 917 |
+
print(f"ONNX inference error for chunk at ({i}, {j}): {e}")
|
| 918 |
+
continue
|
| 919 |
+
|
| 920 |
+
else:
|
| 921 |
+
# Prepare PyTorch input
|
| 922 |
+
chunk_tensor = torch.tensor(chunk, dtype=torch.float32).unsqueeze(0).unsqueeze(0).to(device)
|
| 923 |
+
if not is_mono:
|
| 924 |
+
chunk_tensor = chunk_tensor.repeat(1, 3, 1, 1)
|
| 925 |
+
else:
|
| 926 |
+
chunk_tensor = chunk_tensor.expand(1, 3, chunk_tensor.shape[2], chunk_tensor.shape[3])
|
| 927 |
+
|
| 928 |
+
# PyTorch inference
|
| 929 |
+
try:
|
| 930 |
+
with torch.no_grad():
|
| 931 |
+
if not DISABLE_MIXED_PRECISION and device.type == "cuda":
|
| 932 |
+
with torch.cuda.amp.autocast():
|
| 933 |
+
denoised_output = model(chunk_tensor).squeeze().cpu().numpy()
|
| 934 |
+
else:
|
| 935 |
+
denoised_output = model(chunk_tensor).squeeze().cpu().numpy()
|
| 936 |
+
|
| 937 |
+
if denoised_output.ndim == 3:
|
| 938 |
+
denoised_chunk = denoised_output[0] # Take first channel
|
| 939 |
+
else:
|
| 940 |
+
denoised_chunk = denoised_output
|
| 941 |
+
|
| 942 |
+
except Exception as e:
|
| 943 |
+
print(f"PyTorch inference error for chunk at ({i}, {j}): {e}")
|
| 944 |
+
continue
|
| 945 |
+
|
| 946 |
+
if denoised_chunk is not None:
|
| 947 |
+
denoised_chunks.append((denoised_chunk, i, j))
|
| 948 |
+
else:
|
| 949 |
+
print(f"⚠️ Warning: Denoised chunk at ({i}, {j}) is None — skipping")
|
| 950 |
+
|
| 951 |
+
# Show progress update
|
| 952 |
+
show_progress(idx + 1, len(chunks))
|
| 953 |
+
|
| 954 |
+
# Debug: final validation before stitching
|
| 955 |
+
if not all(isinstance(entry, tuple) and len(entry) == 3 for entry in denoised_chunks):
|
| 956 |
+
print("❗ Error: One or more denoised chunks are malformed before stitching!")
|
| 957 |
+
|
| 958 |
+
# Stitch the chunks back together
|
| 959 |
+
denoised_channel = stitch_chunks_ignore_border(denoised_chunks, channel.shape, chunk_size=chunk_size, overlap=overlap)
|
| 960 |
+
|
| 961 |
+
return denoised_channel
|
| 962 |
+
|
| 963 |
+
|
| 964 |
+
|
| 965 |
+
|
| 966 |
+
|
| 967 |
+
# Main process for denoising images
|
| 968 |
+
# Main process for denoising images
|
| 969 |
+
def process_images(input_dir, output_dir,
|
| 970 |
+
denoise_strength=None,
|
| 971 |
+
color_denoise_strength=None,
|
| 972 |
+
use_gpu=True,
|
| 973 |
+
denoise_mode='luminance',
|
| 974 |
+
separate_channels=False):
|
| 975 |
+
print((r"""
|
| 976 |
+
*# ___ __ ___ __ #
|
| 977 |
+
*# / __/___/ /__ / _ | ___ / /________ #
|
| 978 |
+
*# _\ \/ -_) _ _ / __ |(_-</ __/ __/ _ \ #
|
| 979 |
+
*# /___/\__/\//_/ /_/ |_/___/\__/__/ \___/ #
|
| 980 |
+
*# #
|
| 981 |
+
*# Cosmic Clarity - Denoise V6.6 AI3.6 #
|
| 982 |
+
*# #
|
| 983 |
+
*# SetiAstro #
|
| 984 |
+
*# Copyright © 2025 #
|
| 985 |
+
*# #
|
| 986 |
+
"""))
|
| 987 |
+
|
| 988 |
+
if denoise_strength is None:
|
| 989 |
+
# GUI path
|
| 990 |
+
use_gpu, denoise_strength, denoise_mode, separate_channels, gui_color_strength = get_user_input()
|
| 991 |
+
# if caller didn't supply a color strength, take GUI's; otherwise keep caller's
|
| 992 |
+
if color_denoise_strength is None:
|
| 993 |
+
color_denoise_strength = gui_color_strength
|
| 994 |
+
else:
|
| 995 |
+
# headless path: if color strength not provided, match luminance strength
|
| 996 |
+
if color_denoise_strength is None:
|
| 997 |
+
color_denoise_strength = denoise_strength
|
| 998 |
+
|
| 999 |
+
# Create output directory if it doesn't exist
|
| 1000 |
+
if not os.path.exists(output_dir):
|
| 1001 |
+
os.makedirs(output_dir)
|
| 1002 |
+
|
| 1003 |
+
# Load the denoise model
|
| 1004 |
+
models = load_models(exe_dir, use_gpu)
|
| 1005 |
+
|
| 1006 |
+
# Determine whether we're using ONNX or PyTorch
|
| 1007 |
+
print(f"Using {'ONNX' if models['is_onnx'] else 'PyTorch'} models.")
|
| 1008 |
+
|
| 1009 |
+
# Process each image in the input directory
|
| 1010 |
+
for fname in os.listdir(input_dir):
|
| 1011 |
+
if not fname.lower().endswith(('.png', '.tif', '.tiff', '.fits', '.fit', '.xisf', '.jpg', '.jpeg')):
|
| 1012 |
+
continue
|
| 1013 |
+
|
| 1014 |
+
# Unpack exactly what denoise_image returns
|
| 1015 |
+
denoised_image, original_header, bit_depth, file_extension, is_mono, file_meta, image_meta = denoise_image(
|
| 1016 |
+
os.path.join(input_dir, fname),
|
| 1017 |
+
denoise_strength,
|
| 1018 |
+
models,
|
| 1019 |
+
denoise_mode,
|
| 1020 |
+
separate_channels,
|
| 1021 |
+
color_denoise_strength
|
| 1022 |
+
)
|
| 1023 |
+
# Skip on error
|
| 1024 |
+
if denoised_image is None:
|
| 1025 |
+
continue
|
| 1026 |
+
|
| 1027 |
+
# Build output path
|
| 1028 |
+
output_name = os.path.splitext(fname)[0] + "_denoised"
|
| 1029 |
+
output_image_path = os.path.join(output_dir, output_name + file_extension)
|
| 1030 |
+
|
| 1031 |
+
# Save as FITS file with header information if the original was FITS
|
| 1032 |
+
if file_extension in ['.fits', '.fit']:
|
| 1033 |
+
if original_header is not None:
|
| 1034 |
+
if is_mono: # Grayscale FITS
|
| 1035 |
+
# Convert the grayscale image back to its original 2D format
|
| 1036 |
+
if bit_depth == "16-bit":
|
| 1037 |
+
denoised_image_fits = (denoised_image[:, :, 0] * 65535).astype(np.uint16)
|
| 1038 |
+
actual_bit_depth = "16-bit"
|
| 1039 |
+
elif bit_depth == "32-bit unsigned":
|
| 1040 |
+
bzero = original_header.get('BZERO', 0)
|
| 1041 |
+
bscale = original_header.get('BSCALE', 1)
|
| 1042 |
+
denoised_image_fits = (denoised_image[:, :, 0].astype(np.float32) * bscale + bzero).astype(np.uint32)
|
| 1043 |
+
original_header['BITPIX'] = 32
|
| 1044 |
+
actual_bit_depth = "32-bit unsigned"
|
| 1045 |
+
else: # 32-bit float
|
| 1046 |
+
denoised_image_fits = denoised_image[:, :, 0].astype(np.float32)
|
| 1047 |
+
actual_bit_depth = "32-bit"
|
| 1048 |
+
|
| 1049 |
+
# Update header for a 2D (grayscale) image
|
| 1050 |
+
original_header['NAXIS'] = 2
|
| 1051 |
+
original_header['NAXIS1'] = denoised_image.shape[1] # Width
|
| 1052 |
+
original_header['NAXIS2'] = denoised_image.shape[0] # Height
|
| 1053 |
+
if 'NAXIS3' in original_header:
|
| 1054 |
+
del original_header['NAXIS3'] # Remove if present
|
| 1055 |
+
|
| 1056 |
+
hdu = fits.PrimaryHDU(denoised_image_fits, header=original_header)
|
| 1057 |
+
|
| 1058 |
+
else: # RGB FITS
|
| 1059 |
+
# Transpose RGB image to FITS-compatible format (channels, height, width)
|
| 1060 |
+
denoised_image_transposed = np.transpose(denoised_image, (2, 0, 1))
|
| 1061 |
+
actual_bit_depth=None
|
| 1062 |
+
if bit_depth == "16-bit":
|
| 1063 |
+
denoised_image_fits = (denoised_image_transposed * 65535).astype(np.uint16)
|
| 1064 |
+
actual_bit_depth = "16-bit"
|
| 1065 |
+
elif bit_depth == "32-bit unsigned":
|
| 1066 |
+
denoised_image_fits = denoised_image_transposed.astype(np.float32)
|
| 1067 |
+
original_header['BITPIX'] = -32
|
| 1068 |
+
actual_bit_depth = "32-bit unsigned"
|
| 1069 |
+
else:
|
| 1070 |
+
denoised_image_fits = denoised_image_transposed.astype(np.float32)
|
| 1071 |
+
actual_bit_depth = "32-bit float"
|
| 1072 |
+
|
| 1073 |
+
# Update header for a 3D (RGB) image
|
| 1074 |
+
original_header['NAXIS'] = 3
|
| 1075 |
+
original_header['NAXIS1'] = denoised_image_transposed.shape[2] # Width
|
| 1076 |
+
original_header['NAXIS2'] = denoised_image_transposed.shape[1] # Height
|
| 1077 |
+
original_header['NAXIS3'] = denoised_image_transposed.shape[0] # Channels
|
| 1078 |
+
|
| 1079 |
+
hdu = fits.PrimaryHDU(denoised_image_fits, header=original_header)
|
| 1080 |
+
|
| 1081 |
+
# Write the FITS file
|
| 1082 |
+
hdu.writeto(output_image_path, overwrite=True)
|
| 1083 |
+
print(f"Saved {actual_bit_depth} denoised image to: {output_image_path}")
|
| 1084 |
+
|
| 1085 |
+
|
| 1086 |
+
# Save as TIFF based on the original bit depth if the original was TIFF
|
| 1087 |
+
elif file_extension in ['.tif', '.tiff']:
|
| 1088 |
+
if bit_depth == "16-bit":
|
| 1089 |
+
actual_bit_depth = "16-bit"
|
| 1090 |
+
if is_mono is True: # Grayscale
|
| 1091 |
+
tiff.imwrite(output_image_path, (denoised_image[:, :, 0] * 65535).astype(np.uint16))
|
| 1092 |
+
else: # RGB
|
| 1093 |
+
tiff.imwrite(output_image_path, (denoised_image * 65535).astype(np.uint16))
|
| 1094 |
+
elif bit_depth == "8-bit":
|
| 1095 |
+
actual_bit_depth = "8-bit"
|
| 1096 |
+
if is_mono:
|
| 1097 |
+
tiff.imwrite(output_image_path, (denoised_image[:, :, 0] * 255.0).astype(np.uint8))
|
| 1098 |
+
else:
|
| 1099 |
+
tiff.imwrite(output_image_path, (denoised_image * 255.0).astype(np.uint8))
|
| 1100 |
+
elif bit_depth == "32-bit unsigned":
|
| 1101 |
+
actual_bit_depth = "32-bit unsigned"
|
| 1102 |
+
if is_mono is True: # Grayscale
|
| 1103 |
+
tiff.imwrite(output_image_path, (denoised_image[:, :, 0] * 4294967295).astype(np.uint32))
|
| 1104 |
+
else: # RGB
|
| 1105 |
+
tiff.imwrite(output_image_path, (denoised_image * 4294967295).astype(np.uint32))
|
| 1106 |
+
else:
|
| 1107 |
+
actual_bit_depth = "32-bit float"
|
| 1108 |
+
if is_mono is True: # Grayscale
|
| 1109 |
+
tiff.imwrite(output_image_path, denoised_image[:, :, 0].astype(np.float32))
|
| 1110 |
+
else: # RGB
|
| 1111 |
+
tiff.imwrite(output_image_path, denoised_image.astype(np.float32))
|
| 1112 |
+
|
| 1113 |
+
print(f"Saved {actual_bit_depth} denoised image to: {output_image_path}")
|
| 1114 |
+
|
| 1115 |
+
elif file_extension == '.xisf':
|
| 1116 |
+
try:
|
| 1117 |
+
# Debug: Print original image details
|
| 1118 |
+
print(f"Original image shape: {denoised_image.shape}, dtype: {denoised_image.dtype}")
|
| 1119 |
+
print(f"Bit depth: {bit_depth}")
|
| 1120 |
+
|
| 1121 |
+
# Adjust bit depth
|
| 1122 |
+
if bit_depth == "16-bit":
|
| 1123 |
+
processed_image = (denoised_image * 65535).astype(np.uint16)
|
| 1124 |
+
elif bit_depth == "8-bit":
|
| 1125 |
+
processed_image = (denoised_image * 255.0).astype(np.uint8)
|
| 1126 |
+
elif bit_depth == "32-bit unsigned":
|
| 1127 |
+
processed_image = (denoised_image * 4294967295).astype(np.uint32)
|
| 1128 |
+
else: # Default to 32-bit float
|
| 1129 |
+
processed_image = denoised_image.astype(np.float32)
|
| 1130 |
+
|
| 1131 |
+
# Adjust for mono images
|
| 1132 |
+
if is_mono:
|
| 1133 |
+
print("Preparing mono image...")
|
| 1134 |
+
processed_image = processed_image[:, :, 0] # Take the first channel
|
| 1135 |
+
processed_image = processed_image[:, :, np.newaxis] # Add back channel dimension
|
| 1136 |
+
image_meta[0]['geometry'] = (processed_image.shape[1], processed_image.shape[0], 1)
|
| 1137 |
+
image_meta[0]['colorSpace'] = 'Gray' # Update metadata for mono
|
| 1138 |
+
|
| 1139 |
+
# Debug: Print processed image details
|
| 1140 |
+
print(f"Processed image shape: {processed_image.shape}, dtype: {processed_image.dtype}")
|
| 1141 |
+
|
| 1142 |
+
# Save the image
|
| 1143 |
+
XISF.write(
|
| 1144 |
+
output_image_path, # Correct output path
|
| 1145 |
+
processed_image, # Final processed image
|
| 1146 |
+
creator_app="Seti Astro Cosmic Clarity",
|
| 1147 |
+
image_metadata=image_meta[0], # First block of image metadata
|
| 1148 |
+
xisf_metadata=file_meta, # File-level metadata
|
| 1149 |
+
|
| 1150 |
+
shuffle=True
|
| 1151 |
+
)
|
| 1152 |
+
print(f"Saved {bit_depth} XISF denoised image to: {output_image_path}")
|
| 1153 |
+
|
| 1154 |
+
except Exception as e:
|
| 1155 |
+
print(f"Error saving XISF file: {e}")
|
| 1156 |
+
|
| 1157 |
+
|
| 1158 |
+
|
| 1159 |
+
|
| 1160 |
+
# Save as 8-bit PNG if the original was PNG
|
| 1161 |
+
else:
|
| 1162 |
+
output_image_path = os.path.join(output_dir, output_name + ".png")
|
| 1163 |
+
denoised_image_8bit = (denoised_image * 255).astype(np.uint8)
|
| 1164 |
+
denoised_image_pil = Image.fromarray(denoised_image_8bit)
|
| 1165 |
+
actual_bit_depth = "8-bit"
|
| 1166 |
+
denoised_image_pil.save(output_image_path)
|
| 1167 |
+
print(f"Saved {actual_bit_depth} denoised image to: {output_image_path}")
|
| 1168 |
+
|
| 1169 |
+
|
| 1170 |
+
|
| 1171 |
+
|
| 1172 |
+
# Define input and output directories
|
| 1173 |
+
if __name__ == "__main__":
|
| 1174 |
+
parser = argparse.ArgumentParser(description="Cosmic Clarity Denoise Tool")
|
| 1175 |
+
parser.add_argument(
|
| 1176 |
+
'--denoise_strength',
|
| 1177 |
+
type=float,
|
| 1178 |
+
help="Denoise strength (0–1), overrides the GUI slider if provided"
|
| 1179 |
+
)
|
| 1180 |
+
parser.add_argument(
|
| 1181 |
+
'--disable_gpu',
|
| 1182 |
+
action='store_true',
|
| 1183 |
+
help="Disable GPU acceleration and force CPU usage"
|
| 1184 |
+
)
|
| 1185 |
+
parser.add_argument(
|
| 1186 |
+
'--denoise_mode',
|
| 1187 |
+
choices=['luminance','full','separate'],
|
| 1188 |
+
default='luminance',
|
| 1189 |
+
help="Denoise mode: 'luminance', 'full' color model, or 'separate' per-channel"
|
| 1190 |
+
)
|
| 1191 |
+
parser.add_argument(
|
| 1192 |
+
'--separate_channels',
|
| 1193 |
+
action='store_true',
|
| 1194 |
+
help="Alias for --denoise_mode separate"
|
| 1195 |
+
)
|
| 1196 |
+
|
| 1197 |
+
parser.add_argument(
|
| 1198 |
+
'--color_denoise_strength',
|
| 1199 |
+
type=float,
|
| 1200 |
+
help="Color (chroma) denoise strength (0–1). Defaults to --denoise_strength if omitted."
|
| 1201 |
+
)
|
| 1202 |
+
|
| 1203 |
+
args = parser.parse_args()
|
| 1204 |
+
|
| 1205 |
+
# Ensure input/output folders exist
|
| 1206 |
+
input_dir = os.path.join(exe_dir, 'input')
|
| 1207 |
+
output_dir = os.path.join(exe_dir, 'output')
|
| 1208 |
+
os.makedirs(input_dir, exist_ok=True)
|
| 1209 |
+
os.makedirs(output_dir, exist_ok=True)
|
| 1210 |
+
|
| 1211 |
+
# If the user requested separate_channels, override denoise_mode
|
| 1212 |
+
mode = args.denoise_mode
|
| 1213 |
+
if args.separate_channels:
|
| 1214 |
+
mode = 'separate'
|
| 1215 |
+
|
| 1216 |
+
# Launch processing
|
| 1217 |
+
process_images(
|
| 1218 |
+
input_dir,
|
| 1219 |
+
output_dir,
|
| 1220 |
+
denoise_strength=args.denoise_strength,
|
| 1221 |
+
color_denoise_strength=args.color_denoise_strength,
|
| 1222 |
+
use_gpu=not args.disable_gpu,
|
| 1223 |
+
denoise_mode=mode,
|
| 1224 |
+
separate_channels=args.separate_channels
|
| 1225 |
+
)
|