MilanMagik commited on
Commit
574aa8f
·
verified ·
1 Parent(s): 2cbe551

Add StarDenoise source code

Browse files
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
+ ![Eagle Omega Sh2-54 tuned](outputs_1080p/comparisons/vbg_011_nebulae_strength030_comparison.jpg)
92
+
93
+ ### M17——strength=0.85
94
+
95
+ ![Messier 17](outputs_1080p/comparisons/vbg_012_messier17_comparison.jpg)
96
+
97
+ ### 猎户座星云——strength=0.85
98
+
99
+ ![Orion Nebula](outputs_1080p/comparisons/vbg_013_orion_comparison.jpg)
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
+ )