Sync YOLO training and evaluation utilities (part 2)
Browse files- PREDICTION_THRESHOLD_FIX.md +94 -0
- TRAINING_IMPROVEMENTS.md +140 -0
- improved_loss_functions.py +220 -0
- large_image_concat.py +640 -0
- marine_features.py +59 -0
- predict_large_image_tiles_256.py +602 -0
- predict_seaweed_256x256_simple.py +512 -0
- prepare_labels.py +156 -0
- requirements.txt +13 -0
- scripts/build_marine_feature_dataset.py +327 -0
- scripts/build_pseudo_dataset.py +130 -0
- scripts/compose_task_profile.py +176 -0
- scripts/evaluate_yolo_detection.py +198 -0
- scripts/find_max_batch.ps1 +16 -0
- scripts/import_quality_gated_hf_dataset.py +309 -0
- scripts/infer_whole_scene.py +356 -0
- scripts/launch_ddp_no_libuv.py +48 -0
- scripts/materialize_yolo_from_accepted.py +158 -0
- scripts/postprocess_mask.py +105 -0
- scripts/run_train_2gpu.bat +8 -0
- scripts/search_hf_marine_datasets.py +499 -0
- scripts/train_2gpu.ps1 +28 -0
- scripts/train_yolo_detection.py +56 -0
- scripts/validate_yolo_map.py +47 -0
- scripts/visualize_yolo_errors.py +92 -0
- seaweed_segmentation_dataset.py +139 -0
- seaweed_segmentation_improved_epoch500/config.json +32 -0
- test-org.xml +73 -0
- test.xml +1 -0
- train_config.json +27 -0
- train_config_improved.json +33 -0
- train_seaweed_segmentation.py +537 -0
- train_segmentation_frozen_config.json +29 -0
- vector.py +247 -0
PREDICTION_THRESHOLD_FIX.md
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# 浒苔预测阈值修复说明
|
| 2 |
+
|
| 3 |
+
## 问题描述
|
| 4 |
+
|
| 5 |
+
当处理整张没有浒苔的256x256图片时,模型可能会把整张图片全部预测为浒苔。这是因为:
|
| 6 |
+
|
| 7 |
+
1. **原始预测逻辑**:使用 `torch.argmax()` 直接选择概率最高的类别
|
| 8 |
+
2. **问题原因**:当两个类别的概率接近时(比如都是0.5左右),`argmax` 会选择概率稍高的那个
|
| 9 |
+
3. **类别不平衡**:如果训练数据中浒苔样本较多,模型可能倾向于预测浒苔类别
|
| 10 |
+
|
| 11 |
+
## 解决方案
|
| 12 |
+
|
| 13 |
+
添加了**概率阈值机制**:
|
| 14 |
+
|
| 15 |
+
- **修改前**:直接使用 `argmax` 选择概率最高的类别
|
| 16 |
+
- **修改后**:只有当浒苔(类别1)的概率**超过阈值**(默认0.5)时,才预测为浒苔
|
| 17 |
+
|
| 18 |
+
### 代码修改
|
| 19 |
+
|
| 20 |
+
在 `predict_image()` 函数中:
|
| 21 |
+
|
| 22 |
+
```python
|
| 23 |
+
# 修改前
|
| 24 |
+
pred_class = torch.argmax(pred, dim=1).squeeze(0).cpu().numpy()
|
| 25 |
+
|
| 26 |
+
# 修改后
|
| 27 |
+
seaweed_prob = pred[0, 1].squeeze(0).cpu().numpy()
|
| 28 |
+
pred_class = (seaweed_prob > threshold).astype(np.uint8)
|
| 29 |
+
```
|
| 30 |
+
|
| 31 |
+
## 使用方法
|
| 32 |
+
|
| 33 |
+
### 1. 基本使用(使用默认阈值0.5)
|
| 34 |
+
|
| 35 |
+
直接运行预测脚本即可,默认阈值为0.5:
|
| 36 |
+
|
| 37 |
+
```bash
|
| 38 |
+
python predict_seaweed_256x256_simple.py
|
| 39 |
+
```
|
| 40 |
+
|
| 41 |
+
### 2. 自定义阈值
|
| 42 |
+
|
| 43 |
+
在脚本的 `main()` 函数中修改 `prediction_threshold` 变量:
|
| 44 |
+
|
| 45 |
+
```python
|
| 46 |
+
# 预测阈值:只有当浒苔概率超过此阈值时才预测为浒苔
|
| 47 |
+
# 建议值:0.5-0.7,可以根据验证集调整
|
| 48 |
+
prediction_threshold = 0.5 # 修改为你需要的值
|
| 49 |
+
```
|
| 50 |
+
|
| 51 |
+
### 3. 阈值选择建议
|
| 52 |
+
|
| 53 |
+
- **0.5**:平衡阈值,适合大多数情况
|
| 54 |
+
- **0.6-0.7**:更保守,减少误报(假阳性),但可能漏检一些浒苔
|
| 55 |
+
- **0.4-0.5**:更宽松,减少漏检(假阴性),但可能增加误报
|
| 56 |
+
|
| 57 |
+
**建议**:在验证集上测试不同阈值,选择F1分数最高的阈值。
|
| 58 |
+
|
| 59 |
+
## 修改的文件
|
| 60 |
+
|
| 61 |
+
1. `predict_seaweed_256x256_simple.py` - 单张256x256图片预测脚本
|
| 62 |
+
2. `predict_large_image_tiles_256.py` - 大图瓦片预测脚本
|
| 63 |
+
|
| 64 |
+
## 验证方法
|
| 65 |
+
|
| 66 |
+
运行预测后,检查以下内容:
|
| 67 |
+
|
| 68 |
+
1. **概率图**:查看 `*_probability.png` 文件,确认无浒苔图片的概率值是否低于阈值
|
| 69 |
+
2. **预测结果**:查看 `*_prediction.png` 文件,确认无浒苔图片是否被正确预测为背景
|
| 70 |
+
3. **统计信息**:查看 `prediction_results.json`,检查无浒苔图片的 `seaweed_ratio` 是否接近0
|
| 71 |
+
|
| 72 |
+
## 进一步优化建议
|
| 73 |
+
|
| 74 |
+
如果问题仍然存在,可以考虑:
|
| 75 |
+
|
| 76 |
+
1. **调整阈值**:根据验证集结果调整阈值
|
| 77 |
+
2. **重新训练模型**:增加无浒苔样本的训练数据
|
| 78 |
+
3. **使用类别权重**:在训练时给背景类别更高的权重
|
| 79 |
+
4. **后处理**:添加形态学操作(如开运算)去除小的误检区域
|
| 80 |
+
|
| 81 |
+
## 技术细节
|
| 82 |
+
|
| 83 |
+
### 为什么会出现这个问题?
|
| 84 |
+
|
| 85 |
+
1. **Softmax特性**:softmax会将logits转换为概率分布,即使两个类别的logits很接近,softmax后的概率也会强制归一化
|
| 86 |
+
2. **模型倾向性**:如果训练数据中浒苔样本较多,模型可能学习到倾向于预测浒苔的模式
|
| 87 |
+
3. **边界情况**:当输入特征不明显时,模型可能给出不确定的预测
|
| 88 |
+
|
| 89 |
+
### 阈值机制的工作原理
|
| 90 |
+
|
| 91 |
+
- 阈值机制相当于在softmax概率上添加了一个"置信度门控"
|
| 92 |
+
- 只有当模型对浒苔的预测**足够确信**(概率>阈值)时,才预测为浒苔
|
| 93 |
+
- 这样可以有效减少在不确定情况下的误判
|
| 94 |
+
|
TRAINING_IMPROVEMENTS.md
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# 训练改进说明
|
| 2 |
+
|
| 3 |
+
## 问题诊断
|
| 4 |
+
|
| 5 |
+
即使有1000张负样本,模型仍然会把无浒苔图片和陆地/海岛预测为浒苔。这说明:
|
| 6 |
+
|
| 7 |
+
1. **类别不平衡问题**:每张图片中背景像素通常远多于浒苔像素
|
| 8 |
+
2. **损失函数问题**:原始损失函数没有给浒苔类别足够的权重
|
| 9 |
+
3. **模型可能学习到"总是预测背景"的策略**
|
| 10 |
+
|
| 11 |
+
## 改进方案
|
| 12 |
+
|
| 13 |
+
### 1. 改进损失函数
|
| 14 |
+
|
| 15 |
+
#### 1.1 Focal Loss改进
|
| 16 |
+
- **之前**:`alpha=1`,所有类别权重相同
|
| 17 |
+
- **现在**:`alpha=[1.0, 3.0]`,给浒苔类别3倍权重
|
| 18 |
+
- **添加**:类别权重tensor,在交叉熵中应用
|
| 19 |
+
|
| 20 |
+
#### 1.2 Dice Loss改进
|
| 21 |
+
- **之前**:使用`mean()`,所有类别权重相同
|
| 22 |
+
- **现在**:对每个类别计算dice loss,然后应用类别权重加权平均
|
| 23 |
+
|
| 24 |
+
#### 1.3 类别权重
|
| 25 |
+
- **背景权重**:1.0(默认)
|
| 26 |
+
- **前景权重**:3.0(可调整,建议2.0-5.0)
|
| 27 |
+
|
| 28 |
+
### 2. 检查训练数据
|
| 29 |
+
|
| 30 |
+
运行 `check_training_data.py` 来:
|
| 31 |
+
- 统计类别分布
|
| 32 |
+
- 计算建议的类别权重
|
| 33 |
+
- 检查数据平衡情况
|
| 34 |
+
|
| 35 |
+
```bash
|
| 36 |
+
python check_training_data.py
|
| 37 |
+
```
|
| 38 |
+
|
| 39 |
+
## 使用方法
|
| 40 |
+
|
| 41 |
+
### 步骤1:检查训练数据
|
| 42 |
+
|
| 43 |
+
```bash
|
| 44 |
+
python check_training_data.py
|
| 45 |
+
```
|
| 46 |
+
|
| 47 |
+
这会输出:
|
| 48 |
+
- 类别分布统计
|
| 49 |
+
- 建议的类别权重
|
| 50 |
+
- 推荐的训练配置
|
| 51 |
+
|
| 52 |
+
### 步骤2:更新训练配置
|
| 53 |
+
|
| 54 |
+
根据检查结果,更新 `train_config.json` 或使用 `train_config_improved.json`:
|
| 55 |
+
|
| 56 |
+
```json
|
| 57 |
+
{
|
| 58 |
+
"focal_alpha": [1.0, 3.0],
|
| 59 |
+
"background_weight": 1.0,
|
| 60 |
+
"foreground_weight": 3.0,
|
| 61 |
+
...
|
| 62 |
+
}
|
| 63 |
+
```
|
| 64 |
+
|
| 65 |
+
### 步骤3:重新训练
|
| 66 |
+
|
| 67 |
+
```bash
|
| 68 |
+
python train_seaweed_segmentation.py --config train_config_improved.json
|
| 69 |
+
```
|
| 70 |
+
|
| 71 |
+
## 参数说明
|
| 72 |
+
|
| 73 |
+
### focal_alpha
|
| 74 |
+
- **类型**:列表 `[背景权重, 前景权重]`
|
| 75 |
+
- **默认**:`[1.0, 3.0]`
|
| 76 |
+
- **说明**:Focal Loss的alpha参数,给前景更高的权重
|
| 77 |
+
|
| 78 |
+
### background_weight / foreground_weight
|
| 79 |
+
- **类型**:浮点数
|
| 80 |
+
- **默认**:`1.0` / `3.0`
|
| 81 |
+
- **说明**:类别权重,用于交叉熵和Dice Loss
|
| 82 |
+
|
| 83 |
+
### 如何选择权重
|
| 84 |
+
|
| 85 |
+
1. **运行数据检查脚本**,查看实际的类别分布
|
| 86 |
+
2. **根据建议调整权重**:
|
| 87 |
+
- 如果浒苔像素 < 1%:使用 `foreground_weight >= 5.0`
|
| 88 |
+
- 如果浒苔像素 1-5%:使用 `foreground_weight = 3.0-5.0`
|
| 89 |
+
- 如果浒苔像素 > 5%:使用 `foreground_weight = 2.0-3.0`
|
| 90 |
+
|
| 91 |
+
## 预期效果
|
| 92 |
+
|
| 93 |
+
改进后的损失函数应该能够:
|
| 94 |
+
1. **更好地学习浒苔特征**:给浒苔更高的权重,模型会更关注浒苔区域
|
| 95 |
+
2. **减少误判**:模型不会简单地"总是预测背景"
|
| 96 |
+
3. **提高精度**:在保持召回率的同时,减少假阳性
|
| 97 |
+
|
| 98 |
+
## 进一步优化
|
| 99 |
+
|
| 100 |
+
如果问题仍然存在:
|
| 101 |
+
|
| 102 |
+
### 1. 增加前景权重
|
| 103 |
+
```json
|
| 104 |
+
"foreground_weight": 5.0,
|
| 105 |
+
"focal_alpha": [1.0, 5.0]
|
| 106 |
+
```
|
| 107 |
+
|
| 108 |
+
### 2. 调整损失函数权重
|
| 109 |
+
```json
|
| 110 |
+
"dice_weight": 0.7, // 增加Dice Loss权重
|
| 111 |
+
"focal_weight": 1.0
|
| 112 |
+
```
|
| 113 |
+
|
| 114 |
+
### 3. 使用难例挖掘
|
| 115 |
+
- 重点关注模型误判的样本
|
| 116 |
+
- 增加这些样本在训练中的权重
|
| 117 |
+
|
| 118 |
+
### 4. 检查数据质量
|
| 119 |
+
- 确保标签正确
|
| 120 |
+
- 检查是否有误标注
|
| 121 |
+
- 确保负样本(无浒苔图片)的标签是全0
|
| 122 |
+
|
| 123 |
+
## 验证方法
|
| 124 |
+
|
| 125 |
+
训练后,检查:
|
| 126 |
+
1. **训练损失**:应该逐渐下降
|
| 127 |
+
2. **验证IoU**:应该逐渐上升
|
| 128 |
+
3. **预测结果**:无浒苔图片应该被正确预测为背景
|
| 129 |
+
4. **混淆矩阵**:假阳性(FP)应该减少
|
| 130 |
+
|
| 131 |
+
## 注意事项
|
| 132 |
+
|
| 133 |
+
1. **权重不要过大**:过大的权重可能导致训练不稳定
|
| 134 |
+
2. **监控训练过程**:如果损失不下降,可能需要调整权重
|
| 135 |
+
3. **验证集评估**:使用验证集来评估改进效果
|
| 136 |
+
|
| 137 |
+
|
| 138 |
+
|
| 139 |
+
|
| 140 |
+
|
improved_loss_functions.py
ADDED
|
@@ -0,0 +1,220 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
改进的损失函数 - 更好地处理类别不平衡
|
| 3 |
+
"""
|
| 4 |
+
import torch
|
| 5 |
+
import torch.nn as nn
|
| 6 |
+
import torch.nn.functional as F
|
| 7 |
+
import numpy as np
|
| 8 |
+
|
| 9 |
+
class ImprovedFocalLoss(nn.Module):
|
| 10 |
+
"""
|
| 11 |
+
改进的Focal Loss,支持类别权重
|
| 12 |
+
"""
|
| 13 |
+
|
| 14 |
+
def __init__(self, alpha=None, gamma=2, ignore_index=255, reduction='mean', class_weights=None):
|
| 15 |
+
"""
|
| 16 |
+
Args:
|
| 17 |
+
alpha: 类别权重列表,如果为None则使用平衡权重
|
| 18 |
+
gamma: 聚焦参数
|
| 19 |
+
ignore_index: 忽略的索引
|
| 20 |
+
reduction: 降维方式
|
| 21 |
+
class_weights: 类别权重tensor,shape为(num_classes,)
|
| 22 |
+
"""
|
| 23 |
+
super().__init__()
|
| 24 |
+
if alpha is None:
|
| 25 |
+
alpha = [1.0, 1.0] # 默认平衡权重
|
| 26 |
+
if isinstance(alpha, (list, tuple)):
|
| 27 |
+
alpha = torch.tensor(alpha, dtype=torch.float32)
|
| 28 |
+
self.alpha = alpha
|
| 29 |
+
self.gamma = gamma
|
| 30 |
+
self.ignore_index = ignore_index
|
| 31 |
+
self.reduction = reduction
|
| 32 |
+
self.class_weights = class_weights
|
| 33 |
+
|
| 34 |
+
def forward(self, inputs, targets):
|
| 35 |
+
"""
|
| 36 |
+
Args:
|
| 37 |
+
inputs: 预测值 (N, C, H, W)
|
| 38 |
+
targets: 目标值 (N, H, W)
|
| 39 |
+
"""
|
| 40 |
+
# 处理多输出格式
|
| 41 |
+
if isinstance(inputs, dict):
|
| 42 |
+
inputs = inputs['out']
|
| 43 |
+
|
| 44 |
+
# 忽略指定索引
|
| 45 |
+
if self.ignore_index is not None:
|
| 46 |
+
mask = targets != self.ignore_index
|
| 47 |
+
targets = targets[mask]
|
| 48 |
+
inputs = inputs.permute(0, 2, 3, 1)[mask]
|
| 49 |
+
else:
|
| 50 |
+
inputs = inputs.permute(0, 2, 3, 1).contiguous().view(-1, inputs.size(1))
|
| 51 |
+
targets = targets.view(-1)
|
| 52 |
+
|
| 53 |
+
# 计算交叉熵
|
| 54 |
+
ce_loss = F.cross_entropy(inputs, targets, reduction='none', weight=self.class_weights)
|
| 55 |
+
pt = torch.exp(-ce_loss)
|
| 56 |
+
|
| 57 |
+
# 应用alpha权重
|
| 58 |
+
if self.alpha is not None:
|
| 59 |
+
if self.alpha.device != targets.device:
|
| 60 |
+
self.alpha = self.alpha.to(targets.device)
|
| 61 |
+
alpha_t = self.alpha[targets]
|
| 62 |
+
focal_loss = alpha_t * (1 - pt) ** self.gamma * ce_loss
|
| 63 |
+
else:
|
| 64 |
+
focal_loss = (1 - pt) ** self.gamma * ce_loss
|
| 65 |
+
|
| 66 |
+
if self.reduction == 'mean':
|
| 67 |
+
return focal_loss.mean()
|
| 68 |
+
elif self.reduction == 'sum':
|
| 69 |
+
return focal_loss.sum()
|
| 70 |
+
else:
|
| 71 |
+
return focal_loss
|
| 72 |
+
|
| 73 |
+
|
| 74 |
+
class WeightedDiceLoss(nn.Module):
|
| 75 |
+
"""
|
| 76 |
+
加权Dice Loss,给不同类别不同的权重
|
| 77 |
+
"""
|
| 78 |
+
|
| 79 |
+
def __init__(self, num_classes=2, smooth=1e-6, class_weights=None):
|
| 80 |
+
"""
|
| 81 |
+
Args:
|
| 82 |
+
num_classes: 类别数
|
| 83 |
+
smooth: 平滑系数
|
| 84 |
+
class_weights: 类别权重tensor,shape为(num_classes,)
|
| 85 |
+
"""
|
| 86 |
+
super().__init__()
|
| 87 |
+
self.num_classes = num_classes
|
| 88 |
+
self.smooth = smooth
|
| 89 |
+
self.class_weights = class_weights
|
| 90 |
+
|
| 91 |
+
def forward(self, inputs, targets):
|
| 92 |
+
"""
|
| 93 |
+
Args:
|
| 94 |
+
inputs: 预测值 (N, C, H, W)
|
| 95 |
+
targets: 目标值 (N, H, W)
|
| 96 |
+
"""
|
| 97 |
+
# 处理多输出格式
|
| 98 |
+
if isinstance(inputs, dict):
|
| 99 |
+
inputs = inputs['out']
|
| 100 |
+
|
| 101 |
+
# 将预测转换为概率
|
| 102 |
+
inputs = torch.softmax(inputs, dim=1)
|
| 103 |
+
|
| 104 |
+
# 创建one-hot编码
|
| 105 |
+
targets_one_hot = F.one_hot(targets, num_classes=self.num_classes)
|
| 106 |
+
targets_one_hot = targets_one_hot.permute(0, 3, 1, 2).float()
|
| 107 |
+
|
| 108 |
+
# 计算每个类别的dice系数
|
| 109 |
+
intersection = (inputs * targets_one_hot).sum(dim=(2, 3)) # (N, C)
|
| 110 |
+
union = inputs.sum(dim=(2, 3)) + targets_one_hot.sum(dim=(2, 3)) # (N, C)
|
| 111 |
+
|
| 112 |
+
dice_score = (2. * intersection + self.smooth) / (union + self.smooth) # (N, C)
|
| 113 |
+
|
| 114 |
+
# 计算每个类别的dice loss
|
| 115 |
+
dice_loss_per_class = 1 - dice_score # (N, C)
|
| 116 |
+
|
| 117 |
+
# 应用类别权重
|
| 118 |
+
if self.class_weights is not None:
|
| 119 |
+
if self.class_weights.device != dice_loss_per_class.device:
|
| 120 |
+
self.class_weights = self.class_weights.to(dice_loss_per_class.device)
|
| 121 |
+
# 对每个类别应用权重
|
| 122 |
+
weighted_dice_loss = dice_loss_per_class * self.class_weights.unsqueeze(0)
|
| 123 |
+
dice_loss = weighted_dice_loss.mean()
|
| 124 |
+
else:
|
| 125 |
+
dice_loss = dice_loss_per_class.mean()
|
| 126 |
+
|
| 127 |
+
return dice_loss
|
| 128 |
+
|
| 129 |
+
|
| 130 |
+
class ImprovedSeaweedSegmentationLoss(nn.Module):
|
| 131 |
+
"""
|
| 132 |
+
改进的浒苔分割损失函数
|
| 133 |
+
更好地处理类别不平衡,特别是背景像素远多于浒苔像素的情况
|
| 134 |
+
"""
|
| 135 |
+
|
| 136 |
+
def __init__(self, num_classes=2, focal_alpha=None, focal_gamma=2,
|
| 137 |
+
dice_weight=0.5, focal_weight=1.0,
|
| 138 |
+
background_weight=1.0, foreground_weight=2.0):
|
| 139 |
+
"""
|
| 140 |
+
Args:
|
| 141 |
+
num_classes: 类别数
|
| 142 |
+
focal_alpha: Focal Loss的alpha参数(类别权重)
|
| 143 |
+
focal_gamma: Focal Loss的gamma参数
|
| 144 |
+
dice_weight: Dice Loss的权重
|
| 145 |
+
focal_weight: Focal Loss的权重
|
| 146 |
+
background_weight: 背景类别的权重(类别0)
|
| 147 |
+
foreground_weight: 前景类别(浒苔)的权重(类别1)
|
| 148 |
+
"""
|
| 149 |
+
super().__init__()
|
| 150 |
+
|
| 151 |
+
# 创建类别权重
|
| 152 |
+
class_weights = torch.tensor([background_weight, foreground_weight], dtype=torch.float32)
|
| 153 |
+
|
| 154 |
+
# 创建Focal Loss的alpha权重
|
| 155 |
+
if focal_alpha is None:
|
| 156 |
+
# 默认给前景更高的权重
|
| 157 |
+
focal_alpha = [background_weight, foreground_weight]
|
| 158 |
+
|
| 159 |
+
self.focal_loss = ImprovedFocalLoss(
|
| 160 |
+
alpha=focal_alpha,
|
| 161 |
+
gamma=focal_gamma,
|
| 162 |
+
class_weights=class_weights
|
| 163 |
+
)
|
| 164 |
+
|
| 165 |
+
self.dice_loss = WeightedDiceLoss(
|
| 166 |
+
num_classes=num_classes,
|
| 167 |
+
class_weights=class_weights
|
| 168 |
+
)
|
| 169 |
+
|
| 170 |
+
self.dice_weight = dice_weight
|
| 171 |
+
self.focal_weight = focal_weight
|
| 172 |
+
self.num_classes = num_classes
|
| 173 |
+
|
| 174 |
+
def forward(self, inputs, targets):
|
| 175 |
+
"""计算组合损失"""
|
| 176 |
+
focal_loss = self.focal_loss(inputs, targets)
|
| 177 |
+
dice_loss = self.dice_loss(inputs, targets)
|
| 178 |
+
|
| 179 |
+
total_loss = self.focal_weight * focal_loss + self.dice_weight * dice_loss
|
| 180 |
+
|
| 181 |
+
return {
|
| 182 |
+
'total_loss': total_loss,
|
| 183 |
+
'focal_loss': focal_loss,
|
| 184 |
+
'dice_loss': dice_loss
|
| 185 |
+
}
|
| 186 |
+
|
| 187 |
+
|
| 188 |
+
def calculate_class_weights_from_dataset(dataset, num_classes=2):
|
| 189 |
+
"""
|
| 190 |
+
从数据集中计算类别权重
|
| 191 |
+
用于平衡类别不平衡问题
|
| 192 |
+
"""
|
| 193 |
+
print("正在计算类别权重...")
|
| 194 |
+
total_pixels = 0
|
| 195 |
+
class_counts = torch.zeros(num_classes)
|
| 196 |
+
|
| 197 |
+
for i in range(len(dataset)):
|
| 198 |
+
sample = dataset[i]
|
| 199 |
+
mask = sample['mask']
|
| 200 |
+
|
| 201 |
+
# 统计每个类别的像素数
|
| 202 |
+
for c in range(num_classes):
|
| 203 |
+
class_counts[c] += (mask == c).sum().item()
|
| 204 |
+
|
| 205 |
+
total_pixels += mask.numel()
|
| 206 |
+
|
| 207 |
+
# 计算权重:总像素数 / (类别数 * 该类别的像素数)
|
| 208 |
+
class_weights = total_pixels / (num_classes * class_counts + 1e-6)
|
| 209 |
+
|
| 210 |
+
# 归一化权重
|
| 211 |
+
class_weights = class_weights / class_weights.sum() * num_classes
|
| 212 |
+
|
| 213 |
+
print(f"类别像素统计: {class_counts.tolist()}")
|
| 214 |
+
print(f"类别权重: {class_weights.tolist()}")
|
| 215 |
+
|
| 216 |
+
return class_weights
|
| 217 |
+
|
| 218 |
+
|
| 219 |
+
|
| 220 |
+
|
large_image_concat.py
ADDED
|
@@ -0,0 +1,640 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import json
|
| 2 |
+
import os
|
| 3 |
+
import sys
|
| 4 |
+
from PIL import Image
|
| 5 |
+
import numpy as np
|
| 6 |
+
import cv2
|
| 7 |
+
from osgeo import gdal, ogr, osr
|
| 8 |
+
|
| 9 |
+
# Increase PIL image size limit to handle large images
|
| 10 |
+
Image.MAX_IMAGE_PIXELS = None
|
| 11 |
+
|
| 12 |
+
# =======================================================
|
| 13 |
+
# I. 栅格处理工具函数 (基于您的代码,进行优化和整合)
|
| 14 |
+
# =======================================================
|
| 15 |
+
|
| 16 |
+
def create_geotiff_from_png(png_path, ref_tiff_path, out_tiff_path):
|
| 17 |
+
"""
|
| 18 |
+
将拼接好的二值PNG图转换为具有地理参考信息的GeoTIFF。
|
| 19 |
+
使用GDAL直接读取PNG,避免内存问题。
|
| 20 |
+
"""
|
| 21 |
+
print(f"\n--- 🌐 步骤 II: 转换为 GeoTIFF ---")
|
| 22 |
+
|
| 23 |
+
# 1. 打开原始 GeoTIFF 获取地理信息
|
| 24 |
+
ds_ref = gdal.Open(ref_tiff_path, gdal.GA_ReadOnly)
|
| 25 |
+
if ds_ref is None:
|
| 26 |
+
raise RuntimeError(f"致命错误: 无法打开原始参考文件 {ref_tiff_path}")
|
| 27 |
+
|
| 28 |
+
geo_transform = ds_ref.GetGeoTransform()
|
| 29 |
+
projection = ds_ref.GetProjection()
|
| 30 |
+
|
| 31 |
+
# 2. 使用GDAL直接打开PNG文件(避免PIL内存问题)
|
| 32 |
+
print("⏳ 正在读取PNG文件...")
|
| 33 |
+
try:
|
| 34 |
+
# 使用GDAL打开PNG
|
| 35 |
+
ds_png = gdal.Open(png_path, gdal.GA_ReadOnly)
|
| 36 |
+
if ds_png is None:
|
| 37 |
+
raise RuntimeError(f"无法使用GDAL打开PNG文件: {png_path}")
|
| 38 |
+
|
| 39 |
+
# 获取PNG尺寸
|
| 40 |
+
png_width = ds_png.RasterXSize
|
| 41 |
+
png_height = ds_png.RasterYSize
|
| 42 |
+
|
| 43 |
+
print(f"✅ PNG尺寸: {png_width} x {png_height}")
|
| 44 |
+
|
| 45 |
+
# 读取PNG数据(分块读取以节省内存)
|
| 46 |
+
png_band = ds_png.GetRasterBand(1)
|
| 47 |
+
|
| 48 |
+
except Exception as e:
|
| 49 |
+
# 如果GDAL无法打开PNG,尝试使用PIL(但分块处理)
|
| 50 |
+
print(f"⚠️ GDAL无法打开PNG,尝试使用PIL分块读取...")
|
| 51 |
+
try:
|
| 52 |
+
img_png = Image.open(png_path)
|
| 53 |
+
png_width, png_height = img_png.size
|
| 54 |
+
|
| 55 |
+
# 分块读取(每次读取1000行)
|
| 56 |
+
chunk_size = 1000
|
| 57 |
+
data_chunks = []
|
| 58 |
+
for y_start in range(0, png_height, chunk_size):
|
| 59 |
+
y_end = min(y_start + chunk_size, png_height)
|
| 60 |
+
box = (0, y_start, png_width, y_end)
|
| 61 |
+
chunk = np.array(img_png.crop(box).convert('L'))
|
| 62 |
+
data_chunks.append(chunk)
|
| 63 |
+
if (y_start // chunk_size + 1) % 10 == 0:
|
| 64 |
+
print(f" 已读取 {y_end}/{png_height} 行...")
|
| 65 |
+
|
| 66 |
+
# 合并数据块
|
| 67 |
+
data = np.vstack(data_chunks)
|
| 68 |
+
img_png.close()
|
| 69 |
+
|
| 70 |
+
except Exception as e2:
|
| 71 |
+
raise RuntimeError(f"读取PNG图像失败: {e2}")
|
| 72 |
+
|
| 73 |
+
# 3. 创建新的 GeoTIFF 文件
|
| 74 |
+
driver = gdal.GetDriverByName("GTiff")
|
| 75 |
+
if os.path.exists(out_tiff_path):
|
| 76 |
+
driver.Delete(out_tiff_path) # 确保覆盖旧文件
|
| 77 |
+
|
| 78 |
+
print(f"⏳ 正在创建GeoTIFF文件...")
|
| 79 |
+
ds_new = driver.Create(
|
| 80 |
+
out_tiff_path,
|
| 81 |
+
png_width, # XSize (Width)
|
| 82 |
+
png_height, # YSize (Height)
|
| 83 |
+
1, # Band Count
|
| 84 |
+
gdal.GDT_Byte, # 确保使用 8位无符号整型存储二值数据 (0/1/255)
|
| 85 |
+
options=['COMPRESS=DEFLATE', 'NUM_THREADS=ALL_CPUS', 'TILED=YES', 'BLOCKXSIZE=256', 'BLOCKYSIZE=256']
|
| 86 |
+
)
|
| 87 |
+
|
| 88 |
+
# 4. 设置地理信息和写入数据
|
| 89 |
+
ds_new.SetGeoTransform(geo_transform)
|
| 90 |
+
ds_new.SetProjection(projection)
|
| 91 |
+
|
| 92 |
+
band = ds_new.GetRasterBand(1)
|
| 93 |
+
|
| 94 |
+
# 分块写入数据(如果使用GDAL读取)
|
| 95 |
+
if 'ds_png' in locals():
|
| 96 |
+
print("⏳ 正在分块写入数据...")
|
| 97 |
+
block_size = 1000 # 每次写入1000行
|
| 98 |
+
for y_start in range(0, png_height, block_size):
|
| 99 |
+
y_end = min(y_start + block_size, png_height)
|
| 100 |
+
data_chunk = png_band.ReadAsArray(0, y_start, png_width, y_end - y_start)
|
| 101 |
+
band.WriteArray(data_chunk, 0, y_start)
|
| 102 |
+
if (y_start // block_size + 1) % 10 == 0:
|
| 103 |
+
print(f" 已写入 {y_end}/{png_height} 行...")
|
| 104 |
+
ds_png = None
|
| 105 |
+
else:
|
| 106 |
+
# 如果使用PIL读取,直接写入
|
| 107 |
+
print("⏳ 正在写入数据...")
|
| 108 |
+
band.WriteArray(data)
|
| 109 |
+
|
| 110 |
+
band.SetNoDataValue(0) # 将背景 0 值设置为 NoData(可选)
|
| 111 |
+
|
| 112 |
+
# 5. 清理资源
|
| 113 |
+
ds_new = None
|
| 114 |
+
ds_ref = None
|
| 115 |
+
if 'data' in locals():
|
| 116 |
+
del data # 释放内存
|
| 117 |
+
|
| 118 |
+
print(f"✅ GeoTIFF 转换成功,保存至: {out_tiff_path}")
|
| 119 |
+
|
| 120 |
+
# 可选:立即添加颜色表(使用您的函数)
|
| 121 |
+
add_transparent_color(out_tiff_path)
|
| 122 |
+
|
| 123 |
+
return out_tiff_path
|
| 124 |
+
|
| 125 |
+
def add_transparent_color(raster_path):
|
| 126 |
+
"""把 0 值设成完全透明,1 值设成任意可见色 (适用于 GDT_Byte 或 GDT_UInt16)"""
|
| 127 |
+
try:
|
| 128 |
+
ds = gdal.Open(raster_path, gdal.GA_Update)
|
| 129 |
+
if ds is None:
|
| 130 |
+
print(f"警告: 无法以更新模式打开 {raster_path} 进行颜色表设置。")
|
| 131 |
+
return
|
| 132 |
+
|
| 133 |
+
band = ds.GetRasterBand(1)
|
| 134 |
+
# 强制转换为 Byte 类型,如果不是的话,确保颜色表能生效
|
| 135 |
+
if band.DataType != gdal.GDT_Byte:
|
| 136 |
+
# 如果不是 Byte,先转换为 Byte(但这里假设 create_geotiff_from_png 已经处理了)
|
| 137 |
+
print(f"警告: 栅格类型 {gdal.GetDataTypeName(band.DataType)} 可能不支持颜色表。")
|
| 138 |
+
|
| 139 |
+
ct = gdal.ColorTable()
|
| 140 |
+
ct.SetColorEntry(0, (0, 0, 0, 0)) # A=0 完全透明
|
| 141 |
+
ct.SetColorEntry(1, (255, 0, 0, 180)) # A=180 半透明红色
|
| 142 |
+
band.SetColorTable(ct)
|
| 143 |
+
band.SetRasterColorInterpretation(gdal.GCI_PaletteIndex)
|
| 144 |
+
ds = None
|
| 145 |
+
print(f"✅ {os.path.basename(raster_path)} 已添加透明颜色表。")
|
| 146 |
+
except Exception as e:
|
| 147 |
+
print(f"❌ 颜色表设置失败: {e}")
|
| 148 |
+
|
| 149 |
+
def raster2polygon(in_raster_path, out_shp_path,
|
| 150 |
+
field_name='DN',
|
| 151 |
+
connected_8=True,
|
| 152 |
+
sieve_size=0,
|
| 153 |
+
target_value=255,
|
| 154 |
+
contour_approx_epsilon=None,
|
| 155 |
+
smooth_gaussian=True,
|
| 156 |
+
gaussian_kernel_size=5,
|
| 157 |
+
use_spline=False,
|
| 158 |
+
spline_points=100,
|
| 159 |
+
min_area=0.0,
|
| 160 |
+
check_topology=True):
|
| 161 |
+
"""
|
| 162 |
+
使用OpenCV轮廓检测和轮廓近似进行矢量化,获得平滑边界(减少锐角)
|
| 163 |
+
|
| 164 |
+
Args:
|
| 165 |
+
in_raster_path: 输入栅格路径
|
| 166 |
+
out_shp_path: 输出矢量路径
|
| 167 |
+
field_name: 字段名
|
| 168 |
+
connected_8: 是否使用8连通(保留参数以兼容旧代码,但新方法使用OpenCV轮廓检测)
|
| 169 |
+
sieve_size: 碎斑过滤阈值(像素数)
|
| 170 |
+
target_value: 目标像素值
|
| 171 |
+
contour_approx_epsilon: 轮廓近似精度(像素),None表示自动计算
|
| 172 |
+
值越小越精确但顶点越多,值越大越平滑但可能丢失细节
|
| 173 |
+
建议值:0.5-2.0像素(减小以获得更平滑的边界)
|
| 174 |
+
smooth_gaussian: 是否在矢量化前对栅格进行高斯模糊平滑(默认True)
|
| 175 |
+
gaussian_kernel_size: 高斯模糊核大小(奇数,建议3-7,默认5)
|
| 176 |
+
use_spline: 是否使用样条插值进一步平滑边界(默认False,会增加计算时间)
|
| 177 |
+
spline_points: 样条插值的点数(仅在use_spline=True时有效)
|
| 178 |
+
min_area: 最小面积阈值(平方米),小于此值的多边形将被过滤(默认0.0不过滤)
|
| 179 |
+
check_topology: 是否进行拓扑检查和修复(默认True)
|
| 180 |
+
"""
|
| 181 |
+
print(f"\n--- 🗺️ 步骤 III: 栅格转矢量(轮廓平滑方法)---")
|
| 182 |
+
|
| 183 |
+
ds = gdal.Open(in_raster_path, gdal.GA_ReadOnly)
|
| 184 |
+
if ds is None:
|
| 185 |
+
raise RuntimeError(f'无法打开输入栅格:{in_raster_path}')
|
| 186 |
+
|
| 187 |
+
band = ds.GetRasterBand(1)
|
| 188 |
+
data = band.ReadAsArray()
|
| 189 |
+
|
| 190 |
+
# 获取地理变换参数
|
| 191 |
+
geo_transform = ds.GetGeoTransform()
|
| 192 |
+
projection = ds.GetProjection()
|
| 193 |
+
|
| 194 |
+
# 统计
|
| 195 |
+
target_pixels = np.sum(data == target_value)
|
| 196 |
+
total_pixels = data.size
|
| 197 |
+
print(f"目标像素(值={target_value}): {target_pixels:,} / {total_pixels:,}")
|
| 198 |
+
|
| 199 |
+
if target_pixels == 0:
|
| 200 |
+
print("⚠️ 没有找到目标像素,跳过矢量化")
|
| 201 |
+
ds = None
|
| 202 |
+
return None
|
| 203 |
+
|
| 204 |
+
# 创建掩膜
|
| 205 |
+
mask_data = np.where(data == target_value, 255, 0).astype(np.uint8)
|
| 206 |
+
|
| 207 |
+
# 碎斑过滤(使用OpenCV)
|
| 208 |
+
if sieve_size > 0:
|
| 209 |
+
print(f"⏳ 碎斑过滤 (阈值: {sieve_size} 像素)...")
|
| 210 |
+
# 使用形态学开运算去除小碎斑
|
| 211 |
+
kernel_size = max(3, sieve_size // 2)
|
| 212 |
+
if kernel_size % 2 == 0:
|
| 213 |
+
kernel_size += 1
|
| 214 |
+
kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (kernel_size, kernel_size))
|
| 215 |
+
mask_data = cv2.morphologyEx(mask_data, cv2.MORPH_OPEN, kernel)
|
| 216 |
+
|
| 217 |
+
# 高斯模糊平滑(减少锐角,使边界更平滑)
|
| 218 |
+
if smooth_gaussian:
|
| 219 |
+
print(f"⏳ 高斯模糊平滑 (核大小: {gaussian_kernel_size})...")
|
| 220 |
+
# 确保核大小为奇数
|
| 221 |
+
if gaussian_kernel_size % 2 == 0:
|
| 222 |
+
gaussian_kernel_size += 1
|
| 223 |
+
mask_data = cv2.GaussianBlur(mask_data, (gaussian_kernel_size, gaussian_kernel_size), 0)
|
| 224 |
+
# 重新二值化(高斯模糊后值会变化)
|
| 225 |
+
_, mask_data = cv2.threshold(mask_data, 127, 255, cv2.THRESH_BINARY)
|
| 226 |
+
|
| 227 |
+
# 创建输出目录
|
| 228 |
+
out_dir = os.path.dirname(out_shp_path)
|
| 229 |
+
if out_dir and not os.path.exists(out_dir):
|
| 230 |
+
os.makedirs(out_dir, exist_ok=True)
|
| 231 |
+
|
| 232 |
+
# 删除已存在的文件
|
| 233 |
+
if os.path.exists(out_shp_path):
|
| 234 |
+
for ext in ['.shp', '.shx', '.dbf', '.prj', '.qpj']:
|
| 235 |
+
try:
|
| 236 |
+
os.remove(out_shp_path.replace('.shp', ext))
|
| 237 |
+
except:
|
| 238 |
+
pass
|
| 239 |
+
|
| 240 |
+
# 创建矢量文件
|
| 241 |
+
driver = ogr.GetDriverByName('ESRI Shapefile')
|
| 242 |
+
out_ds = driver.CreateDataSource(out_shp_path)
|
| 243 |
+
|
| 244 |
+
srs = osr.SpatialReference()
|
| 245 |
+
srs.ImportFromWkt(projection)
|
| 246 |
+
|
| 247 |
+
out_layer = out_ds.CreateLayer(
|
| 248 |
+
name=os.path.splitext(os.path.basename(out_shp_path))[0],
|
| 249 |
+
geom_type=ogr.wkbPolygon,
|
| 250 |
+
srs=srs
|
| 251 |
+
)
|
| 252 |
+
|
| 253 |
+
fd = ogr.FieldDefn(field_name, ogr.OFTInteger)
|
| 254 |
+
out_layer.CreateField(fd)
|
| 255 |
+
|
| 256 |
+
# 使用OpenCV轮廓检测
|
| 257 |
+
print("⏳ 正在检测轮廓...")
|
| 258 |
+
# 使用CHAIN_APPROX_NONE获取所有点,然后进行平滑处理(减少锐角)
|
| 259 |
+
contours, hierarchy = cv2.findContours(
|
| 260 |
+
mask_data,
|
| 261 |
+
cv2.RETR_CCOMP, # 检测所有轮廓,包括孔洞
|
| 262 |
+
cv2.CHAIN_APPROX_NONE # 获取所有点,便于后续平滑处理
|
| 263 |
+
)
|
| 264 |
+
|
| 265 |
+
print(f" 检测到 {len(contours)} 个轮廓")
|
| 266 |
+
|
| 267 |
+
# 计算轮廓近似精度(如果未指定)
|
| 268 |
+
if contour_approx_epsilon is None:
|
| 269 |
+
# 根据图像分辨率自动计算
|
| 270 |
+
# 为了获得更平滑的边界,使用更小的epsilon值(增加顶点数)
|
| 271 |
+
pixel_size_x = abs(geo_transform[1])
|
| 272 |
+
pixel_size_y = abs(geo_transform[5])
|
| 273 |
+
avg_pixel_size = (pixel_size_x + pixel_size_y) / 2
|
| 274 |
+
|
| 275 |
+
# 转换为像素单位(假设地理坐标单位是度)
|
| 276 |
+
# 对于约16米分辨率,约0.00015度,对应约1-2像素
|
| 277 |
+
# 使用更小的值以获得更平滑的边界(减少锐角)
|
| 278 |
+
if avg_pixel_size < 0.001: # 地理坐标系(度)
|
| 279 |
+
contour_approx_epsilon = 1.0 # 从2.0减小到1.0,增加顶点数
|
| 280 |
+
else: # 投影坐标系(米)
|
| 281 |
+
contour_approx_epsilon = max(0.5, avg_pixel_size / 16.0) # 从8.0改为16.0,更精细
|
| 282 |
+
|
| 283 |
+
print(f" 轮廓近似精度: {contour_approx_epsilon:.2f} 像素(值越小边界越平滑)")
|
| 284 |
+
|
| 285 |
+
# 辅助函数:样条插值平滑轮廓
|
| 286 |
+
def smooth_contour_with_spline(contour, num_points=100):
|
| 287 |
+
"""
|
| 288 |
+
使用样条插值平滑轮廓,减少锐角
|
| 289 |
+
|
| 290 |
+
Args:
|
| 291 |
+
contour: OpenCV轮廓点
|
| 292 |
+
num_points: 插值后的点数
|
| 293 |
+
|
| 294 |
+
Returns:
|
| 295 |
+
平滑后的轮廓点
|
| 296 |
+
"""
|
| 297 |
+
if len(contour) < 4:
|
| 298 |
+
return contour
|
| 299 |
+
|
| 300 |
+
# 提取x和y坐标
|
| 301 |
+
points = contour.reshape(-1, 2)
|
| 302 |
+
x = points[:, 0].astype(np.float32)
|
| 303 |
+
y = points[:, 1].astype(np.float32)
|
| 304 |
+
|
| 305 |
+
# 闭合轮廓(添加第一个点到末尾)
|
| 306 |
+
x = np.append(x, x[0])
|
| 307 |
+
y = np.append(y, y[0])
|
| 308 |
+
|
| 309 |
+
# 计算累积距离作为参数
|
| 310 |
+
distances = np.zeros(len(x))
|
| 311 |
+
for i in range(1, len(x)):
|
| 312 |
+
dx = x[i] - x[i-1]
|
| 313 |
+
dy = y[i] - y[i-1]
|
| 314 |
+
distances[i] = distances[i-1] + np.sqrt(dx*dx + dy*dy)
|
| 315 |
+
|
| 316 |
+
# 归一化参数到[0, 1]
|
| 317 |
+
if distances[-1] > 0:
|
| 318 |
+
t = distances / distances[-1]
|
| 319 |
+
else:
|
| 320 |
+
return contour
|
| 321 |
+
|
| 322 |
+
# 生成新的参数点
|
| 323 |
+
t_new = np.linspace(0, 1, num_points)
|
| 324 |
+
|
| 325 |
+
# 样条插值
|
| 326 |
+
try:
|
| 327 |
+
from scipy.interpolate import interp1d
|
| 328 |
+
# 使用三次样条插值
|
| 329 |
+
fx = interp1d(t, x, kind='cubic', bounds_error=False, fill_value='extrapolate')
|
| 330 |
+
fy = interp1d(t, y, kind='cubic', bounds_error=False, fill_value='extrapolate')
|
| 331 |
+
|
| 332 |
+
x_new = fx(t_new)
|
| 333 |
+
y_new = fy(t_new)
|
| 334 |
+
|
| 335 |
+
# 转换为OpenCV轮廓格式
|
| 336 |
+
smoothed = np.array([[int(x_new[i]), int(y_new[i])] for i in range(len(x_new))], dtype=np.int32)
|
| 337 |
+
return smoothed.reshape(-1, 1, 2)
|
| 338 |
+
except ImportError:
|
| 339 |
+
# 如果没有scipy,使用简单的线性插值
|
| 340 |
+
print(" 警告: 未安装scipy,使用线性插值代替样条插值")
|
| 341 |
+
fx = np.interp(t_new, t, x)
|
| 342 |
+
fy = np.interp(t_new, t, y)
|
| 343 |
+
smoothed = np.array([[int(fx[i]), int(fy[i])] for i in range(len(fx))], dtype=np.int32)
|
| 344 |
+
return smoothed.reshape(-1, 1, 2)
|
| 345 |
+
|
| 346 |
+
# 辅助函数:将轮廓转换为OGR环
|
| 347 |
+
def contour_to_ring(contour, geo_transform):
|
| 348 |
+
"""将OpenCV轮廓转换为OGR线性环"""
|
| 349 |
+
ring = ogr.Geometry(ogr.wkbLinearRing)
|
| 350 |
+
for point in contour:
|
| 351 |
+
x_pixel = point[0][0]
|
| 352 |
+
y_pixel = point[0][1]
|
| 353 |
+
|
| 354 |
+
# 转换为地理坐标
|
| 355 |
+
x_geo = geo_transform[0] + x_pixel * geo_transform[1] + y_pixel * geo_transform[2]
|
| 356 |
+
y_geo = geo_transform[3] + x_pixel * geo_transform[4] + y_pixel * geo_transform[5]
|
| 357 |
+
|
| 358 |
+
ring.AddPoint(x_geo, y_geo)
|
| 359 |
+
|
| 360 |
+
# 闭合环
|
| 361 |
+
if ring.GetPointCount() > 0:
|
| 362 |
+
first_point = ring.GetPoint(0)
|
| 363 |
+
ring.AddPoint(first_point[0], first_point[1])
|
| 364 |
+
|
| 365 |
+
return ring
|
| 366 |
+
|
| 367 |
+
# 处理轮廓:先处理外环,再处理对��的内环(孔洞)
|
| 368 |
+
feature_count = 0
|
| 369 |
+
processed_indices = set()
|
| 370 |
+
|
| 371 |
+
for i, contour in enumerate(contours):
|
| 372 |
+
if i in processed_indices or len(contour) < 3:
|
| 373 |
+
continue
|
| 374 |
+
|
| 375 |
+
# 检查是否是外环(hierarchy[i][3] == -1)
|
| 376 |
+
parent_idx = hierarchy[0][i][3]
|
| 377 |
+
if parent_idx != -1: # 这是内环(孔洞),跳过,稍后处理
|
| 378 |
+
continue
|
| 379 |
+
|
| 380 |
+
# 轮廓处理:先进行样条插值平滑(如果启用),再进行Douglas-Peucker近似
|
| 381 |
+
processed_contour = contour
|
| 382 |
+
|
| 383 |
+
# 样条插值平滑(减少锐角)
|
| 384 |
+
if use_spline and len(contour) >= 4:
|
| 385 |
+
processed_contour = smooth_contour_with_spline(contour, spline_points)
|
| 386 |
+
|
| 387 |
+
# 轮廓近似(Douglas-Peucker算法)- 使用较小的epsilon以获得更平滑的边界
|
| 388 |
+
approx = cv2.approxPolyDP(processed_contour, contour_approx_epsilon, closed=True)
|
| 389 |
+
|
| 390 |
+
if len(approx) < 3:
|
| 391 |
+
continue
|
| 392 |
+
|
| 393 |
+
# 创建外环
|
| 394 |
+
exterior_ring = contour_to_ring(approx, geo_transform)
|
| 395 |
+
|
| 396 |
+
# 创建多边形
|
| 397 |
+
poly = ogr.Geometry(ogr.wkbPolygon)
|
| 398 |
+
poly.AddGeometry(exterior_ring)
|
| 399 |
+
|
| 400 |
+
# 查找并添加内环(孔洞)
|
| 401 |
+
# 遍历所有轮廓,找到父轮廓是当前轮廓的内环
|
| 402 |
+
child_idx = hierarchy[0][i][2] # 第一个子轮廓索引
|
| 403 |
+
while child_idx != -1:
|
| 404 |
+
if child_idx < len(contours):
|
| 405 |
+
child_contour = contours[child_idx]
|
| 406 |
+
if len(child_contour) >= 3:
|
| 407 |
+
# 对内环也进行平滑处理
|
| 408 |
+
processed_child = child_contour
|
| 409 |
+
if use_spline and len(child_contour) >= 4:
|
| 410 |
+
processed_child = smooth_contour_with_spline(child_contour, spline_points)
|
| 411 |
+
|
| 412 |
+
# 近似内环
|
| 413 |
+
child_approx = cv2.approxPolyDP(processed_child, contour_approx_epsilon, closed=True)
|
| 414 |
+
if len(child_approx) >= 3:
|
| 415 |
+
interior_ring = contour_to_ring(child_approx, geo_transform)
|
| 416 |
+
poly.AddGeometry(interior_ring)
|
| 417 |
+
processed_indices.add(child_idx)
|
| 418 |
+
# 移动到下一个兄弟轮廓
|
| 419 |
+
child_idx = hierarchy[0][child_idx][0]
|
| 420 |
+
else:
|
| 421 |
+
break
|
| 422 |
+
|
| 423 |
+
# 拓扑检查和修复
|
| 424 |
+
if check_topology:
|
| 425 |
+
# 检查几何有效性
|
| 426 |
+
if not poly.IsValid():
|
| 427 |
+
# 尝试修复无效几何(Buffer(0)可以修复一些拓扑错误)
|
| 428 |
+
try:
|
| 429 |
+
poly_fixed = poly.Buffer(0)
|
| 430 |
+
if poly_fixed.IsValid():
|
| 431 |
+
poly = poly_fixed
|
| 432 |
+
print(f" ✓ 修复无效几何(轮廓 {i})")
|
| 433 |
+
else:
|
| 434 |
+
print(f" ✗ 无法修复无效几何(轮廓 {i}),跳过")
|
| 435 |
+
processed_indices.add(i)
|
| 436 |
+
continue
|
| 437 |
+
except Exception as e:
|
| 438 |
+
print(f" ✗ 修复几何失败(轮廓 {i}): {e},跳过")
|
| 439 |
+
processed_indices.add(i)
|
| 440 |
+
continue
|
| 441 |
+
|
| 442 |
+
# 检查面积(过滤太小的多边形)
|
| 443 |
+
area = poly.GetArea()
|
| 444 |
+
if area <= 0:
|
| 445 |
+
print(f" ✗ 跳过零面积几何(轮廓 {i})")
|
| 446 |
+
processed_indices.add(i)
|
| 447 |
+
continue
|
| 448 |
+
|
| 449 |
+
# 如果设置了最小面积阈值,过滤太小的多边形
|
| 450 |
+
if min_area > 0 and area < min_area:
|
| 451 |
+
print(f" ✗ 跳过面积过小的几何(轮廓 {i},面积: {area:.2f} 平方米)")
|
| 452 |
+
processed_indices.add(i)
|
| 453 |
+
continue
|
| 454 |
+
|
| 455 |
+
# 创建要素
|
| 456 |
+
feature = ogr.Feature(out_layer.GetLayerDefn())
|
| 457 |
+
feature.SetGeometry(poly)
|
| 458 |
+
feature.SetField(field_name, target_value)
|
| 459 |
+
out_layer.CreateFeature(feature)
|
| 460 |
+
feature = None
|
| 461 |
+
feature_count += 1
|
| 462 |
+
processed_indices.add(i)
|
| 463 |
+
|
| 464 |
+
# 清理
|
| 465 |
+
out_ds = None
|
| 466 |
+
ds = None
|
| 467 |
+
|
| 468 |
+
print(f"✅ 矢量化完成,共 {feature_count} 个面要素")
|
| 469 |
+
print(f" 输出: {out_shp_path}")
|
| 470 |
+
|
| 471 |
+
return out_shp_path
|
| 472 |
+
|
| 473 |
+
# =======================================================
|
| 474 |
+
# II. 拼接主函数 (基于上一次修正)
|
| 475 |
+
# =======================================================
|
| 476 |
+
|
| 477 |
+
def stitch_binary_predictions_patch_final(output_dir, json_filename, output_filename="stitched_prediction.png"):
|
| 478 |
+
"""
|
| 479 |
+
拼接切片为 PNG 文件,确保与 'patch00000_prediction.png' 命名规则匹配。
|
| 480 |
+
(代码与上一个回答的最终版本一致,这里仅作为整合)
|
| 481 |
+
"""
|
| 482 |
+
json_filepath = os.path.join(output_dir, json_filename)
|
| 483 |
+
print(f"--- 🚀 步骤 I: 拼接二值图 ---")
|
| 484 |
+
|
| 485 |
+
try:
|
| 486 |
+
with open(json_filepath, 'r') as f:
|
| 487 |
+
data = json.load(f)
|
| 488 |
+
except Exception as e:
|
| 489 |
+
raise RuntimeError(f"❌ 错误: 读取或解析JSON文件出错: {e}")
|
| 490 |
+
|
| 491 |
+
tile_results = data.get('tile_results', [])
|
| 492 |
+
if not tile_results:
|
| 493 |
+
print("⚠️ 警告: JSON文件中没有找到 'tile_results' 数据。")
|
| 494 |
+
return None
|
| 495 |
+
|
| 496 |
+
def get_tile_filename(tile_id):
|
| 497 |
+
return f"patch{tile_id:05d}_prediction.png"
|
| 498 |
+
|
| 499 |
+
first_tile_id = tile_results[0]['tile_id']
|
| 500 |
+
first_tile_path = os.path.join(output_dir, get_tile_filename(first_tile_id))
|
| 501 |
+
|
| 502 |
+
try:
|
| 503 |
+
with Image.open(first_tile_path) as img:
|
| 504 |
+
tile_width, tile_height = img.size
|
| 505 |
+
except Exception as e:
|
| 506 |
+
raise RuntimeError(f"❌ 致命错误: 无法读取第一个切片 {first_tile_path}。请确认文件名。")
|
| 507 |
+
|
| 508 |
+
max_x = max(item['x'] for item in tile_results)
|
| 509 |
+
max_y = max(item['y'] for item in tile_results)
|
| 510 |
+
stitched_width = max_x + tile_width
|
| 511 |
+
stitched_height = max_y + tile_height
|
| 512 |
+
|
| 513 |
+
print(f"✅ 切片尺寸 (W x H): {tile_width} x {tile_height} | 预计大图尺寸: {stitched_width} x {stitched_height}")
|
| 514 |
+
|
| 515 |
+
# 检查内存需求(估算)
|
| 516 |
+
estimated_memory_mb = (stitched_width * stitched_height * 1) / (1024 * 1024) # 1字节每像素
|
| 517 |
+
print(f"📊 预计内存需求: {estimated_memory_mb:.2f} MB")
|
| 518 |
+
|
| 519 |
+
if estimated_memory_mb > 10000: # 超过10GB
|
| 520 |
+
print("⚠️ 警告: 图像很大,可能需要大量内存")
|
| 521 |
+
|
| 522 |
+
try:
|
| 523 |
+
stitched_image = Image.new('L', (stitched_width, stitched_height))
|
| 524 |
+
except MemoryError:
|
| 525 |
+
error_msg = (f"❌ 内存不足: 无法创建 {stitched_width}x{stitched_height} 的图像。\n"
|
| 526 |
+
f" 预计需要约 {estimated_memory_mb:.2f} MB 内存。\n"
|
| 527 |
+
f" 建议: 1) 关闭其他程序 2) 使用更大的内存 3) 分块处理")
|
| 528 |
+
raise RuntimeError(error_msg)
|
| 529 |
+
except Exception as e:
|
| 530 |
+
raise RuntimeError(f"❌ 创建拼接图像失败: {e}")
|
| 531 |
+
|
| 532 |
+
print(f"⏳ 开始拼接 {len(tile_results)} 个切片...")
|
| 533 |
+
processed_count = 0
|
| 534 |
+
|
| 535 |
+
for tile_info in tile_results:
|
| 536 |
+
tile_id = tile_info['tile_id']
|
| 537 |
+
x_offset = tile_info['x']
|
| 538 |
+
y_offset = tile_info['y']
|
| 539 |
+
tile_path = os.path.join(output_dir, get_tile_filename(tile_id))
|
| 540 |
+
|
| 541 |
+
try:
|
| 542 |
+
with Image.open(tile_path) as tile_img:
|
| 543 |
+
if tile_img.mode != 'L':
|
| 544 |
+
tile_img = tile_img.convert('L')
|
| 545 |
+
stitched_image.paste(tile_img, (x_offset, y_offset))
|
| 546 |
+
processed_count += 1
|
| 547 |
+
if processed_count % 1000 == 0:
|
| 548 |
+
print(f" 已处理 {processed_count}/{len(tile_results)} 个切片...")
|
| 549 |
+
except FileNotFoundError:
|
| 550 |
+
print(f"⚠️ 警告: 找不到切片图像文件,跳过: {tile_path}")
|
| 551 |
+
except Exception as e:
|
| 552 |
+
print(f"❌ 处理切片 {tile_id} 时出错,跳过: {e}")
|
| 553 |
+
|
| 554 |
+
print(f"✅ 完成拼接,共处理 {processed_count} 个切片")
|
| 555 |
+
|
| 556 |
+
final_output_path = os.path.join(output_dir, output_filename)
|
| 557 |
+
|
| 558 |
+
# 直接保存,避免转换为numpy数组(节省内存)
|
| 559 |
+
print(f"⏳ 正在保存拼接结果到 {output_filename}...")
|
| 560 |
+
try:
|
| 561 |
+
# 直接保存PIL Image,不需要转换为numpy
|
| 562 |
+
stitched_image.save(final_output_path, format='PNG', compress_level=1)
|
| 563 |
+
print(f"✅ PNG 拼接完成,保存至: {final_output_path}")
|
| 564 |
+
except MemoryError:
|
| 565 |
+
# 如果直接保存也失败,尝试使用更低的压缩级别或分块保存
|
| 566 |
+
print("⚠️ 直接保存失败,尝试使用分块保存...")
|
| 567 |
+
try:
|
| 568 |
+
# 尝试使用更低的压缩级别
|
| 569 |
+
stitched_image.save(final_output_path, format='PNG', compress_level=9, optimize=True)
|
| 570 |
+
print(f"✅ PNG 拼接完成(使用压缩),保存至: {final_output_path}")
|
| 571 |
+
except Exception as e:
|
| 572 |
+
raise RuntimeError(f"❌ 保存失败: {e}\n"
|
| 573 |
+
f" 图像太大 ({stitched_width}x{stitched_height}),内存不足。\n"
|
| 574 |
+
f" 建议: 1) 关闭其他程序 2) 增加虚拟内存 3) 使用分块处理")
|
| 575 |
+
except Exception as e:
|
| 576 |
+
raise RuntimeError(f"❌ 保存PNG文件失败: {e}")
|
| 577 |
+
|
| 578 |
+
# 清理内存
|
| 579 |
+
stitched_image = None
|
| 580 |
+
|
| 581 |
+
return final_output_path
|
| 582 |
+
|
| 583 |
+
# =======================================================
|
| 584 |
+
# III. 主流程调用
|
| 585 |
+
# =======================================================
|
| 586 |
+
if __name__ == '__main__':
|
| 587 |
+
# ------------------- 配置文件路径 -------------------
|
| 588 |
+
# 存放切片和 JSON 的目录
|
| 589 |
+
PREDICTION_OUTPUT_DIR = "outputs/test_large"
|
| 590 |
+
# JSON 文件名
|
| 591 |
+
JSON_FILENAME = "GF6_PMS_E121.1_N33.6_20250604_L1A1420584616-MUX_fuse_tile_results.json"
|
| 592 |
+
|
| 593 |
+
# 原始影像路径 (用于获取地理参考信息,您在 JSON 中提到过文件名)
|
| 594 |
+
ORIGINAL_TIFF_PATH = r"datu/test/GF6_PMS_E121.1_N33.6_20250604_L1A1420584616-MUX_fuse.tif"
|
| 595 |
+
|
| 596 |
+
# 输出文件名定义
|
| 597 |
+
STITCHED_PNG_FILENAME = "stitched_prediction.png"
|
| 598 |
+
OUTPUT_GEOTIFF_FILENAME = "final_binary_prediction.tif"
|
| 599 |
+
OUTPUT_SHP_FILENAME = "output_vector_test/sargassum_polygon.shp"
|
| 600 |
+
|
| 601 |
+
# 碎斑过滤设置
|
| 602 |
+
SIEVE_PIXELS = 0
|
| 603 |
+
# ----------------------------------------------------
|
| 604 |
+
|
| 605 |
+
try:
|
| 606 |
+
# 1. 拼接切片 (生成 PNG)
|
| 607 |
+
stitched_png_path = stitch_binary_predictions_patch_final(
|
| 608 |
+
PREDICTION_OUTPUT_DIR,
|
| 609 |
+
JSON_FILENAME,
|
| 610 |
+
STITCHED_PNG_FILENAME
|
| 611 |
+
)
|
| 612 |
+
if not stitched_png_path:
|
| 613 |
+
sys.exit(1)
|
| 614 |
+
|
| 615 |
+
# 2. 转换为 GeoTIFF (添加地理参考)
|
| 616 |
+
stitched_tiff_path = create_geotiff_from_png(
|
| 617 |
+
stitched_png_path,
|
| 618 |
+
ORIGINAL_TIFF_PATH,
|
| 619 |
+
os.path.join(PREDICTION_OUTPUT_DIR, OUTPUT_GEOTIFF_FILENAME)
|
| 620 |
+
)
|
| 621 |
+
|
| 622 |
+
# 3. 栅格矢量化
|
| 623 |
+
raster2polygon(
|
| 624 |
+
in_raster_path=stitched_tiff_path,
|
| 625 |
+
out_shp_path=os.path.join(PREDICTION_OUTPUT_DIR, OUTPUT_SHP_FILENAME),
|
| 626 |
+
sieve_size=SIEVE_PIXELS,
|
| 627 |
+
connected_8=True
|
| 628 |
+
)
|
| 629 |
+
|
| 630 |
+
except Exception as e:
|
| 631 |
+
import traceback
|
| 632 |
+
print(f"\n❌ 致命错误发生: {e}")
|
| 633 |
+
print("\n详细错误信息:")
|
| 634 |
+
traceback.print_exc()
|
| 635 |
+
print("\n可能的原因:")
|
| 636 |
+
print("1. 文件路径不正确或文件不存在")
|
| 637 |
+
print("2. 内存不足(图像太大)")
|
| 638 |
+
print("3. 文件权限问题")
|
| 639 |
+
print("4. GDAL库配置问题")
|
| 640 |
+
sys.exit(1)
|
marine_features.py
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Seed labels and metadata helpers for registry-driven marine feature tasks."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
from dataclasses import dataclass
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
@dataclass(frozen=True)
|
| 9 |
+
class SeedLabel:
|
| 10 |
+
id: int
|
| 11 |
+
name: str
|
| 12 |
+
description: str
|
| 13 |
+
role: str
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
SEED_CONTEXT_LABELS = [
|
| 17 |
+
SeedLabel(0, "other", "valid background or unlabeled non-target area", "context"),
|
| 18 |
+
SeedLabel(1, "invalid", "black border, no-data, saturated, missing, or unusable pixels", "validity"),
|
| 19 |
+
SeedLabel(2, "water", "valid water background", "context"),
|
| 20 |
+
SeedLabel(3, "land", "land or non-water hard negative", "context"),
|
| 21 |
+
SeedLabel(4, "cloud_shadow", "cloud, haze, cloud shadow, or atmospheric interference", "context"),
|
| 22 |
+
]
|
| 23 |
+
|
| 24 |
+
SEED_ELEMENT_CARDS = {
|
| 25 |
+
"green_tide": "Enteromorpha / green tide / seaweed",
|
| 26 |
+
"red_tide": "red tide / harmful algal bloom",
|
| 27 |
+
"golden_tide": "Sargassum / golden tide",
|
| 28 |
+
"aquaculture": "aquaculture areas, rafts, cages, ponds, or facilities",
|
| 29 |
+
"ship": "ship or vessel target",
|
| 30 |
+
"oil_spill": "oil film or suspected oil-spill area",
|
| 31 |
+
"sea_ice": "sea ice or ice-water boundary",
|
| 32 |
+
}
|
| 33 |
+
|
| 34 |
+
TASK_TYPES = {
|
| 35 |
+
"semantic_segmentation",
|
| 36 |
+
"instance_segmentation",
|
| 37 |
+
"detection",
|
| 38 |
+
"polygon_extraction",
|
| 39 |
+
"change_detection",
|
| 40 |
+
"anomaly_detection",
|
| 41 |
+
}
|
| 42 |
+
|
| 43 |
+
DEFAULT_BANDS_4CH = ["blue", "green", "red", "nir"]
|
| 44 |
+
FUSION_STATES = {"none", "fused_product", "runtime_fusion", "unknown"}
|
| 45 |
+
|
| 46 |
+
LABEL_NAME_TO_ID = {item.name: item.id for item in SEED_CONTEXT_LABELS}
|
| 47 |
+
LABEL_ID_TO_NAME = {item.id: item.name for item in SEED_CONTEXT_LABELS}
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
def is_seed_element(name: str) -> bool:
|
| 51 |
+
return name in SEED_ELEMENT_CARDS
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
def is_fusion_state(value: str) -> bool:
|
| 55 |
+
return value in FUSION_STATES
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
def context_label_id(name: str) -> int:
|
| 59 |
+
return LABEL_NAME_TO_ID[name]
|
predict_large_image_tiles_256.py
ADDED
|
@@ -0,0 +1,602 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
大图浒苔分割预测脚本 - 256x256瓦片版本
|
| 3 |
+
读取大图遥感图像,切割为256x256小patch,对每个patch进行预测
|
| 4 |
+
预测结果格式与predict_seaweed_256x256_simple.py保持一致
|
| 5 |
+
"""
|
| 6 |
+
import os
|
| 7 |
+
import torch
|
| 8 |
+
import numpy as np
|
| 9 |
+
from pathlib import Path
|
| 10 |
+
import json
|
| 11 |
+
import time
|
| 12 |
+
from datetime import datetime
|
| 13 |
+
import cv2
|
| 14 |
+
from tqdm import tqdm
|
| 15 |
+
import rasterio
|
| 16 |
+
from rasterio.windows import Window
|
| 17 |
+
import warnings
|
| 18 |
+
warnings.filterwarnings('ignore')
|
| 19 |
+
|
| 20 |
+
# 导入自定义模块
|
| 21 |
+
from dinov3_deeplabv3plus import DinoV3DeepLabV3Plus
|
| 22 |
+
|
| 23 |
+
def load_model(model_path, config, device):
|
| 24 |
+
"""加载训练好的模型 - 与predict_seaweed_256x256_simple.py保持一致"""
|
| 25 |
+
print(f"正在加载模型: {model_path}")
|
| 26 |
+
|
| 27 |
+
# 创建模型
|
| 28 |
+
model = DinoV3DeepLabV3Plus(
|
| 29 |
+
num_classes=config['num_classes'],
|
| 30 |
+
backbone_name=config['backbone_name'],
|
| 31 |
+
pretrained=False, # 不加载预训练权重,使用我们自己的权重
|
| 32 |
+
weights=config['backbone_weights'],
|
| 33 |
+
use_4channel=config['use_4channel']
|
| 34 |
+
).to(device)
|
| 35 |
+
|
| 36 |
+
# 加载权重
|
| 37 |
+
checkpoint = torch.load(model_path, map_location=device)
|
| 38 |
+
|
| 39 |
+
# 处理不同的checkpoint格式
|
| 40 |
+
if 'model_state_dict' in checkpoint:
|
| 41 |
+
model.load_state_dict(checkpoint['model_state_dict'])
|
| 42 |
+
print(f"加载模型权重 (epoch {checkpoint.get('epoch', 'unknown')})")
|
| 43 |
+
elif 'state_dict' in checkpoint:
|
| 44 |
+
model.load_state_dict(checkpoint['state_dict'])
|
| 45 |
+
print("加载模型权重")
|
| 46 |
+
else:
|
| 47 |
+
# 直接加载state dict
|
| 48 |
+
model.load_state_dict(checkpoint)
|
| 49 |
+
print("加载模型权重")
|
| 50 |
+
|
| 51 |
+
model.eval()
|
| 52 |
+
print("模型加载完成,进入评估模式")
|
| 53 |
+
return model
|
| 54 |
+
|
| 55 |
+
def process_image_for_model(image_path, target_size=256, use_4channel=True):
|
| 56 |
+
"""处理图像用于模型预测 - 完全复用predict_seaweed_256x256_simple.py的逻辑"""
|
| 57 |
+
# 使用OpenCV读取16位TIF图像
|
| 58 |
+
img_16bit = cv2.imread(image_path, cv2.IMREAD_UNCHANGED)
|
| 59 |
+
|
| 60 |
+
if img_16bit is None:
|
| 61 |
+
raise ValueError(f"无法读取图像文件: {image_path}")
|
| 62 |
+
|
| 63 |
+
# 使用rasterio方式读取多波段数据
|
| 64 |
+
try:
|
| 65 |
+
import rasterio
|
| 66 |
+
with rasterio.open(image_path) as src:
|
| 67 |
+
# 读取所有波段
|
| 68 |
+
image = src.read()
|
| 69 |
+
# 转换为HWC格式
|
| 70 |
+
image = np.transpose(image, (1, 2, 0))
|
| 71 |
+
except Exception as e:
|
| 72 |
+
print(f"rasterio读取失败,使用OpenCV: {e}")
|
| 73 |
+
# 回退到OpenCV
|
| 74 |
+
image = img_16bit
|
| 75 |
+
|
| 76 |
+
# 处理通道数
|
| 77 |
+
if use_4channel and image.shape[2] >= 4:
|
| 78 |
+
# 使用4通道
|
| 79 |
+
processed_image = image[:, :, :4]
|
| 80 |
+
else:
|
| 81 |
+
# 使用3通道假彩色(432波段)
|
| 82 |
+
if image.shape[2] >= 4:
|
| 83 |
+
# 选择第4,3,2波段(索引3,2,1)
|
| 84 |
+
processed_image = image[:, :, [3, 2, 1]]
|
| 85 |
+
else:
|
| 86 |
+
# 如果通道数不足,使用可用通道
|
| 87 |
+
processed_image = image[:, :, :3]
|
| 88 |
+
if image.shape[2] < 3:
|
| 89 |
+
# 如果还是不足3通道,重复填充
|
| 90 |
+
while processed_image.shape[2] < 3:
|
| 91 |
+
processed_image = np.concatenate([processed_image, processed_image[:, :, -1:]], axis=2)
|
| 92 |
+
|
| 93 |
+
# 调整尺寸
|
| 94 |
+
if processed_image.shape[0] != target_size or processed_image.shape[1] != target_size:
|
| 95 |
+
processed_image = cv2.resize(processed_image, (target_size, target_size), interpolation=cv2.INTER_LINEAR)
|
| 96 |
+
|
| 97 |
+
# 转换为float32并标准化到0-1范围
|
| 98 |
+
processed_image = processed_image.astype(np.float32)
|
| 99 |
+
if processed_image.max() > 1.0:
|
| 100 |
+
processed_image = processed_image / 65535.0 # 16位最大值
|
| 101 |
+
|
| 102 |
+
# 标准化
|
| 103 |
+
if use_4channel:
|
| 104 |
+
# 4通道标准化
|
| 105 |
+
mean = np.array([0.430, 0.411, 0.296, 0.350])
|
| 106 |
+
std = np.array([0.213, 0.156, 0.143, 0.180])
|
| 107 |
+
else:
|
| 108 |
+
# 3通道标准化
|
| 109 |
+
mean = np.array([0.430, 0.411, 0.296])
|
| 110 |
+
std = np.array([0.213, 0.156, 0.143])
|
| 111 |
+
|
| 112 |
+
# 应用标准化
|
| 113 |
+
for i in range(processed_image.shape[2]):
|
| 114 |
+
processed_image[:, :, i] = (processed_image[:, :, i] - mean[i]) / std[i]
|
| 115 |
+
|
| 116 |
+
# 转换为tensor并添加batch维度
|
| 117 |
+
img_tensor = torch.from_numpy(processed_image).permute(2, 0, 1).float()
|
| 118 |
+
|
| 119 |
+
return img_tensor.unsqueeze(0) # 添加batch维度
|
| 120 |
+
|
| 121 |
+
def predict_image(model, image_tensor, device, config, threshold=0.5):
|
| 122 |
+
"""
|
| 123 |
+
预测单张图像 - 与predict_seaweed_256x256_simple.py保持一致
|
| 124 |
+
|
| 125 |
+
Args:
|
| 126 |
+
model: 训练好的模型
|
| 127 |
+
image_tensor: 图像tensor
|
| 128 |
+
device: 设备
|
| 129 |
+
config: 配置字典
|
| 130 |
+
threshold: 浒苔预测阈值,只有当浒苔概率超过此阈值时才预测为浒苔(默认0.5)
|
| 131 |
+
"""
|
| 132 |
+
# 预测
|
| 133 |
+
with torch.no_grad():
|
| 134 |
+
start_time = time.time()
|
| 135 |
+
output = model(image_tensor)
|
| 136 |
+
inference_time = time.time() - start_time
|
| 137 |
+
|
| 138 |
+
# 处理输出格式
|
| 139 |
+
if isinstance(output, dict):
|
| 140 |
+
output = output['out']
|
| 141 |
+
|
| 142 |
+
# 获取预测结果
|
| 143 |
+
pred = torch.softmax(output, dim=1)
|
| 144 |
+
|
| 145 |
+
# 计算浒苔概率
|
| 146 |
+
seaweed_prob = pred[0, 1].squeeze(0).cpu().numpy() # 类别1是浒苔
|
| 147 |
+
|
| 148 |
+
# 使用阈值进行预测:只有当浒苔概率超过阈值时才预测为浒苔
|
| 149 |
+
# 这样可以避免在无浒苔图片中,由于两个类别概率接近而误判
|
| 150 |
+
pred_class = (seaweed_prob > threshold).astype(np.uint8)
|
| 151 |
+
|
| 152 |
+
return {
|
| 153 |
+
'prediction': pred_class,
|
| 154 |
+
'seaweed_probability': seaweed_prob,
|
| 155 |
+
'inference_time': inference_time
|
| 156 |
+
}
|
| 157 |
+
|
| 158 |
+
def save_prediction_results(prediction, seaweed_prob, output_path, base_name):
|
| 159 |
+
"""保存预测结果到文件 - 与predict_seaweed_256x256_simple.py保持一致"""
|
| 160 |
+
# 保存预测掩码 (0-1)
|
| 161 |
+
pred_mask = prediction.astype(np.uint8)
|
| 162 |
+
np.save(str(output_path / f"{base_name}_prediction.npy"), pred_mask)
|
| 163 |
+
|
| 164 |
+
# 保存概率图 (0-1)
|
| 165 |
+
np.save(str(output_path / f"{base_name}_probability.npy"), seaweed_prob)
|
| 166 |
+
|
| 167 |
+
# 保存为PNG图像
|
| 168 |
+
pred_png = (prediction * 255).astype(np.uint8)
|
| 169 |
+
prob_png = (seaweed_prob * 255).astype(np.uint8)
|
| 170 |
+
|
| 171 |
+
cv2.imwrite(str(output_path / f"{base_name}_prediction.png"), pred_png)
|
| 172 |
+
cv2.imwrite(str(output_path / f"{base_name}_probability.png"), prob_png)
|
| 173 |
+
|
| 174 |
+
def calculate_statistics(prediction, seaweed_prob):
|
| 175 |
+
"""计算统计信息 - 与predict_seaweed_256x256_simple.py保持一致"""
|
| 176 |
+
total_pixels = prediction.size
|
| 177 |
+
seaweed_pixels = np.sum(prediction == 1)
|
| 178 |
+
seaweed_ratio = seaweed_pixels / total_pixels
|
| 179 |
+
|
| 180 |
+
# 计算平均概率
|
| 181 |
+
avg_probability = np.mean(seaweed_prob)
|
| 182 |
+
max_probability = np.max(seaweed_prob)
|
| 183 |
+
|
| 184 |
+
return {
|
| 185 |
+
'total_pixels': int(total_pixels),
|
| 186 |
+
'seaweed_pixels': int(seaweed_pixels),
|
| 187 |
+
'seaweed_ratio': float(seaweed_ratio),
|
| 188 |
+
'avg_probability': float(avg_probability),
|
| 189 |
+
'max_probability': float(max_probability)
|
| 190 |
+
}
|
| 191 |
+
|
| 192 |
+
def get_tile_info_from_large_image(image_path, tile_size=256, overlap=0):
|
| 193 |
+
"""
|
| 194 |
+
获取大图的瓦片信息,但不实际读取数据(内存友好)
|
| 195 |
+
|
| 196 |
+
Args:
|
| 197 |
+
image_path: 大图路径
|
| 198 |
+
tile_size: 瓦片大小
|
| 199 |
+
overlap: 重叠大小
|
| 200 |
+
|
| 201 |
+
Returns:
|
| 202 |
+
list: 瓦片信息列表,每个元素包含(x, y, window, tile_id)
|
| 203 |
+
"""
|
| 204 |
+
tile_info_list = []
|
| 205 |
+
|
| 206 |
+
with rasterio.open(image_path) as src:
|
| 207 |
+
height, width = src.height, src.width
|
| 208 |
+
bands = src.count
|
| 209 |
+
|
| 210 |
+
print(f"大图信息: {width}x{height}, {bands}波段")
|
| 211 |
+
|
| 212 |
+
# 计算步长
|
| 213 |
+
stride = tile_size - overlap
|
| 214 |
+
|
| 215 |
+
# 计算瓦片数量
|
| 216 |
+
tiles_y = (height - tile_size) // stride + 1
|
| 217 |
+
tiles_x = (width - tile_size) // stride + 1
|
| 218 |
+
|
| 219 |
+
# 处理边界情况
|
| 220 |
+
if (height - tile_size) % stride != 0:
|
| 221 |
+
tiles_y += 1
|
| 222 |
+
if (width - tile_size) % stride != 0:
|
| 223 |
+
tiles_x += 1
|
| 224 |
+
|
| 225 |
+
total_tiles = tiles_x * tiles_y
|
| 226 |
+
print(f"将提取 {total_tiles} 个瓦片 ({tiles_x} x {tiles_y})")
|
| 227 |
+
|
| 228 |
+
# 生成瓦片信息
|
| 229 |
+
tile_count = 0
|
| 230 |
+
for y in range(0, height - tile_size + 1, stride):
|
| 231 |
+
for x in range(0, width - tile_size + 1, stride):
|
| 232 |
+
|
| 233 |
+
# 处理边界情况
|
| 234 |
+
actual_x = min(x, width - tile_size)
|
| 235 |
+
actual_y = min(y, height - tile_size)
|
| 236 |
+
|
| 237 |
+
# 创建窗口信息
|
| 238 |
+
window = Window(actual_x, actual_y, tile_size, tile_size)
|
| 239 |
+
|
| 240 |
+
tile_info_list.append({
|
| 241 |
+
'x': actual_x,
|
| 242 |
+
'y': actual_y,
|
| 243 |
+
'window': window,
|
| 244 |
+
'tile_id': tile_count
|
| 245 |
+
})
|
| 246 |
+
|
| 247 |
+
tile_count += 1
|
| 248 |
+
|
| 249 |
+
return tile_info_list
|
| 250 |
+
|
| 251 |
+
def read_tile_data(image_path, window, bands):
|
| 252 |
+
"""读取单个瓦片的数据"""
|
| 253 |
+
with rasterio.open(image_path) as src:
|
| 254 |
+
# 读取多波段数据
|
| 255 |
+
if bands >= 4:
|
| 256 |
+
# 使用4通道
|
| 257 |
+
tile_data = src.read([1, 2, 3, 4], window=window)
|
| 258 |
+
else:
|
| 259 |
+
# 使用可用通道
|
| 260 |
+
tile_data = src.read(list(range(1, min(bands, 4) + 1)), window=window)
|
| 261 |
+
|
| 262 |
+
# 转换为HWC格式
|
| 263 |
+
tile_data = np.transpose(tile_data, (1, 2, 0))
|
| 264 |
+
|
| 265 |
+
return tile_data
|
| 266 |
+
|
| 267 |
+
def save_tile_temporarily(tile_data, temp_dir, tile_id):
|
| 268 |
+
"""临时保存瓦片为文件,用于process_image_for_model函数"""
|
| 269 |
+
temp_path = os.path.join(temp_dir, f"temp_tile_{tile_id}.tif")
|
| 270 |
+
|
| 271 |
+
# 确保临时目录存在
|
| 272 |
+
os.makedirs(temp_dir, exist_ok=True)
|
| 273 |
+
|
| 274 |
+
# 保存为临时TIFF文件
|
| 275 |
+
with rasterio.open(
|
| 276 |
+
temp_path, 'w',
|
| 277 |
+
driver='GTiff',
|
| 278 |
+
height=tile_data.shape[0],
|
| 279 |
+
width=tile_data.shape[1],
|
| 280 |
+
count=tile_data.shape[2],
|
| 281 |
+
dtype=tile_data.dtype,
|
| 282 |
+
compress='lzw'
|
| 283 |
+
) as dst:
|
| 284 |
+
for i in range(tile_data.shape[2]):
|
| 285 |
+
dst.write(tile_data[:, :, i], i + 1)
|
| 286 |
+
|
| 287 |
+
return temp_path
|
| 288 |
+
|
| 289 |
+
def predict_tiles(tiles, model, device, config, output_dir, threshold=0.5):
|
| 290 |
+
"""
|
| 291 |
+
对所有瓦片进行预测
|
| 292 |
+
|
| 293 |
+
Args:
|
| 294 |
+
tiles: 瓦片列表
|
| 295 |
+
model: 模型
|
| 296 |
+
device: 设备
|
| 297 |
+
config: 配置
|
| 298 |
+
output_dir: 输出目录
|
| 299 |
+
threshold: 预测阈值
|
| 300 |
+
"""
|
| 301 |
+
results = []
|
| 302 |
+
total_inference_time = 0
|
| 303 |
+
|
| 304 |
+
# 创建临时目录
|
| 305 |
+
temp_dir = os.path.join(output_dir, "temp_tiles")
|
| 306 |
+
|
| 307 |
+
print(f"\n开始预测 {len(tiles)} 个瓦片...")
|
| 308 |
+
|
| 309 |
+
# 使用tqdm显示进度条
|
| 310 |
+
for tile_info in tqdm(tiles, desc="预测瓦片"):
|
| 311 |
+
try:
|
| 312 |
+
tile_id = tile_info['tile_id']
|
| 313 |
+
tile_data = tile_info['data']
|
| 314 |
+
|
| 315 |
+
# 临时保存瓦片为文件
|
| 316 |
+
temp_tile_path = save_tile_temporarily(tile_data, temp_dir, tile_id)
|
| 317 |
+
|
| 318 |
+
# 处理图像用于模型
|
| 319 |
+
image_tensor = process_image_for_model(
|
| 320 |
+
temp_tile_path,
|
| 321 |
+
target_size=config['image_size'],
|
| 322 |
+
use_4channel=config['use_4channel']
|
| 323 |
+
).to(device)
|
| 324 |
+
|
| 325 |
+
# 预测(使用阈值)
|
| 326 |
+
result = predict_image(model, image_tensor, device, config, threshold=threshold)
|
| 327 |
+
|
| 328 |
+
# 计算统计信息
|
| 329 |
+
stats = calculate_statistics(result['prediction'], result['seaweed_probability'])
|
| 330 |
+
|
| 331 |
+
# 保存预测结果
|
| 332 |
+
base_name = f"patch{tile_id:05d}"
|
| 333 |
+
save_prediction_results(
|
| 334 |
+
result['prediction'],
|
| 335 |
+
result['seaweed_probability'],
|
| 336 |
+
Path(output_dir),
|
| 337 |
+
base_name
|
| 338 |
+
)
|
| 339 |
+
|
| 340 |
+
# 保存结果信息
|
| 341 |
+
result_info = {
|
| 342 |
+
'tile_id': tile_id,
|
| 343 |
+
'x': tile_info['x'],
|
| 344 |
+
'y': tile_info['y'],
|
| 345 |
+
'inference_time': float(result['inference_time']),
|
| 346 |
+
'statistics': stats
|
| 347 |
+
}
|
| 348 |
+
results.append(result_info)
|
| 349 |
+
|
| 350 |
+
total_inference_time += result['inference_time']
|
| 351 |
+
|
| 352 |
+
# 清理临时文件
|
| 353 |
+
if os.path.exists(temp_tile_path):
|
| 354 |
+
os.remove(temp_tile_path)
|
| 355 |
+
|
| 356 |
+
except Exception as e:
|
| 357 |
+
print(f" - 瓦片 {tile_id} 预测失败: {str(e)}")
|
| 358 |
+
continue
|
| 359 |
+
|
| 360 |
+
# 清理临时目录
|
| 361 |
+
if os.path.exists(temp_dir):
|
| 362 |
+
try:
|
| 363 |
+
os.rmdir(temp_dir)
|
| 364 |
+
except:
|
| 365 |
+
pass
|
| 366 |
+
|
| 367 |
+
return results, total_inference_time
|
| 368 |
+
|
| 369 |
+
def predict_tiles_memory_friendly(tile_info_list, image_path, model, device, config, output_dir, threshold=0.5):
|
| 370 |
+
"""
|
| 371 |
+
内存友好的瓦片预测 - 逐块处理
|
| 372 |
+
|
| 373 |
+
Args:
|
| 374 |
+
tile_info_list: 瓦片信息列表
|
| 375 |
+
image_path: 图像路径
|
| 376 |
+
model: 模型
|
| 377 |
+
device: 设备
|
| 378 |
+
config: 配置
|
| 379 |
+
output_dir: 输出目录
|
| 380 |
+
threshold: 预测阈值
|
| 381 |
+
"""
|
| 382 |
+
results = []
|
| 383 |
+
total_inference_time = 0
|
| 384 |
+
|
| 385 |
+
# 创建临时目录
|
| 386 |
+
temp_dir = os.path.join(output_dir, "temp_tiles")
|
| 387 |
+
os.makedirs(temp_dir, exist_ok=True)
|
| 388 |
+
|
| 389 |
+
# 获取图像信息
|
| 390 |
+
with rasterio.open(image_path) as src:
|
| 391 |
+
bands = src.count
|
| 392 |
+
|
| 393 |
+
print(f"\n开始预测 {len(tile_info_list)} 个瓦片...")
|
| 394 |
+
|
| 395 |
+
# 使用tqdm显示进度条
|
| 396 |
+
for tile_info in tqdm(tile_info_list, desc="预测瓦片"):
|
| 397 |
+
try:
|
| 398 |
+
tile_id = tile_info['tile_id']
|
| 399 |
+
|
| 400 |
+
# 读取瓦片数据
|
| 401 |
+
tile_data = read_tile_data(image_path, tile_info['window'], bands)
|
| 402 |
+
|
| 403 |
+
# 临时保存瓦片为文件
|
| 404 |
+
temp_tile_path = save_tile_temporarily(tile_data, temp_dir, tile_id)
|
| 405 |
+
|
| 406 |
+
# 处理图像用于模型
|
| 407 |
+
image_tensor = process_image_for_model(
|
| 408 |
+
temp_tile_path,
|
| 409 |
+
target_size=config['image_size'],
|
| 410 |
+
use_4channel=config['use_4channel']
|
| 411 |
+
).to(device)
|
| 412 |
+
|
| 413 |
+
# 预测(使用阈值)
|
| 414 |
+
result = predict_image(model, image_tensor, device, config, threshold=threshold)
|
| 415 |
+
|
| 416 |
+
# 计算统计信息
|
| 417 |
+
stats = calculate_statistics(result['prediction'], result['seaweed_probability'])
|
| 418 |
+
|
| 419 |
+
# 保存预测结果
|
| 420 |
+
base_name = f"patch{tile_id:05d}"
|
| 421 |
+
save_prediction_results(
|
| 422 |
+
result['prediction'],
|
| 423 |
+
result['seaweed_probability'],
|
| 424 |
+
Path(output_dir),
|
| 425 |
+
base_name
|
| 426 |
+
)
|
| 427 |
+
|
| 428 |
+
# 保存结果信息
|
| 429 |
+
result_info = {
|
| 430 |
+
'tile_id': tile_id,
|
| 431 |
+
'x': tile_info['x'],
|
| 432 |
+
'y': tile_info['y'],
|
| 433 |
+
'inference_time': float(result['inference_time']),
|
| 434 |
+
'statistics': stats
|
| 435 |
+
}
|
| 436 |
+
results.append(result_info)
|
| 437 |
+
|
| 438 |
+
total_inference_time += result['inference_time']
|
| 439 |
+
|
| 440 |
+
# 清理临时文件
|
| 441 |
+
if os.path.exists(temp_tile_path):
|
| 442 |
+
os.remove(temp_tile_path)
|
| 443 |
+
|
| 444 |
+
except Exception as e:
|
| 445 |
+
print(f" - 瓦片 {tile_id} 预测失败: {str(e)}")
|
| 446 |
+
continue
|
| 447 |
+
|
| 448 |
+
# 清理临时目录
|
| 449 |
+
try:
|
| 450 |
+
os.rmdir(temp_dir)
|
| 451 |
+
except:
|
| 452 |
+
pass
|
| 453 |
+
|
| 454 |
+
return results, total_inference_time
|
| 455 |
+
|
| 456 |
+
def main():
|
| 457 |
+
"""主函数"""
|
| 458 |
+
print("=" * 60)
|
| 459 |
+
print("大图瓦片浒苔分割预测系统 - 256x256版本")
|
| 460 |
+
print("=" * 60)
|
| 461 |
+
|
| 462 |
+
# 配置
|
| 463 |
+
model_path = "seaweed_segmentation_improved_epoch500/best_checkpoint.pth"
|
| 464 |
+
config_path = "seaweed_segmentation_improved_epoch500/config.json"
|
| 465 |
+
large_image_dir = "E:\GF6\山科影像\浒苔\GF6_PMS_E121.1_N33.6_20250604_L1A1420584616-MUX_fuse.tif"
|
| 466 |
+
output_dir = "outputs/test_large"
|
| 467 |
+
|
| 468 |
+
# 瓦片配置
|
| 469 |
+
tile_size = 256
|
| 470 |
+
overlap = 0 # 无重叠
|
| 471 |
+
|
| 472 |
+
# 预测阈值:只有当浒苔概率超过此阈值时才预测为浒苔
|
| 473 |
+
# 建议值:0.5-0.7,可以根据验证集调整
|
| 474 |
+
prediction_threshold = 0.5
|
| 475 |
+
|
| 476 |
+
# 创建设备
|
| 477 |
+
device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')
|
| 478 |
+
print(f"使用设备: {device}")
|
| 479 |
+
|
| 480 |
+
# 加载配置
|
| 481 |
+
with open(config_path, 'r', encoding='utf-8') as f:
|
| 482 |
+
config = json.load(f)
|
| 483 |
+
|
| 484 |
+
print(f"模型配置:")
|
| 485 |
+
print(f" - 输入通道: {4 if config['use_4channel'] else 3}")
|
| 486 |
+
print(f" - 图像尺寸: {config['image_size']}x{config['image_size']}")
|
| 487 |
+
print(f" - 类别数: {config['num_classes']}")
|
| 488 |
+
print(f" - 瓦片大小: {tile_size}x{tile_size}")
|
| 489 |
+
print(f" - 重叠: {overlap}")
|
| 490 |
+
print(f" - 预测阈值: {prediction_threshold}")
|
| 491 |
+
|
| 492 |
+
# 加载模型
|
| 493 |
+
model = load_model(model_path, config, device)
|
| 494 |
+
|
| 495 |
+
# 创建输出目录
|
| 496 |
+
output_path = Path(output_dir)
|
| 497 |
+
output_path.mkdir(parents=True, exist_ok=True)
|
| 498 |
+
|
| 499 |
+
# 获取大图文件
|
| 500 |
+
image_extensions = ['.tif', '.tiff']
|
| 501 |
+
large_images = []
|
| 502 |
+
for ext in image_extensions:
|
| 503 |
+
large_images.extend(Path(large_image_dir).glob(f"*{ext}"))
|
| 504 |
+
|
| 505 |
+
print(f"\n找到 {len(large_images)} 个大图文件")
|
| 506 |
+
|
| 507 |
+
# 处理每个大图
|
| 508 |
+
all_results = []
|
| 509 |
+
|
| 510 |
+
for image_file in large_images:
|
| 511 |
+
print(f"\n处理大图: {image_file.name}")
|
| 512 |
+
|
| 513 |
+
try:
|
| 514 |
+
# 获取瓦片信息(不读取实际数据)
|
| 515 |
+
tile_info_list = get_tile_info_from_large_image(
|
| 516 |
+
str(image_file),
|
| 517 |
+
tile_size=tile_size,
|
| 518 |
+
overlap=overlap
|
| 519 |
+
)
|
| 520 |
+
|
| 521 |
+
if not tile_info_list:
|
| 522 |
+
print(f" - 未能生成瓦片信息,跳过")
|
| 523 |
+
continue
|
| 524 |
+
|
| 525 |
+
# 内存友好的瓦片预测
|
| 526 |
+
results, total_inference_time = predict_tiles_memory_friendly(
|
| 527 |
+
tile_info_list, str(image_file), model, device, config, output_path, threshold=prediction_threshold
|
| 528 |
+
)
|
| 529 |
+
|
| 530 |
+
if results:
|
| 531 |
+
# 计算平均推理时间
|
| 532 |
+
avg_inference_time = total_inference_time / len(results)
|
| 533 |
+
|
| 534 |
+
# 计算平均统计
|
| 535 |
+
all_ratios = [r['statistics']['seaweed_ratio'] for r in results]
|
| 536 |
+
avg_seaweed_ratio = np.mean(all_ratios)
|
| 537 |
+
|
| 538 |
+
print(f"\n瓦片预测完成:")
|
| 539 |
+
print(f" - 总瓦片数: {len(results)}")
|
| 540 |
+
print(f" - 平均推理时间: {avg_inference_time:.3f}s")
|
| 541 |
+
print(f" - 平均浒苔比例: {avg_seaweed_ratio:.2%}")
|
| 542 |
+
|
| 543 |
+
# 保存大图处理结果
|
| 544 |
+
image_results = {
|
| 545 |
+
'image_file': str(image_file),
|
| 546 |
+
'total_tiles': len(results),
|
| 547 |
+
'avg_inference_time': avg_inference_time,
|
| 548 |
+
'avg_seaweed_ratio': float(avg_seaweed_ratio),
|
| 549 |
+
'tile_results': results
|
| 550 |
+
}
|
| 551 |
+
all_results.append(image_results)
|
| 552 |
+
|
| 553 |
+
# 保存结果文件
|
| 554 |
+
result_file = output_path / f"{image_file.stem}_tile_results.json"
|
| 555 |
+
with open(result_file, 'w', encoding='utf-8') as f:
|
| 556 |
+
json.dump(image_results, f, indent=2, ensure_ascii=False, default=str)
|
| 557 |
+
|
| 558 |
+
print(f" - 结果保存: {result_file}")
|
| 559 |
+
|
| 560 |
+
except Exception as e:
|
| 561 |
+
print(f" - 处理失败: {str(e)}")
|
| 562 |
+
import traceback
|
| 563 |
+
traceback.print_exc()
|
| 564 |
+
continue
|
| 565 |
+
|
| 566 |
+
# 保存总体结果
|
| 567 |
+
if all_results:
|
| 568 |
+
summary = {
|
| 569 |
+
'timestamp': datetime.now().isoformat(),
|
| 570 |
+
'total_images': len(all_results),
|
| 571 |
+
'total_tiles': sum([r['total_tiles'] for r in all_results]),
|
| 572 |
+
'results': all_results
|
| 573 |
+
}
|
| 574 |
+
|
| 575 |
+
summary_file = output_path / "large_image_tile_summary.json"
|
| 576 |
+
with open(summary_file, 'w', encoding='utf-8') as f:
|
| 577 |
+
json.dump(summary, f, indent=2, ensure_ascii=False, default=str)
|
| 578 |
+
|
| 579 |
+
print(f"\n" + "=" * 60)
|
| 580 |
+
print("大图瓦片预测完成总结")
|
| 581 |
+
print("=" * 60)
|
| 582 |
+
print(f"总图像数: {len(all_results)}")
|
| 583 |
+
print(f"总瓦片数: {summary['total_tiles']}")
|
| 584 |
+
print(f"结果保存目录: {output_path}")
|
| 585 |
+
print(f"总结文件: {summary_file}")
|
| 586 |
+
|
| 587 |
+
# 显示浒苔分布统计
|
| 588 |
+
all_ratios = []
|
| 589 |
+
for result in all_results:
|
| 590 |
+
all_ratios.extend([r['statistics']['seaweed_ratio'] for r in result['tile_results']])
|
| 591 |
+
|
| 592 |
+
if all_ratios:
|
| 593 |
+
print(f"\n浒苔分布统计:")
|
| 594 |
+
print(f" - 最小浒苔比例: {min(all_ratios):.2%}")
|
| 595 |
+
print(f" - 最大浒苔比例: {max(all_ratios):.2%}")
|
| 596 |
+
print(f" - 浒苔比例标准差: {np.std(all_ratios):.2%}")
|
| 597 |
+
|
| 598 |
+
print("\n🎉 大图瓦片浒苔预测完成!")
|
| 599 |
+
print(f"预测结果保存在: {output_dir}")
|
| 600 |
+
|
| 601 |
+
if __name__ == "__main__":
|
| 602 |
+
main()
|
predict_seaweed_256x256_simple.py
ADDED
|
@@ -0,0 +1,512 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
浒苔预测脚本 - 256x256图像尺寸
|
| 3 |
+
使用训练好的模型进行浒苔分割预测 - 简化版本
|
| 4 |
+
"""
|
| 5 |
+
import os
|
| 6 |
+
import torch
|
| 7 |
+
import numpy as np
|
| 8 |
+
from pathlib import Path
|
| 9 |
+
import json
|
| 10 |
+
import time
|
| 11 |
+
from datetime import datetime
|
| 12 |
+
import cv2
|
| 13 |
+
from tqdm import tqdm
|
| 14 |
+
|
| 15 |
+
# 导入自定义模块
|
| 16 |
+
from dinov3_deeplabv3plus import DinoV3DeepLabV3Plus
|
| 17 |
+
|
| 18 |
+
def load_model(model_path, config, device):
|
| 19 |
+
"""加载训练好的模型"""
|
| 20 |
+
print(f"正在加载模型: {model_path}")
|
| 21 |
+
|
| 22 |
+
# 创建模型
|
| 23 |
+
model = DinoV3DeepLabV3Plus(
|
| 24 |
+
num_classes=config['num_classes'],
|
| 25 |
+
backbone_name=config['backbone_name'],
|
| 26 |
+
pretrained=False, # 不加载预训练权重,使用我们自己的权重
|
| 27 |
+
weights=config['backbone_weights'],
|
| 28 |
+
use_4channel=config['use_4channel']
|
| 29 |
+
).to(device)
|
| 30 |
+
|
| 31 |
+
# 加载权重
|
| 32 |
+
checkpoint = torch.load(model_path, map_location=device)
|
| 33 |
+
|
| 34 |
+
# 处理不同的checkpoint格式
|
| 35 |
+
if 'model_state_dict' in checkpoint:
|
| 36 |
+
model.load_state_dict(checkpoint['model_state_dict'])
|
| 37 |
+
print(f"加载模型权重 (epoch {checkpoint.get('epoch', 'unknown')})")
|
| 38 |
+
elif 'state_dict' in checkpoint:
|
| 39 |
+
model.load_state_dict(checkpoint['state_dict'])
|
| 40 |
+
print("加载模型权重")
|
| 41 |
+
else:
|
| 42 |
+
# 直接加载state dict
|
| 43 |
+
model.load_state_dict(checkpoint)
|
| 44 |
+
print("加载模型权重")
|
| 45 |
+
|
| 46 |
+
model.eval()
|
| 47 |
+
print("模型加载完成,进入评估模式")
|
| 48 |
+
return model
|
| 49 |
+
|
| 50 |
+
def process_image_for_model(image_path, target_size=256, use_4channel=True):
|
| 51 |
+
"""处理图像用于模型预测 - 使用与训练相同的方式"""
|
| 52 |
+
# 使用OpenCV读取16位TIF图像
|
| 53 |
+
img_16bit = cv2.imread(image_path, cv2.IMREAD_UNCHANGED)
|
| 54 |
+
|
| 55 |
+
if img_16bit is None:
|
| 56 |
+
raise ValueError(f"无法读取图像文件: {image_path}")
|
| 57 |
+
|
| 58 |
+
# 使用rasterio方式读取多波段数据
|
| 59 |
+
try:
|
| 60 |
+
import rasterio
|
| 61 |
+
with rasterio.open(image_path) as src:
|
| 62 |
+
# 读取所有波段
|
| 63 |
+
image = src.read()
|
| 64 |
+
# 转换为HWC格式
|
| 65 |
+
image = np.transpose(image, (1, 2, 0))
|
| 66 |
+
except Exception as e:
|
| 67 |
+
print(f"rasterio读取失败,使用OpenCV: {e}")
|
| 68 |
+
# 回退到OpenCV
|
| 69 |
+
image = img_16bit
|
| 70 |
+
|
| 71 |
+
# 处理通道数
|
| 72 |
+
if use_4channel and image.shape[2] >= 4:
|
| 73 |
+
# 使用4通道
|
| 74 |
+
processed_image = image[:, :, :4]
|
| 75 |
+
else:
|
| 76 |
+
# 使用3通道假彩色(432波段)
|
| 77 |
+
if image.shape[2] >= 4:
|
| 78 |
+
# 选择第4,3,2波段(索引3,2,1)
|
| 79 |
+
processed_image = image[:, :, [3, 2, 1]]
|
| 80 |
+
else:
|
| 81 |
+
# 如果通道数不足,使用可用通道
|
| 82 |
+
processed_image = image[:, :, :3]
|
| 83 |
+
if image.shape[2] < 3:
|
| 84 |
+
# 如果还是不足3通道,重复填充
|
| 85 |
+
while processed_image.shape[2] < 3:
|
| 86 |
+
processed_image = np.concatenate([processed_image, processed_image[:, :, -1:]], axis=2)
|
| 87 |
+
|
| 88 |
+
# 调整尺寸
|
| 89 |
+
if processed_image.shape[0] != target_size or processed_image.shape[1] != target_size:
|
| 90 |
+
processed_image = cv2.resize(processed_image, (target_size, target_size), interpolation=cv2.INTER_LINEAR)
|
| 91 |
+
|
| 92 |
+
# 转换为float32并标准化到0-1范围
|
| 93 |
+
processed_image = processed_image.astype(np.float32)
|
| 94 |
+
if processed_image.max() > 1.0:
|
| 95 |
+
processed_image = processed_image / 65535.0 # 16位最大值
|
| 96 |
+
|
| 97 |
+
# 标准化
|
| 98 |
+
if use_4channel:
|
| 99 |
+
# 4通道标准化
|
| 100 |
+
mean = np.array([0.430, 0.411, 0.296, 0.350])
|
| 101 |
+
std = np.array([0.213, 0.156, 0.143, 0.180])
|
| 102 |
+
else:
|
| 103 |
+
# 3通道标准化
|
| 104 |
+
mean = np.array([0.430, 0.411, 0.296])
|
| 105 |
+
std = np.array([0.213, 0.156, 0.143])
|
| 106 |
+
|
| 107 |
+
# 应用标准化
|
| 108 |
+
for i in range(processed_image.shape[2]):
|
| 109 |
+
processed_image[:, :, i] = (processed_image[:, :, i] - mean[i]) / std[i]
|
| 110 |
+
|
| 111 |
+
# 转换为tensor并添加batch维度
|
| 112 |
+
img_tensor = torch.from_numpy(processed_image).permute(2, 0, 1).float()
|
| 113 |
+
|
| 114 |
+
return img_tensor.unsqueeze(0) # 添加batch维度
|
| 115 |
+
|
| 116 |
+
def predict_image(model, image_path, device, config, threshold=0.5):
|
| 117 |
+
"""
|
| 118 |
+
预测单张图像
|
| 119 |
+
|
| 120 |
+
Args:
|
| 121 |
+
model: 训练好的模型
|
| 122 |
+
image_path: 图像路径
|
| 123 |
+
device: 设备
|
| 124 |
+
config: 配置字典
|
| 125 |
+
threshold: 浒苔预测阈值,只有当浒苔概率超过此阈值时才预测为浒苔(默认0.5)
|
| 126 |
+
"""
|
| 127 |
+
# 处理图像
|
| 128 |
+
image_tensor = process_image_for_model(
|
| 129 |
+
image_path,
|
| 130 |
+
target_size=config['image_size'],
|
| 131 |
+
use_4channel=config['use_4channel']
|
| 132 |
+
).to(device)
|
| 133 |
+
|
| 134 |
+
# 预测
|
| 135 |
+
with torch.no_grad():
|
| 136 |
+
start_time = time.time()
|
| 137 |
+
output = model(image_tensor)
|
| 138 |
+
inference_time = time.time() - start_time
|
| 139 |
+
|
| 140 |
+
# 处理输出格式
|
| 141 |
+
if isinstance(output, dict):
|
| 142 |
+
output = output['out']
|
| 143 |
+
|
| 144 |
+
# 获取预测结果
|
| 145 |
+
pred = torch.softmax(output, dim=1)
|
| 146 |
+
|
| 147 |
+
# 计算浒苔概率
|
| 148 |
+
seaweed_prob = pred[0, 1].squeeze(0).cpu().numpy() # 类别1是浒苔
|
| 149 |
+
|
| 150 |
+
# 使用阈值进行预测:只有当浒苔概率超过阈值时才预测为浒苔
|
| 151 |
+
# 这样可以避免在无浒苔图片中,由于两个类别概率接近而误判
|
| 152 |
+
pred_class = (seaweed_prob > threshold).astype(np.uint8)
|
| 153 |
+
|
| 154 |
+
return {
|
| 155 |
+
'prediction': pred_class,
|
| 156 |
+
'seaweed_probability': seaweed_prob,
|
| 157 |
+
'inference_time': inference_time
|
| 158 |
+
}
|
| 159 |
+
|
| 160 |
+
def save_prediction_results(prediction, seaweed_prob, output_path, base_name):
|
| 161 |
+
"""保存预测结果到文件"""
|
| 162 |
+
# 保存预测掩码 (0-1)
|
| 163 |
+
pred_mask = prediction.astype(np.uint8)
|
| 164 |
+
np.save(str(output_path / f"{base_name}_prediction.npy"), pred_mask)
|
| 165 |
+
|
| 166 |
+
# 保存概率图 (0-1)
|
| 167 |
+
np.save(str(output_path / f"{base_name}_probability.npy"), seaweed_prob)
|
| 168 |
+
|
| 169 |
+
# 保存为PNG图像
|
| 170 |
+
pred_png = (prediction * 255).astype(np.uint8)
|
| 171 |
+
prob_png = (seaweed_prob * 255).astype(np.uint8)
|
| 172 |
+
|
| 173 |
+
cv2.imwrite(str(output_path / f"{base_name}_prediction.png"), pred_png)
|
| 174 |
+
cv2.imwrite(str(output_path / f"{base_name}_probability.png"), prob_png)
|
| 175 |
+
|
| 176 |
+
def calculate_metrics(prediction, target, num_classes=2):
|
| 177 |
+
"""计算评估指标 - 精确率、召回率、IoU、F1"""
|
| 178 |
+
# 确保输入是numpy数组
|
| 179 |
+
if isinstance(prediction, torch.Tensor):
|
| 180 |
+
prediction = prediction.cpu().numpy()
|
| 181 |
+
if isinstance(target, torch.Tensor):
|
| 182 |
+
target = target.cpu().numpy()
|
| 183 |
+
|
| 184 |
+
# 展平数组
|
| 185 |
+
pred_flat = prediction.flatten()
|
| 186 |
+
target_flat = target.flatten()
|
| 187 |
+
|
| 188 |
+
# 计算混淆矩阵
|
| 189 |
+
confusion_matrix = np.zeros((num_classes, num_classes))
|
| 190 |
+
for i in range(num_classes):
|
| 191 |
+
for j in range(num_classes):
|
| 192 |
+
confusion_matrix[i, j] = np.sum((pred_flat == i) & (target_flat == j))
|
| 193 |
+
|
| 194 |
+
# 计算每个类别的指标
|
| 195 |
+
metrics_per_class = []
|
| 196 |
+
for i in range(num_classes):
|
| 197 |
+
tp = confusion_matrix[i, i] # 真正例
|
| 198 |
+
fp = confusion_matrix[i, :].sum() - tp # 假正例
|
| 199 |
+
fn = confusion_matrix[:, i].sum() - tp # 假负例
|
| 200 |
+
tn = confusion_matrix.sum() - tp - fp - fn # 真负例
|
| 201 |
+
|
| 202 |
+
# 计算指标
|
| 203 |
+
precision = tp / (tp + fp) if (tp + fp) > 0 else 0.0
|
| 204 |
+
recall = tp / (tp + fn) if (tp + fn) > 0 else 0.0
|
| 205 |
+
iou = tp / (tp + fp + fn) if (tp + fp + fn) > 0 else 0.0
|
| 206 |
+
f1 = 2 * (precision * recall) / (precision + recall) if (precision + recall) > 0 else 0.0
|
| 207 |
+
|
| 208 |
+
metrics_per_class.append({
|
| 209 |
+
'precision': precision,
|
| 210 |
+
'recall': recall,
|
| 211 |
+
'iou': iou,
|
| 212 |
+
'f1': f1,
|
| 213 |
+
'tp': int(tp),
|
| 214 |
+
'fp': int(fp),
|
| 215 |
+
'fn': int(fn),
|
| 216 |
+
'tn': int(tn)
|
| 217 |
+
})
|
| 218 |
+
|
| 219 |
+
# 只计算前景(浒苔,类别1)的指标
|
| 220 |
+
foreground_metrics = metrics_per_class[1] if len(metrics_per_class) > 1 else metrics_per_class[0]
|
| 221 |
+
|
| 222 |
+
# 计算整体准确率
|
| 223 |
+
accuracy = np.sum(pred_flat == target_flat) / len(pred_flat)
|
| 224 |
+
|
| 225 |
+
return {
|
| 226 |
+
'accuracy': float(accuracy),
|
| 227 |
+
'foreground_precision': foreground_metrics['precision'],
|
| 228 |
+
'foreground_recall': foreground_metrics['recall'],
|
| 229 |
+
'foreground_iou': foreground_metrics['iou'],
|
| 230 |
+
'foreground_f1': foreground_metrics['f1'],
|
| 231 |
+
'foreground_tp': foreground_metrics['tp'],
|
| 232 |
+
'foreground_fp': foreground_metrics['fp'],
|
| 233 |
+
'foreground_fn': foreground_metrics['fn'],
|
| 234 |
+
'foreground_tn': foreground_metrics['tn'],
|
| 235 |
+
'metrics_per_class': metrics_per_class
|
| 236 |
+
}
|
| 237 |
+
|
| 238 |
+
def calculate_statistics(prediction, seaweed_prob):
|
| 239 |
+
"""计算统计信息"""
|
| 240 |
+
total_pixels = prediction.size
|
| 241 |
+
seaweed_pixels = np.sum(prediction == 1)
|
| 242 |
+
seaweed_ratio = seaweed_pixels / total_pixels
|
| 243 |
+
|
| 244 |
+
# 计算平均概率
|
| 245 |
+
avg_probability = np.mean(seaweed_prob)
|
| 246 |
+
max_probability = np.max(seaweed_prob)
|
| 247 |
+
|
| 248 |
+
return {
|
| 249 |
+
'total_pixels': int(total_pixels),
|
| 250 |
+
'seaweed_pixels': int(seaweed_pixels),
|
| 251 |
+
'seaweed_ratio': float(seaweed_ratio),
|
| 252 |
+
'avg_probability': float(avg_probability),
|
| 253 |
+
'max_probability': float(max_probability)
|
| 254 |
+
}
|
| 255 |
+
|
| 256 |
+
def load_label_mask(mask_path, target_size=256):
|
| 257 |
+
"""加载标签掩码"""
|
| 258 |
+
try:
|
| 259 |
+
# 尝试使用OpenCV读取
|
| 260 |
+
mask = cv2.imread(mask_path, cv2.IMREAD_UNCHANGED)
|
| 261 |
+
if mask is None:
|
| 262 |
+
# 如果OpenCV失败,尝试使用rasterio
|
| 263 |
+
import rasterio
|
| 264 |
+
with rasterio.open(mask_path) as src:
|
| 265 |
+
mask = src.read(1) # 读取第一个波段
|
| 266 |
+
else:
|
| 267 |
+
# 如果OpenCV成功但有多通道,只取第一个通道
|
| 268 |
+
if len(mask.shape) > 2:
|
| 269 |
+
mask = mask[:, :, 0]
|
| 270 |
+
|
| 271 |
+
# 调整尺寸
|
| 272 |
+
if mask.shape[0] != target_size or mask.shape[1] != target_size:
|
| 273 |
+
mask = cv2.resize(mask, (target_size, target_size), interpolation=cv2.INTER_NEAREST)
|
| 274 |
+
|
| 275 |
+
# 确保是二值掩码 (0, 1)
|
| 276 |
+
mask = (mask > 0).astype(np.uint8)
|
| 277 |
+
|
| 278 |
+
return mask
|
| 279 |
+
|
| 280 |
+
except Exception as e:
|
| 281 |
+
print(f"加载标签失败 {mask_path}: {str(e)}")
|
| 282 |
+
return None
|
| 283 |
+
|
| 284 |
+
def main():
|
| 285 |
+
"""主预测函数"""
|
| 286 |
+
print("=" * 60)
|
| 287 |
+
print("浒苔预测系统 - 256x256图像 (带评估指标)")
|
| 288 |
+
print("=" * 60)
|
| 289 |
+
|
| 290 |
+
# 配置
|
| 291 |
+
model_path = "outputs/seaweed_segmentation_improved_epoch500/best_checkpoint.pth"
|
| 292 |
+
config_path = "outputs/seaweed_segmentation_improved_epoch500/config.json"
|
| 293 |
+
test_image_dir = "data/test/images"
|
| 294 |
+
test_mask_dir = "data/test/masks" # 添加标签目录
|
| 295 |
+
output_dir = "outputs/predictions_256x256_results"
|
| 296 |
+
|
| 297 |
+
# 预测阈值:只有当浒苔概率超过此阈值时才预测为浒苔
|
| 298 |
+
# 建议值:0.5-0.7,可以根据验证集调整
|
| 299 |
+
prediction_threshold = 0.5
|
| 300 |
+
|
| 301 |
+
# 创建设备
|
| 302 |
+
device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')
|
| 303 |
+
print(f"使用设备: {device}")
|
| 304 |
+
|
| 305 |
+
# 加载配置
|
| 306 |
+
with open(config_path, 'r', encoding='utf-8') as f:
|
| 307 |
+
config = json.load(f)
|
| 308 |
+
|
| 309 |
+
print(f"模型配置:")
|
| 310 |
+
print(f" - 输入通道: {4 if config['use_4channel'] else 3}")
|
| 311 |
+
print(f" - 图像尺寸: {config['image_size']}x{config['image_size']}")
|
| 312 |
+
print(f" - 类别数: {config['num_classes']}")
|
| 313 |
+
print(f" - 预测阈值: {prediction_threshold}")
|
| 314 |
+
|
| 315 |
+
# 加载模型
|
| 316 |
+
model = load_model(model_path, config, device)
|
| 317 |
+
|
| 318 |
+
# 创建输出目录
|
| 319 |
+
output_path = Path(output_dir)
|
| 320 |
+
output_path.mkdir(parents=True, exist_ok=True)
|
| 321 |
+
|
| 322 |
+
# 获取测试图像
|
| 323 |
+
test_images = [f for f in os.listdir(test_image_dir) if f.endswith('.TIF')]
|
| 324 |
+
print(f"\n找到 {len(test_images)} 张测试图像")
|
| 325 |
+
|
| 326 |
+
# 检查是否有标签数据
|
| 327 |
+
has_labels = os.path.exists(test_mask_dir)
|
| 328 |
+
if has_labels:
|
| 329 |
+
print(f"找到标签目录,将进行评估指标计算")
|
| 330 |
+
else:
|
| 331 |
+
print(f"未找到标签目录,仅进行预测")
|
| 332 |
+
|
| 333 |
+
# 预测结果统计
|
| 334 |
+
results = []
|
| 335 |
+
evaluation_results = []
|
| 336 |
+
total_inference_time = 0
|
| 337 |
+
|
| 338 |
+
print("\n开始预测...")
|
| 339 |
+
|
| 340 |
+
# 使用tqdm显示进度条
|
| 341 |
+
for i, image_file in enumerate(tqdm(test_images, desc="预测进度"), 1):
|
| 342 |
+
image_path = os.path.join(test_image_dir, image_file)
|
| 343 |
+
|
| 344 |
+
try:
|
| 345 |
+
# 预测(使用阈值)
|
| 346 |
+
result = predict_image(model, image_path, device, config, threshold=prediction_threshold)
|
| 347 |
+
|
| 348 |
+
# 计算统计信息
|
| 349 |
+
stats = calculate_statistics(result['prediction'], result['seaweed_probability'])
|
| 350 |
+
|
| 351 |
+
# 如果有标签,计算评估指标
|
| 352 |
+
if has_labels:
|
| 353 |
+
# 构建对应的标签文件名
|
| 354 |
+
mask_file = image_file.replace('.TIF', '.png') # 假设标签是PNG格式
|
| 355 |
+
mask_path = os.path.join(test_mask_dir, mask_file)
|
| 356 |
+
|
| 357 |
+
if os.path.exists(mask_path):
|
| 358 |
+
# 加载真实标签
|
| 359 |
+
true_mask = load_label_mask(mask_path, config['image_size'])
|
| 360 |
+
|
| 361 |
+
if true_mask is not None:
|
| 362 |
+
# 计算评估指标
|
| 363 |
+
metrics = calculate_metrics(result['prediction'], true_mask)
|
| 364 |
+
|
| 365 |
+
# 保存评估结果
|
| 366 |
+
eval_result = {
|
| 367 |
+
'image_file': image_file,
|
| 368 |
+
'accuracy': metrics['accuracy'],
|
| 369 |
+
'precision': metrics['foreground_precision'],
|
| 370 |
+
'recall': metrics['foreground_recall'],
|
| 371 |
+
'iou': metrics['foreground_iou'],
|
| 372 |
+
'f1': metrics['foreground_f1'],
|
| 373 |
+
'tp': metrics['foreground_tp'],
|
| 374 |
+
'fp': metrics['foreground_fp'],
|
| 375 |
+
'fn': metrics['foreground_fn'],
|
| 376 |
+
'tn': metrics['foreground_tn']
|
| 377 |
+
}
|
| 378 |
+
evaluation_results.append(eval_result)
|
| 379 |
+
|
| 380 |
+
# 在统计信息中添加评估指标
|
| 381 |
+
stats.update({
|
| 382 |
+
'accuracy': metrics['accuracy'],
|
| 383 |
+
'precision': metrics['foreground_precision'],
|
| 384 |
+
'recall': metrics['foreground_recall'],
|
| 385 |
+
'iou': metrics['foreground_iou'],
|
| 386 |
+
'f1': metrics['foreground_f1']
|
| 387 |
+
})
|
| 388 |
+
|
| 389 |
+
# 保存预测结果
|
| 390 |
+
base_name = Path(image_file).stem
|
| 391 |
+
save_prediction_results(result['prediction'], result['seaweed_probability'], output_path, base_name)
|
| 392 |
+
|
| 393 |
+
# 保存结果
|
| 394 |
+
result_info = {
|
| 395 |
+
'image_file': image_file,
|
| 396 |
+
'inference_time': float(result['inference_time']),
|
| 397 |
+
'statistics': stats
|
| 398 |
+
}
|
| 399 |
+
results.append(result_info)
|
| 400 |
+
|
| 401 |
+
total_inference_time += result['inference_time']
|
| 402 |
+
|
| 403 |
+
except Exception as e:
|
| 404 |
+
print(f" - 预测失败: {str(e)}")
|
| 405 |
+
continue
|
| 406 |
+
|
| 407 |
+
# 保存预测结果
|
| 408 |
+
results_file = output_path / "prediction_results.json"
|
| 409 |
+
with open(results_file, 'w', encoding='utf-8') as f:
|
| 410 |
+
json.dump(results, f, indent=2, ensure_ascii=False, default=str)
|
| 411 |
+
|
| 412 |
+
# 保存评估结果
|
| 413 |
+
if evaluation_results:
|
| 414 |
+
eval_file = output_path / "evaluation_results.json"
|
| 415 |
+
with open(eval_file, 'w', encoding='utf-8') as f:
|
| 416 |
+
json.dump(evaluation_results, f, indent=2, ensure_ascii=False, default=str)
|
| 417 |
+
|
| 418 |
+
# 生成总结报告
|
| 419 |
+
if results:
|
| 420 |
+
avg_inference_time = total_inference_time / len(results)
|
| 421 |
+
|
| 422 |
+
# 计算平均统计
|
| 423 |
+
all_ratios = [r['statistics']['seaweed_ratio'] for r in results]
|
| 424 |
+
avg_seaweed_ratio = np.mean(all_ratios)
|
| 425 |
+
|
| 426 |
+
print("\n" + "=" * 60)
|
| 427 |
+
print("预测完成总结")
|
| 428 |
+
print("=" * 60)
|
| 429 |
+
print(f"总图像数: {len(results)}")
|
| 430 |
+
print(f"平均推理时间: {avg_inference_time:.3f}s")
|
| 431 |
+
print(f"平均浒苔比例: {avg_seaweed_ratio:.2%}")
|
| 432 |
+
|
| 433 |
+
# 如果有评估结果,显示评估指标
|
| 434 |
+
if evaluation_results:
|
| 435 |
+
avg_accuracy = np.mean([r['accuracy'] for r in evaluation_results])
|
| 436 |
+
avg_precision = np.mean([r['precision'] for r in evaluation_results])
|
| 437 |
+
avg_recall = np.mean([r['recall'] for r in evaluation_results])
|
| 438 |
+
avg_iou = np.mean([r['iou'] for r in evaluation_results])
|
| 439 |
+
avg_f1 = np.mean([r['f1'] for r in evaluation_results])
|
| 440 |
+
|
| 441 |
+
print(f"\n评估指标:")
|
| 442 |
+
print(f" - 准确率 (Accuracy): {avg_accuracy:.4f}")
|
| 443 |
+
print(f" - 精确率 (Precision): {avg_precision:.4f}")
|
| 444 |
+
print(f" - 召回率 (Recall): {avg_recall:.4f}")
|
| 445 |
+
print(f" - IoU: {avg_iou:.4f}")
|
| 446 |
+
print(f" - F1分数: {avg_f1:.4f}")
|
| 447 |
+
|
| 448 |
+
# 计算总体混淆矩阵
|
| 449 |
+
total_tp = sum([r['tp'] for r in evaluation_results])
|
| 450 |
+
total_fp = sum([r['fp'] for r in evaluation_results])
|
| 451 |
+
total_fn = sum([r['fn'] for r in evaluation_results])
|
| 452 |
+
total_tn = sum([r['tn'] for r in evaluation_results])
|
| 453 |
+
|
| 454 |
+
print(f"\n总体混淆矩阵:")
|
| 455 |
+
print(f" - 真正例 (TP): {total_tp:,}")
|
| 456 |
+
print(f" - 假正例 (FP): {total_fp:,}")
|
| 457 |
+
print(f" - 假负例 (FN): {total_fn:,}")
|
| 458 |
+
print(f" - 真负例 (TN): {total_tn:,}")
|
| 459 |
+
|
| 460 |
+
print(f"\n结果保存目录: {output_path}")
|
| 461 |
+
print(f"详细结果文件: {results_file}")
|
| 462 |
+
|
| 463 |
+
# 保存总结报告
|
| 464 |
+
summary = {
|
| 465 |
+
'timestamp': datetime.now().isoformat(),
|
| 466 |
+
'total_images': len(results),
|
| 467 |
+
'avg_inference_time': avg_inference_time,
|
| 468 |
+
'avg_seaweed_ratio': float(avg_seaweed_ratio),
|
| 469 |
+
'results': results
|
| 470 |
+
}
|
| 471 |
+
|
| 472 |
+
# 添加评估指标到总结报告
|
| 473 |
+
if evaluation_results:
|
| 474 |
+
summary.update({
|
| 475 |
+
'avg_accuracy': float(avg_accuracy),
|
| 476 |
+
'avg_precision': float(avg_precision),
|
| 477 |
+
'avg_recall': float(avg_recall),
|
| 478 |
+
'avg_iou': float(avg_iou),
|
| 479 |
+
'avg_f1': float(avg_f1),
|
| 480 |
+
'total_confusion_matrix': {
|
| 481 |
+
'tp': total_tp,
|
| 482 |
+
'fp': total_fp,
|
| 483 |
+
'fn': total_fn,
|
| 484 |
+
'tn': total_tn
|
| 485 |
+
},
|
| 486 |
+
'evaluation_results': evaluation_results
|
| 487 |
+
})
|
| 488 |
+
|
| 489 |
+
summary_file = output_path / "prediction_summary.json"
|
| 490 |
+
with open(summary_file, 'w', encoding='utf-8') as f:
|
| 491 |
+
json.dump(summary, f, indent=2, ensure_ascii=False, default=str)
|
| 492 |
+
|
| 493 |
+
print(f"总结报告: {summary_file}")
|
| 494 |
+
|
| 495 |
+
# 显示一些统计信息
|
| 496 |
+
ratios = [r['statistics']['seaweed_ratio'] for r in results]
|
| 497 |
+
print(f"\n浒苔分布统计:")
|
| 498 |
+
print(f" - 最小浒苔比例: {min(ratios):.2%}")
|
| 499 |
+
print(f" - 最大浒苔比例: {max(ratios):.2%}")
|
| 500 |
+
print(f" - 浒苔比例标准差: {np.std(ratios):.2%}")
|
| 501 |
+
|
| 502 |
+
# 列出保存的文件类型
|
| 503 |
+
print(f"\n保存的文件类型:")
|
| 504 |
+
print(f" - .npy文件: NumPy数组格式的预测结果")
|
| 505 |
+
print(f" - .png文件: 可视化的图像格式")
|
| 506 |
+
print(f" - .json文件: 预测结果统计信息和评估指标")
|
| 507 |
+
|
| 508 |
+
print("\n🎉 浒苔预测和评估完成!")
|
| 509 |
+
print(f"预测结果保存在: {output_dir}")
|
| 510 |
+
|
| 511 |
+
if __name__ == "__main__":
|
| 512 |
+
main()
|
prepare_labels.py
ADDED
|
@@ -0,0 +1,156 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
标签数据预处理脚本
|
| 3 |
+
将TIF格式的标签转换为PNG格式的二值mask
|
| 4 |
+
"""
|
| 5 |
+
import os
|
| 6 |
+
import numpy as np
|
| 7 |
+
from PIL import Image
|
| 8 |
+
import rasterio
|
| 9 |
+
from pathlib import Path
|
| 10 |
+
import shutil
|
| 11 |
+
|
| 12 |
+
def convert_tif_label_to_png_mask(tif_path, output_path, threshold=1):
|
| 13 |
+
"""将TIF标签转换为PNG二值mask"""
|
| 14 |
+
try:
|
| 15 |
+
with rasterio.open(tif_path) as src:
|
| 16 |
+
# 读取第一个波段
|
| 17 |
+
label_data = src.read(1)
|
| 18 |
+
|
| 19 |
+
# 创建二值mask (0: 背景, 255: 前景)
|
| 20 |
+
mask = np.zeros_like(label_data, dtype=np.uint8)
|
| 21 |
+
mask[label_data >= threshold] = 255
|
| 22 |
+
|
| 23 |
+
# 保存为PNG
|
| 24 |
+
mask_img = Image.fromarray(mask, mode='L')
|
| 25 |
+
mask_img.save(output_path)
|
| 26 |
+
|
| 27 |
+
print(f"转换: {tif_path} -> {output_path}")
|
| 28 |
+
print(f" 原始数据范围: {label_data.min()} - {label_data.max()}")
|
| 29 |
+
print(f" Mask唯一值: {np.unique(mask)}")
|
| 30 |
+
return True
|
| 31 |
+
|
| 32 |
+
except Exception as e:
|
| 33 |
+
print(f"转换失败 {tif_path}: {str(e)}")
|
| 34 |
+
return False
|
| 35 |
+
|
| 36 |
+
def prepare_dataset_labels():
|
| 37 |
+
"""准备数据集标签"""
|
| 38 |
+
|
| 39 |
+
# 创建mask目录
|
| 40 |
+
train_mask_dir = Path("data/train/masks")
|
| 41 |
+
val_mask_dir = Path("data/val/masks")
|
| 42 |
+
test_mask_dir = Path("data/test/masks")
|
| 43 |
+
|
| 44 |
+
train_mask_dir.mkdir(parents=True, exist_ok=True)
|
| 45 |
+
val_mask_dir.mkdir(parents=True, exist_ok=True)
|
| 46 |
+
test_mask_dir.mkdir(parents=True, exist_ok=True)
|
| 47 |
+
|
| 48 |
+
# 处理训练集标签
|
| 49 |
+
train_label_dir = Path("data/train/labels")
|
| 50 |
+
if train_label_dir.exists():
|
| 51 |
+
print("处理训练集标签...")
|
| 52 |
+
label_files = [f for f in train_label_dir.glob("*.TIF")]
|
| 53 |
+
|
| 54 |
+
for label_file in label_files:
|
| 55 |
+
# 获取对应的图像文件名
|
| 56 |
+
# 假设标签文件名是 labelXXXXX.TIF,图像是 patchXXXXX.TIF
|
| 57 |
+
label_name = label_file.stem # label10012
|
| 58 |
+
if label_name.startswith('label'):
|
| 59 |
+
# 提取数字部分
|
| 60 |
+
number = label_name[5:] # 10012
|
| 61 |
+
image_name = f"patch{number}.TIF"
|
| 62 |
+
|
| 63 |
+
# 检查对应的图像是否存在
|
| 64 |
+
image_path = Path("data/train/images") / image_name
|
| 65 |
+
if image_path.exists():
|
| 66 |
+
# 转换标签为mask
|
| 67 |
+
mask_name = f"patch{number}.png"
|
| 68 |
+
mask_path = train_mask_dir / mask_name
|
| 69 |
+
|
| 70 |
+
if convert_tif_label_to_png_mask(str(label_file), str(mask_path)):
|
| 71 |
+
print(f" 成功: {label_name} -> {mask_name}")
|
| 72 |
+
else:
|
| 73 |
+
print(f" 警告: 找不到对应的图像 {image_name}")
|
| 74 |
+
|
| 75 |
+
# 处理验证集标签
|
| 76 |
+
val_label_dir = Path("data/val/labels")
|
| 77 |
+
if val_label_dir.exists():
|
| 78 |
+
print("\n处理验证集标签...")
|
| 79 |
+
label_files = [f for f in val_label_dir.glob("*.TIF")]
|
| 80 |
+
|
| 81 |
+
for label_file in label_files:
|
| 82 |
+
label_name = label_file.stem
|
| 83 |
+
if label_name.startswith('label'):
|
| 84 |
+
number = label_name[5:]
|
| 85 |
+
image_name = f"patch{number}.TIF"
|
| 86 |
+
|
| 87 |
+
image_path = Path("data/val/images") / image_name
|
| 88 |
+
if image_path.exists():
|
| 89 |
+
mask_name = f"patch{number}.png"
|
| 90 |
+
mask_path = val_mask_dir / mask_name
|
| 91 |
+
|
| 92 |
+
if convert_tif_label_to_png_mask(str(label_file), str(mask_path)):
|
| 93 |
+
print(f" 成功: {label_name} -> {mask_name}")
|
| 94 |
+
|
| 95 |
+
# 处理测试集标签
|
| 96 |
+
test_label_dir = Path("data/test/labels")
|
| 97 |
+
if test_label_dir.exists():
|
| 98 |
+
print("\n处理测试集标签...")
|
| 99 |
+
label_files = [f for f in test_label_dir.glob("*.TIF")]
|
| 100 |
+
|
| 101 |
+
for label_file in label_files:
|
| 102 |
+
label_name = label_file.stem
|
| 103 |
+
if label_name.startswith('label'):
|
| 104 |
+
number = label_name[5:]
|
| 105 |
+
image_name = f"patch{number}.TIF"
|
| 106 |
+
|
| 107 |
+
image_path = Path("data/test/images") / image_name
|
| 108 |
+
if image_path.exists():
|
| 109 |
+
mask_name = f"patch{number}.png"
|
| 110 |
+
mask_path = test_mask_dir / mask_name
|
| 111 |
+
|
| 112 |
+
if convert_tif_label_to_png_mask(str(label_file), str(mask_path)):
|
| 113 |
+
print(f" 成功: {label_name} -> {mask_name}")
|
| 114 |
+
|
| 115 |
+
print("\n标签数据准备完成!")
|
| 116 |
+
|
| 117 |
+
def check_data_integrity():
|
| 118 |
+
"""检查数据完整性"""
|
| 119 |
+
print("\n检查数据完整性...")
|
| 120 |
+
|
| 121 |
+
datasets = [
|
| 122 |
+
("训练集", "data/train/images", "data/train/masks"),
|
| 123 |
+
("验证集", "data/val/images", "data/val/masks"),
|
| 124 |
+
("测试集", "data/test/images", "data/test/masks")
|
| 125 |
+
]
|
| 126 |
+
|
| 127 |
+
for name, image_dir, mask_dir in datasets:
|
| 128 |
+
image_path = Path(image_dir)
|
| 129 |
+
mask_path = Path(mask_dir)
|
| 130 |
+
|
| 131 |
+
if image_path.exists() and mask_path.exists():
|
| 132 |
+
image_files = list(image_path.glob("*.TIF"))
|
| 133 |
+
mask_files = list(mask_path.glob("*.png"))
|
| 134 |
+
|
| 135 |
+
print(f"{name}:")
|
| 136 |
+
print(f" 图像文件: {len(image_files)} 个")
|
| 137 |
+
print(f" Mask文件: {len(mask_files)} 个")
|
| 138 |
+
|
| 139 |
+
# 检查匹配情况
|
| 140 |
+
matched = 0
|
| 141 |
+
for image_file in image_files:
|
| 142 |
+
expected_mask = mask_path / (image_file.stem + ".png")
|
| 143 |
+
if expected_mask.exists():
|
| 144 |
+
matched += 1
|
| 145 |
+
|
| 146 |
+
print(f" 匹配文件: {matched} 个")
|
| 147 |
+
if matched < len(image_files):
|
| 148 |
+
print(f" ⚠️ 警告: 有 {len(image_files) - matched} 个图像缺少对应的mask")
|
| 149 |
+
else:
|
| 150 |
+
print(f"{name}: 目录不存在")
|
| 151 |
+
|
| 152 |
+
if __name__ == "__main__":
|
| 153 |
+
print("开始准备标签数据...")
|
| 154 |
+
prepare_dataset_labels()
|
| 155 |
+
check_data_integrity()
|
| 156 |
+
print("\n数据准备完成!")
|
requirements.txt
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
torch
|
| 2 |
+
torchvision
|
| 3 |
+
numpy
|
| 4 |
+
pillow
|
| 5 |
+
rasterio
|
| 6 |
+
albumentations
|
| 7 |
+
opencv-python
|
| 8 |
+
matplotlib
|
| 9 |
+
tqdm
|
| 10 |
+
scipy
|
| 11 |
+
einops
|
| 12 |
+
omegaconf
|
| 13 |
+
deepspeed; platform_system != "Windows"
|
scripts/build_marine_feature_dataset.py
ADDED
|
@@ -0,0 +1,327 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Build a normalized manifest for marine ecological feature datasets.
|
| 2 |
+
|
| 3 |
+
The script scans one or more roots, classifies likely feature types from path
|
| 4 |
+
keywords, pairs images and masks when possible, and writes a manifest without
|
| 5 |
+
copying large raster files.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
from __future__ import annotations
|
| 9 |
+
|
| 10 |
+
import argparse
|
| 11 |
+
import csv
|
| 12 |
+
import json
|
| 13 |
+
import re
|
| 14 |
+
from dataclasses import asdict, dataclass
|
| 15 |
+
from datetime import datetime
|
| 16 |
+
from pathlib import Path
|
| 17 |
+
from typing import Iterable
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
IMAGE_EXTS = {".tif", ".tiff", ".png", ".jpg", ".jpeg"}
|
| 21 |
+
MASK_HINTS = ("mask", "masks", "label", "labels", "gt", "annotation", "annotations", "seg")
|
| 22 |
+
IMAGE_HINTS = ("image", "images", "img", "imgs", "tif", "tile", "tiles")
|
| 23 |
+
|
| 24 |
+
ELEMENT_KEYWORDS = {
|
| 25 |
+
"green_tide": ("浒苔", "绿潮", "green_tide", "greentide", "entgreentide", "enteromorpha", "seaweed"),
|
| 26 |
+
"red_tide": ("赤潮", "red_tide", "redtide", "harmful_algal", "hab"),
|
| 27 |
+
"golden_tide": ("马尾藻", "金潮", "sarg", "sargassum", "golden_tide", "goldentide"),
|
| 28 |
+
"aquaculture": ("养殖", "aquaculture", "raft", "cage", "pond"),
|
| 29 |
+
}
|
| 30 |
+
|
| 31 |
+
SATELLITE_PATTERN = re.compile(r"\b(GF\d+|HY\d+|Sentinel-?2|Landsat-?\d*)\b", re.IGNORECASE)
|
| 32 |
+
DATE_PATTERN = re.compile(r"(20\d{6}|19\d{6})")
|
| 33 |
+
PATCH_SIZE_PATTERN = re.compile(r"(?:^|[_\\/\-])(?:size)?(128|256|512|1024)(?:[_\\/\-]|$)")
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
@dataclass
|
| 37 |
+
class AssetRecord:
|
| 38 |
+
asset_id: str
|
| 39 |
+
path: str
|
| 40 |
+
filename: str
|
| 41 |
+
suffix: str
|
| 42 |
+
role: str
|
| 43 |
+
element: str
|
| 44 |
+
satellite: str | None
|
| 45 |
+
sensor: str | None
|
| 46 |
+
acquired_at: str | None
|
| 47 |
+
patch_size: int | None
|
| 48 |
+
source_project: str
|
| 49 |
+
source_dataset: str
|
| 50 |
+
size_bytes: int
|
| 51 |
+
modified_at: str
|
| 52 |
+
quality_flags: list[str]
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
@dataclass
|
| 56 |
+
class SampleRecord:
|
| 57 |
+
sample_id: str
|
| 58 |
+
element: str
|
| 59 |
+
task_type: str
|
| 60 |
+
image_path: str
|
| 61 |
+
mask_path: str | None
|
| 62 |
+
label_encoding: dict[str, str] | None
|
| 63 |
+
satellite: str | None
|
| 64 |
+
sensor: str | None
|
| 65 |
+
resolution_m: float | None
|
| 66 |
+
patch_size: int | None
|
| 67 |
+
bands: list[str] | None
|
| 68 |
+
band_count: int | None
|
| 69 |
+
dtype: str | None
|
| 70 |
+
fusion: dict
|
| 71 |
+
acquired_at: str | None
|
| 72 |
+
source_project: str
|
| 73 |
+
source_dataset: str
|
| 74 |
+
split: str | None
|
| 75 |
+
quality_flags: list[str]
|
| 76 |
+
notes: str
|
| 77 |
+
|
| 78 |
+
|
| 79 |
+
def parse_args() -> argparse.Namespace:
|
| 80 |
+
parser = argparse.ArgumentParser(description=__doc__)
|
| 81 |
+
parser.add_argument("--roots", nargs="+", required=True, help="Dataset roots to scan.")
|
| 82 |
+
parser.add_argument("--output-root", required=True, help="Output normalized dataset root.")
|
| 83 |
+
parser.add_argument("--max-files", type=int, default=0, help="Optional scan limit for debugging.")
|
| 84 |
+
return parser.parse_args()
|
| 85 |
+
|
| 86 |
+
|
| 87 |
+
def norm_text(path: Path) -> str:
|
| 88 |
+
return str(path).replace("\\", "/").lower()
|
| 89 |
+
|
| 90 |
+
|
| 91 |
+
def infer_element(path: Path) -> str:
|
| 92 |
+
text = norm_text(path)
|
| 93 |
+
for element, keywords in ELEMENT_KEYWORDS.items():
|
| 94 |
+
if any(keyword.lower() in text for keyword in keywords):
|
| 95 |
+
return element
|
| 96 |
+
return "unknown"
|
| 97 |
+
|
| 98 |
+
|
| 99 |
+
def infer_role(path: Path) -> str:
|
| 100 |
+
parts = [part.lower() for part in path.parts]
|
| 101 |
+
stem = path.stem.lower()
|
| 102 |
+
if any(hint in parts or hint in stem for hint in MASK_HINTS):
|
| 103 |
+
return "mask"
|
| 104 |
+
if any(hint in parts for hint in IMAGE_HINTS):
|
| 105 |
+
return "image"
|
| 106 |
+
if path.suffix.lower() in {".png", ".jpg", ".jpeg"} and any(hint in stem for hint in MASK_HINTS):
|
| 107 |
+
return "mask"
|
| 108 |
+
return "image"
|
| 109 |
+
|
| 110 |
+
|
| 111 |
+
def infer_satellite(path: Path) -> str | None:
|
| 112 |
+
match = SATELLITE_PATTERN.search(str(path))
|
| 113 |
+
return match.group(1).upper().replace("-", "") if match else None
|
| 114 |
+
|
| 115 |
+
|
| 116 |
+
def infer_sensor(path: Path) -> str | None:
|
| 117 |
+
upper = path.name.upper()
|
| 118 |
+
for sensor in ("PMS", "MUX", "MSS", "PAN", "WFV"):
|
| 119 |
+
if sensor in upper:
|
| 120 |
+
return sensor
|
| 121 |
+
return None
|
| 122 |
+
|
| 123 |
+
|
| 124 |
+
def infer_date(path: Path) -> str | None:
|
| 125 |
+
match = DATE_PATTERN.search(path.name)
|
| 126 |
+
if not match:
|
| 127 |
+
return None
|
| 128 |
+
raw = match.group(1)
|
| 129 |
+
try:
|
| 130 |
+
return datetime.strptime(raw, "%Y%m%d").date().isoformat()
|
| 131 |
+
except ValueError:
|
| 132 |
+
return None
|
| 133 |
+
|
| 134 |
+
|
| 135 |
+
def infer_patch_size(path: Path) -> int | None:
|
| 136 |
+
match = PATCH_SIZE_PATTERN.search(str(path))
|
| 137 |
+
return int(match.group(1)) if match else None
|
| 138 |
+
|
| 139 |
+
|
| 140 |
+
def infer_source_project(path: Path, roots: list[Path]) -> str:
|
| 141 |
+
for root in roots:
|
| 142 |
+
try:
|
| 143 |
+
rel = path.relative_to(root)
|
| 144 |
+
except ValueError:
|
| 145 |
+
continue
|
| 146 |
+
return rel.parts[0] if len(rel.parts) > 1 else root.name
|
| 147 |
+
return path.parent.name
|
| 148 |
+
|
| 149 |
+
|
| 150 |
+
def infer_split(path: Path) -> str | None:
|
| 151 |
+
parts = {part.lower() for part in path.parts}
|
| 152 |
+
for split in ("train", "val", "test"):
|
| 153 |
+
if split in parts:
|
| 154 |
+
return split
|
| 155 |
+
return None
|
| 156 |
+
|
| 157 |
+
|
| 158 |
+
def source_dataset(path: Path) -> str:
|
| 159 |
+
for part in reversed(path.parts):
|
| 160 |
+
lower = part.lower()
|
| 161 |
+
if any(token in lower for token in ("gf", "sentinel", "landsat", "浒苔", "赤潮", "马尾藻", "养殖")):
|
| 162 |
+
return part
|
| 163 |
+
return path.parent.name
|
| 164 |
+
|
| 165 |
+
|
| 166 |
+
def is_fused(path: Path) -> bool | None:
|
| 167 |
+
lower = path.name.lower()
|
| 168 |
+
if "fuse" in lower or "fusion" in lower or "pan" not in lower and "mux" in lower:
|
| 169 |
+
return True
|
| 170 |
+
if "pan" in lower or "mss" in lower:
|
| 171 |
+
return False
|
| 172 |
+
return None
|
| 173 |
+
|
| 174 |
+
|
| 175 |
+
def infer_fusion(path: Path) -> dict:
|
| 176 |
+
fused = is_fused(path)
|
| 177 |
+
lower = path.name.lower()
|
| 178 |
+
if fused is True:
|
| 179 |
+
state = "fused_product"
|
| 180 |
+
method = "unknown_vendor_product"
|
| 181 |
+
persisted = True
|
| 182 |
+
elif fused is False and ("pan" in lower or "mss" in lower):
|
| 183 |
+
state = "none"
|
| 184 |
+
method = "none"
|
| 185 |
+
persisted = False
|
| 186 |
+
else:
|
| 187 |
+
state = "unknown"
|
| 188 |
+
method = "unknown"
|
| 189 |
+
persisted = False
|
| 190 |
+
return {
|
| 191 |
+
"state": state,
|
| 192 |
+
"method": method,
|
| 193 |
+
"sources": [{"role": "source", "path": str(path), "resolution_m": None}],
|
| 194 |
+
"target_resolution_m": None,
|
| 195 |
+
"native_multispectral_resolution_m": None,
|
| 196 |
+
"persisted": persisted,
|
| 197 |
+
"reproducible": state != "unknown",
|
| 198 |
+
"spectral_preservation": "unknown",
|
| 199 |
+
"notes": "Auto-inferred from local filename; verify before training.",
|
| 200 |
+
}
|
| 201 |
+
|
| 202 |
+
|
| 203 |
+
def asset_id(path: Path) -> str:
|
| 204 |
+
safe = re.sub(r"[^A-Za-z0-9]+", "_", str(path.stem)).strip("_").lower()
|
| 205 |
+
return safe[:180]
|
| 206 |
+
|
| 207 |
+
|
| 208 |
+
def iter_files(roots: Iterable[Path], max_files: int) -> Iterable[Path]:
|
| 209 |
+
count = 0
|
| 210 |
+
for root in roots:
|
| 211 |
+
if not root.exists():
|
| 212 |
+
continue
|
| 213 |
+
for path in root.rglob("*"):
|
| 214 |
+
if not path.is_file() or path.suffix.lower() not in IMAGE_EXTS:
|
| 215 |
+
continue
|
| 216 |
+
yield path
|
| 217 |
+
count += 1
|
| 218 |
+
if max_files and count >= max_files:
|
| 219 |
+
return
|
| 220 |
+
|
| 221 |
+
|
| 222 |
+
def write_jsonl(path: Path, rows: Iterable[dict]) -> None:
|
| 223 |
+
with path.open("w", encoding="utf-8") as f:
|
| 224 |
+
for row in rows:
|
| 225 |
+
f.write(json.dumps(row, ensure_ascii=False) + "\n")
|
| 226 |
+
|
| 227 |
+
|
| 228 |
+
def main() -> None:
|
| 229 |
+
args = parse_args()
|
| 230 |
+
roots = [Path(root) for root in args.roots]
|
| 231 |
+
output_root = Path(args.output_root)
|
| 232 |
+
manifest_dir = output_root / "manifests"
|
| 233 |
+
report_dir = output_root / "reports"
|
| 234 |
+
manifest_dir.mkdir(parents=True, exist_ok=True)
|
| 235 |
+
report_dir.mkdir(parents=True, exist_ok=True)
|
| 236 |
+
|
| 237 |
+
assets: list[AssetRecord] = []
|
| 238 |
+
for path in iter_files(roots, args.max_files):
|
| 239 |
+
stat = path.stat()
|
| 240 |
+
role = infer_role(path)
|
| 241 |
+
flags = []
|
| 242 |
+
if infer_element(path) == "unknown":
|
| 243 |
+
flags.append("unknown_element")
|
| 244 |
+
if role == "image" and "black" in norm_text(path):
|
| 245 |
+
flags.append("possibly_invalid")
|
| 246 |
+
assets.append(
|
| 247 |
+
AssetRecord(
|
| 248 |
+
asset_id=asset_id(path),
|
| 249 |
+
path=str(path),
|
| 250 |
+
filename=path.name,
|
| 251 |
+
suffix=path.suffix.lower(),
|
| 252 |
+
role=role,
|
| 253 |
+
element=infer_element(path),
|
| 254 |
+
satellite=infer_satellite(path),
|
| 255 |
+
sensor=infer_sensor(path),
|
| 256 |
+
acquired_at=infer_date(path),
|
| 257 |
+
patch_size=infer_patch_size(path),
|
| 258 |
+
source_project=infer_source_project(path, roots),
|
| 259 |
+
source_dataset=source_dataset(path),
|
| 260 |
+
size_bytes=stat.st_size,
|
| 261 |
+
modified_at=datetime.fromtimestamp(stat.st_mtime).isoformat(timespec="seconds"),
|
| 262 |
+
quality_flags=flags,
|
| 263 |
+
)
|
| 264 |
+
)
|
| 265 |
+
|
| 266 |
+
masks_by_stem = {Path(asset.path).stem.lower(): asset for asset in assets if asset.role == "mask"}
|
| 267 |
+
samples: list[SampleRecord] = []
|
| 268 |
+
for asset in assets:
|
| 269 |
+
if asset.role != "image":
|
| 270 |
+
continue
|
| 271 |
+
path = Path(asset.path)
|
| 272 |
+
mask_asset = masks_by_stem.get(path.stem.lower())
|
| 273 |
+
element = asset.element if asset.element != "unknown" else (mask_asset.element if mask_asset else "unknown")
|
| 274 |
+
flags = list(asset.quality_flags)
|
| 275 |
+
if mask_asset is None:
|
| 276 |
+
flags.append("unpaired_image")
|
| 277 |
+
sample_id = f"{element}_{asset.asset_id}"
|
| 278 |
+
samples.append(
|
| 279 |
+
SampleRecord(
|
| 280 |
+
sample_id=sample_id,
|
| 281 |
+
element=element,
|
| 282 |
+
task_type="semantic_segmentation",
|
| 283 |
+
image_path=asset.path,
|
| 284 |
+
mask_path=mask_asset.path if mask_asset else None,
|
| 285 |
+
label_encoding={"0": "background", "1": element} if mask_asset else None,
|
| 286 |
+
satellite=asset.satellite,
|
| 287 |
+
sensor=asset.sensor,
|
| 288 |
+
resolution_m=None,
|
| 289 |
+
patch_size=asset.patch_size,
|
| 290 |
+
bands=None,
|
| 291 |
+
band_count=None,
|
| 292 |
+
dtype=None,
|
| 293 |
+
fusion=infer_fusion(path),
|
| 294 |
+
acquired_at=asset.acquired_at,
|
| 295 |
+
source_project=asset.source_project,
|
| 296 |
+
source_dataset=asset.source_dataset,
|
| 297 |
+
split=infer_split(path),
|
| 298 |
+
quality_flags=flags,
|
| 299 |
+
notes="auto-generated; verify ambiguous labels before training",
|
| 300 |
+
)
|
| 301 |
+
)
|
| 302 |
+
|
| 303 |
+
write_jsonl(manifest_dir / "assets_raw.jsonl", (asdict(asset) for asset in assets))
|
| 304 |
+
write_jsonl(manifest_dir / "samples.jsonl", (asdict(sample) for sample in samples))
|
| 305 |
+
|
| 306 |
+
with (report_dir / "asset_inventory.csv").open("w", newline="", encoding="utf-8-sig") as f:
|
| 307 |
+
writer = csv.DictWriter(f, fieldnames=list(asdict(assets[0]).keys()) if assets else ["asset_id"])
|
| 308 |
+
writer.writeheader()
|
| 309 |
+
for asset in assets:
|
| 310 |
+
row = asdict(asset)
|
| 311 |
+
row["quality_flags"] = ";".join(row["quality_flags"])
|
| 312 |
+
writer.writerow(row)
|
| 313 |
+
|
| 314 |
+
summary = {
|
| 315 |
+
"roots": [str(root) for root in roots],
|
| 316 |
+
"assets": len(assets),
|
| 317 |
+
"samples": len(samples),
|
| 318 |
+
"by_element": {},
|
| 319 |
+
"output_root": str(output_root),
|
| 320 |
+
}
|
| 321 |
+
for sample in samples:
|
| 322 |
+
summary["by_element"][sample.element] = summary["by_element"].get(sample.element, 0) + 1
|
| 323 |
+
print(json.dumps(summary, indent=2, ensure_ascii=False))
|
| 324 |
+
|
| 325 |
+
|
| 326 |
+
if __name__ == "__main__":
|
| 327 |
+
main()
|
scripts/build_pseudo_dataset.py
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Build a train/val dataset from large images and prediction rasters.
|
| 2 |
+
|
| 3 |
+
This is a bootstrap utility. Masks created from previous predictions are
|
| 4 |
+
pseudo-labels, not human-verified ground truth.
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
from __future__ import annotations
|
| 8 |
+
|
| 9 |
+
import argparse
|
| 10 |
+
import random
|
| 11 |
+
from pathlib import Path
|
| 12 |
+
|
| 13 |
+
import numpy as np
|
| 14 |
+
import rasterio
|
| 15 |
+
from rasterio.windows import Window
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
def find_pairs(source_dir: Path):
|
| 19 |
+
images = [p for p in source_dir.glob("*.tif") if "_prediction" not in p.stem.lower()]
|
| 20 |
+
pairs = []
|
| 21 |
+
for image in images:
|
| 22 |
+
pred = None
|
| 23 |
+
for candidate in source_dir.glob(f"{image.stem}_*/{image.stem}_prediction.tif"):
|
| 24 |
+
pred = candidate
|
| 25 |
+
break
|
| 26 |
+
if pred:
|
| 27 |
+
pairs.append((image, pred))
|
| 28 |
+
return pairs
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
def ensure_layout(output_dir: Path):
|
| 32 |
+
for split in ("train", "val"):
|
| 33 |
+
(output_dir / split / "images").mkdir(parents=True, exist_ok=True)
|
| 34 |
+
(output_dir / split / "masks").mkdir(parents=True, exist_ok=True)
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
def write_tile(src, mask_src, window: Window, image_path: Path, mask_path: Path, foreground_threshold: int):
|
| 38 |
+
image = src.read(window=window)
|
| 39 |
+
mask = mask_src.read(1, window=window)
|
| 40 |
+
if image.shape[1] != window.height or image.shape[2] != window.width:
|
| 41 |
+
return False
|
| 42 |
+
if mask.shape[0] != window.height or mask.shape[1] != window.width:
|
| 43 |
+
return False
|
| 44 |
+
|
| 45 |
+
image_meta = src.meta.copy()
|
| 46 |
+
image_meta.update(
|
| 47 |
+
{
|
| 48 |
+
"height": int(window.height),
|
| 49 |
+
"width": int(window.width),
|
| 50 |
+
"transform": src.window_transform(window),
|
| 51 |
+
"compress": "lzw",
|
| 52 |
+
}
|
| 53 |
+
)
|
| 54 |
+
mask_meta = mask_src.meta.copy()
|
| 55 |
+
mask_meta.update(
|
| 56 |
+
{
|
| 57 |
+
"count": 1,
|
| 58 |
+
"dtype": "uint8",
|
| 59 |
+
"height": int(window.height),
|
| 60 |
+
"width": int(window.width),
|
| 61 |
+
"transform": mask_src.window_transform(window),
|
| 62 |
+
"compress": "lzw",
|
| 63 |
+
}
|
| 64 |
+
)
|
| 65 |
+
|
| 66 |
+
binary_mask = (mask >= foreground_threshold).astype(np.uint8) * 255
|
| 67 |
+
with rasterio.open(image_path, "w", **image_meta) as dst:
|
| 68 |
+
dst.write(image)
|
| 69 |
+
with rasterio.open(mask_path, "w", **mask_meta) as dst:
|
| 70 |
+
dst.write(binary_mask, 1)
|
| 71 |
+
return True
|
| 72 |
+
|
| 73 |
+
|
| 74 |
+
def build_dataset(source_dir: Path, output_dir: Path, tile_size: int, stride: int, val_ratio: float, foreground_threshold: int):
|
| 75 |
+
pairs = find_pairs(source_dir)
|
| 76 |
+
if not pairs:
|
| 77 |
+
raise RuntimeError(f"No image/prediction pairs found under {source_dir}")
|
| 78 |
+
ensure_layout(output_dir)
|
| 79 |
+
|
| 80 |
+
rng = random.Random(42)
|
| 81 |
+
written = {"train": 0, "val": 0}
|
| 82 |
+
|
| 83 |
+
for pair_index, (image_path, pred_path) in enumerate(pairs):
|
| 84 |
+
with rasterio.open(image_path) as src, rasterio.open(pred_path) as mask_src:
|
| 85 |
+
windows = []
|
| 86 |
+
for y in range(0, src.height - tile_size + 1, stride):
|
| 87 |
+
for x in range(0, src.width - tile_size + 1, stride):
|
| 88 |
+
windows.append(Window(x, y, tile_size, tile_size))
|
| 89 |
+
rng.shuffle(windows)
|
| 90 |
+
|
| 91 |
+
for tile_index, window in enumerate(windows):
|
| 92 |
+
split = "val" if rng.random() < val_ratio else "train"
|
| 93 |
+
name = f"pair{pair_index:02d}_{tile_index:06d}.tif"
|
| 94 |
+
ok = write_tile(
|
| 95 |
+
src,
|
| 96 |
+
mask_src,
|
| 97 |
+
window,
|
| 98 |
+
output_dir / split / "images" / name,
|
| 99 |
+
output_dir / split / "masks" / name,
|
| 100 |
+
foreground_threshold,
|
| 101 |
+
)
|
| 102 |
+
if ok:
|
| 103 |
+
written[split] += 1
|
| 104 |
+
|
| 105 |
+
print(f"Wrote pseudo dataset to {output_dir}")
|
| 106 |
+
print(f"train tiles: {written['train']}")
|
| 107 |
+
print(f"val tiles: {written['val']}")
|
| 108 |
+
|
| 109 |
+
|
| 110 |
+
def main():
|
| 111 |
+
parser = argparse.ArgumentParser()
|
| 112 |
+
parser.add_argument("--source-dir", default="data")
|
| 113 |
+
parser.add_argument("--output-dir", default="data_pseudo")
|
| 114 |
+
parser.add_argument("--tile-size", type=int, default=256)
|
| 115 |
+
parser.add_argument("--stride", type=int, default=256)
|
| 116 |
+
parser.add_argument("--val-ratio", type=float, default=0.15)
|
| 117 |
+
parser.add_argument("--foreground-threshold", type=int, default=1)
|
| 118 |
+
args = parser.parse_args()
|
| 119 |
+
build_dataset(
|
| 120 |
+
Path(args.source_dir),
|
| 121 |
+
Path(args.output_dir),
|
| 122 |
+
args.tile_size,
|
| 123 |
+
args.stride,
|
| 124 |
+
args.val_ratio,
|
| 125 |
+
args.foreground_threshold,
|
| 126 |
+
)
|
| 127 |
+
|
| 128 |
+
|
| 129 |
+
if __name__ == "__main__":
|
| 130 |
+
main()
|
scripts/compose_task_profile.py
ADDED
|
@@ -0,0 +1,176 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Compose task profiles from Markdown capability cards."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import argparse
|
| 6 |
+
import json
|
| 7 |
+
from pathlib import Path
|
| 8 |
+
from typing import Any
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
REGISTRY_ROOT = Path(__file__).resolve().parents[1] / "docs" / "registry"
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
def parse_scalar(value: str) -> Any:
|
| 15 |
+
value = value.strip()
|
| 16 |
+
if value in {"true", "True"}:
|
| 17 |
+
return True
|
| 18 |
+
if value in {"false", "False"}:
|
| 19 |
+
return False
|
| 20 |
+
if value in {"null", "None"}:
|
| 21 |
+
return None
|
| 22 |
+
if value.startswith("[") and value.endswith("]"):
|
| 23 |
+
inner = value[1:-1].strip()
|
| 24 |
+
if not inner:
|
| 25 |
+
return []
|
| 26 |
+
return [parse_scalar(part.strip()) for part in inner.split(",")]
|
| 27 |
+
try:
|
| 28 |
+
if "." in value:
|
| 29 |
+
return float(value)
|
| 30 |
+
return int(value)
|
| 31 |
+
except ValueError:
|
| 32 |
+
return value.strip("\"'")
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
def parse_front_matter(text: str) -> tuple[dict[str, Any], str]:
|
| 36 |
+
lines = text.splitlines()
|
| 37 |
+
if not lines or lines[0].strip() != "---":
|
| 38 |
+
return {}, text
|
| 39 |
+
meta: dict[str, Any] = {}
|
| 40 |
+
end = None
|
| 41 |
+
for idx, line in enumerate(lines[1:], start=1):
|
| 42 |
+
if line.strip() == "---":
|
| 43 |
+
end = idx
|
| 44 |
+
break
|
| 45 |
+
if not line.strip() or line.lstrip().startswith("#"):
|
| 46 |
+
continue
|
| 47 |
+
if ":" not in line:
|
| 48 |
+
continue
|
| 49 |
+
key, value = line.split(":", 1)
|
| 50 |
+
meta[key.strip()] = parse_scalar(value)
|
| 51 |
+
if end is None:
|
| 52 |
+
return meta, text
|
| 53 |
+
return meta, "\n".join(lines[end + 1 :]).strip()
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
def load_card(kind: str, card_id: str, registry_root: Path) -> dict[str, Any]:
|
| 57 |
+
path = registry_root / kind / f"{card_id}.md"
|
| 58 |
+
if not path.exists():
|
| 59 |
+
available = sorted(p.stem for p in (registry_root / kind).glob("*.md"))
|
| 60 |
+
raise FileNotFoundError(f"Card not found: {path}. Available {kind}: {available}")
|
| 61 |
+
text = path.read_text(encoding="utf-8")
|
| 62 |
+
meta, body = parse_front_matter(text)
|
| 63 |
+
return {"kind": kind, "id": card_id, "path": str(path), "meta": meta, "body": body}
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
def list_cards(registry_root: Path) -> dict[str, list[str]]:
|
| 67 |
+
result: dict[str, list[str]] = {}
|
| 68 |
+
for kind in ("elements", "satellites", "sensors", "resolutions"):
|
| 69 |
+
folder = registry_root / kind
|
| 70 |
+
result[kind] = sorted(p.stem for p in folder.glob("*.md")) if folder.exists() else []
|
| 71 |
+
return result
|
| 72 |
+
|
| 73 |
+
|
| 74 |
+
def parse_args() -> argparse.Namespace:
|
| 75 |
+
parser = argparse.ArgumentParser(description=__doc__)
|
| 76 |
+
parser.add_argument("--element")
|
| 77 |
+
parser.add_argument("--satellite")
|
| 78 |
+
parser.add_argument("--sensor")
|
| 79 |
+
parser.add_argument("--resolution")
|
| 80 |
+
parser.add_argument(
|
| 81 |
+
"--fusion",
|
| 82 |
+
help="Optional sensor/product fusion card, for example FUSED_OPTICAL or STREAM_FUSION.",
|
| 83 |
+
)
|
| 84 |
+
parser.add_argument("--registry-root", default=str(REGISTRY_ROOT))
|
| 85 |
+
parser.add_argument("--output", required=False)
|
| 86 |
+
parser.add_argument("--list", action="store_true", help="List available cards and exit.")
|
| 87 |
+
return parser.parse_args()
|
| 88 |
+
|
| 89 |
+
|
| 90 |
+
def main() -> None:
|
| 91 |
+
args = parse_args()
|
| 92 |
+
registry_root = Path(args.registry_root)
|
| 93 |
+
|
| 94 |
+
if args.list:
|
| 95 |
+
print(json.dumps(list_cards(registry_root), indent=2, ensure_ascii=False))
|
| 96 |
+
return
|
| 97 |
+
|
| 98 |
+
required = {
|
| 99 |
+
"elements": args.element,
|
| 100 |
+
"satellites": args.satellite,
|
| 101 |
+
"sensors": args.sensor,
|
| 102 |
+
"resolutions": args.resolution,
|
| 103 |
+
}
|
| 104 |
+
missing = [key for key, value in required.items() if not value]
|
| 105 |
+
if missing:
|
| 106 |
+
raise SystemExit(f"Missing required cards: {missing}. Use --list to inspect available cards.")
|
| 107 |
+
|
| 108 |
+
cards = {kind: load_card(kind, card_id, registry_root) for kind, card_id in required.items() if card_id}
|
| 109 |
+
if args.fusion:
|
| 110 |
+
cards["fusion"] = load_card("sensors", args.fusion, registry_root)
|
| 111 |
+
|
| 112 |
+
element_meta = cards["elements"]["meta"]
|
| 113 |
+
sensor_meta = cards["sensors"]["meta"]
|
| 114 |
+
resolution_meta = cards["resolutions"]["meta"]
|
| 115 |
+
fusion_meta = cards.get("fusion", {}).get("meta", {})
|
| 116 |
+
fusion_state = fusion_meta.get("fusion_state", sensor_meta.get("fusion_state", "none"))
|
| 117 |
+
|
| 118 |
+
profile = {
|
| 119 |
+
"profile_id": "_".join(
|
| 120 |
+
part for part in [args.element, args.satellite, args.sensor, args.fusion, args.resolution] if part
|
| 121 |
+
),
|
| 122 |
+
"cards": cards,
|
| 123 |
+
"task": {
|
| 124 |
+
"element": args.element,
|
| 125 |
+
"task_types": element_meta.get("task_types", []),
|
| 126 |
+
"preferred_heads": element_meta.get("preferred_heads", []),
|
| 127 |
+
"label_formats": element_meta.get("label_formats", []),
|
| 128 |
+
"negative_policy": element_meta.get("negative_policy", "unlabeled_is_ignore"),
|
| 129 |
+
},
|
| 130 |
+
"input": {
|
| 131 |
+
"satellite": args.satellite,
|
| 132 |
+
"sensor": args.sensor,
|
| 133 |
+
"resolution_m": resolution_meta.get("resolution_m"),
|
| 134 |
+
"modalities": sensor_meta.get("modalities", []),
|
| 135 |
+
"common_bands": sensor_meta.get("common_bands", []),
|
| 136 |
+
"recommended_patch_sizes": resolution_meta.get("recommended_patch_sizes", []),
|
| 137 |
+
},
|
| 138 |
+
"fusion": {
|
| 139 |
+
"card": args.fusion,
|
| 140 |
+
"state": fusion_state,
|
| 141 |
+
"modalities": fusion_meta.get("modalities", sensor_meta.get("modalities", [])),
|
| 142 |
+
"supports_streaming_fusion": fusion_meta.get(
|
| 143 |
+
"supports_streaming_fusion", sensor_meta.get("supports_streaming_fusion", False)
|
| 144 |
+
),
|
| 145 |
+
"requires_fusion_metadata": fusion_meta.get(
|
| 146 |
+
"requires_fusion_metadata", sensor_meta.get("requires_fusion_metadata", False)
|
| 147 |
+
),
|
| 148 |
+
"required_manifest_fields": [
|
| 149 |
+
"state",
|
| 150 |
+
"method",
|
| 151 |
+
"sources",
|
| 152 |
+
"target_resolution_m",
|
| 153 |
+
"native_multispectral_resolution_m",
|
| 154 |
+
"persisted",
|
| 155 |
+
"reproducible",
|
| 156 |
+
"spectral_preservation",
|
| 157 |
+
],
|
| 158 |
+
},
|
| 159 |
+
"constraints": {
|
| 160 |
+
"do_not_assume_external_coastline_or_land_mask": True,
|
| 161 |
+
"unlabeled_elements_are_ignore_not_negative": True,
|
| 162 |
+
},
|
| 163 |
+
}
|
| 164 |
+
|
| 165 |
+
output = json.dumps(profile, indent=2, ensure_ascii=False)
|
| 166 |
+
if args.output:
|
| 167 |
+
output_path = Path(args.output)
|
| 168 |
+
output_path.parent.mkdir(parents=True, exist_ok=True)
|
| 169 |
+
output_path.write_text(output + "\n", encoding="utf-8")
|
| 170 |
+
print(output_path)
|
| 171 |
+
else:
|
| 172 |
+
print(output)
|
| 173 |
+
|
| 174 |
+
|
| 175 |
+
if __name__ == "__main__":
|
| 176 |
+
main()
|
scripts/evaluate_yolo_detection.py
ADDED
|
@@ -0,0 +1,198 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Evaluate a YOLO detector on a dataset split with fixed-threshold metrics."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import argparse
|
| 6 |
+
import json
|
| 7 |
+
from pathlib import Path
|
| 8 |
+
|
| 9 |
+
import yaml
|
| 10 |
+
from PIL import Image
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
def parse_args() -> argparse.Namespace:
|
| 14 |
+
parser = argparse.ArgumentParser(description=__doc__)
|
| 15 |
+
parser.add_argument("--weights", required=True)
|
| 16 |
+
parser.add_argument("--data", required=True)
|
| 17 |
+
parser.add_argument("--split", default="test", choices=["train", "val", "test"])
|
| 18 |
+
parser.add_argument("--imgsz", type=int, default=640)
|
| 19 |
+
parser.add_argument("--conf", type=float, default=0.25)
|
| 20 |
+
parser.add_argument("--iou", type=float, default=0.5)
|
| 21 |
+
parser.add_argument("--device", default="0")
|
| 22 |
+
parser.add_argument("--output", required=True)
|
| 23 |
+
return parser.parse_args()
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
def xywhn_to_xyxy(row: list[float], width: int, height: int) -> list[float]:
|
| 27 |
+
_, x, y, w, h = row[:5]
|
| 28 |
+
cx = x * width
|
| 29 |
+
cy = y * height
|
| 30 |
+
bw = w * width
|
| 31 |
+
bh = h * height
|
| 32 |
+
return [cx - bw / 2, cy - bh / 2, cx + bw / 2, cy + bh / 2]
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
def box_iou(a: list[float], b: list[float]) -> float:
|
| 36 |
+
x1 = max(a[0], b[0])
|
| 37 |
+
y1 = max(a[1], b[1])
|
| 38 |
+
x2 = min(a[2], b[2])
|
| 39 |
+
y2 = min(a[3], b[3])
|
| 40 |
+
inter = max(0.0, x2 - x1) * max(0.0, y2 - y1)
|
| 41 |
+
area_a = max(0.0, a[2] - a[0]) * max(0.0, a[3] - a[1])
|
| 42 |
+
area_b = max(0.0, b[2] - b[0]) * max(0.0, b[3] - b[1])
|
| 43 |
+
denom = area_a + area_b - inter
|
| 44 |
+
return inter / denom if denom else 0.0
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
def read_label(label_path: Path, width: int, height: int) -> list[list[float]]:
|
| 48 |
+
if not label_path.exists():
|
| 49 |
+
return []
|
| 50 |
+
boxes: list[list[float]] = []
|
| 51 |
+
for line in label_path.read_text(encoding="utf-8").splitlines():
|
| 52 |
+
if not line.strip():
|
| 53 |
+
continue
|
| 54 |
+
values = [float(part) for part in line.split()]
|
| 55 |
+
boxes.append(xywhn_to_xyxy(values, width, height))
|
| 56 |
+
return boxes
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
def resolve_split_images(data_yaml: Path, split: str) -> list[Path]:
|
| 60 |
+
data = yaml.safe_load(data_yaml.read_text(encoding="utf-8"))
|
| 61 |
+
root = Path(data.get("path", data_yaml.parent))
|
| 62 |
+
split_value = Path(data[split])
|
| 63 |
+
image_dir = split_value if split_value.is_absolute() else root / split_value
|
| 64 |
+
images = []
|
| 65 |
+
for suffix in ("*.jpg", "*.jpeg", "*.png", "*.tif", "*.tiff"):
|
| 66 |
+
images.extend(image_dir.glob(suffix))
|
| 67 |
+
return sorted(images)
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
def label_for_image(image_path: Path) -> Path:
|
| 71 |
+
parts = list(image_path.parts)
|
| 72 |
+
for idx, part in enumerate(parts):
|
| 73 |
+
if part == "images":
|
| 74 |
+
parts[idx] = "labels"
|
| 75 |
+
return Path(*parts).with_suffix(".txt")
|
| 76 |
+
return image_path.with_suffix(".txt")
|
| 77 |
+
|
| 78 |
+
|
| 79 |
+
def main() -> None:
|
| 80 |
+
args = parse_args()
|
| 81 |
+
from ultralytics import YOLO
|
| 82 |
+
|
| 83 |
+
image_paths = resolve_split_images(Path(args.data), args.split)
|
| 84 |
+
model = YOLO(args.weights)
|
| 85 |
+
|
| 86 |
+
image_tp = image_fn = image_fp = image_tn = 0
|
| 87 |
+
matched = false_positive_instances = false_negative_instances = 0
|
| 88 |
+
gt_instances = pred_instances = 0
|
| 89 |
+
matched_ious: list[float] = []
|
| 90 |
+
error_images: list[dict[str, object]] = []
|
| 91 |
+
|
| 92 |
+
for image_path in image_paths:
|
| 93 |
+
with Image.open(image_path) as image:
|
| 94 |
+
width, height = image.size
|
| 95 |
+
gt_boxes = read_label(label_for_image(image_path), width, height)
|
| 96 |
+
result = model.predict(
|
| 97 |
+
source=str(image_path),
|
| 98 |
+
imgsz=args.imgsz,
|
| 99 |
+
conf=args.conf,
|
| 100 |
+
device=args.device,
|
| 101 |
+
verbose=False,
|
| 102 |
+
)[0]
|
| 103 |
+
pred_boxes = result.boxes.xyxy.cpu().tolist() if result.boxes is not None else []
|
| 104 |
+
pred_scores = result.boxes.conf.cpu().tolist() if result.boxes is not None else []
|
| 105 |
+
pred_order = sorted(range(len(pred_boxes)), key=lambda i: pred_scores[i], reverse=True)
|
| 106 |
+
|
| 107 |
+
gt_instances += len(gt_boxes)
|
| 108 |
+
pred_instances += len(pred_boxes)
|
| 109 |
+
used_gt: set[int] = set()
|
| 110 |
+
image_matched = 0
|
| 111 |
+
image_false_positives = 0
|
| 112 |
+
|
| 113 |
+
for pred_idx in pred_order:
|
| 114 |
+
best_gt = -1
|
| 115 |
+
best_iou = 0.0
|
| 116 |
+
for gt_idx, gt_box in enumerate(gt_boxes):
|
| 117 |
+
if gt_idx in used_gt:
|
| 118 |
+
continue
|
| 119 |
+
iou = box_iou(pred_boxes[pred_idx], gt_box)
|
| 120 |
+
if iou > best_iou:
|
| 121 |
+
best_iou = iou
|
| 122 |
+
best_gt = gt_idx
|
| 123 |
+
if best_gt >= 0 and best_iou >= args.iou:
|
| 124 |
+
used_gt.add(best_gt)
|
| 125 |
+
matched += 1
|
| 126 |
+
image_matched += 1
|
| 127 |
+
matched_ious.append(best_iou)
|
| 128 |
+
else:
|
| 129 |
+
false_positive_instances += 1
|
| 130 |
+
image_false_positives += 1
|
| 131 |
+
|
| 132 |
+
missed = len(gt_boxes) - image_matched
|
| 133 |
+
false_negative_instances += missed
|
| 134 |
+
if missed or image_false_positives:
|
| 135 |
+
error_images.append(
|
| 136 |
+
{
|
| 137 |
+
"image": str(image_path),
|
| 138 |
+
"gt_instances": len(gt_boxes),
|
| 139 |
+
"pred_instances": len(pred_boxes),
|
| 140 |
+
"matched_instances": image_matched,
|
| 141 |
+
"false_positives": image_false_positives,
|
| 142 |
+
"false_negatives": missed,
|
| 143 |
+
}
|
| 144 |
+
)
|
| 145 |
+
|
| 146 |
+
if gt_boxes:
|
| 147 |
+
if image_matched:
|
| 148 |
+
image_tp += 1
|
| 149 |
+
else:
|
| 150 |
+
image_fn += 1
|
| 151 |
+
elif image_false_positives:
|
| 152 |
+
image_fp += 1
|
| 153 |
+
else:
|
| 154 |
+
image_tn += 1
|
| 155 |
+
|
| 156 |
+
tpr = image_tp / (image_tp + image_fn) if image_tp + image_fn else None
|
| 157 |
+
fpr = image_fp / (image_fp + image_tn) if image_fp + image_tn else None
|
| 158 |
+
accuracy = (image_tp + image_tn) / len(image_paths) if image_paths else None
|
| 159 |
+
precision = matched / (matched + false_positive_instances) if matched + false_positive_instances else None
|
| 160 |
+
recall = matched / gt_instances if gt_instances else None
|
| 161 |
+
mean_iou = sum(matched_ious) / len(matched_ious) if matched_ious else None
|
| 162 |
+
|
| 163 |
+
metrics = {
|
| 164 |
+
"weights": args.weights,
|
| 165 |
+
"data": args.data,
|
| 166 |
+
"split": args.split,
|
| 167 |
+
"images": len(image_paths),
|
| 168 |
+
"conf_threshold": args.conf,
|
| 169 |
+
"iou_threshold": args.iou,
|
| 170 |
+
"classification": {
|
| 171 |
+
"TPR": tpr,
|
| 172 |
+
"FPR": fpr,
|
| 173 |
+
"Accuracy": accuracy,
|
| 174 |
+
"TP": image_tp,
|
| 175 |
+
"FN": image_fn,
|
| 176 |
+
"FP": image_fp,
|
| 177 |
+
"TN": image_tn,
|
| 178 |
+
},
|
| 179 |
+
"detection": {
|
| 180 |
+
"gt_instances": gt_instances,
|
| 181 |
+
"pred_instances": pred_instances,
|
| 182 |
+
"matched_instances": matched,
|
| 183 |
+
"false_positives": false_positive_instances,
|
| 184 |
+
"false_negatives": false_negative_instances,
|
| 185 |
+
"precision": precision,
|
| 186 |
+
"recall": recall,
|
| 187 |
+
"mean_matched_iou": mean_iou,
|
| 188 |
+
},
|
| 189 |
+
"error_images": error_images,
|
| 190 |
+
}
|
| 191 |
+
output = Path(args.output)
|
| 192 |
+
output.parent.mkdir(parents=True, exist_ok=True)
|
| 193 |
+
output.write_text(json.dumps(metrics, indent=2, ensure_ascii=False), encoding="utf-8")
|
| 194 |
+
print(json.dumps(metrics, indent=2, ensure_ascii=False))
|
| 195 |
+
|
| 196 |
+
|
| 197 |
+
if __name__ == "__main__":
|
| 198 |
+
main()
|
scripts/find_max_batch.ps1
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
param(
|
| 2 |
+
[string]$Config = "configs/train_2gpu_4090d.json",
|
| 3 |
+
[int]$Start = 8,
|
| 4 |
+
[int]$Stop = 32,
|
| 5 |
+
[int]$Step = 2
|
| 6 |
+
)
|
| 7 |
+
|
| 8 |
+
$ErrorActionPreference = "Continue"
|
| 9 |
+
for ($batch = $Start; $batch -le $Stop; $batch += $Step) {
|
| 10 |
+
Write-Host "Trying per-GPU batch=$batch"
|
| 11 |
+
powershell -ExecutionPolicy Bypass -File scripts/train_2gpu.ps1 -Config $Config -BatchSize $batch -Epochs 1
|
| 12 |
+
if ($LASTEXITCODE -ne 0) {
|
| 13 |
+
Write-Host "Failed at per-GPU batch=$batch. Use the previous successful value."
|
| 14 |
+
exit 0
|
| 15 |
+
}
|
| 16 |
+
}
|
scripts/import_quality_gated_hf_dataset.py
ADDED
|
@@ -0,0 +1,309 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Import only quality-gated Hugging Face samples into project manifests.
|
| 2 |
+
|
| 3 |
+
This script consumes `hf_assets_raw.jsonl` produced by
|
| 4 |
+
`search_hf_marine_datasets.py`. It accepts only datasets with an explicit adapter
|
| 5 |
+
and verifiable labels. Everything else is rejected or left for review.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
from __future__ import annotations
|
| 9 |
+
|
| 10 |
+
import argparse
|
| 11 |
+
import csv
|
| 12 |
+
import json
|
| 13 |
+
import os
|
| 14 |
+
from dataclasses import asdict, dataclass
|
| 15 |
+
from pathlib import Path
|
| 16 |
+
from typing import Iterable
|
| 17 |
+
|
| 18 |
+
from huggingface_hub import snapshot_download
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
ACCEPTED_REGLAB_REPO = "reglab/aquaculture_detection"
|
| 22 |
+
MIN_IMAGE_BYTES = 10_000
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
@dataclass
|
| 26 |
+
class ImportedSample:
|
| 27 |
+
sample_id: str
|
| 28 |
+
element: str
|
| 29 |
+
task_type: str
|
| 30 |
+
image_path: str
|
| 31 |
+
mask_path: str | None
|
| 32 |
+
annotation_path: str | None
|
| 33 |
+
annotation_format: str | None
|
| 34 |
+
label_encoding: dict[str, str]
|
| 35 |
+
satellite: str | None
|
| 36 |
+
sensor: str | None
|
| 37 |
+
resolution_m: float | None
|
| 38 |
+
patch_size: int | None
|
| 39 |
+
bands: list[str] | None
|
| 40 |
+
band_count: int | None
|
| 41 |
+
dtype: str | None
|
| 42 |
+
fusion: dict
|
| 43 |
+
acquired_at: str | None
|
| 44 |
+
source_project: str
|
| 45 |
+
source_dataset: str
|
| 46 |
+
split: str | None
|
| 47 |
+
license: str | None
|
| 48 |
+
quality_flags: list[str]
|
| 49 |
+
quality_score: float
|
| 50 |
+
notes: str
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
def parse_args() -> argparse.Namespace:
|
| 54 |
+
parser = argparse.ArgumentParser(description=__doc__)
|
| 55 |
+
parser.add_argument("--discovery-root", required=True)
|
| 56 |
+
parser.add_argument("--output-root", required=True)
|
| 57 |
+
parser.add_argument("--hf-cache-dir", default=None)
|
| 58 |
+
parser.add_argument(
|
| 59 |
+
"--discard-unaccepted",
|
| 60 |
+
action="store_true",
|
| 61 |
+
help="Write only accepted samples and aggregate discard counts; do not keep rejected/review sample manifests.",
|
| 62 |
+
)
|
| 63 |
+
return parser.parse_args()
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
def read_jsonl(path: Path) -> Iterable[dict]:
|
| 67 |
+
with path.open(encoding="utf-8") as f:
|
| 68 |
+
for line in f:
|
| 69 |
+
if line.strip():
|
| 70 |
+
yield json.loads(line)
|
| 71 |
+
|
| 72 |
+
|
| 73 |
+
def write_jsonl(path: Path, rows: Iterable[dict]) -> None:
|
| 74 |
+
path.parent.mkdir(parents=True, exist_ok=True)
|
| 75 |
+
with path.open("w", encoding="utf-8") as f:
|
| 76 |
+
for row in rows:
|
| 77 |
+
f.write(json.dumps(row, ensure_ascii=False) + "\n")
|
| 78 |
+
|
| 79 |
+
|
| 80 |
+
def image_label_stem(path: str) -> str:
|
| 81 |
+
return Path(path).stem
|
| 82 |
+
|
| 83 |
+
|
| 84 |
+
def is_valid_yolo_label(local_path: Path) -> tuple[bool, int, str]:
|
| 85 |
+
rows = 0
|
| 86 |
+
try:
|
| 87 |
+
text = local_path.read_text(encoding="utf-8").strip()
|
| 88 |
+
except UnicodeDecodeError:
|
| 89 |
+
text = local_path.read_text(encoding="latin-1").strip()
|
| 90 |
+
if not text:
|
| 91 |
+
return False, 0, "empty_label"
|
| 92 |
+
for line in text.splitlines():
|
| 93 |
+
parts = line.split()
|
| 94 |
+
if len(parts) != 5:
|
| 95 |
+
return False, rows, "invalid_yolo_column_count"
|
| 96 |
+
try:
|
| 97 |
+
cls = int(float(parts[0]))
|
| 98 |
+
x, y, w, h = [float(value) for value in parts[1:]]
|
| 99 |
+
except ValueError:
|
| 100 |
+
return False, rows, "non_numeric_yolo_value"
|
| 101 |
+
if cls < 0:
|
| 102 |
+
return False, rows, "negative_class_id"
|
| 103 |
+
if not all(0.0 <= value <= 1.0 for value in (x, y, w, h)):
|
| 104 |
+
return False, rows, "bbox_value_out_of_range"
|
| 105 |
+
if w <= 0.0 or h <= 0.0:
|
| 106 |
+
return False, rows, "non_positive_bbox"
|
| 107 |
+
rows += 1
|
| 108 |
+
return rows > 0, rows, "ok"
|
| 109 |
+
|
| 110 |
+
|
| 111 |
+
def reject(asset: dict, reason: str) -> dict:
|
| 112 |
+
return {
|
| 113 |
+
"repo_id": asset.get("repo_id"),
|
| 114 |
+
"path": asset.get("path"),
|
| 115 |
+
"role": asset.get("role"),
|
| 116 |
+
"element": asset.get("element"),
|
| 117 |
+
"reason": reason,
|
| 118 |
+
}
|
| 119 |
+
|
| 120 |
+
|
| 121 |
+
def review(asset: dict, reason: str) -> dict:
|
| 122 |
+
row = reject(asset, reason)
|
| 123 |
+
row["hf_path"] = asset.get("hf_path")
|
| 124 |
+
row["license"] = asset.get("license")
|
| 125 |
+
return row
|
| 126 |
+
|
| 127 |
+
|
| 128 |
+
def import_reglab_aquaculture(assets: list[dict], cache_dir: str | None) -> tuple[list[ImportedSample], list[dict], list[dict]]:
|
| 129 |
+
repo_assets = [asset for asset in assets if asset["repo_id"] == ACCEPTED_REGLAB_REPO]
|
| 130 |
+
images = {image_label_stem(asset["path"]): asset for asset in repo_assets if asset["role"] == "image"}
|
| 131 |
+
labels = {image_label_stem(asset["path"]): asset for asset in repo_assets if asset["role"] == "annotation_table"}
|
| 132 |
+
|
| 133 |
+
accepted: list[ImportedSample] = []
|
| 134 |
+
rejected: list[dict] = []
|
| 135 |
+
review_rows: list[dict] = []
|
| 136 |
+
|
| 137 |
+
try:
|
| 138 |
+
repo_snapshot = Path(
|
| 139 |
+
snapshot_download(
|
| 140 |
+
repo_id=ACCEPTED_REGLAB_REPO,
|
| 141 |
+
repo_type="dataset",
|
| 142 |
+
allow_patterns=["labels/*.txt"],
|
| 143 |
+
token=os.environ.get("HF_TOKEN"),
|
| 144 |
+
cache_dir=cache_dir,
|
| 145 |
+
max_workers=8,
|
| 146 |
+
)
|
| 147 |
+
)
|
| 148 |
+
except Exception as exc: # noqa: BLE001 - keep the whole import from blocking on HF.
|
| 149 |
+
for asset in repo_assets:
|
| 150 |
+
review_rows.append(review(asset, f"adapter_label_download_failed:{exc}"))
|
| 151 |
+
return accepted, rejected, review_rows
|
| 152 |
+
|
| 153 |
+
for stem, image in sorted(images.items()):
|
| 154 |
+
label = labels.get(stem)
|
| 155 |
+
if not label:
|
| 156 |
+
review_rows.append(review(image, "missing_matching_yolo_label"))
|
| 157 |
+
continue
|
| 158 |
+
if (image.get("size_bytes") or 0) < MIN_IMAGE_BYTES:
|
| 159 |
+
rejected.append(reject(image, "image_file_too_small"))
|
| 160 |
+
continue
|
| 161 |
+
if image.get("element") != "aquaculture":
|
| 162 |
+
rejected.append(reject(image, "unexpected_element"))
|
| 163 |
+
continue
|
| 164 |
+
if image.get("license") != "cc-by-nc-2.0":
|
| 165 |
+
rejected.append(reject(image, "license_not_recorded_as_expected"))
|
| 166 |
+
continue
|
| 167 |
+
|
| 168 |
+
local_label = repo_snapshot / label["path"]
|
| 169 |
+
if not local_label.exists():
|
| 170 |
+
review_rows.append(review(label, "label_not_available_in_snapshot"))
|
| 171 |
+
continue
|
| 172 |
+
ok, bbox_count, label_reason = is_valid_yolo_label(local_label)
|
| 173 |
+
if not ok:
|
| 174 |
+
rejected.append(reject(label, label_reason))
|
| 175 |
+
continue
|
| 176 |
+
|
| 177 |
+
accepted.append(
|
| 178 |
+
ImportedSample(
|
| 179 |
+
sample_id=f"hf_reglab_aquaculture_{stem.lower()}",
|
| 180 |
+
element="aquaculture",
|
| 181 |
+
task_type="object_detection",
|
| 182 |
+
image_path=image["hf_path"],
|
| 183 |
+
mask_path=None,
|
| 184 |
+
annotation_path=label["hf_path"],
|
| 185 |
+
annotation_format="yolo_bbox_txt",
|
| 186 |
+
label_encoding={"0": "aquaculture"},
|
| 187 |
+
satellite=None,
|
| 188 |
+
sensor=None,
|
| 189 |
+
resolution_m=None,
|
| 190 |
+
patch_size=1024,
|
| 191 |
+
bands=["red", "green", "blue"],
|
| 192 |
+
band_count=3,
|
| 193 |
+
dtype=None,
|
| 194 |
+
fusion={
|
| 195 |
+
"state": "none",
|
| 196 |
+
"method": "none",
|
| 197 |
+
"sources": [{"role": "orthophoto_tile", "path": image["hf_path"], "resolution_m": None}],
|
| 198 |
+
"target_resolution_m": None,
|
| 199 |
+
"native_multispectral_resolution_m": None,
|
| 200 |
+
"persisted": False,
|
| 201 |
+
"reproducible": True,
|
| 202 |
+
"spectral_preservation": "not_applicable_rgb_orthophoto",
|
| 203 |
+
"notes": "RGB orthophoto tile from HF aquaculture detection dataset.",
|
| 204 |
+
},
|
| 205 |
+
acquired_at=None,
|
| 206 |
+
source_project="aquaculture_detection",
|
| 207 |
+
source_dataset=ACCEPTED_REGLAB_REPO,
|
| 208 |
+
split=None,
|
| 209 |
+
license=image.get("license"),
|
| 210 |
+
quality_flags=[
|
| 211 |
+
"accepted",
|
| 212 |
+
"explicit_adapter:reglab_aquaculture_yolo",
|
| 213 |
+
"paired_image_label",
|
| 214 |
+
"valid_yolo_bbox",
|
| 215 |
+
f"bbox_count:{bbox_count}",
|
| 216 |
+
"manual_training_gate_required_before_download",
|
| 217 |
+
],
|
| 218 |
+
quality_score=0.92,
|
| 219 |
+
notes="Accepted by strict adapter: paired image and YOLO label, valid bbox rows, known aquaculture semantics.",
|
| 220 |
+
)
|
| 221 |
+
)
|
| 222 |
+
|
| 223 |
+
for stem, label in sorted(labels.items()):
|
| 224 |
+
if stem not in images:
|
| 225 |
+
review_rows.append(review(label, "label_without_matching_image"))
|
| 226 |
+
|
| 227 |
+
return accepted, rejected, review_rows
|
| 228 |
+
|
| 229 |
+
|
| 230 |
+
def main() -> None:
|
| 231 |
+
args = parse_args()
|
| 232 |
+
discovery_root = Path(args.discovery_root)
|
| 233 |
+
output_root = Path(args.output_root)
|
| 234 |
+
manifest_dir = output_root / "manifests"
|
| 235 |
+
report_dir = output_root / "reports"
|
| 236 |
+
manifest_dir.mkdir(parents=True, exist_ok=True)
|
| 237 |
+
report_dir.mkdir(parents=True, exist_ok=True)
|
| 238 |
+
|
| 239 |
+
assets = list(read_jsonl(discovery_root / "manifests" / "hf_assets_raw.jsonl"))
|
| 240 |
+
accepted, rejected, review_rows = import_reglab_aquaculture(assets, args.hf_cache_dir)
|
| 241 |
+
|
| 242 |
+
accepted_repo_paths = {(sample.source_dataset, sample.image_path) for sample in accepted}
|
| 243 |
+
explicitly_seen = {ACCEPTED_REGLAB_REPO}
|
| 244 |
+
for asset in assets:
|
| 245 |
+
if asset["repo_id"] in explicitly_seen:
|
| 246 |
+
continue
|
| 247 |
+
if asset["role"] in {"image", "mask", "annotation_table", "archive"}:
|
| 248 |
+
review_rows.append(review(asset, "no_quality_adapter_or_unverified_semantics"))
|
| 249 |
+
|
| 250 |
+
write_jsonl(manifest_dir / "accepted_samples.jsonl", (asdict(sample) for sample in accepted))
|
| 251 |
+
rejected_path = manifest_dir / "rejected_samples.jsonl"
|
| 252 |
+
review_path = manifest_dir / "needs_manual_review.jsonl"
|
| 253 |
+
if args.discard_unaccepted:
|
| 254 |
+
rejected_path.unlink(missing_ok=True)
|
| 255 |
+
review_path.unlink(missing_ok=True)
|
| 256 |
+
else:
|
| 257 |
+
write_jsonl(rejected_path, rejected)
|
| 258 |
+
write_jsonl(review_path, review_rows)
|
| 259 |
+
|
| 260 |
+
with (report_dir / "quality_report.csv").open("w", newline="", encoding="utf-8-sig") as f:
|
| 261 |
+
writer = csv.DictWriter(
|
| 262 |
+
f,
|
| 263 |
+
fieldnames=[
|
| 264 |
+
"sample_id",
|
| 265 |
+
"element",
|
| 266 |
+
"task_type",
|
| 267 |
+
"source_dataset",
|
| 268 |
+
"image_path",
|
| 269 |
+
"annotation_path",
|
| 270 |
+
"license",
|
| 271 |
+
"quality_score",
|
| 272 |
+
"quality_flags",
|
| 273 |
+
],
|
| 274 |
+
)
|
| 275 |
+
writer.writeheader()
|
| 276 |
+
for sample in accepted:
|
| 277 |
+
row = asdict(sample)
|
| 278 |
+
row["quality_flags"] = ";".join(sample.quality_flags)
|
| 279 |
+
writer.writerow({key: row.get(key) for key in writer.fieldnames})
|
| 280 |
+
|
| 281 |
+
summary = {
|
| 282 |
+
"accepted_samples": len(accepted),
|
| 283 |
+
"rejected_assets": len(rejected),
|
| 284 |
+
"needs_manual_review": len(review_rows),
|
| 285 |
+
"unaccepted_sample_details_retained": not args.discard_unaccepted,
|
| 286 |
+
"accepted_by_element": {},
|
| 287 |
+
"accepted_by_task_type": {},
|
| 288 |
+
"quality_policy": "Only explicit adapters with verified labels can enter accepted_samples.jsonl.",
|
| 289 |
+
}
|
| 290 |
+
for sample in accepted:
|
| 291 |
+
summary["accepted_by_element"][sample.element] = summary["accepted_by_element"].get(sample.element, 0) + 1
|
| 292 |
+
summary["accepted_by_task_type"][sample.task_type] = summary["accepted_by_task_type"].get(sample.task_type, 0) + 1
|
| 293 |
+
(report_dir / "quality_summary.json").write_text(json.dumps(summary, indent=2, ensure_ascii=False), encoding="utf-8")
|
| 294 |
+
if args.discard_unaccepted:
|
| 295 |
+
discard_summary = {
|
| 296 |
+
"discarded_rejected_assets": len(rejected),
|
| 297 |
+
"discarded_review_assets": len(review_rows),
|
| 298 |
+
"reason": "User requested all non-accepted samples be discarded.",
|
| 299 |
+
"retained_manifest": "manifests/accepted_samples.jsonl",
|
| 300 |
+
}
|
| 301 |
+
(report_dir / "discard_summary.json").write_text(
|
| 302 |
+
json.dumps(discard_summary, indent=2, ensure_ascii=False), encoding="utf-8"
|
| 303 |
+
)
|
| 304 |
+
|
| 305 |
+
print(json.dumps(summary, indent=2, ensure_ascii=False))
|
| 306 |
+
|
| 307 |
+
|
| 308 |
+
if __name__ == "__main__":
|
| 309 |
+
main()
|
scripts/infer_whole_scene.py
ADDED
|
@@ -0,0 +1,356 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Sliding-window inference for full-scene seaweed segmentation rasters."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import argparse
|
| 6 |
+
import json
|
| 7 |
+
import sys
|
| 8 |
+
import time
|
| 9 |
+
from pathlib import Path
|
| 10 |
+
|
| 11 |
+
import numpy as np
|
| 12 |
+
import rasterio
|
| 13 |
+
import torch
|
| 14 |
+
from rasterio.windows import Window
|
| 15 |
+
from torchvision import transforms
|
| 16 |
+
from tqdm import tqdm
|
| 17 |
+
|
| 18 |
+
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
| 19 |
+
|
| 20 |
+
from dinov3_deeplabv3plus import DinoV3DeepLabV3Plus
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
NORMALIZE_3CH = transforms.Normalize(mean=(0.430, 0.411, 0.296), std=(0.213, 0.156, 0.143))
|
| 24 |
+
NORMALIZE_4CH = transforms.Normalize(mean=(0.430, 0.411, 0.296, 0.350), std=(0.213, 0.156, 0.143, 0.180))
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
def parse_args() -> argparse.Namespace:
|
| 28 |
+
parser = argparse.ArgumentParser(description=__doc__)
|
| 29 |
+
parser.add_argument("--image", required=True, help="Input multispectral whole-scene raster path.")
|
| 30 |
+
parser.add_argument("--pan", default=None, help="Optional panchromatic raster path for on-the-fly pan sharpening.")
|
| 31 |
+
parser.add_argument("--checkpoint", required=True, help="Model checkpoint .pth path.")
|
| 32 |
+
parser.add_argument("--output-dir", default="outputs/scene_inference", help="Directory for prediction rasters.")
|
| 33 |
+
parser.add_argument("--tile-size", type=int, default=256, help="Inference tile size.")
|
| 34 |
+
parser.add_argument("--overlap", type=int, default=32, help="Tile overlap in output pixels for weighted blending.")
|
| 35 |
+
parser.add_argument("--stripe-height", type=int, default=1024, help="Rows to blend/write at a time.")
|
| 36 |
+
parser.add_argument("--batch-size", type=int, default=4, help="Number of tiles per forward pass.")
|
| 37 |
+
parser.add_argument("--threshold", type=float, default=0.5, help="Foreground probability threshold.")
|
| 38 |
+
parser.add_argument("--max-tiles", type=int, default=0, help="Optional smoke-test tile limit; 0 means full scene.")
|
| 39 |
+
parser.add_argument("--max-stripes", type=int, default=0, help="Optional smoke-test stripe limit; 0 means all stripes.")
|
| 40 |
+
parser.add_argument("--device", default="cuda", choices=["cuda", "cpu"], help="Inference device.")
|
| 41 |
+
parser.add_argument("--write-probability", action="store_true", help="Write the foreground probability raster.")
|
| 42 |
+
parser.add_argument("--no-probability", action="store_true", help="Deprecated; probability output is disabled by default.")
|
| 43 |
+
return parser.parse_args()
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
def get_checkpoint_state(path: Path, device: torch.device) -> dict:
|
| 47 |
+
checkpoint = torch.load(path, map_location=device, weights_only=False)
|
| 48 |
+
if not isinstance(checkpoint, dict):
|
| 49 |
+
raise ValueError(f"Unsupported checkpoint format: {path}")
|
| 50 |
+
return checkpoint
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
def build_model(checkpoint: dict, device: torch.device) -> DinoV3DeepLabV3Plus:
|
| 54 |
+
config = checkpoint.get("config") or {}
|
| 55 |
+
model = DinoV3DeepLabV3Plus(
|
| 56 |
+
num_classes=int(config.get("num_classes", 2)),
|
| 57 |
+
backbone_name=config.get("backbone_name", "dinov3_vitl16"),
|
| 58 |
+
pretrained=False,
|
| 59 |
+
weights=config.get("backbone_weights", "SAT493M"),
|
| 60 |
+
use_4channel=bool(config.get("use_4channel", True)),
|
| 61 |
+
freeze_backbone=False,
|
| 62 |
+
).to(device)
|
| 63 |
+
|
| 64 |
+
state_dict = checkpoint.get("model_state_dict") or checkpoint.get("state_dict")
|
| 65 |
+
if state_dict is None:
|
| 66 |
+
raise KeyError("Checkpoint does not contain model_state_dict/state_dict.")
|
| 67 |
+
cleaned = {k.removeprefix("module."): v for k, v in state_dict.items()}
|
| 68 |
+
model.load_state_dict(cleaned, strict=True)
|
| 69 |
+
model.eval()
|
| 70 |
+
return model
|
| 71 |
+
|
| 72 |
+
|
| 73 |
+
def axis_starts(length: int, tile_size: int, overlap: int) -> list[int]:
|
| 74 |
+
if length <= tile_size:
|
| 75 |
+
return [0]
|
| 76 |
+
stride = tile_size - overlap
|
| 77 |
+
if stride <= 0:
|
| 78 |
+
raise ValueError("--overlap must be smaller than --tile-size.")
|
| 79 |
+
starts = list(range(0, length - tile_size + 1, stride))
|
| 80 |
+
last = length - tile_size
|
| 81 |
+
if starts[-1] != last:
|
| 82 |
+
starts.append(last)
|
| 83 |
+
return starts
|
| 84 |
+
|
| 85 |
+
|
| 86 |
+
def tile_grid(width: int, height: int, tile_size: int, overlap: int) -> list[tuple[int, int, int, int]]:
|
| 87 |
+
tiles: list[tuple[int, int, int, int]] = []
|
| 88 |
+
for y in axis_starts(height, tile_size, overlap):
|
| 89 |
+
for x in axis_starts(width, tile_size, overlap):
|
| 90 |
+
w = min(tile_size, width - x)
|
| 91 |
+
h = min(tile_size, height - y)
|
| 92 |
+
tiles.append((x, y, w, h))
|
| 93 |
+
return tiles
|
| 94 |
+
|
| 95 |
+
|
| 96 |
+
def blend_weight(tile_size: int, overlap: int) -> np.ndarray:
|
| 97 |
+
if overlap <= 0:
|
| 98 |
+
return np.ones((tile_size, tile_size), dtype=np.float32)
|
| 99 |
+
ramp = np.minimum(np.arange(tile_size, dtype=np.float32) + 1, tile_size - np.arange(tile_size, dtype=np.float32))
|
| 100 |
+
ramp = np.clip(ramp / float(overlap), 1.0 / float(overlap), 1.0)
|
| 101 |
+
return np.minimum(ramp[:, None], ramp[None, :]).astype(np.float32, copy=False)
|
| 102 |
+
|
| 103 |
+
|
| 104 |
+
def pad_chw(tile: np.ndarray, tile_size: int) -> np.ndarray:
|
| 105 |
+
bands, height, width = tile.shape
|
| 106 |
+
padded = np.zeros((bands, tile_size, tile_size), dtype=np.float32)
|
| 107 |
+
padded[:, :height, :width] = tile.astype(np.float32, copy=False)
|
| 108 |
+
return padded
|
| 109 |
+
|
| 110 |
+
|
| 111 |
+
def match_pan_to_intensity(pan: np.ndarray, intensity: np.ndarray) -> np.ndarray:
|
| 112 |
+
pan = pan.astype(np.float32, copy=False)
|
| 113 |
+
intensity = intensity.astype(np.float32, copy=False)
|
| 114 |
+
pan_std = float(np.std(pan))
|
| 115 |
+
intensity_std = float(np.std(intensity))
|
| 116 |
+
if pan_std < 1e-6 or intensity_std < 1e-6:
|
| 117 |
+
return pan
|
| 118 |
+
return (pan - float(np.mean(pan))) * (intensity_std / pan_std) + float(np.mean(intensity))
|
| 119 |
+
|
| 120 |
+
|
| 121 |
+
def pan_sharpen_tile(ms_tile: np.ndarray, pan_tile: np.ndarray, tile_size: int) -> np.ndarray:
|
| 122 |
+
"""Lightweight additive component substitution for one tile.
|
| 123 |
+
|
| 124 |
+
The result keeps the multispectral band count and PAN spatial resolution.
|
| 125 |
+
It is intended for streaming inference, not radiometric product generation.
|
| 126 |
+
"""
|
| 127 |
+
ms_padded = pad_chw(ms_tile, tile_size)
|
| 128 |
+
pan_padded = pad_chw(pan_tile[:1], tile_size)[0]
|
| 129 |
+
bands = ms_padded[:4] if ms_padded.shape[0] >= 4 else ms_padded
|
| 130 |
+
intensity = np.mean(bands, axis=0)
|
| 131 |
+
matched_pan = match_pan_to_intensity(pan_padded, intensity)
|
| 132 |
+
fused = bands + (matched_pan - intensity)[None, :, :]
|
| 133 |
+
return np.clip(fused, 0, 65535).astype(np.float32, copy=False)
|
| 134 |
+
|
| 135 |
+
|
| 136 |
+
def to_model_tensor(tile: np.ndarray, tile_size: int, use_4channel: bool) -> torch.Tensor:
|
| 137 |
+
# rasterio returns C,H,W. Pad edge tiles to the training tile size.
|
| 138 |
+
padded = pad_chw(tile, tile_size)
|
| 139 |
+
|
| 140 |
+
if use_4channel:
|
| 141 |
+
if padded.shape[0] < 4:
|
| 142 |
+
padded = np.pad(padded, ((0, 4 - padded.shape[0]), (0, 0), (0, 0)), mode="edge")
|
| 143 |
+
data = padded[:4]
|
| 144 |
+
normalizer = NORMALIZE_4CH
|
| 145 |
+
else:
|
| 146 |
+
if padded.shape[0] >= 4:
|
| 147 |
+
data = padded[[3, 2, 1]]
|
| 148 |
+
else:
|
| 149 |
+
data = padded[: min(3, padded.shape[0])]
|
| 150 |
+
while data.shape[0] < 3:
|
| 151 |
+
data = np.concatenate([data, data[-1:]], axis=0)
|
| 152 |
+
normalizer = NORMALIZE_3CH
|
| 153 |
+
|
| 154 |
+
tensor = torch.from_numpy(data)
|
| 155 |
+
if float(tensor.max()) > 1.0:
|
| 156 |
+
tensor = tensor / 65535.0
|
| 157 |
+
return normalizer(tensor)
|
| 158 |
+
|
| 159 |
+
|
| 160 |
+
def read_fused_tile(
|
| 161 |
+
ms_src: rasterio.DatasetReader,
|
| 162 |
+
pan_src: rasterio.DatasetReader,
|
| 163 |
+
x: int,
|
| 164 |
+
y: int,
|
| 165 |
+
w: int,
|
| 166 |
+
h: int,
|
| 167 |
+
tile_size: int,
|
| 168 |
+
) -> np.ndarray:
|
| 169 |
+
scale_x = pan_src.width / ms_src.width
|
| 170 |
+
scale_y = pan_src.height / ms_src.height
|
| 171 |
+
ms_window = Window(x / scale_x, y / scale_y, w / scale_x, h / scale_y)
|
| 172 |
+
ms_tile = ms_src.read(
|
| 173 |
+
window=ms_window,
|
| 174 |
+
out_shape=(ms_src.count, h, w),
|
| 175 |
+
resampling=rasterio.enums.Resampling.bilinear,
|
| 176 |
+
boundless=True,
|
| 177 |
+
fill_value=0,
|
| 178 |
+
)
|
| 179 |
+
pan_tile = pan_src.read(1, window=Window(x, y, w, h), boundless=True, fill_value=0)[None, :, :]
|
| 180 |
+
return pan_sharpen_tile(ms_tile, pan_tile, tile_size)
|
| 181 |
+
|
| 182 |
+
|
| 183 |
+
def run_batch(model: torch.nn.Module, batch: list[torch.Tensor], device: torch.device) -> np.ndarray:
|
| 184 |
+
inputs = torch.stack(batch, dim=0).to(device, non_blocking=True)
|
| 185 |
+
with torch.inference_mode():
|
| 186 |
+
with torch.autocast(device_type="cuda", enabled=device.type == "cuda"):
|
| 187 |
+
output = model(inputs)
|
| 188 |
+
logits = output["out"] if isinstance(output, dict) else output
|
| 189 |
+
probs = torch.softmax(logits.float(), dim=1)[:, 1]
|
| 190 |
+
return probs.detach().cpu().numpy()
|
| 191 |
+
|
| 192 |
+
|
| 193 |
+
def add_probs_to_stripe(
|
| 194 |
+
probs: np.ndarray,
|
| 195 |
+
windows: list[tuple[int, int, int, int]],
|
| 196 |
+
stripe_y: int,
|
| 197 |
+
stripe_prob_sum: np.ndarray,
|
| 198 |
+
stripe_weight_sum: np.ndarray,
|
| 199 |
+
weight: np.ndarray,
|
| 200 |
+
) -> None:
|
| 201 |
+
for prob, (wx, wy, ww, wh) in zip(probs, windows):
|
| 202 |
+
out_y0 = max(wy, stripe_y)
|
| 203 |
+
out_y1 = min(wy + wh, stripe_y + stripe_prob_sum.shape[0])
|
| 204 |
+
if out_y0 >= out_y1:
|
| 205 |
+
continue
|
| 206 |
+
prob_y0 = out_y0 - wy
|
| 207 |
+
prob_y1 = out_y1 - wy
|
| 208 |
+
stripe_local_y0 = out_y0 - stripe_y
|
| 209 |
+
stripe_local_y1 = out_y1 - stripe_y
|
| 210 |
+
|
| 211 |
+
cropped = prob[prob_y0:prob_y1, :ww]
|
| 212 |
+
cropped_weight = weight[prob_y0:prob_y1, :ww]
|
| 213 |
+
stripe_prob_sum[stripe_local_y0:stripe_local_y1, wx : wx + ww] += (
|
| 214 |
+
cropped.astype(np.float32, copy=False) * cropped_weight
|
| 215 |
+
)
|
| 216 |
+
stripe_weight_sum[stripe_local_y0:stripe_local_y1, wx : wx + ww] += cropped_weight
|
| 217 |
+
|
| 218 |
+
|
| 219 |
+
def write_stripe(
|
| 220 |
+
mask_dst: rasterio.DatasetWriter,
|
| 221 |
+
prob_dst: rasterio.DatasetWriter | None,
|
| 222 |
+
stripe_y: int,
|
| 223 |
+
stripe_prob_sum: np.ndarray,
|
| 224 |
+
stripe_weight_sum: np.ndarray,
|
| 225 |
+
threshold: float,
|
| 226 |
+
) -> None:
|
| 227 |
+
probs = np.divide(
|
| 228 |
+
stripe_prob_sum,
|
| 229 |
+
stripe_weight_sum,
|
| 230 |
+
out=np.zeros_like(stripe_prob_sum, dtype=np.float32),
|
| 231 |
+
where=stripe_weight_sum > 0,
|
| 232 |
+
)
|
| 233 |
+
mask = (probs >= threshold).astype(np.uint8) * 255
|
| 234 |
+
window = Window(0, stripe_y, probs.shape[1], probs.shape[0])
|
| 235 |
+
mask_dst.write(mask, 1, window=window)
|
| 236 |
+
if prob_dst is not None:
|
| 237 |
+
prob_dst.write(probs.astype(np.float32, copy=False), 1, window=window)
|
| 238 |
+
|
| 239 |
+
|
| 240 |
+
def main() -> None:
|
| 241 |
+
args = parse_args()
|
| 242 |
+
write_probability = bool(args.write_probability) and not bool(args.no_probability)
|
| 243 |
+
image_path = Path(args.image)
|
| 244 |
+
pan_path = Path(args.pan) if args.pan else None
|
| 245 |
+
checkpoint_path = Path(args.checkpoint)
|
| 246 |
+
output_dir = Path(args.output_dir)
|
| 247 |
+
output_dir.mkdir(parents=True, exist_ok=True)
|
| 248 |
+
|
| 249 |
+
if args.device == "cuda" and not torch.cuda.is_available():
|
| 250 |
+
print("CUDA is not available; falling back to CPU.")
|
| 251 |
+
device = torch.device("cpu")
|
| 252 |
+
else:
|
| 253 |
+
device = torch.device(args.device)
|
| 254 |
+
|
| 255 |
+
checkpoint = get_checkpoint_state(checkpoint_path, device)
|
| 256 |
+
config = checkpoint.get("config") or {}
|
| 257 |
+
use_4channel = bool(config.get("use_4channel", True))
|
| 258 |
+
model = build_model(checkpoint, device)
|
| 259 |
+
|
| 260 |
+
start = time.time()
|
| 261 |
+
with rasterio.open(image_path) as ms_src:
|
| 262 |
+
if use_4channel and ms_src.count < 4:
|
| 263 |
+
raise ValueError(f"Model expects 4 channels, but image has {ms_src.count}: {image_path}")
|
| 264 |
+
|
| 265 |
+
pan_src = rasterio.open(pan_path) if pan_path else None
|
| 266 |
+
ref_src = pan_src or ms_src
|
| 267 |
+
all_tiles = tile_grid(ref_src.width, ref_src.height, args.tile_size, args.overlap)
|
| 268 |
+
tiles = all_tiles[: args.max_tiles] if args.max_tiles > 0 else all_tiles
|
| 269 |
+
stem = image_path.stem
|
| 270 |
+
if pan_src is not None:
|
| 271 |
+
stem = f"{stem}_pansharpened"
|
| 272 |
+
suffix = "smoke" if args.max_tiles > 0 else "full"
|
| 273 |
+
mask_path = output_dir / f"{stem}_{suffix}_mask.tif"
|
| 274 |
+
prob_path = output_dir / f"{stem}_{suffix}_prob.tif"
|
| 275 |
+
|
| 276 |
+
profile = ref_src.profile.copy()
|
| 277 |
+
mask_profile = profile.copy()
|
| 278 |
+
mask_profile.update(count=1, dtype="uint8", compress="lzw", nodata=0)
|
| 279 |
+
prob_profile = profile.copy()
|
| 280 |
+
prob_profile.update(count=1, dtype="float32", compress="lzw", nodata=0.0)
|
| 281 |
+
weight = blend_weight(args.tile_size, args.overlap)
|
| 282 |
+
|
| 283 |
+
try:
|
| 284 |
+
mode = "Pan-sharpen inference" if pan_src is not None else "Inference"
|
| 285 |
+
prob_dst = None
|
| 286 |
+
with rasterio.open(mask_path, "w", **mask_profile) as mask_dst:
|
| 287 |
+
if write_probability:
|
| 288 |
+
prob_dst = rasterio.open(prob_path, "w", **prob_profile)
|
| 289 |
+
try:
|
| 290 |
+
stripe_starts = list(range(0, ref_src.height, args.stripe_height))
|
| 291 |
+
if args.max_stripes > 0:
|
| 292 |
+
stripe_starts = stripe_starts[: args.max_stripes]
|
| 293 |
+
for stripe_y in tqdm(
|
| 294 |
+
stripe_starts,
|
| 295 |
+
desc=f"{mode} stripes {image_path.name}",
|
| 296 |
+
unit="stripe",
|
| 297 |
+
):
|
| 298 |
+
stripe_h = min(args.stripe_height, ref_src.height - stripe_y)
|
| 299 |
+
stripe_prob_sum = np.zeros((stripe_h, ref_src.width), dtype=np.float32)
|
| 300 |
+
stripe_weight_sum = np.zeros((stripe_h, ref_src.width), dtype=np.float32)
|
| 301 |
+
stripe_tiles = [
|
| 302 |
+
tile
|
| 303 |
+
for tile in tiles
|
| 304 |
+
if tile[1] < stripe_y + stripe_h and tile[1] + tile[3] > stripe_y
|
| 305 |
+
]
|
| 306 |
+
|
| 307 |
+
batch: list[torch.Tensor] = []
|
| 308 |
+
windows: list[tuple[int, int, int, int]] = []
|
| 309 |
+
for x, y, w, h in stripe_tiles:
|
| 310 |
+
if pan_src is None:
|
| 311 |
+
tile = ms_src.read(window=Window(x, y, w, h))
|
| 312 |
+
else:
|
| 313 |
+
tile = read_fused_tile(ms_src, pan_src, x, y, w, h, args.tile_size)
|
| 314 |
+
batch.append(to_model_tensor(tile, args.tile_size, use_4channel))
|
| 315 |
+
windows.append((x, y, w, h))
|
| 316 |
+
|
| 317 |
+
if len(batch) == args.batch_size:
|
| 318 |
+
probs = run_batch(model, batch, device)
|
| 319 |
+
add_probs_to_stripe(probs, windows, stripe_y, stripe_prob_sum, stripe_weight_sum, weight)
|
| 320 |
+
batch.clear()
|
| 321 |
+
windows.clear()
|
| 322 |
+
|
| 323 |
+
if batch:
|
| 324 |
+
probs = run_batch(model, batch, device)
|
| 325 |
+
add_probs_to_stripe(probs, windows, stripe_y, stripe_prob_sum, stripe_weight_sum, weight)
|
| 326 |
+
|
| 327 |
+
write_stripe(mask_dst, prob_dst, stripe_y, stripe_prob_sum, stripe_weight_sum, args.threshold)
|
| 328 |
+
finally:
|
| 329 |
+
if prob_dst is not None:
|
| 330 |
+
prob_dst.close()
|
| 331 |
+
finally:
|
| 332 |
+
if pan_src is not None:
|
| 333 |
+
pan_src.close()
|
| 334 |
+
|
| 335 |
+
summary = {
|
| 336 |
+
"image": str(image_path),
|
| 337 |
+
"pan": str(pan_path) if pan_path else None,
|
| 338 |
+
"checkpoint": str(checkpoint_path),
|
| 339 |
+
"device": str(device),
|
| 340 |
+
"tile_size": args.tile_size,
|
| 341 |
+
"overlap": args.overlap,
|
| 342 |
+
"batch_size": args.batch_size,
|
| 343 |
+
"tiles_processed": len(tiles),
|
| 344 |
+
"tiles_total": len(all_tiles),
|
| 345 |
+
"max_stripes": args.max_stripes,
|
| 346 |
+
"mask": str(mask_path),
|
| 347 |
+
"probability": str(prob_path) if write_probability else None,
|
| 348 |
+
"seconds": round(time.time() - start, 2),
|
| 349 |
+
"checkpoint_epoch": checkpoint.get("epoch"),
|
| 350 |
+
"checkpoint_best_val_iou": checkpoint.get("best_val_iou"),
|
| 351 |
+
}
|
| 352 |
+
print(json.dumps(summary, indent=2, ensure_ascii=False))
|
| 353 |
+
|
| 354 |
+
|
| 355 |
+
if __name__ == "__main__":
|
| 356 |
+
main()
|
scripts/launch_ddp_no_libuv.py
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Windows-friendly two-process DDP launcher for PyTorch builds without libuv."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import argparse
|
| 6 |
+
import os
|
| 7 |
+
import subprocess
|
| 8 |
+
import sys
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
def main():
|
| 12 |
+
parser = argparse.ArgumentParser()
|
| 13 |
+
parser.add_argument("--nproc_per_node", type=int, default=2)
|
| 14 |
+
parser.add_argument("--master_addr", default="127.0.0.1")
|
| 15 |
+
parser.add_argument("--master_port", default="29500")
|
| 16 |
+
parser.add_argument("training_script")
|
| 17 |
+
parser.add_argument("training_args", nargs=argparse.REMAINDER)
|
| 18 |
+
args = parser.parse_args()
|
| 19 |
+
|
| 20 |
+
processes = []
|
| 21 |
+
for local_rank in range(args.nproc_per_node):
|
| 22 |
+
env = os.environ.copy()
|
| 23 |
+
env.update(
|
| 24 |
+
{
|
| 25 |
+
"MASTER_ADDR": args.master_addr,
|
| 26 |
+
"MASTER_PORT": args.master_port,
|
| 27 |
+
"WORLD_SIZE": str(args.nproc_per_node),
|
| 28 |
+
"RANK": str(local_rank),
|
| 29 |
+
"LOCAL_RANK": str(local_rank),
|
| 30 |
+
"USE_LIBUV": "0",
|
| 31 |
+
}
|
| 32 |
+
)
|
| 33 |
+
cmd = [sys.executable, args.training_script, *args.training_args]
|
| 34 |
+
processes.append(subprocess.Popen(cmd, env=env))
|
| 35 |
+
|
| 36 |
+
exit_code = 0
|
| 37 |
+
for process in processes:
|
| 38 |
+
exit_code = max(exit_code, process.wait())
|
| 39 |
+
|
| 40 |
+
if exit_code:
|
| 41 |
+
for process in processes:
|
| 42 |
+
if process.poll() is None:
|
| 43 |
+
process.terminate()
|
| 44 |
+
raise SystemExit(exit_code)
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
if __name__ == "__main__":
|
| 48 |
+
main()
|
scripts/materialize_yolo_from_accepted.py
ADDED
|
@@ -0,0 +1,158 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Materialize accepted HF object-detection samples as a YOLO dataset."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import argparse
|
| 6 |
+
import json
|
| 7 |
+
import random
|
| 8 |
+
import shutil
|
| 9 |
+
from pathlib import Path
|
| 10 |
+
|
| 11 |
+
from huggingface_hub import snapshot_download
|
| 12 |
+
from PIL import Image
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
def parse_args() -> argparse.Namespace:
|
| 16 |
+
parser = argparse.ArgumentParser(description=__doc__)
|
| 17 |
+
parser.add_argument("--accepted-manifest", required=True)
|
| 18 |
+
parser.add_argument("--output-root", required=True)
|
| 19 |
+
parser.add_argument("--seed", type=int, default=20260704)
|
| 20 |
+
parser.add_argument("--train-ratio", type=float, default=0.8)
|
| 21 |
+
parser.add_argument("--val-ratio", type=float, default=0.1)
|
| 22 |
+
return parser.parse_args()
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
def read_jsonl(path: Path) -> list[dict]:
|
| 26 |
+
rows: list[dict] = []
|
| 27 |
+
with path.open(encoding="utf-8") as f:
|
| 28 |
+
for line in f:
|
| 29 |
+
if line.strip():
|
| 30 |
+
rows.append(json.loads(line))
|
| 31 |
+
return rows
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
def parse_hf_path(hf_path: str) -> tuple[str, str]:
|
| 35 |
+
prefix = "hf://datasets/"
|
| 36 |
+
if not hf_path.startswith(prefix):
|
| 37 |
+
raise ValueError(f"Unsupported path: {hf_path}")
|
| 38 |
+
rest = hf_path[len(prefix) :]
|
| 39 |
+
owner, name, rel = rest.split("/", 2)
|
| 40 |
+
return f"{owner}/{name}", rel
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
def split_rows(rows: list[dict], seed: int, train_ratio: float, val_ratio: float) -> dict[str, list[dict]]:
|
| 44 |
+
rng = random.Random(seed)
|
| 45 |
+
shuffled = list(rows)
|
| 46 |
+
rng.shuffle(shuffled)
|
| 47 |
+
train_end = int(len(shuffled) * train_ratio)
|
| 48 |
+
val_end = train_end + int(len(shuffled) * val_ratio)
|
| 49 |
+
return {
|
| 50 |
+
"train": shuffled[:train_end],
|
| 51 |
+
"val": shuffled[train_end:val_end],
|
| 52 |
+
"test": shuffled[val_end:],
|
| 53 |
+
}
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
def copy_sample(row: dict, split: str, output_root: Path, snapshots: dict[str, Path]) -> tuple[int, int]:
|
| 57 |
+
image_repo, image_rel = parse_hf_path(row["image_path"])
|
| 58 |
+
label_repo, label_rel = parse_hf_path(row["annotation_path"])
|
| 59 |
+
if image_repo != label_repo:
|
| 60 |
+
raise ValueError(f"Image and label repos differ: {image_repo} vs {label_repo}")
|
| 61 |
+
|
| 62 |
+
snapshot = snapshots[image_repo]
|
| 63 |
+
image_src = snapshot / image_rel
|
| 64 |
+
label_src = snapshot / label_rel
|
| 65 |
+
if not image_src.exists() or not label_src.exists():
|
| 66 |
+
raise FileNotFoundError(f"Missing snapshot files: {image_rel}, {label_rel}")
|
| 67 |
+
|
| 68 |
+
image_dst = output_root / "images" / split / f"{row['sample_id']}{image_src.suffix.lower()}"
|
| 69 |
+
label_dst = output_root / "labels" / split / f"{row['sample_id']}.txt"
|
| 70 |
+
image_dst.parent.mkdir(parents=True, exist_ok=True)
|
| 71 |
+
label_dst.parent.mkdir(parents=True, exist_ok=True)
|
| 72 |
+
|
| 73 |
+
shutil.copy2(image_src, image_dst)
|
| 74 |
+
remap_yolo_label_to_single_class(label_src, label_dst)
|
| 75 |
+
|
| 76 |
+
with Image.open(image_dst) as img:
|
| 77 |
+
width, height = img.size
|
| 78 |
+
if width <= 0 or height <= 0:
|
| 79 |
+
raise ValueError(f"Invalid image dimensions: {image_dst}")
|
| 80 |
+
text = label_dst.read_text(encoding="utf-8").strip()
|
| 81 |
+
if not text:
|
| 82 |
+
raise ValueError(f"Empty label: {label_dst}")
|
| 83 |
+
return width, height
|
| 84 |
+
|
| 85 |
+
|
| 86 |
+
def remap_yolo_label_to_single_class(src: Path, dst: Path) -> None:
|
| 87 |
+
"""Collapse source dataset subclasses into class 0 for the element card."""
|
| 88 |
+
lines: list[str] = []
|
| 89 |
+
for raw in src.read_text(encoding="utf-8").splitlines():
|
| 90 |
+
if not raw.strip():
|
| 91 |
+
continue
|
| 92 |
+
parts = raw.split()
|
| 93 |
+
if len(parts) != 5:
|
| 94 |
+
raise ValueError(f"Invalid YOLO row in {src}: {raw}")
|
| 95 |
+
parts[0] = "0"
|
| 96 |
+
lines.append(" ".join(parts))
|
| 97 |
+
if not lines:
|
| 98 |
+
raise ValueError(f"Empty YOLO label after remap: {src}")
|
| 99 |
+
dst.write_text("\n".join(lines) + "\n", encoding="utf-8")
|
| 100 |
+
|
| 101 |
+
|
| 102 |
+
def main() -> None:
|
| 103 |
+
args = parse_args()
|
| 104 |
+
accepted_manifest = Path(args.accepted_manifest)
|
| 105 |
+
output_root = Path(args.output_root)
|
| 106 |
+
if output_root.exists():
|
| 107 |
+
shutil.rmtree(output_root)
|
| 108 |
+
output_root.mkdir(parents=True)
|
| 109 |
+
|
| 110 |
+
rows = read_jsonl(accepted_manifest)
|
| 111 |
+
repos = sorted({parse_hf_path(row["image_path"])[0] for row in rows})
|
| 112 |
+
snapshots = {
|
| 113 |
+
repo: Path(
|
| 114 |
+
snapshot_download(
|
| 115 |
+
repo_id=repo,
|
| 116 |
+
repo_type="dataset",
|
| 117 |
+
allow_patterns=["images/*", "labels/*"],
|
| 118 |
+
max_workers=8,
|
| 119 |
+
)
|
| 120 |
+
)
|
| 121 |
+
for repo in repos
|
| 122 |
+
}
|
| 123 |
+
splits = split_rows(rows, args.seed, args.train_ratio, args.val_ratio)
|
| 124 |
+
stats = {
|
| 125 |
+
"source_manifest": str(accepted_manifest),
|
| 126 |
+
"total": len(rows),
|
| 127 |
+
"splits": {split: len(items) for split, items in splits.items()},
|
| 128 |
+
"classes": ["aquaculture"],
|
| 129 |
+
"image_sizes": {},
|
| 130 |
+
}
|
| 131 |
+
|
| 132 |
+
for split, items in splits.items():
|
| 133 |
+
for row in items:
|
| 134 |
+
width, height = copy_sample(row, split, output_root, snapshots)
|
| 135 |
+
key = f"{width}x{height}"
|
| 136 |
+
stats["image_sizes"][key] = stats["image_sizes"].get(key, 0) + 1
|
| 137 |
+
|
| 138 |
+
data_yaml = output_root / "data.yaml"
|
| 139 |
+
data_yaml.write_text(
|
| 140 |
+
"\n".join(
|
| 141 |
+
[
|
| 142 |
+
f"path: {output_root.as_posix()}",
|
| 143 |
+
"train: images/train",
|
| 144 |
+
"val: images/val",
|
| 145 |
+
"test: images/test",
|
| 146 |
+
"names:",
|
| 147 |
+
" 0: aquaculture",
|
| 148 |
+
"",
|
| 149 |
+
]
|
| 150 |
+
),
|
| 151 |
+
encoding="utf-8",
|
| 152 |
+
)
|
| 153 |
+
(output_root / "dataset_summary.json").write_text(json.dumps(stats, indent=2, ensure_ascii=False), encoding="utf-8")
|
| 154 |
+
print(json.dumps(stats, indent=2, ensure_ascii=False))
|
| 155 |
+
|
| 156 |
+
|
| 157 |
+
if __name__ == "__main__":
|
| 158 |
+
main()
|
scripts/postprocess_mask.py
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Post-process seaweed masks with no-data and conservative water constraints."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import argparse
|
| 6 |
+
from pathlib import Path
|
| 7 |
+
|
| 8 |
+
import numpy as np
|
| 9 |
+
import rasterio
|
| 10 |
+
from rasterio.windows import Window
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
def parse_args() -> argparse.Namespace:
|
| 14 |
+
parser = argparse.ArgumentParser(description=__doc__)
|
| 15 |
+
parser.add_argument("--image", required=True, help="Source 4-band fused/multispectral raster.")
|
| 16 |
+
parser.add_argument("--mask", required=True, help="Predicted binary mask raster.")
|
| 17 |
+
parser.add_argument("--output", required=True, help="Filtered output mask raster.")
|
| 18 |
+
parser.add_argument("--stripe-height", type=int, default=1024)
|
| 19 |
+
parser.add_argument("--black-threshold", type=float, default=32.0, help="Pixels with all bands <= this are no-data.")
|
| 20 |
+
parser.add_argument(
|
| 21 |
+
"--land-ndwi-threshold",
|
| 22 |
+
type=float,
|
| 23 |
+
default=-0.35,
|
| 24 |
+
help="Very conservative NDWI cutoff for obvious dry land. Lower is safer for floating algae.",
|
| 25 |
+
)
|
| 26 |
+
parser.add_argument(
|
| 27 |
+
"--land-nir-ratio",
|
| 28 |
+
type=float,
|
| 29 |
+
default=1.6,
|
| 30 |
+
help="Only suppress NDWI-low pixels when NIR is this many times brighter than green.",
|
| 31 |
+
)
|
| 32 |
+
parser.add_argument("--no-land-filter", action="store_true", help="Only remove black/no-data regions.")
|
| 33 |
+
return parser.parse_args()
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
def obvious_land_mask(tile: np.ndarray, ndwi_threshold: float, nir_ratio: float) -> np.ndarray:
|
| 37 |
+
"""Return a conservative land mask from B,G,R,NIR-like 4-band data.
|
| 38 |
+
|
| 39 |
+
This is not a substitute for an official coastline/water mask. It only removes
|
| 40 |
+
strongly land-like pixels to avoid deleting real floating algae.
|
| 41 |
+
"""
|
| 42 |
+
if tile.shape[0] < 4:
|
| 43 |
+
return np.zeros(tile.shape[1:], dtype=bool)
|
| 44 |
+
green = tile[1].astype(np.float32, copy=False)
|
| 45 |
+
nir = tile[3].astype(np.float32, copy=False)
|
| 46 |
+
ndwi = (green - nir) / (green + nir + 1e-6)
|
| 47 |
+
return (ndwi < ndwi_threshold) & (nir > green * nir_ratio)
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
def main() -> None:
|
| 51 |
+
args = parse_args()
|
| 52 |
+
image_path = Path(args.image)
|
| 53 |
+
mask_path = Path(args.mask)
|
| 54 |
+
output_path = Path(args.output)
|
| 55 |
+
output_path.parent.mkdir(parents=True, exist_ok=True)
|
| 56 |
+
|
| 57 |
+
with rasterio.open(image_path) as image_src, rasterio.open(mask_path) as mask_src:
|
| 58 |
+
if (image_src.width, image_src.height) != (mask_src.width, mask_src.height):
|
| 59 |
+
raise ValueError(
|
| 60 |
+
f"Image and mask sizes differ: image={image_src.width}x{image_src.height}, "
|
| 61 |
+
f"mask={mask_src.width}x{mask_src.height}"
|
| 62 |
+
)
|
| 63 |
+
profile = mask_src.profile.copy()
|
| 64 |
+
profile.update(count=1, dtype="uint8", compress="lzw", nodata=0)
|
| 65 |
+
|
| 66 |
+
total_pixels = image_src.width * image_src.height
|
| 67 |
+
input_fg = 0
|
| 68 |
+
output_fg = 0
|
| 69 |
+
invalid_pixels = 0
|
| 70 |
+
land_pixels = 0
|
| 71 |
+
|
| 72 |
+
with rasterio.open(output_path, "w", **profile) as dst:
|
| 73 |
+
for y in range(0, image_src.height, args.stripe_height):
|
| 74 |
+
height = min(args.stripe_height, image_src.height - y)
|
| 75 |
+
window = Window(0, y, image_src.width, height)
|
| 76 |
+
image = image_src.read(window=window)
|
| 77 |
+
mask = mask_src.read(1, window=window)
|
| 78 |
+
|
| 79 |
+
predicted = mask > 0
|
| 80 |
+
valid = np.max(image, axis=0) > args.black_threshold
|
| 81 |
+
land = np.zeros(valid.shape, dtype=bool)
|
| 82 |
+
if not args.no_land_filter:
|
| 83 |
+
land = obvious_land_mask(image, args.land_ndwi_threshold, args.land_nir_ratio)
|
| 84 |
+
|
| 85 |
+
filtered = predicted & valid & ~land
|
| 86 |
+
dst.write((filtered.astype(np.uint8) * 255), 1, window=window)
|
| 87 |
+
|
| 88 |
+
input_fg += int(predicted.sum())
|
| 89 |
+
output_fg += int(filtered.sum())
|
| 90 |
+
invalid_pixels += int((~valid).sum())
|
| 91 |
+
land_pixels += int(land.sum())
|
| 92 |
+
|
| 93 |
+
print(f"image={image_path}")
|
| 94 |
+
print(f"mask={mask_path}")
|
| 95 |
+
print(f"output={output_path}")
|
| 96 |
+
print(f"total_pixels={total_pixels}")
|
| 97 |
+
print(f"input_foreground={input_fg} ratio={input_fg / total_pixels:.6f}")
|
| 98 |
+
print(f"output_foreground={output_fg} ratio={output_fg / total_pixels:.6f}")
|
| 99 |
+
print(f"removed_foreground={input_fg - output_fg} ratio={(input_fg - output_fg) / total_pixels:.6f}")
|
| 100 |
+
print(f"invalid_or_black_pixels={invalid_pixels} ratio={invalid_pixels / total_pixels:.6f}")
|
| 101 |
+
print(f"conservative_land_pixels={land_pixels} ratio={land_pixels / total_pixels:.6f}")
|
| 102 |
+
|
| 103 |
+
|
| 104 |
+
if __name__ == "__main__":
|
| 105 |
+
main()
|
scripts/run_train_2gpu.bat
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
@echo off
|
| 2 |
+
setlocal
|
| 3 |
+
cd /d D:\hutai-2
|
| 4 |
+
set CUDA_VISIBLE_DEVICES=0,1
|
| 5 |
+
set PYTHONUTF8=1
|
| 6 |
+
set USE_LIBUV=0
|
| 7 |
+
set NO_ALBUMENTATIONS_UPDATE=1
|
| 8 |
+
call D:\anaconda3\condabin\conda.bat run -n dinov3 python scripts\launch_ddp_no_libuv.py --nproc_per_node=2 --master_addr=127.0.0.1 --master_port=29502 train_seaweed_segmentation.py --config configs\train_2gpu_4090d.json --batch-size 2 --epochs 500
|
scripts/search_hf_marine_datasets.py
ADDED
|
@@ -0,0 +1,499 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Search Hugging Face for marine feature datasets and write normalized manifests.
|
| 2 |
+
|
| 3 |
+
The script does not download full datasets. It inspects dataset repository
|
| 4 |
+
metadata and file lists, then writes project-compatible manifests using hf://
|
| 5 |
+
paths so large public datasets can be reviewed before any costly download.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
from __future__ import annotations
|
| 9 |
+
|
| 10 |
+
import argparse
|
| 11 |
+
import csv
|
| 12 |
+
import json
|
| 13 |
+
import os
|
| 14 |
+
import re
|
| 15 |
+
from dataclasses import asdict, dataclass
|
| 16 |
+
from datetime import datetime, timezone
|
| 17 |
+
from pathlib import Path
|
| 18 |
+
from typing import Iterable
|
| 19 |
+
|
| 20 |
+
from huggingface_hub import HfApi
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
IMAGE_EXTS = {".png", ".jpg", ".jpeg", ".tif", ".tiff", ".jp2", ".bmp", ".webp"}
|
| 24 |
+
METADATA_EXTS = {".csv", ".json", ".jsonl", ".parquet", ".txt"}
|
| 25 |
+
ARCHIVE_EXTS = {".zip", ".tar", ".gz", ".tgz", ".7z"}
|
| 26 |
+
TRACKED_EXTS = IMAGE_EXTS | METADATA_EXTS | ARCHIVE_EXTS
|
| 27 |
+
MASK_HINTS = ("mask", "label", "labels", "gt", "annotation", "annotations", "seg", "target")
|
| 28 |
+
TRAIN_SPLITS = ("train", "training", "val", "valid", "validation", "test")
|
| 29 |
+
|
| 30 |
+
QUERIES = [
|
| 31 |
+
"seaweed",
|
| 32 |
+
"green tide",
|
| 33 |
+
"red tide",
|
| 34 |
+
"sargassum",
|
| 35 |
+
"aquaculture",
|
| 36 |
+
"ship",
|
| 37 |
+
"oil spill",
|
| 38 |
+
"sea ice",
|
| 39 |
+
"marine",
|
| 40 |
+
"ocean",
|
| 41 |
+
"remote sensing",
|
| 42 |
+
"sar ship",
|
| 43 |
+
"satellite imagery",
|
| 44 |
+
]
|
| 45 |
+
|
| 46 |
+
ELEMENT_KEYWORDS = {
|
| 47 |
+
"green_tide": ("green_tide", "greentide", "green tide", "enteromorpha", "seaweed"),
|
| 48 |
+
"red_tide": ("red_tide", "redtide", "red tide", "harmful algal", "hab"),
|
| 49 |
+
"golden_tide": ("golden_tide", "goldentide", "sargassum", "sarg"),
|
| 50 |
+
"aquaculture": ("aquaculture", "oyster", "raft", "fish cage", "fish-cage"),
|
| 51 |
+
"ship": ("ship", "sar ship"),
|
| 52 |
+
"oil_spill": ("oilspill", "oil_spill", "oil spill", "oil-spill"),
|
| 53 |
+
"sea_ice": ("seaice", "sea_ice", "sea ice", "arctic ice"),
|
| 54 |
+
}
|
| 55 |
+
|
| 56 |
+
SHIP_CONTEXT_KEYWORDS = (
|
| 57 |
+
"ship",
|
| 58 |
+
"sar",
|
| 59 |
+
"satellite",
|
| 60 |
+
"remote sensing",
|
| 61 |
+
"marine",
|
| 62 |
+
"ocean",
|
| 63 |
+
"sentinel",
|
| 64 |
+
"ais",
|
| 65 |
+
)
|
| 66 |
+
|
| 67 |
+
EXCLUDE_KEYWORDS = (
|
| 68 |
+
"medical",
|
| 69 |
+
"retinal",
|
| 70 |
+
"retina",
|
| 71 |
+
"miccai",
|
| 72 |
+
"flare",
|
| 73 |
+
"drive digital retinal",
|
| 74 |
+
"godot",
|
| 75 |
+
"shipping law",
|
| 76 |
+
"shipping orders",
|
| 77 |
+
"textual inversion",
|
| 78 |
+
"kantaicollection",
|
| 79 |
+
"waifu",
|
| 80 |
+
"anime",
|
| 81 |
+
"cancer",
|
| 82 |
+
"community health",
|
| 83 |
+
"plankton",
|
| 84 |
+
"mammal",
|
| 85 |
+
"legal",
|
| 86 |
+
)
|
| 87 |
+
|
| 88 |
+
SATELLITE_PATTERN = re.compile(r"\b(GF\d+|HY\d+|Sentinel-?1|Sentinel-?2|Landsat-?\d*|SAR)\b", re.IGNORECASE)
|
| 89 |
+
PATCH_SIZE_PATTERN = re.compile(r"(?:^|[_/\-])(?:size)?(128|256|512|1024)(?:[_/\-]|$)")
|
| 90 |
+
|
| 91 |
+
|
| 92 |
+
@dataclass
|
| 93 |
+
class HfAssetRecord:
|
| 94 |
+
asset_id: str
|
| 95 |
+
repo_id: str
|
| 96 |
+
path: str
|
| 97 |
+
hf_path: str
|
| 98 |
+
filename: str
|
| 99 |
+
suffix: str
|
| 100 |
+
role: str
|
| 101 |
+
element: str
|
| 102 |
+
satellite: str | None
|
| 103 |
+
sensor: str | None
|
| 104 |
+
patch_size: int | None
|
| 105 |
+
source_project: str
|
| 106 |
+
source_dataset: str
|
| 107 |
+
size_bytes: int | None
|
| 108 |
+
downloads: int | None
|
| 109 |
+
likes: int | None
|
| 110 |
+
license: str | None
|
| 111 |
+
tags: list[str]
|
| 112 |
+
discovered_by: list[str]
|
| 113 |
+
quality_flags: list[str]
|
| 114 |
+
|
| 115 |
+
|
| 116 |
+
@dataclass
|
| 117 |
+
class HfSampleRecord:
|
| 118 |
+
sample_id: str
|
| 119 |
+
element: str
|
| 120 |
+
task_type: str
|
| 121 |
+
image_path: str
|
| 122 |
+
mask_path: str | None
|
| 123 |
+
label_encoding: dict[str, str] | None
|
| 124 |
+
satellite: str | None
|
| 125 |
+
sensor: str | None
|
| 126 |
+
resolution_m: float | None
|
| 127 |
+
patch_size: int | None
|
| 128 |
+
bands: list[str] | None
|
| 129 |
+
band_count: int | None
|
| 130 |
+
dtype: str | None
|
| 131 |
+
fusion: dict
|
| 132 |
+
acquired_at: str | None
|
| 133 |
+
source_project: str
|
| 134 |
+
source_dataset: str
|
| 135 |
+
split: str | None
|
| 136 |
+
quality_flags: list[str]
|
| 137 |
+
notes: str
|
| 138 |
+
|
| 139 |
+
|
| 140 |
+
def parse_args() -> argparse.Namespace:
|
| 141 |
+
parser = argparse.ArgumentParser(description=__doc__)
|
| 142 |
+
parser.add_argument("--output-root", required=True)
|
| 143 |
+
parser.add_argument("--author", default="cuibinge", help="HF namespace to always include.")
|
| 144 |
+
parser.add_argument("--limit-per-query", type=int, default=20)
|
| 145 |
+
parser.add_argument("--max-repos", type=int, default=80)
|
| 146 |
+
parser.add_argument("--max-files-per-repo", type=int, default=5000)
|
| 147 |
+
parser.add_argument("--include-low-confidence", action="store_true")
|
| 148 |
+
return parser.parse_args()
|
| 149 |
+
|
| 150 |
+
|
| 151 |
+
def normalize_text(value: str) -> str:
|
| 152 |
+
return value.replace("_", " ").replace("-", " ").replace("/", " ").lower()
|
| 153 |
+
|
| 154 |
+
|
| 155 |
+
def infer_element(*parts: str) -> str:
|
| 156 |
+
text = normalize_text(" ".join(part for part in parts if part))
|
| 157 |
+
if "vessel" in text and all(keyword not in text for keyword in SHIP_CONTEXT_KEYWORDS):
|
| 158 |
+
return "unknown"
|
| 159 |
+
for element, keywords in ELEMENT_KEYWORDS.items():
|
| 160 |
+
if any(keyword in text for keyword in keywords):
|
| 161 |
+
return element
|
| 162 |
+
if "vessel" in text and any(keyword in text for keyword in SHIP_CONTEXT_KEYWORDS):
|
| 163 |
+
return "ship"
|
| 164 |
+
return "unknown"
|
| 165 |
+
|
| 166 |
+
|
| 167 |
+
def infer_role(path: str) -> str:
|
| 168 |
+
lower = path.lower()
|
| 169 |
+
stem = Path(path).stem.lower()
|
| 170 |
+
suffix = Path(path).suffix.lower()
|
| 171 |
+
if suffix in ARCHIVE_EXTS:
|
| 172 |
+
return "archive"
|
| 173 |
+
if suffix in METADATA_EXTS:
|
| 174 |
+
if any(hint in lower or hint in stem for hint in MASK_HINTS) or stem in {"train", "val", "test"}:
|
| 175 |
+
return "annotation_table"
|
| 176 |
+
return "metadata"
|
| 177 |
+
if any(hint in lower or hint in stem for hint in MASK_HINTS):
|
| 178 |
+
return "mask"
|
| 179 |
+
return "image"
|
| 180 |
+
|
| 181 |
+
|
| 182 |
+
def infer_satellite(*parts: str) -> str | None:
|
| 183 |
+
match = SATELLITE_PATTERN.search(" ".join(parts))
|
| 184 |
+
return match.group(1).upper().replace("-", "") if match else None
|
| 185 |
+
|
| 186 |
+
|
| 187 |
+
def infer_sensor(*parts: str) -> str | None:
|
| 188 |
+
upper = " ".join(parts).upper()
|
| 189 |
+
for sensor in ("PMS", "MUX", "MSS", "PAN", "WFV", "SAR", "MSI", "OLI"):
|
| 190 |
+
if sensor in upper:
|
| 191 |
+
return sensor
|
| 192 |
+
return None
|
| 193 |
+
|
| 194 |
+
|
| 195 |
+
def infer_patch_size(path: str) -> int | None:
|
| 196 |
+
match = PATCH_SIZE_PATTERN.search(path)
|
| 197 |
+
return int(match.group(1)) if match else None
|
| 198 |
+
|
| 199 |
+
|
| 200 |
+
def infer_split(path: str) -> str | None:
|
| 201 |
+
parts = {part.lower() for part in Path(path).parts}
|
| 202 |
+
for split in TRAIN_SPLITS:
|
| 203 |
+
if split in parts:
|
| 204 |
+
return "val" if split in {"valid", "validation"} else "train" if split == "training" else split
|
| 205 |
+
return None
|
| 206 |
+
|
| 207 |
+
|
| 208 |
+
def infer_license(tags: list[str]) -> str | None:
|
| 209 |
+
for tag in tags:
|
| 210 |
+
if tag.startswith("license:"):
|
| 211 |
+
return tag.split(":", 1)[1]
|
| 212 |
+
return None
|
| 213 |
+
|
| 214 |
+
|
| 215 |
+
def infer_fusion(repo_id: str, path: str, sensor: str | None) -> dict:
|
| 216 |
+
lower = f"{repo_id}/{path}".lower()
|
| 217 |
+
if "fuse" in lower or "fusion" in lower:
|
| 218 |
+
state = "fused_product"
|
| 219 |
+
method = "unknown_vendor_product"
|
| 220 |
+
persisted = True
|
| 221 |
+
elif sensor in {"SAR", "MSI", "OLI"}:
|
| 222 |
+
state = "none"
|
| 223 |
+
method = "none"
|
| 224 |
+
persisted = False
|
| 225 |
+
else:
|
| 226 |
+
state = "unknown"
|
| 227 |
+
method = "unknown"
|
| 228 |
+
persisted = False
|
| 229 |
+
return {
|
| 230 |
+
"state": state,
|
| 231 |
+
"method": method,
|
| 232 |
+
"sources": [{"role": "hf_dataset_file", "path": f"hf://datasets/{repo_id}/{path}", "resolution_m": None}],
|
| 233 |
+
"target_resolution_m": None,
|
| 234 |
+
"native_multispectral_resolution_m": None,
|
| 235 |
+
"persisted": persisted,
|
| 236 |
+
"reproducible": False,
|
| 237 |
+
"spectral_preservation": "unknown",
|
| 238 |
+
"notes": "Inferred from Hugging Face repository metadata; verify before training.",
|
| 239 |
+
}
|
| 240 |
+
|
| 241 |
+
|
| 242 |
+
def safe_id(value: str) -> str:
|
| 243 |
+
return re.sub(r"[^A-Za-z0-9]+", "_", value).strip("_").lower()[:180]
|
| 244 |
+
|
| 245 |
+
|
| 246 |
+
def dataset_search(api: HfApi, author: str, limit_per_query: int, max_repos: int) -> dict[str, dict]:
|
| 247 |
+
repos: dict[str, dict] = {}
|
| 248 |
+
|
| 249 |
+
def add_repo(dataset, reason: str) -> None:
|
| 250 |
+
entry = repos.setdefault(dataset.id, {"dataset": dataset, "reasons": []})
|
| 251 |
+
if reason not in entry["reasons"]:
|
| 252 |
+
entry["reasons"].append(reason)
|
| 253 |
+
|
| 254 |
+
for dataset in api.list_datasets(author=author, full=True):
|
| 255 |
+
add_repo(dataset, f"author:{author}")
|
| 256 |
+
|
| 257 |
+
for query in QUERIES:
|
| 258 |
+
for dataset in api.list_datasets(search=query, limit=limit_per_query, full=True):
|
| 259 |
+
add_repo(dataset, f"query:{query}")
|
| 260 |
+
if len(repos) >= max_repos:
|
| 261 |
+
return repos
|
| 262 |
+
return repos
|
| 263 |
+
|
| 264 |
+
|
| 265 |
+
def relevant_repo(repo_id: str, tags: list[str], reasons: list[str]) -> bool:
|
| 266 |
+
text = normalize_text(" ".join([repo_id, " ".join(tags), " ".join(reasons)]))
|
| 267 |
+
if any(keyword in text for keyword in EXCLUDE_KEYWORDS):
|
| 268 |
+
return False
|
| 269 |
+
return any(keyword in text for keywords in ELEMENT_KEYWORDS.values() for keyword in keywords) or any(
|
| 270 |
+
token in text for token in ("marine", "ocean", "remote sensing", "satellite", "sar", "coast", "sea land")
|
| 271 |
+
)
|
| 272 |
+
|
| 273 |
+
|
| 274 |
+
def iter_siblings(api: HfApi, repo_id: str, max_files: int) -> Iterable:
|
| 275 |
+
info = api.dataset_info(repo_id=repo_id, files_metadata=True)
|
| 276 |
+
for idx, sibling in enumerate(info.siblings or []):
|
| 277 |
+
if idx >= max_files:
|
| 278 |
+
break
|
| 279 |
+
yield sibling
|
| 280 |
+
|
| 281 |
+
|
| 282 |
+
def write_jsonl(path: Path, rows: Iterable[dict]) -> None:
|
| 283 |
+
with path.open("w", encoding="utf-8") as f:
|
| 284 |
+
for row in rows:
|
| 285 |
+
f.write(json.dumps(row, ensure_ascii=False) + "\n")
|
| 286 |
+
|
| 287 |
+
|
| 288 |
+
def main() -> None:
|
| 289 |
+
args = parse_args()
|
| 290 |
+
output_root = Path(args.output_root)
|
| 291 |
+
manifest_dir = output_root / "manifests"
|
| 292 |
+
report_dir = output_root / "reports"
|
| 293 |
+
manifest_dir.mkdir(parents=True, exist_ok=True)
|
| 294 |
+
report_dir.mkdir(parents=True, exist_ok=True)
|
| 295 |
+
|
| 296 |
+
api = HfApi(token=os.environ.get("HF_TOKEN"))
|
| 297 |
+
repos = dataset_search(api, args.author, args.limit_per_query, args.max_repos)
|
| 298 |
+
|
| 299 |
+
assets: list[HfAssetRecord] = []
|
| 300 |
+
repo_rows: list[dict] = []
|
| 301 |
+
|
| 302 |
+
for repo_id, entry in sorted(repos.items()):
|
| 303 |
+
dataset = entry["dataset"]
|
| 304 |
+
tags = list(getattr(dataset, "tags", []) or [])
|
| 305 |
+
reasons = entry["reasons"]
|
| 306 |
+
if not args.include_low_confidence and not relevant_repo(repo_id, tags, reasons):
|
| 307 |
+
continue
|
| 308 |
+
|
| 309 |
+
try:
|
| 310 |
+
siblings = list(iter_siblings(api, repo_id, args.max_files_per_repo))
|
| 311 |
+
except Exception as exc: # noqa: BLE001 - keep discovery resilient.
|
| 312 |
+
repo_rows.append({"repo_id": repo_id, "status": "error", "error": str(exc), "reasons": ";".join(reasons)})
|
| 313 |
+
continue
|
| 314 |
+
|
| 315 |
+
image_count = 0
|
| 316 |
+
asset_count = 0
|
| 317 |
+
for sibling in siblings:
|
| 318 |
+
rel_path = sibling.rfilename
|
| 319 |
+
suffix = Path(rel_path).suffix.lower()
|
| 320 |
+
if suffix not in TRACKED_EXTS:
|
| 321 |
+
continue
|
| 322 |
+
role = infer_role(rel_path)
|
| 323 |
+
if suffix in IMAGE_EXTS:
|
| 324 |
+
image_count += 1
|
| 325 |
+
element = infer_element(repo_id, rel_path, " ".join(tags))
|
| 326 |
+
sensor = infer_sensor(repo_id, rel_path, " ".join(tags))
|
| 327 |
+
flags: list[str] = []
|
| 328 |
+
if element == "unknown":
|
| 329 |
+
flags.append("unknown_element")
|
| 330 |
+
if role in {"mask", "annotation_table"}:
|
| 331 |
+
flags.append("mask_asset")
|
| 332 |
+
assets.append(
|
| 333 |
+
HfAssetRecord(
|
| 334 |
+
asset_id=safe_id(f"{repo_id}_{rel_path}"),
|
| 335 |
+
repo_id=repo_id,
|
| 336 |
+
path=rel_path,
|
| 337 |
+
hf_path=f"hf://datasets/{repo_id}/{rel_path}",
|
| 338 |
+
filename=Path(rel_path).name,
|
| 339 |
+
suffix=suffix,
|
| 340 |
+
role=role,
|
| 341 |
+
element=element,
|
| 342 |
+
satellite=infer_satellite(repo_id, rel_path, " ".join(tags)),
|
| 343 |
+
sensor=sensor,
|
| 344 |
+
patch_size=infer_patch_size(rel_path),
|
| 345 |
+
source_project=repo_id.split("/", 1)[-1],
|
| 346 |
+
source_dataset=repo_id,
|
| 347 |
+
size_bytes=getattr(sibling, "size", None),
|
| 348 |
+
downloads=getattr(dataset, "downloads", None),
|
| 349 |
+
likes=getattr(dataset, "likes", None),
|
| 350 |
+
license=infer_license(tags),
|
| 351 |
+
tags=tags,
|
| 352 |
+
discovered_by=reasons,
|
| 353 |
+
quality_flags=flags,
|
| 354 |
+
)
|
| 355 |
+
)
|
| 356 |
+
asset_count += 1
|
| 357 |
+
|
| 358 |
+
repo_rows.append(
|
| 359 |
+
{
|
| 360 |
+
"repo_id": repo_id,
|
| 361 |
+
"status": "ok",
|
| 362 |
+
"reasons": ";".join(reasons),
|
| 363 |
+
"downloads": getattr(dataset, "downloads", None),
|
| 364 |
+
"likes": getattr(dataset, "likes", None),
|
| 365 |
+
"license": infer_license(tags),
|
| 366 |
+
"tags": ";".join(tags[:20]),
|
| 367 |
+
"files_scanned": len(siblings),
|
| 368 |
+
"image_assets": image_count,
|
| 369 |
+
"normalized_assets": asset_count,
|
| 370 |
+
}
|
| 371 |
+
)
|
| 372 |
+
|
| 373 |
+
masks_by_key: dict[tuple[str, str], HfAssetRecord] = {}
|
| 374 |
+
for asset in assets:
|
| 375 |
+
if asset.role == "mask":
|
| 376 |
+
masks_by_key[(asset.repo_id, Path(asset.path).stem.lower())] = asset
|
| 377 |
+
|
| 378 |
+
samples: list[HfSampleRecord] = []
|
| 379 |
+
for asset in assets:
|
| 380 |
+
if asset.role != "image":
|
| 381 |
+
continue
|
| 382 |
+
mask = masks_by_key.get((asset.repo_id, Path(asset.path).stem.lower()))
|
| 383 |
+
flags = list(asset.quality_flags)
|
| 384 |
+
if mask is None:
|
| 385 |
+
flags.append("unpaired_hf_image")
|
| 386 |
+
if asset.element == "unknown":
|
| 387 |
+
flags.append("needs_element_review")
|
| 388 |
+
samples.append(
|
| 389 |
+
HfSampleRecord(
|
| 390 |
+
sample_id=f"hf_{safe_id(asset.repo_id)}_{asset.asset_id}",
|
| 391 |
+
element=asset.element,
|
| 392 |
+
task_type="semantic_segmentation" if mask else "image_asset",
|
| 393 |
+
image_path=asset.hf_path,
|
| 394 |
+
mask_path=mask.hf_path if mask else None,
|
| 395 |
+
label_encoding={"0": "background", "1": asset.element} if mask and asset.element != "unknown" else None,
|
| 396 |
+
satellite=asset.satellite,
|
| 397 |
+
sensor=asset.sensor,
|
| 398 |
+
resolution_m=None,
|
| 399 |
+
patch_size=asset.patch_size,
|
| 400 |
+
bands=None,
|
| 401 |
+
band_count=None,
|
| 402 |
+
dtype=None,
|
| 403 |
+
fusion=infer_fusion(asset.repo_id, asset.path, asset.sensor),
|
| 404 |
+
acquired_at=None,
|
| 405 |
+
source_project=asset.source_project,
|
| 406 |
+
source_dataset=asset.source_dataset,
|
| 407 |
+
split=infer_split(asset.path),
|
| 408 |
+
quality_flags=flags,
|
| 409 |
+
notes="HF-discovered asset; inspect license, labels, georeferencing, and split before training.",
|
| 410 |
+
)
|
| 411 |
+
)
|
| 412 |
+
|
| 413 |
+
ready_samples = [
|
| 414 |
+
sample
|
| 415 |
+
for sample in samples
|
| 416 |
+
if sample.element != "unknown" and sample.task_type == "semantic_segmentation" and sample.mask_path
|
| 417 |
+
]
|
| 418 |
+
review_samples = [sample for sample in samples if sample not in ready_samples]
|
| 419 |
+
|
| 420 |
+
write_jsonl(manifest_dir / "hf_assets_raw.jsonl", (asdict(asset) for asset in assets))
|
| 421 |
+
write_jsonl(manifest_dir / "samples.jsonl", (asdict(sample) for sample in samples))
|
| 422 |
+
write_jsonl(manifest_dir / "samples_ready.jsonl", (asdict(sample) for sample in ready_samples))
|
| 423 |
+
write_jsonl(manifest_dir / "samples_review.jsonl", (asdict(sample) for sample in review_samples))
|
| 424 |
+
|
| 425 |
+
with (report_dir / "hf_dataset_inventory.csv").open("w", newline="", encoding="utf-8-sig") as f:
|
| 426 |
+
fieldnames = [
|
| 427 |
+
"repo_id",
|
| 428 |
+
"status",
|
| 429 |
+
"reasons",
|
| 430 |
+
"downloads",
|
| 431 |
+
"likes",
|
| 432 |
+
"license",
|
| 433 |
+
"tags",
|
| 434 |
+
"files_scanned",
|
| 435 |
+
"image_assets",
|
| 436 |
+
"normalized_assets",
|
| 437 |
+
"error",
|
| 438 |
+
]
|
| 439 |
+
writer = csv.DictWriter(f, fieldnames=fieldnames)
|
| 440 |
+
writer.writeheader()
|
| 441 |
+
for row in repo_rows:
|
| 442 |
+
writer.writerow({name: row.get(name, "") for name in fieldnames})
|
| 443 |
+
|
| 444 |
+
summary = {
|
| 445 |
+
"created_at": datetime.now(timezone.utc).isoformat(),
|
| 446 |
+
"repo_count": len(repo_rows),
|
| 447 |
+
"asset_count": len(assets),
|
| 448 |
+
"sample_count": len(samples),
|
| 449 |
+
"ready_sample_count": len(ready_samples),
|
| 450 |
+
"review_sample_count": len(review_samples),
|
| 451 |
+
"by_element": {},
|
| 452 |
+
"ready_by_element": {},
|
| 453 |
+
"output_root": str(output_root),
|
| 454 |
+
"notes": [
|
| 455 |
+
"hf:// paths are references; full dataset download is intentionally deferred.",
|
| 456 |
+
"Unknown elements and unpaired images require manual review before training.",
|
| 457 |
+
"No external coastline or land-mask vector is assumed.",
|
| 458 |
+
],
|
| 459 |
+
}
|
| 460 |
+
for sample in samples:
|
| 461 |
+
summary["by_element"][sample.element] = summary["by_element"].get(sample.element, 0) + 1
|
| 462 |
+
for sample in ready_samples:
|
| 463 |
+
summary["ready_by_element"][sample.element] = summary["ready_by_element"].get(sample.element, 0) + 1
|
| 464 |
+
(report_dir / "hf_dataset_summary.json").write_text(json.dumps(summary, indent=2, ensure_ascii=False), encoding="utf-8")
|
| 465 |
+
|
| 466 |
+
md_lines = [
|
| 467 |
+
"# Hugging Face Marine Dataset Discovery",
|
| 468 |
+
"",
|
| 469 |
+
f"- Created: {summary['created_at']}",
|
| 470 |
+
f"- Repositories reviewed: {summary['repo_count']}",
|
| 471 |
+
f"- Image assets indexed: {summary['asset_count']}",
|
| 472 |
+
f"- Standardized samples written: {summary['sample_count']}",
|
| 473 |
+
f"- Training-ready samples: {summary['ready_sample_count']}",
|
| 474 |
+
f"- Review samples: {summary['review_sample_count']}",
|
| 475 |
+
"",
|
| 476 |
+
"## Samples By Element",
|
| 477 |
+
"",
|
| 478 |
+
]
|
| 479 |
+
for element, count in sorted(summary["by_element"].items()):
|
| 480 |
+
md_lines.append(f"- `{element}`: {count}")
|
| 481 |
+
md_lines.extend(["", "## Training-Ready Samples By Element", ""])
|
| 482 |
+
for element, count in sorted(summary["ready_by_element"].items()):
|
| 483 |
+
md_lines.append(f"- `{element}`: {count}")
|
| 484 |
+
md_lines.extend(
|
| 485 |
+
[
|
| 486 |
+
"",
|
| 487 |
+
"## Important Notes",
|
| 488 |
+
"",
|
| 489 |
+
"- Manifests use `hf://datasets/<repo>/<path>` references and do not imply files were downloaded.",
|
| 490 |
+
"- Licenses and label semantics must be checked before a dataset is used for training.",
|
| 491 |
+
"- Unknown or unpaired assets are kept for review, not treated as training-ready negatives.",
|
| 492 |
+
]
|
| 493 |
+
)
|
| 494 |
+
(report_dir / "hf_dataset_discovery.md").write_text("\n".join(md_lines) + "\n", encoding="utf-8")
|
| 495 |
+
print(json.dumps(summary, indent=2, ensure_ascii=False))
|
| 496 |
+
|
| 497 |
+
|
| 498 |
+
if __name__ == "__main__":
|
| 499 |
+
main()
|
scripts/train_2gpu.ps1
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
param(
|
| 2 |
+
[string]$Config = "configs/train_2gpu_4090d.json",
|
| 3 |
+
[int]$BatchSize = 10,
|
| 4 |
+
[int]$Epochs = 500,
|
| 5 |
+
[string]$Weights = ""
|
| 6 |
+
)
|
| 7 |
+
|
| 8 |
+
$ErrorActionPreference = "Stop"
|
| 9 |
+
$env:CUDA_VISIBLE_DEVICES = "0,1"
|
| 10 |
+
$env:PYTHONUTF8 = "1"
|
| 11 |
+
$env:USE_LIBUV = "0"
|
| 12 |
+
|
| 13 |
+
$argsList = @(
|
| 14 |
+
"scripts/launch_ddp_no_libuv.py",
|
| 15 |
+
"--nproc_per_node=2",
|
| 16 |
+
"--master_addr=127.0.0.1",
|
| 17 |
+
"--master_port=29500",
|
| 18 |
+
"train_seaweed_segmentation.py",
|
| 19 |
+
"--config", $Config,
|
| 20 |
+
"--batch-size", "$BatchSize",
|
| 21 |
+
"--epochs", "$Epochs"
|
| 22 |
+
)
|
| 23 |
+
|
| 24 |
+
if ($Weights -ne "") {
|
| 25 |
+
$argsList += @("--weights", $Weights)
|
| 26 |
+
}
|
| 27 |
+
|
| 28 |
+
python @argsList
|
scripts/train_yolo_detection.py
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Train a YOLO detector from a prepared data.yaml file."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import argparse
|
| 6 |
+
from pathlib import Path
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
def parse_args() -> argparse.Namespace:
|
| 10 |
+
parser = argparse.ArgumentParser(description=__doc__)
|
| 11 |
+
parser.add_argument("--data", required=True)
|
| 12 |
+
parser.add_argument("--model", default="yolov8n.pt")
|
| 13 |
+
parser.add_argument("--epochs", type=int, default=80)
|
| 14 |
+
parser.add_argument("--imgsz", type=int, default=640)
|
| 15 |
+
parser.add_argument("--batch", type=int, default=4)
|
| 16 |
+
parser.add_argument("--device", default="0")
|
| 17 |
+
parser.add_argument("--workers", type=int, default=2)
|
| 18 |
+
parser.add_argument("--project", required=True)
|
| 19 |
+
parser.add_argument("--name", required=True)
|
| 20 |
+
parser.add_argument("--patience", type=int, default=20)
|
| 21 |
+
parser.add_argument("--amp", action=argparse.BooleanOptionalAction, default=True)
|
| 22 |
+
return parser.parse_args()
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
def main() -> None:
|
| 26 |
+
args = parse_args()
|
| 27 |
+
from ultralytics import YOLO
|
| 28 |
+
import torch
|
| 29 |
+
|
| 30 |
+
print(f"torch={torch.__version__} cuda={torch.cuda.is_available()} devices={torch.cuda.device_count()}", flush=True)
|
| 31 |
+
print(f"data={args.data}", flush=True)
|
| 32 |
+
print(f"model={args.model}", flush=True)
|
| 33 |
+
print(f"amp={args.amp}", flush=True)
|
| 34 |
+
|
| 35 |
+
Path(args.project).mkdir(parents=True, exist_ok=True)
|
| 36 |
+
model = YOLO(args.model)
|
| 37 |
+
result = model.train(
|
| 38 |
+
data=args.data,
|
| 39 |
+
epochs=args.epochs,
|
| 40 |
+
imgsz=args.imgsz,
|
| 41 |
+
batch=args.batch,
|
| 42 |
+
device=args.device,
|
| 43 |
+
workers=args.workers,
|
| 44 |
+
project=args.project,
|
| 45 |
+
name=args.name,
|
| 46 |
+
exist_ok=True,
|
| 47 |
+
patience=args.patience,
|
| 48 |
+
amp=args.amp,
|
| 49 |
+
cache=False,
|
| 50 |
+
plots=True,
|
| 51 |
+
)
|
| 52 |
+
print(result, flush=True)
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
if __name__ == "__main__":
|
| 56 |
+
main()
|
scripts/validate_yolo_map.py
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Run Ultralytics validation and save compact mAP metrics."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import argparse
|
| 6 |
+
import json
|
| 7 |
+
from pathlib import Path
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
def parse_args() -> argparse.Namespace:
|
| 11 |
+
parser = argparse.ArgumentParser(description=__doc__)
|
| 12 |
+
parser.add_argument("--weights", required=True)
|
| 13 |
+
parser.add_argument("--data", required=True)
|
| 14 |
+
parser.add_argument("--split", default="test", choices=["train", "val", "test"])
|
| 15 |
+
parser.add_argument("--imgsz", type=int, default=640)
|
| 16 |
+
parser.add_argument("--batch", type=int, default=16)
|
| 17 |
+
parser.add_argument("--device", default="0")
|
| 18 |
+
parser.add_argument("--project", required=True)
|
| 19 |
+
parser.add_argument("--name", required=True)
|
| 20 |
+
parser.add_argument("--output", required=True)
|
| 21 |
+
return parser.parse_args()
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
def main() -> None:
|
| 25 |
+
args = parse_args()
|
| 26 |
+
from ultralytics import YOLO
|
| 27 |
+
|
| 28 |
+
model = YOLO(args.weights)
|
| 29 |
+
result = model.val(
|
| 30 |
+
data=args.data,
|
| 31 |
+
split=args.split,
|
| 32 |
+
imgsz=args.imgsz,
|
| 33 |
+
batch=args.batch,
|
| 34 |
+
device=args.device,
|
| 35 |
+
plots=True,
|
| 36 |
+
project=args.project,
|
| 37 |
+
name=args.name,
|
| 38 |
+
)
|
| 39 |
+
metrics = {key: float(value) for key, value in result.results_dict.items()}
|
| 40 |
+
output = Path(args.output)
|
| 41 |
+
output.parent.mkdir(parents=True, exist_ok=True)
|
| 42 |
+
output.write_text(json.dumps(metrics, indent=2), encoding="utf-8")
|
| 43 |
+
print(json.dumps(metrics, indent=2))
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
if __name__ == "__main__":
|
| 47 |
+
main()
|
scripts/visualize_yolo_errors.py
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Create a contact sheet for YOLO error cases with GT and prediction boxes."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import argparse
|
| 6 |
+
import json
|
| 7 |
+
from pathlib import Path
|
| 8 |
+
|
| 9 |
+
from PIL import Image, ImageDraw, ImageFont
|
| 10 |
+
|
| 11 |
+
from evaluate_yolo_detection import label_for_image, read_label
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
def parse_args() -> argparse.Namespace:
|
| 15 |
+
parser = argparse.ArgumentParser(description=__doc__)
|
| 16 |
+
parser.add_argument("--weights", required=True)
|
| 17 |
+
parser.add_argument("--metrics-json", required=True)
|
| 18 |
+
parser.add_argument("--output", required=True)
|
| 19 |
+
parser.add_argument("--imgsz", type=int, default=640)
|
| 20 |
+
parser.add_argument("--conf", type=float, default=0.25)
|
| 21 |
+
parser.add_argument("--device", default="0")
|
| 22 |
+
parser.add_argument("--limit", type=int, default=12)
|
| 23 |
+
parser.add_argument("--thumb", type=int, default=360)
|
| 24 |
+
return parser.parse_args()
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
def draw_scaled_box(draw: ImageDraw.ImageDraw, box: list[float], scale: float, color: str, width: int = 3) -> None:
|
| 28 |
+
coords = [box[0] * scale, box[1] * scale, box[2] * scale, box[3] * scale]
|
| 29 |
+
draw.rectangle(coords, outline=color, width=width)
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
def main() -> None:
|
| 33 |
+
args = parse_args()
|
| 34 |
+
from ultralytics import YOLO
|
| 35 |
+
|
| 36 |
+
metrics = json.loads(Path(args.metrics_json).read_text(encoding="utf-8"))
|
| 37 |
+
error_images = sorted(
|
| 38 |
+
metrics["error_images"],
|
| 39 |
+
key=lambda item: int(item["false_negatives"]) + int(item["false_positives"]),
|
| 40 |
+
reverse=True,
|
| 41 |
+
)[: args.limit]
|
| 42 |
+
model = YOLO(args.weights)
|
| 43 |
+
|
| 44 |
+
tiles: list[Image.Image] = []
|
| 45 |
+
for item in error_images:
|
| 46 |
+
image_path = Path(str(item["image"]))
|
| 47 |
+
image = Image.open(image_path).convert("RGB")
|
| 48 |
+
width, height = image.size
|
| 49 |
+
scale = args.thumb / max(width, height)
|
| 50 |
+
tile_size = (int(width * scale), int(height * scale))
|
| 51 |
+
tile = image.resize(tile_size)
|
| 52 |
+
draw = ImageDraw.Draw(tile)
|
| 53 |
+
|
| 54 |
+
gt_boxes = read_label(label_for_image(image_path), width, height)
|
| 55 |
+
for box in gt_boxes:
|
| 56 |
+
draw_scaled_box(draw, box, scale, "lime")
|
| 57 |
+
|
| 58 |
+
result = model.predict(
|
| 59 |
+
source=str(image_path),
|
| 60 |
+
imgsz=args.imgsz,
|
| 61 |
+
conf=args.conf,
|
| 62 |
+
device=args.device,
|
| 63 |
+
verbose=False,
|
| 64 |
+
)[0]
|
| 65 |
+
pred_boxes = result.boxes.xyxy.cpu().tolist() if result.boxes is not None else []
|
| 66 |
+
for box in pred_boxes:
|
| 67 |
+
draw_scaled_box(draw, box, scale, "red")
|
| 68 |
+
|
| 69 |
+
caption = f"FN {item['false_negatives']} FP {item['false_positives']} {image_path.name}"
|
| 70 |
+
caption_h = 34
|
| 71 |
+
panel = Image.new("RGB", (args.thumb, args.thumb + caption_h), "white")
|
| 72 |
+
panel.paste(tile, ((args.thumb - tile.width) // 2, 0))
|
| 73 |
+
panel_draw = ImageDraw.Draw(panel)
|
| 74 |
+
panel_draw.text((6, args.thumb + 8), caption[:70], fill="black", font=ImageFont.load_default())
|
| 75 |
+
tiles.append(panel)
|
| 76 |
+
|
| 77 |
+
cols = 3
|
| 78 |
+
rows = (len(tiles) + cols - 1) // cols
|
| 79 |
+
sheet = Image.new("RGB", (cols * args.thumb, rows * (args.thumb + 34)), "white")
|
| 80 |
+
for idx, tile in enumerate(tiles):
|
| 81 |
+
x = (idx % cols) * args.thumb
|
| 82 |
+
y = (idx // cols) * (args.thumb + 34)
|
| 83 |
+
sheet.paste(tile, (x, y))
|
| 84 |
+
|
| 85 |
+
output = Path(args.output)
|
| 86 |
+
output.parent.mkdir(parents=True, exist_ok=True)
|
| 87 |
+
sheet.save(output)
|
| 88 |
+
print(output)
|
| 89 |
+
|
| 90 |
+
|
| 91 |
+
if __name__ == "__main__":
|
| 92 |
+
main()
|
seaweed_segmentation_dataset.py
ADDED
|
@@ -0,0 +1,139 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Dataset utilities for seaweed binary segmentation."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
from pathlib import Path
|
| 6 |
+
|
| 7 |
+
import albumentations as A
|
| 8 |
+
import numpy as np
|
| 9 |
+
import rasterio
|
| 10 |
+
import torch
|
| 11 |
+
from PIL import Image
|
| 12 |
+
from torch.utils.data import Dataset
|
| 13 |
+
from torchvision import transforms
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
IMAGE_EXTS = {".tif", ".tiff", ".png", ".jpg", ".jpeg"}
|
| 17 |
+
MASK_EXTS = (".png", ".tif", ".tiff", ".jpg", ".jpeg")
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
class SeaweedSegmentationDataset(Dataset):
|
| 21 |
+
def __init__(self, image_dir, mask_dir, transform=None, target_size=256, use_4channel=True):
|
| 22 |
+
self.image_dir = Path(image_dir)
|
| 23 |
+
self.mask_dir = Path(mask_dir)
|
| 24 |
+
self.transform = transform
|
| 25 |
+
self.target_size = int(target_size)
|
| 26 |
+
self.use_4channel = bool(use_4channel)
|
| 27 |
+
|
| 28 |
+
if not self.image_dir.exists():
|
| 29 |
+
raise FileNotFoundError(f"Image directory not found: {self.image_dir}")
|
| 30 |
+
if not self.mask_dir.exists():
|
| 31 |
+
raise FileNotFoundError(f"Mask directory not found: {self.mask_dir}")
|
| 32 |
+
|
| 33 |
+
self.images = sorted(p.name for p in self.image_dir.iterdir() if p.is_file() and p.suffix.lower() in IMAGE_EXTS)
|
| 34 |
+
|
| 35 |
+
self.normalize_3ch = transforms.Normalize(mean=(0.430, 0.411, 0.296), std=(0.213, 0.156, 0.143))
|
| 36 |
+
self.normalize_4ch = transforms.Normalize(mean=(0.430, 0.411, 0.296, 0.350), std=(0.213, 0.156, 0.143, 0.180))
|
| 37 |
+
|
| 38 |
+
def __len__(self):
|
| 39 |
+
return len(self.images)
|
| 40 |
+
|
| 41 |
+
def find_mask_path(self, image_name: str) -> Path:
|
| 42 |
+
stem = Path(image_name).stem
|
| 43 |
+
for ext in MASK_EXTS:
|
| 44 |
+
for suffix in (ext, ext.upper()):
|
| 45 |
+
candidate = self.mask_dir / f"{stem}{suffix}"
|
| 46 |
+
if candidate.exists():
|
| 47 |
+
return candidate
|
| 48 |
+
return self.mask_dir / f"{stem}.png"
|
| 49 |
+
|
| 50 |
+
@staticmethod
|
| 51 |
+
def read_raster(path: Path) -> np.ndarray:
|
| 52 |
+
if path.suffix.lower() in {".tif", ".tiff"}:
|
| 53 |
+
with rasterio.open(path) as src:
|
| 54 |
+
return np.transpose(src.read(), (1, 2, 0))
|
| 55 |
+
image = Image.open(path)
|
| 56 |
+
return np.asarray(image)
|
| 57 |
+
|
| 58 |
+
@staticmethod
|
| 59 |
+
def extract_432_bands(image: np.ndarray) -> np.ndarray:
|
| 60 |
+
if image.ndim == 2:
|
| 61 |
+
image = image[:, :, None]
|
| 62 |
+
if image.shape[2] >= 4:
|
| 63 |
+
return image[:, :, [3, 2, 1]]
|
| 64 |
+
output = image[:, :, : min(3, image.shape[2])]
|
| 65 |
+
while output.shape[2] < 3:
|
| 66 |
+
output = np.concatenate([output, output[:, :, -1:]], axis=2)
|
| 67 |
+
return output
|
| 68 |
+
|
| 69 |
+
@staticmethod
|
| 70 |
+
def read_mask(path: Path, fallback_shape: tuple[int, int]) -> np.ndarray:
|
| 71 |
+
if not path.exists():
|
| 72 |
+
return np.zeros(fallback_shape, dtype=np.uint8)
|
| 73 |
+
if path.suffix.lower() in {".tif", ".tiff"}:
|
| 74 |
+
with rasterio.open(path) as src:
|
| 75 |
+
mask = src.read(1)
|
| 76 |
+
else:
|
| 77 |
+
mask = np.asarray(Image.open(path).convert("L"))
|
| 78 |
+
return (mask > 127).astype(np.uint8)
|
| 79 |
+
|
| 80 |
+
def __getitem__(self, idx):
|
| 81 |
+
img_name = self.images[idx]
|
| 82 |
+
img_path = self.image_dir / img_name
|
| 83 |
+
mask_path = self.find_mask_path(img_name)
|
| 84 |
+
|
| 85 |
+
try:
|
| 86 |
+
image = self.read_raster(img_path)
|
| 87 |
+
if image.ndim == 2:
|
| 88 |
+
image = image[:, :, None]
|
| 89 |
+
mask = self.read_mask(mask_path, image.shape[:2])
|
| 90 |
+
|
| 91 |
+
if self.use_4channel and image.shape[2] >= 4:
|
| 92 |
+
processed = image[:, :, :4]
|
| 93 |
+
processed = torch.from_numpy(processed.astype(np.float32))
|
| 94 |
+
if processed.max() > 1.0:
|
| 95 |
+
processed = processed / 65535.0
|
| 96 |
+
processed = self.normalize_4ch(processed.permute(2, 0, 1))
|
| 97 |
+
else:
|
| 98 |
+
processed = self.extract_432_bands(image)
|
| 99 |
+
processed = torch.from_numpy(processed.astype(np.float32))
|
| 100 |
+
if processed.max() > 1.0:
|
| 101 |
+
processed = processed / 65535.0
|
| 102 |
+
processed = self.normalize_3ch(processed.permute(2, 0, 1))
|
| 103 |
+
|
| 104 |
+
mask_tensor = torch.from_numpy(mask).long()
|
| 105 |
+
|
| 106 |
+
if self.target_size != processed.shape[1] or self.target_size != processed.shape[2]:
|
| 107 |
+
processed = transforms.Resize((self.target_size, self.target_size), antialias=True)(processed)
|
| 108 |
+
mask_tensor = transforms.Resize(
|
| 109 |
+
(self.target_size, self.target_size),
|
| 110 |
+
interpolation=transforms.InterpolationMode.NEAREST,
|
| 111 |
+
)(mask_tensor.unsqueeze(0)).squeeze(0)
|
| 112 |
+
|
| 113 |
+
if self.transform:
|
| 114 |
+
augmented = self.transform(image=processed.permute(1, 2, 0).numpy(), mask=mask_tensor.numpy())
|
| 115 |
+
processed = torch.from_numpy(augmented["image"]).permute(2, 0, 1).float()
|
| 116 |
+
mask_tensor = torch.from_numpy(augmented["mask"]).long()
|
| 117 |
+
|
| 118 |
+
return {"image": processed, "mask": mask_tensor, "filename": img_name}
|
| 119 |
+
except Exception as exc:
|
| 120 |
+
print(f"Error loading {img_name}: {exc}")
|
| 121 |
+
return None
|
| 122 |
+
|
| 123 |
+
|
| 124 |
+
def get_train_transforms(target_size=256, use_4channel=True):
|
| 125 |
+
return A.Compose(
|
| 126 |
+
[
|
| 127 |
+
A.Resize(target_size, target_size),
|
| 128 |
+
A.HorizontalFlip(p=0.5),
|
| 129 |
+
A.VerticalFlip(p=0.3),
|
| 130 |
+
A.RandomRotate90(p=0.3),
|
| 131 |
+
A.ShiftScaleRotate(shift_limit=0.1, scale_limit=0.1, rotate_limit=15, p=0.5),
|
| 132 |
+
A.RandomBrightnessContrast(p=0.3),
|
| 133 |
+
A.GaussNoise(p=0.2),
|
| 134 |
+
]
|
| 135 |
+
)
|
| 136 |
+
|
| 137 |
+
|
| 138 |
+
def get_val_transforms(target_size=256, use_4channel=True):
|
| 139 |
+
return None
|
seaweed_segmentation_improved_epoch500/config.json
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"train_image_dir": "data/train/images",
|
| 3 |
+
"train_mask_dir": "data/train/masks",
|
| 4 |
+
"val_image_dir": "data/val/images",
|
| 5 |
+
"val_mask_dir": "data/val/masks",
|
| 6 |
+
"num_classes": 2,
|
| 7 |
+
"backbone_name": "dinov3_vitl16",
|
| 8 |
+
"pretrained": true,
|
| 9 |
+
"backbone_weights": "dinov3_vitl16_pretrain_sat493m-eadcf0ff.pth",
|
| 10 |
+
"use_4channel": true,
|
| 11 |
+
"image_size": 256,
|
| 12 |
+
"batch_size": 8,
|
| 13 |
+
"num_epochs": 500,
|
| 14 |
+
"backbone_lr": 1e-05,
|
| 15 |
+
"decoder_lr": 0.0001,
|
| 16 |
+
"weight_decay": 0.0001,
|
| 17 |
+
"scheduler": "cosine",
|
| 18 |
+
"grad_clip": 1.0,
|
| 19 |
+
"focal_alpha": [
|
| 20 |
+
1.0,
|
| 21 |
+
3.0
|
| 22 |
+
],
|
| 23 |
+
"focal_gamma": 2,
|
| 24 |
+
"dice_weight": 0.5,
|
| 25 |
+
"focal_weight": 1.0,
|
| 26 |
+
"num_workers": 0,
|
| 27 |
+
"output_dir": "outputs/seaweed_segmentation_improved_epoch500",
|
| 28 |
+
"plot_interval": 10,
|
| 29 |
+
"device": "cuda",
|
| 30 |
+
"background_weight": 1.0,
|
| 31 |
+
"foreground_weight": 3.0
|
| 32 |
+
}
|
test-org.xml
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<?xml version="1.0" encoding="utf-8" ?>
|
| 2 |
+
<Job_Order>
|
| 3 |
+
<!--配置参数-->
|
| 4 |
+
<Conf>
|
| 5 |
+
<!--处理组件名称-->
|
| 6 |
+
<Processor_Name>coral</Processor_Name>
|
| 7 |
+
<!--处理组件版本号-->
|
| 8 |
+
<Version>v01.01</Version>
|
| 9 |
+
<!--订单号-->
|
| 10 |
+
<Order_ID>0000029777</Order_ID>
|
| 11 |
+
<!--工作目录-->
|
| 12 |
+
<working_directory>E:\yqj\code\GF\coral\v01.01\processing</working_directory>
|
| 13 |
+
<!--标准日志位置-->
|
| 14 |
+
<Stdout_Log_Level>Logs</Stdout_Log_Level>
|
| 15 |
+
<!--错误日志位置-->
|
| 16 |
+
<Stderr_Log_Level>Logs</Stderr_Log_Level>
|
| 17 |
+
<!--是否测试组件-->
|
| 18 |
+
<Test>false</Test>
|
| 19 |
+
<!--arcpy地址-->
|
| 20 |
+
<Proc1>C:\Python27\ArcGIS10.3\python.exe</Proc1>
|
| 21 |
+
<!--配置文件-->
|
| 22 |
+
<Config_Files>
|
| 23 |
+
<!---->
|
| 24 |
+
<Conf_File_Name>null</Conf_File_Name>
|
| 25 |
+
<Conf_File_Name>null</Conf_File_Name>
|
| 26 |
+
<Conf_File_Name>null</Conf_File_Name>
|
| 27 |
+
</Config_Files>
|
| 28 |
+
<!--触发时间-->
|
| 29 |
+
<Sensing_Time>
|
| 30 |
+
<Start>2021-11-19T21:10:00Z</Start>
|
| 31 |
+
<Stop>2021-11-19T21:18:00Z</Stop>
|
| 32 |
+
</Sensing_Time>
|
| 33 |
+
<!--处理组件参数-->
|
| 34 |
+
<Parameters>
|
| 35 |
+
<Parameter>
|
| 36 |
+
<Name>thematic_map_title</Name>
|
| 37 |
+
<Value>养殖区遥感监测信息提取专题产品</Value>
|
| 38 |
+
</Parameter>
|
| 39 |
+
</Parameters>
|
| 40 |
+
<!--是否生产专题图-->
|
| 41 |
+
<Proc1>True</Proc1>
|
| 42 |
+
</Conf>
|
| 43 |
+
<!--执行进程-->
|
| 44 |
+
<Procs>
|
| 45 |
+
<!--单个进程-->
|
| 46 |
+
<Proc>
|
| 47 |
+
<!--任务名称-->
|
| 48 |
+
<Task_Name>RAA</Task_Name>
|
| 49 |
+
<!--任务版本-->
|
| 50 |
+
<Task_Version>01.00</Task_Version>
|
| 51 |
+
<Inputs>
|
| 52 |
+
<!--输入数据-->
|
| 53 |
+
<Input>
|
| 54 |
+
<!--文件类型-->
|
| 55 |
+
<File_Type>1</File_Type>
|
| 56 |
+
<File_Names>
|
| 57 |
+
<!--多光谱原始影像-->
|
| 58 |
+
<File_Name>G:\webGis\line\GF1_PMS1_E119.3_N35.0_20160818_L1A0001770060-MSS1.tiff</File_Name>
|
| 59 |
+
|
| 60 |
+
</File_Names>
|
| 61 |
+
</Input>
|
| 62 |
+
</Inputs>
|
| 63 |
+
<Outputs>
|
| 64 |
+
<!--输出数据-->
|
| 65 |
+
<Output>
|
| 66 |
+
<!--只包含输出路径+文件名,不带扩展名-->
|
| 67 |
+
<File_Name>G:\webGis\line\result</File_Name>
|
| 68 |
+
</Output>
|
| 69 |
+
|
| 70 |
+
</Outputs>
|
| 71 |
+
</Proc>
|
| 72 |
+
</Procs>
|
| 73 |
+
</Job_Order>
|
test.xml
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
<JSONObject><Conf><Processor_Name>coral</Processor_Name><Version>v01.01</Version><Order_ID>0000029777</Order_ID><working_directory>E:\yqj\code\GF\coral\v01.01\processing</working_directory><Stdout_Log_Level>Logs</Stdout_Log_Level><Stderr_Log_Level>Logs</Stderr_Log_Level><Test>false</Test><Proc1>C:\Python27\ArcGIS10.3\python.exe</Proc1><Proc1>True</Proc1><Config_Files><Conf_File_Name>null</Conf_File_Name><Conf_File_Name>null</Conf_File_Name><Conf_File_Name>null</Conf_File_Name></Config_Files><Sensing_Time><Start>2021-11-19T21:10:00Z</Start><Stop>2021-11-19T21:18:00Z</Stop></Sensing_Time><Parameters><Parameter><Name>thematic_map_title</Name><Value>养殖区遥感监测信息提取专题产品</Value></Parameter></Parameters></Conf><Procs><Proc><Task_Name>RAA</Task_Name><Task_Version>01.00</Task_Version><Inputs><Input><File_Type>1</File_Type><File_Names><File_Name>F:/skzh/uploadPath/upload/temp/GF1_WFV1_E122.2_N34.7_20210525_L1A0005664506.tiff</File_Name></File_Names></Input></Inputs><Outputs><Output><File_Name>F:/Results/admin/GF1/157</File_Name></Output></Outputs></Proc></Procs></JSONObject>
|
train_config.json
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"train_image_dir": "data/train/images",
|
| 3 |
+
"train_mask_dir": "data/train/masks",
|
| 4 |
+
"val_image_dir": "data/val/images",
|
| 5 |
+
"val_mask_dir": "data/val/masks",
|
| 6 |
+
"num_classes": 2,
|
| 7 |
+
"backbone_name": "dinov3_vitl16",
|
| 8 |
+
"pretrained": true,
|
| 9 |
+
"backbone_weights": "dinov3_vitl16_pretrain_sat493m-eadcf0ff.pth",
|
| 10 |
+
"use_4channel": true,
|
| 11 |
+
"image_size": 256,
|
| 12 |
+
"batch_size": 8,
|
| 13 |
+
"num_epochs": 200,
|
| 14 |
+
"backbone_lr": 1e-05,
|
| 15 |
+
"decoder_lr": 0.0001,
|
| 16 |
+
"weight_decay": 0.0001,
|
| 17 |
+
"scheduler": "cosine",
|
| 18 |
+
"grad_clip": 1.0,
|
| 19 |
+
"focal_alpha": 1,
|
| 20 |
+
"focal_gamma": 2,
|
| 21 |
+
"dice_weight": 0.5,
|
| 22 |
+
"focal_weight": 1.0,
|
| 23 |
+
"num_workers": 0,
|
| 24 |
+
"output_dir": "outputs/seaweed_segmentation_full_epoch200",
|
| 25 |
+
"plot_interval": 10,
|
| 26 |
+
"device": "cuda"
|
| 27 |
+
}
|
train_config_improved.json
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"train_image_dir": "data/train/images",
|
| 3 |
+
"train_mask_dir": "data/train/masks",
|
| 4 |
+
"val_image_dir": "data/val/images",
|
| 5 |
+
"val_mask_dir": "data/val/masks",
|
| 6 |
+
"num_classes": 2,
|
| 7 |
+
"backbone_name": "dinov3_vitl16",
|
| 8 |
+
"pretrained": true,
|
| 9 |
+
"backbone_weights": "dinov3_vitl16_pretrain_sat493m-eadcf0ff.pth",
|
| 10 |
+
"use_4channel": true,
|
| 11 |
+
"image_size": 256,
|
| 12 |
+
"batch_size": 8,
|
| 13 |
+
"num_epochs": 500,
|
| 14 |
+
"backbone_lr": 1e-05,
|
| 15 |
+
"decoder_lr": 0.0001,
|
| 16 |
+
"weight_decay": 0.0001,
|
| 17 |
+
"scheduler": "cosine",
|
| 18 |
+
"grad_clip": 1.0,
|
| 19 |
+
"focal_alpha": [1.0, 3.0],
|
| 20 |
+
"focal_gamma": 2,
|
| 21 |
+
"dice_weight": 0.5,
|
| 22 |
+
"focal_weight": 1.0,
|
| 23 |
+
"background_weight": 1.0,
|
| 24 |
+
"foreground_weight": 3.0,
|
| 25 |
+
"num_workers": 0,
|
| 26 |
+
"output_dir": "outputs/seaweed_segmentation_improved_epoch500",
|
| 27 |
+
"plot_interval": 10,
|
| 28 |
+
"device": "cuda"
|
| 29 |
+
}
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
|
train_seaweed_segmentation.py
ADDED
|
@@ -0,0 +1,537 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Train DINOv3 + DeepLabV3+ for seaweed segmentation.
|
| 2 |
+
|
| 3 |
+
The script supports single GPU, torchrun DDP, AMP, gradient accumulation,
|
| 4 |
+
checkpoint resume, and rank-0-only logging/checkpointing.
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
from __future__ import annotations
|
| 8 |
+
|
| 9 |
+
import argparse
|
| 10 |
+
import json
|
| 11 |
+
import os
|
| 12 |
+
import random
|
| 13 |
+
from datetime import datetime
|
| 14 |
+
from pathlib import Path
|
| 15 |
+
from typing import Any
|
| 16 |
+
|
| 17 |
+
import matplotlib
|
| 18 |
+
|
| 19 |
+
matplotlib.use("Agg")
|
| 20 |
+
import matplotlib.pyplot as plt
|
| 21 |
+
import numpy as np
|
| 22 |
+
import torch
|
| 23 |
+
import torch.distributed as dist
|
| 24 |
+
import torch.nn as nn
|
| 25 |
+
import torch.optim as optim
|
| 26 |
+
from torch.cuda.amp import GradScaler, autocast
|
| 27 |
+
from torch.nn.parallel import DistributedDataParallel as DDP
|
| 28 |
+
from torch.utils.data import DataLoader
|
| 29 |
+
from torch.utils.data.distributed import DistributedSampler
|
| 30 |
+
from tqdm import tqdm
|
| 31 |
+
|
| 32 |
+
from dinov3_deeplabv3plus import DinoV3DeepLabV3Plus, SeaweedSegmentationLoss
|
| 33 |
+
from seaweed_segmentation_dataset import SeaweedSegmentationDataset, get_train_transforms, get_val_transforms
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
def load_json(path: str | Path) -> dict[str, Any]:
|
| 37 |
+
with open(path, "r", encoding="utf-8") as f:
|
| 38 |
+
return json.load(f)
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
def is_dist() -> bool:
|
| 42 |
+
return dist.is_available() and dist.is_initialized()
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
def rank() -> int:
|
| 46 |
+
return dist.get_rank() if is_dist() else 0
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
def world_size() -> int:
|
| 50 |
+
return dist.get_world_size() if is_dist() else 1
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
def is_main_process() -> bool:
|
| 54 |
+
return rank() == 0
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
def setup_distributed() -> tuple[torch.device, int]:
|
| 58 |
+
local_rank = int(os.environ.get("LOCAL_RANK", "0"))
|
| 59 |
+
distributed = int(os.environ.get("WORLD_SIZE", "1")) > 1
|
| 60 |
+
|
| 61 |
+
if distributed:
|
| 62 |
+
backend = "nccl" if os.name != "nt" else "gloo"
|
| 63 |
+
rank_id = int(os.environ["RANK"])
|
| 64 |
+
size = int(os.environ["WORLD_SIZE"])
|
| 65 |
+
if os.environ.get("USE_LIBUV", "1") == "0":
|
| 66 |
+
from datetime import timedelta
|
| 67 |
+
|
| 68 |
+
store = dist.TCPStore(
|
| 69 |
+
os.environ.get("MASTER_ADDR", "127.0.0.1"),
|
| 70 |
+
int(os.environ.get("MASTER_PORT", "29500")),
|
| 71 |
+
world_size=size,
|
| 72 |
+
is_master=rank_id == 0,
|
| 73 |
+
timeout=timedelta(seconds=180),
|
| 74 |
+
use_libuv=False,
|
| 75 |
+
)
|
| 76 |
+
dist.init_process_group(backend=backend, store=store, rank=rank_id, world_size=size)
|
| 77 |
+
else:
|
| 78 |
+
dist.init_process_group(backend=backend)
|
| 79 |
+
|
| 80 |
+
if torch.cuda.is_available():
|
| 81 |
+
if distributed:
|
| 82 |
+
torch.cuda.set_device(local_rank)
|
| 83 |
+
device = torch.device(f"cuda:{local_rank}")
|
| 84 |
+
else:
|
| 85 |
+
device = torch.device("cpu")
|
| 86 |
+
return device, local_rank
|
| 87 |
+
|
| 88 |
+
|
| 89 |
+
def cleanup_distributed() -> None:
|
| 90 |
+
if is_dist():
|
| 91 |
+
dist.barrier()
|
| 92 |
+
dist.destroy_process_group()
|
| 93 |
+
|
| 94 |
+
|
| 95 |
+
def seed_everything(seed: int) -> None:
|
| 96 |
+
random.seed(seed)
|
| 97 |
+
np.random.seed(seed)
|
| 98 |
+
torch.manual_seed(seed)
|
| 99 |
+
torch.cuda.manual_seed_all(seed)
|
| 100 |
+
|
| 101 |
+
|
| 102 |
+
def resolve_path(path: str | None, roots: list[str]) -> str | None:
|
| 103 |
+
if not path:
|
| 104 |
+
return path
|
| 105 |
+
candidate = Path(path)
|
| 106 |
+
if candidate.exists():
|
| 107 |
+
return str(candidate)
|
| 108 |
+
for root in roots:
|
| 109 |
+
rooted = Path(root) / path
|
| 110 |
+
if rooted.exists():
|
| 111 |
+
return str(rooted)
|
| 112 |
+
return path
|
| 113 |
+
|
| 114 |
+
|
| 115 |
+
def collate_skip_none(batch: list[dict[str, Any] | None]) -> dict[str, Any]:
|
| 116 |
+
valid = [item for item in batch if item is not None]
|
| 117 |
+
if not valid:
|
| 118 |
+
raise RuntimeError("All samples in this batch failed to load.")
|
| 119 |
+
images = torch.stack([item["image"] for item in valid], dim=0)
|
| 120 |
+
masks = torch.stack([item["mask"] for item in valid], dim=0)
|
| 121 |
+
filenames = [item["filename"] for item in valid]
|
| 122 |
+
return {"image": images, "mask": masks, "filename": filenames}
|
| 123 |
+
|
| 124 |
+
|
| 125 |
+
class SeaweedSegmentationTrainer:
|
| 126 |
+
def __init__(self, config: dict[str, Any], device: torch.device, local_rank: int):
|
| 127 |
+
self.config = config
|
| 128 |
+
self.device = device
|
| 129 |
+
self.local_rank = local_rank
|
| 130 |
+
self.use_amp = bool(config.get("amp", True)) and device.type == "cuda"
|
| 131 |
+
self.grad_accum_steps = int(config.get("grad_accum_steps", 1))
|
| 132 |
+
|
| 133 |
+
weight_roots = config.get("weight_search_roots", [])
|
| 134 |
+
config["backbone_weights"] = resolve_path(config.get("backbone_weights"), weight_roots)
|
| 135 |
+
|
| 136 |
+
seed_everything(int(config.get("seed", 42)) + rank())
|
| 137 |
+
|
| 138 |
+
model = DinoV3DeepLabV3Plus(
|
| 139 |
+
num_classes=config["num_classes"],
|
| 140 |
+
backbone_name=config["backbone_name"],
|
| 141 |
+
pretrained=config["pretrained"],
|
| 142 |
+
weights=config["backbone_weights"],
|
| 143 |
+
use_4channel=config["use_4channel"],
|
| 144 |
+
freeze_backbone=config.get("freeze_backbone", False),
|
| 145 |
+
).to(device)
|
| 146 |
+
|
| 147 |
+
if is_dist():
|
| 148 |
+
self.model = DDP(
|
| 149 |
+
model,
|
| 150 |
+
device_ids=[local_rank] if device.type == "cuda" else None,
|
| 151 |
+
find_unused_parameters=True,
|
| 152 |
+
)
|
| 153 |
+
else:
|
| 154 |
+
self.model = model
|
| 155 |
+
|
| 156 |
+
self.criterion = SeaweedSegmentationLoss(
|
| 157 |
+
num_classes=config["num_classes"],
|
| 158 |
+
focal_alpha=config.get("focal_alpha", [1.0, 2.0]),
|
| 159 |
+
focal_gamma=config.get("focal_gamma", 2),
|
| 160 |
+
dice_weight=config.get("dice_weight", 0.5),
|
| 161 |
+
focal_weight=config.get("focal_weight", 1.0),
|
| 162 |
+
background_weight=config.get("background_weight", 1.0),
|
| 163 |
+
foreground_weight=config.get("foreground_weight", 2.0),
|
| 164 |
+
).to(device)
|
| 165 |
+
|
| 166 |
+
self.optimizer = self._create_optimizer()
|
| 167 |
+
self.scheduler = self._create_scheduler()
|
| 168 |
+
self.scaler = GradScaler(enabled=self.use_amp)
|
| 169 |
+
self.train_loader, self.val_loader = self._create_data_loaders()
|
| 170 |
+
|
| 171 |
+
self.train_history = {
|
| 172 |
+
"train_loss": [],
|
| 173 |
+
"train_focal": [],
|
| 174 |
+
"train_dice": [],
|
| 175 |
+
"val_loss": [],
|
| 176 |
+
"val_focal": [],
|
| 177 |
+
"val_dice": [],
|
| 178 |
+
"val_iou": [],
|
| 179 |
+
"val_accuracy": [],
|
| 180 |
+
}
|
| 181 |
+
self.best_val_loss = float("inf")
|
| 182 |
+
self.best_val_iou = 0.0
|
| 183 |
+
self.start_epoch = 0
|
| 184 |
+
|
| 185 |
+
self.output_dir = Path(config["output_dir"])
|
| 186 |
+
if is_main_process():
|
| 187 |
+
self.output_dir.mkdir(parents=True, exist_ok=True)
|
| 188 |
+
with open(self.output_dir / "config.json", "w", encoding="utf-8") as f:
|
| 189 |
+
json.dump(config, f, indent=2, ensure_ascii=False)
|
| 190 |
+
|
| 191 |
+
if config.get("resume"):
|
| 192 |
+
self.load_checkpoint(config["resume"])
|
| 193 |
+
|
| 194 |
+
@property
|
| 195 |
+
def raw_model(self) -> nn.Module:
|
| 196 |
+
return self.model.module if isinstance(self.model, DDP) else self.model
|
| 197 |
+
|
| 198 |
+
def _create_optimizer(self):
|
| 199 |
+
raw_model = self.raw_model
|
| 200 |
+
if self.config.get("freeze_backbone", False):
|
| 201 |
+
params = raw_model.get_trainable_parameters()
|
| 202 |
+
lr = self.config.get("decoder_lr", 1e-4)
|
| 203 |
+
if is_main_process():
|
| 204 |
+
print(f"Frozen backbone mode. Trainable tensors: {len(params)}")
|
| 205 |
+
return optim.AdamW(params, lr=lr, weight_decay=self.config.get("weight_decay", 1e-4))
|
| 206 |
+
|
| 207 |
+
backbone_params = list(raw_model.get_backbone_params())
|
| 208 |
+
decoder_params = list(raw_model.get_decoder_params())
|
| 209 |
+
return optim.AdamW(
|
| 210 |
+
[
|
| 211 |
+
{"params": backbone_params, "lr": self.config.get("backbone_lr", 1e-5)},
|
| 212 |
+
{"params": decoder_params, "lr": self.config.get("decoder_lr", 1e-4)},
|
| 213 |
+
],
|
| 214 |
+
weight_decay=self.config.get("weight_decay", 1e-4),
|
| 215 |
+
)
|
| 216 |
+
|
| 217 |
+
def _create_scheduler(self):
|
| 218 |
+
scheduler_type = self.config.get("scheduler", "cosine")
|
| 219 |
+
if scheduler_type == "cosine":
|
| 220 |
+
return optim.lr_scheduler.CosineAnnealingWarmRestarts(self.optimizer, T_0=10, T_mult=2, eta_min=1e-7)
|
| 221 |
+
if scheduler_type == "step":
|
| 222 |
+
return optim.lr_scheduler.StepLR(self.optimizer, step_size=30, gamma=0.1)
|
| 223 |
+
return None
|
| 224 |
+
|
| 225 |
+
def _create_data_loaders(self):
|
| 226 |
+
use_aug = self.config.get("use_data_augmentation", True)
|
| 227 |
+
train_transform = (
|
| 228 |
+
get_train_transforms(self.config["image_size"], self.config["use_4channel"])
|
| 229 |
+
if use_aug
|
| 230 |
+
else get_val_transforms(self.config["image_size"], self.config["use_4channel"])
|
| 231 |
+
)
|
| 232 |
+
|
| 233 |
+
train_dataset = SeaweedSegmentationDataset(
|
| 234 |
+
image_dir=self.config["train_image_dir"],
|
| 235 |
+
mask_dir=self.config["train_mask_dir"],
|
| 236 |
+
transform=train_transform,
|
| 237 |
+
target_size=self.config["image_size"],
|
| 238 |
+
use_4channel=self.config["use_4channel"],
|
| 239 |
+
)
|
| 240 |
+
val_dataset = SeaweedSegmentationDataset(
|
| 241 |
+
image_dir=self.config["val_image_dir"],
|
| 242 |
+
mask_dir=self.config["val_mask_dir"],
|
| 243 |
+
transform=get_val_transforms(self.config["image_size"], self.config["use_4channel"]),
|
| 244 |
+
target_size=self.config["image_size"],
|
| 245 |
+
use_4channel=self.config["use_4channel"],
|
| 246 |
+
)
|
| 247 |
+
|
| 248 |
+
if len(train_dataset) == 0 or len(val_dataset) == 0:
|
| 249 |
+
raise RuntimeError(
|
| 250 |
+
f"Empty dataset: train={len(train_dataset)}, val={len(val_dataset)}. "
|
| 251 |
+
"Check image/mask directories in the config."
|
| 252 |
+
)
|
| 253 |
+
|
| 254 |
+
train_sampler = DistributedSampler(train_dataset, shuffle=True) if is_dist() else None
|
| 255 |
+
val_sampler = DistributedSampler(val_dataset, shuffle=False) if is_dist() else None
|
| 256 |
+
num_workers = int(self.config.get("num_workers", 4))
|
| 257 |
+
loader_kwargs = {
|
| 258 |
+
"batch_size": int(self.config["batch_size"]),
|
| 259 |
+
"num_workers": num_workers,
|
| 260 |
+
"pin_memory": self.device.type == "cuda",
|
| 261 |
+
"collate_fn": collate_skip_none,
|
| 262 |
+
"persistent_workers": num_workers > 0,
|
| 263 |
+
}
|
| 264 |
+
train_loader = DataLoader(
|
| 265 |
+
train_dataset,
|
| 266 |
+
shuffle=train_sampler is None,
|
| 267 |
+
sampler=train_sampler,
|
| 268 |
+
drop_last=True,
|
| 269 |
+
**loader_kwargs,
|
| 270 |
+
)
|
| 271 |
+
val_loader = DataLoader(
|
| 272 |
+
val_dataset,
|
| 273 |
+
shuffle=False,
|
| 274 |
+
sampler=val_sampler,
|
| 275 |
+
drop_last=False,
|
| 276 |
+
**loader_kwargs,
|
| 277 |
+
)
|
| 278 |
+
return train_loader, val_loader
|
| 279 |
+
|
| 280 |
+
def calculate_metrics(self, pred, target):
|
| 281 |
+
if isinstance(pred, dict):
|
| 282 |
+
pred = pred["out"]
|
| 283 |
+
pred_classes = torch.argmax(pred, dim=1)
|
| 284 |
+
foreground_pred = pred_classes == 1
|
| 285 |
+
foreground_target = target == 1
|
| 286 |
+
intersection = (foreground_pred & foreground_target).sum().float()
|
| 287 |
+
union = (foreground_pred | foreground_target).sum().float()
|
| 288 |
+
iou = intersection / union.clamp_min(1)
|
| 289 |
+
accuracy = (pred_classes == target).float().mean()
|
| 290 |
+
return {"iou": iou.detach(), "accuracy": accuracy.detach()}
|
| 291 |
+
|
| 292 |
+
def _reduce_scalar(self, value: torch.Tensor) -> float:
|
| 293 |
+
value = value.detach().float()
|
| 294 |
+
if is_dist():
|
| 295 |
+
dist.all_reduce(value, op=dist.ReduceOp.SUM)
|
| 296 |
+
value /= world_size()
|
| 297 |
+
return value.item()
|
| 298 |
+
|
| 299 |
+
def run_epoch(self, epoch: int, train: bool):
|
| 300 |
+
self.model.train(train)
|
| 301 |
+
loader = self.train_loader if train else self.val_loader
|
| 302 |
+
if train and isinstance(loader.sampler, DistributedSampler):
|
| 303 |
+
loader.sampler.set_epoch(epoch)
|
| 304 |
+
|
| 305 |
+
totals = {"total_loss": 0.0, "focal_loss": 0.0, "dice_loss": 0.0, "iou": 0.0, "accuracy": 0.0}
|
| 306 |
+
steps = 0
|
| 307 |
+
desc = f"Epoch {epoch + 1}/{self.config['num_epochs']} - {'Train' if train else 'Val'}"
|
| 308 |
+
iterator = tqdm(loader, desc=desc, disable=not is_main_process())
|
| 309 |
+
|
| 310 |
+
if train:
|
| 311 |
+
self.optimizer.zero_grad(set_to_none=True)
|
| 312 |
+
|
| 313 |
+
for step, batch in enumerate(iterator):
|
| 314 |
+
max_batches = self.config.get("max_train_batches" if train else "max_val_batches")
|
| 315 |
+
if max_batches is not None and step >= int(max_batches):
|
| 316 |
+
break
|
| 317 |
+
|
| 318 |
+
images = batch["image"].to(self.device, non_blocking=True)
|
| 319 |
+
masks = batch["mask"].to(self.device, non_blocking=True)
|
| 320 |
+
|
| 321 |
+
with torch.set_grad_enabled(train):
|
| 322 |
+
with autocast(enabled=self.use_amp):
|
| 323 |
+
outputs = self.model(images)
|
| 324 |
+
losses = self.criterion(outputs, masks)
|
| 325 |
+
loss = losses["total_loss"] / self.grad_accum_steps
|
| 326 |
+
|
| 327 |
+
if train:
|
| 328 |
+
self.scaler.scale(loss).backward()
|
| 329 |
+
should_step = (step + 1) % self.grad_accum_steps == 0 or (step + 1) == len(loader)
|
| 330 |
+
if should_step:
|
| 331 |
+
if self.config.get("grad_clip"):
|
| 332 |
+
self.scaler.unscale_(self.optimizer)
|
| 333 |
+
torch.nn.utils.clip_grad_norm_(self.raw_model.parameters(), self.config["grad_clip"])
|
| 334 |
+
self.scaler.step(self.optimizer)
|
| 335 |
+
self.scaler.update()
|
| 336 |
+
self.optimizer.zero_grad(set_to_none=True)
|
| 337 |
+
|
| 338 |
+
metrics = self.calculate_metrics(outputs, masks)
|
| 339 |
+
totals["total_loss"] += self._reduce_scalar(losses["total_loss"])
|
| 340 |
+
totals["focal_loss"] += self._reduce_scalar(losses["focal_loss"])
|
| 341 |
+
totals["dice_loss"] += self._reduce_scalar(losses["dice_loss"])
|
| 342 |
+
totals["iou"] += self._reduce_scalar(metrics["iou"])
|
| 343 |
+
totals["accuracy"] += self._reduce_scalar(metrics["accuracy"])
|
| 344 |
+
steps += 1
|
| 345 |
+
|
| 346 |
+
if is_main_process():
|
| 347 |
+
iterator.set_postfix(
|
| 348 |
+
loss=f"{totals['total_loss'] / steps:.4f}",
|
| 349 |
+
iou=f"{totals['iou'] / steps:.4f}",
|
| 350 |
+
acc=f"{totals['accuracy'] / steps:.4f}",
|
| 351 |
+
)
|
| 352 |
+
|
| 353 |
+
return {k: v / max(steps, 1) for k, v in totals.items()}
|
| 354 |
+
|
| 355 |
+
def save_checkpoint(self, epoch: int, is_best: bool) -> None:
|
| 356 |
+
if not is_main_process():
|
| 357 |
+
return
|
| 358 |
+
checkpoint = {
|
| 359 |
+
"epoch": epoch,
|
| 360 |
+
"model_state_dict": self.raw_model.state_dict(),
|
| 361 |
+
"optimizer_state_dict": self.optimizer.state_dict(),
|
| 362 |
+
"scheduler_state_dict": self.scheduler.state_dict() if self.scheduler else None,
|
| 363 |
+
"scaler_state_dict": self.scaler.state_dict(),
|
| 364 |
+
"train_history": self.train_history,
|
| 365 |
+
"config": self.config,
|
| 366 |
+
"best_val_loss": self.best_val_loss,
|
| 367 |
+
"best_val_iou": self.best_val_iou,
|
| 368 |
+
}
|
| 369 |
+
torch.save(checkpoint, self.output_dir / "latest_checkpoint.pth")
|
| 370 |
+
if is_best:
|
| 371 |
+
torch.save(checkpoint, self.output_dir / "best_checkpoint.pth")
|
| 372 |
+
print(f"Saved best checkpoint: {self.output_dir / 'best_checkpoint.pth'}")
|
| 373 |
+
|
| 374 |
+
def load_checkpoint(self, path: str) -> None:
|
| 375 |
+
checkpoint = torch.load(path, map_location=self.device)
|
| 376 |
+
self.raw_model.load_state_dict(checkpoint["model_state_dict"], strict=True)
|
| 377 |
+
self.optimizer.load_state_dict(checkpoint["optimizer_state_dict"])
|
| 378 |
+
if self.scheduler and checkpoint.get("scheduler_state_dict"):
|
| 379 |
+
self.scheduler.load_state_dict(checkpoint["scheduler_state_dict"])
|
| 380 |
+
if checkpoint.get("scaler_state_dict"):
|
| 381 |
+
self.scaler.load_state_dict(checkpoint["scaler_state_dict"])
|
| 382 |
+
self.train_history = checkpoint.get("train_history", self.train_history)
|
| 383 |
+
self.best_val_loss = checkpoint.get("best_val_loss", self.best_val_loss)
|
| 384 |
+
self.best_val_iou = checkpoint.get("best_val_iou", self.best_val_iou)
|
| 385 |
+
self.start_epoch = int(checkpoint.get("epoch", -1)) + 1
|
| 386 |
+
if is_main_process():
|
| 387 |
+
print(f"Resumed from {path} at epoch {self.start_epoch}")
|
| 388 |
+
|
| 389 |
+
def plot_training_history(self) -> None:
|
| 390 |
+
if not is_main_process() or not self.train_history["train_loss"]:
|
| 391 |
+
return
|
| 392 |
+
epochs = range(1, len(self.train_history["train_loss"]) + 1)
|
| 393 |
+
fig, axes = plt.subplots(2, 2, figsize=(15, 10))
|
| 394 |
+
axes[0, 0].plot(epochs, self.train_history["train_loss"], label="Train")
|
| 395 |
+
axes[0, 0].plot(epochs, self.train_history["val_loss"], label="Val")
|
| 396 |
+
axes[0, 0].set_title("Loss")
|
| 397 |
+
axes[0, 0].legend()
|
| 398 |
+
axes[0, 1].plot(epochs, self.train_history["train_focal"], label="Train")
|
| 399 |
+
axes[0, 1].plot(epochs, self.train_history["val_focal"], label="Val")
|
| 400 |
+
axes[0, 1].set_title("Focal Loss")
|
| 401 |
+
axes[0, 1].legend()
|
| 402 |
+
axes[1, 0].plot(epochs, self.train_history["train_dice"], label="Train")
|
| 403 |
+
axes[1, 0].plot(epochs, self.train_history["val_dice"], label="Val")
|
| 404 |
+
axes[1, 0].set_title("Dice Loss")
|
| 405 |
+
axes[1, 0].legend()
|
| 406 |
+
axes[1, 1].plot(epochs, self.train_history["val_iou"], label="Val IoU")
|
| 407 |
+
axes[1, 1].plot(epochs, self.train_history["val_accuracy"], label="Val Acc")
|
| 408 |
+
axes[1, 1].set_title("Metrics")
|
| 409 |
+
axes[1, 1].legend()
|
| 410 |
+
for ax in axes.ravel():
|
| 411 |
+
ax.grid(True)
|
| 412 |
+
plt.tight_layout()
|
| 413 |
+
plt.savefig(self.output_dir / "training_history.png", dpi=200, bbox_inches="tight")
|
| 414 |
+
plt.close(fig)
|
| 415 |
+
|
| 416 |
+
def train(self):
|
| 417 |
+
if is_main_process():
|
| 418 |
+
effective_batch = self.config["batch_size"] * world_size() * self.grad_accum_steps
|
| 419 |
+
print(f"Device: {self.device}; world_size={world_size()}; AMP={self.use_amp}")
|
| 420 |
+
print(f"Per-GPU batch: {self.config['batch_size']}; effective batch: {effective_batch}")
|
| 421 |
+
print(f"Train samples: {len(self.train_loader.dataset)}; val samples: {len(self.val_loader.dataset)}")
|
| 422 |
+
print(f"Parameters: {sum(p.numel() for p in self.raw_model.parameters()):,}")
|
| 423 |
+
|
| 424 |
+
for epoch in range(self.start_epoch, int(self.config["num_epochs"])):
|
| 425 |
+
train_stats = self.run_epoch(epoch, train=True)
|
| 426 |
+
val_stats = self.run_epoch(epoch, train=False)
|
| 427 |
+
if self.scheduler:
|
| 428 |
+
self.scheduler.step()
|
| 429 |
+
|
| 430 |
+
self.train_history["train_loss"].append(train_stats["total_loss"])
|
| 431 |
+
self.train_history["train_focal"].append(train_stats["focal_loss"])
|
| 432 |
+
self.train_history["train_dice"].append(train_stats["dice_loss"])
|
| 433 |
+
self.train_history["val_loss"].append(val_stats["total_loss"])
|
| 434 |
+
self.train_history["val_focal"].append(val_stats["focal_loss"])
|
| 435 |
+
self.train_history["val_dice"].append(val_stats["dice_loss"])
|
| 436 |
+
self.train_history["val_iou"].append(val_stats["iou"])
|
| 437 |
+
self.train_history["val_accuracy"].append(val_stats["accuracy"])
|
| 438 |
+
|
| 439 |
+
is_best = val_stats["total_loss"] < self.best_val_loss or val_stats["iou"] > self.best_val_iou
|
| 440 |
+
self.best_val_loss = min(self.best_val_loss, val_stats["total_loss"])
|
| 441 |
+
self.best_val_iou = max(self.best_val_iou, val_stats["iou"])
|
| 442 |
+
|
| 443 |
+
if is_main_process():
|
| 444 |
+
print(
|
| 445 |
+
f"Epoch {epoch + 1}: train_loss={train_stats['total_loss']:.4f}, "
|
| 446 |
+
f"val_loss={val_stats['total_loss']:.4f}, val_iou={val_stats['iou']:.4f}"
|
| 447 |
+
)
|
| 448 |
+
self.save_checkpoint(epoch, is_best)
|
| 449 |
+
|
| 450 |
+
if (epoch + 1) % int(self.config.get("plot_interval", 10)) == 0:
|
| 451 |
+
self.plot_training_history()
|
| 452 |
+
|
| 453 |
+
self.plot_training_history()
|
| 454 |
+
if is_main_process():
|
| 455 |
+
print(f"Done. best_val_loss={self.best_val_loss:.4f}, best_val_iou={self.best_val_iou:.4f}")
|
| 456 |
+
|
| 457 |
+
|
| 458 |
+
def default_config() -> dict[str, Any]:
|
| 459 |
+
return {
|
| 460 |
+
"train_image_dir": "data/train/images",
|
| 461 |
+
"train_mask_dir": "data/train/masks",
|
| 462 |
+
"val_image_dir": "data/val/images",
|
| 463 |
+
"val_mask_dir": "data/val/masks",
|
| 464 |
+
"num_classes": 2,
|
| 465 |
+
"backbone_name": "dinov3_vitl16",
|
| 466 |
+
"pretrained": True,
|
| 467 |
+
"backbone_weights": "dinov3_vitl16_pretrain_sat493m-eadcf0ff.pth",
|
| 468 |
+
"weight_search_roots": ["D:/DINOv3_pretrained_weights", "D:/DINOv3预训练权重", "dinov3-main"],
|
| 469 |
+
"use_4channel": True,
|
| 470 |
+
"image_size": 256,
|
| 471 |
+
"batch_size": 8,
|
| 472 |
+
"num_epochs": 100,
|
| 473 |
+
"backbone_lr": 1e-5,
|
| 474 |
+
"decoder_lr": 1e-4,
|
| 475 |
+
"weight_decay": 1e-4,
|
| 476 |
+
"scheduler": "cosine",
|
| 477 |
+
"grad_clip": 1.0,
|
| 478 |
+
"grad_accum_steps": 1,
|
| 479 |
+
"amp": True,
|
| 480 |
+
"focal_alpha": [1.0, 3.0],
|
| 481 |
+
"focal_gamma": 2,
|
| 482 |
+
"dice_weight": 0.5,
|
| 483 |
+
"focal_weight": 1.0,
|
| 484 |
+
"background_weight": 1.0,
|
| 485 |
+
"foreground_weight": 3.0,
|
| 486 |
+
"num_workers": 4,
|
| 487 |
+
"output_dir": f"outputs/seaweed_segmentation_{datetime.now().strftime('%Y%m%d_%H%M%S')}",
|
| 488 |
+
"plot_interval": 10,
|
| 489 |
+
"seed": 42,
|
| 490 |
+
"freeze_backbone": False,
|
| 491 |
+
"use_data_augmentation": True,
|
| 492 |
+
}
|
| 493 |
+
|
| 494 |
+
|
| 495 |
+
def parse_args() -> argparse.Namespace:
|
| 496 |
+
parser = argparse.ArgumentParser(description="Train seaweed segmentation")
|
| 497 |
+
parser.add_argument("--config", type=str, default=None, help="JSON config path")
|
| 498 |
+
parser.add_argument("--resume", type=str, default=None, help="Checkpoint to resume")
|
| 499 |
+
parser.add_argument("--batch-size", type=int, default=None, help="Override per-GPU batch size")
|
| 500 |
+
parser.add_argument("--epochs", type=int, default=None, help="Override epoch count")
|
| 501 |
+
parser.add_argument("--output-dir", type=str, default=None, help="Override output directory")
|
| 502 |
+
parser.add_argument("--weights", type=str, default=None, help="Override DINOv3 weights path")
|
| 503 |
+
parser.add_argument("--max-train-batches", type=int, default=None, help="Debug limit for train batches per epoch")
|
| 504 |
+
parser.add_argument("--max-val-batches", type=int, default=None, help="Debug limit for val batches per epoch")
|
| 505 |
+
return parser.parse_args()
|
| 506 |
+
|
| 507 |
+
|
| 508 |
+
def main() -> None:
|
| 509 |
+
args = parse_args()
|
| 510 |
+
config = default_config()
|
| 511 |
+
if args.config:
|
| 512 |
+
config.update(load_json(args.config))
|
| 513 |
+
if args.resume:
|
| 514 |
+
config["resume"] = args.resume
|
| 515 |
+
if args.batch_size:
|
| 516 |
+
config["batch_size"] = args.batch_size
|
| 517 |
+
if args.epochs:
|
| 518 |
+
config["num_epochs"] = args.epochs
|
| 519 |
+
if args.output_dir:
|
| 520 |
+
config["output_dir"] = args.output_dir
|
| 521 |
+
if args.weights:
|
| 522 |
+
config["backbone_weights"] = args.weights
|
| 523 |
+
if args.max_train_batches is not None:
|
| 524 |
+
config["max_train_batches"] = args.max_train_batches
|
| 525 |
+
if args.max_val_batches is not None:
|
| 526 |
+
config["max_val_batches"] = args.max_val_batches
|
| 527 |
+
|
| 528 |
+
device, local_rank = setup_distributed()
|
| 529 |
+
try:
|
| 530 |
+
trainer = SeaweedSegmentationTrainer(config, device, local_rank)
|
| 531 |
+
trainer.train()
|
| 532 |
+
finally:
|
| 533 |
+
cleanup_distributed()
|
| 534 |
+
|
| 535 |
+
|
| 536 |
+
if __name__ == "__main__":
|
| 537 |
+
main()
|
train_segmentation_frozen_config.json
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"train_image_dir": "data/train/images",
|
| 3 |
+
"train_mask_dir": "data/train/masks",
|
| 4 |
+
"val_image_dir": "data/val/images",
|
| 5 |
+
"val_mask_dir": "data/val/masks",
|
| 6 |
+
"num_classes": 2,
|
| 7 |
+
"backbone_name": "dinov3_vitl16",
|
| 8 |
+
"pretrained": true,
|
| 9 |
+
"backbone_weights": "dinov3_vitl16_pretrain_sat493m-eadcf0ff.pth",
|
| 10 |
+
"use_4channel": true,
|
| 11 |
+
"image_size": 256,
|
| 12 |
+
"batch_size": 8,
|
| 13 |
+
"num_epochs": 50,
|
| 14 |
+
"backbone_lr": 1e-05,
|
| 15 |
+
"decoder_lr": 5e-05,
|
| 16 |
+
"weight_decay": 0.0001,
|
| 17 |
+
"scheduler": "cosine",
|
| 18 |
+
"grad_clip": 0.5,
|
| 19 |
+
"focal_alpha": 0.25,
|
| 20 |
+
"focal_gamma": 2,
|
| 21 |
+
"dice_weight": 0.5,
|
| 22 |
+
"focal_weight": 1,
|
| 23 |
+
"num_workers": 0,
|
| 24 |
+
"output_dir": "outputs/seaweed_segmentation_frozen",
|
| 25 |
+
"plot_interval": 10,
|
| 26 |
+
"device": "cuda",
|
| 27 |
+
"freeze_backbone": true,
|
| 28 |
+
"use_data_augmentation": false
|
| 29 |
+
}
|
vector.py
ADDED
|
@@ -0,0 +1,247 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# -*- coding: utf-8 -*-
|
| 2 |
+
"""
|
| 3 |
+
一张大图端到端:瓦片预测 → 拼接 → 矢量化
|
| 4 |
+
"""
|
| 5 |
+
import os
|
| 6 |
+
import json
|
| 7 |
+
import time
|
| 8 |
+
import torch
|
| 9 |
+
import numpy as np
|
| 10 |
+
import cv2
|
| 11 |
+
from pathlib import Path
|
| 12 |
+
from tqdm import tqdm
|
| 13 |
+
import rasterio
|
| 14 |
+
from rasterio.windows import Window
|
| 15 |
+
from osgeo import gdal, ogr, osr
|
| 16 |
+
import warnings
|
| 17 |
+
warnings.filterwarnings('ignore')
|
| 18 |
+
|
| 19 |
+
import xml.etree.ElementTree as ET
|
| 20 |
+
|
| 21 |
+
def parse_xml(xml_path: str):
|
| 22 |
+
"""返回 (影像绝对路径, 输出目录绝对路径)"""
|
| 23 |
+
root = ET.parse(xml_path).getroot()
|
| 24 |
+
img_node = root.find(".//Input/File_Names/File_Name")
|
| 25 |
+
out_node = root.find(".//Output/File_Name")
|
| 26 |
+
if img_node is None or out_node is None:
|
| 27 |
+
raise RuntimeError("XML 中缺少 Input//File_Name 或 Output//File_Name")
|
| 28 |
+
img_path = os.path.abspath(img_node.text.strip())
|
| 29 |
+
out_path = os.path.abspath(out_node.text.strip())
|
| 30 |
+
return img_path, out_path
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
# ---------- 模型加载 ----------
|
| 37 |
+
def load_model(model_path, config, device):
|
| 38 |
+
from dinov3_deeplabv3plus import DinoV3DeepLabV3Plus
|
| 39 |
+
print("加载模型...")
|
| 40 |
+
model = DinoV3DeepLabV3Plus(
|
| 41 |
+
num_classes=config['num_classes'],
|
| 42 |
+
backbone_name=config['backbone_name'],
|
| 43 |
+
pretrained=False,
|
| 44 |
+
weights=config['backbone_weights'],
|
| 45 |
+
use_4channel=config['use_4channel']
|
| 46 |
+
).to(device)
|
| 47 |
+
|
| 48 |
+
ckpt = torch.load(model_path, map_location=device)
|
| 49 |
+
model.load_state_dict(ckpt.get('model_state_dict', ckpt))
|
| 50 |
+
model.eval()
|
| 51 |
+
return model
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
# ---------- 瓦片预测 ----------
|
| 55 |
+
def predict_tile(model, tile, device, cfg, threshold):
|
| 56 |
+
with torch.no_grad():
|
| 57 |
+
out = model(tile.to(device))
|
| 58 |
+
if isinstance(out, dict):
|
| 59 |
+
out = out['out']
|
| 60 |
+
prob = torch.softmax(out, dim=1)[0, 1].cpu().numpy()
|
| 61 |
+
mask = (prob > threshold).astype(np.uint8)
|
| 62 |
+
return mask, prob
|
| 63 |
+
|
| 64 |
+
|
| 65 |
+
# ---------- 预处理 ----------
|
| 66 |
+
def preprocess_tile(tile_arr, cfg):
|
| 67 |
+
# tile_arr: HWC float32
|
| 68 |
+
img = cv2.resize(tile_arr, (cfg['image_size'], cfg['image_size']),
|
| 69 |
+
interpolation=cv2.INTER_LINEAR)
|
| 70 |
+
if img.max() > 1:
|
| 71 |
+
img = img / 65535.0
|
| 72 |
+
# 标准化
|
| 73 |
+
mean = np.array([0.430, 0.411, 0.296, 0.350]) if cfg['use_4channel'] else np.array([0.430, 0.411, 0.296])
|
| 74 |
+
std = np.array([0.213, 0.156, 0.143, 0.180]) if cfg['use_4channel'] else np.array([0.213, 0.156, 0.143])
|
| 75 |
+
for i in range(img.shape[2]):
|
| 76 |
+
img[:, :, i] = (img[:, :, i] - mean[i]) / std[i]
|
| 77 |
+
tensor = torch.from_numpy(img).permute(2, 0, 1).float().unsqueeze(0)
|
| 78 |
+
return tensor
|
| 79 |
+
|
| 80 |
+
|
| 81 |
+
# ---------- 主流程 ----------
|
| 82 |
+
def run_one_shot(cfg: dict):
|
| 83 |
+
device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')
|
| 84 |
+
|
| 85 |
+
# 0. 路径准备
|
| 86 |
+
tif_path = Path(cfg['tif_path'])
|
| 87 |
+
base_name = tif_path.stem
|
| 88 |
+
out_dir = tif_path.parent / f"{base_name}_1"
|
| 89 |
+
out_dir.mkdir(exist_ok=True)
|
| 90 |
+
|
| 91 |
+
# 1. 加载模型 & 配置
|
| 92 |
+
with open(cfg['config_path'], 'r', encoding='utf-8') as f:
|
| 93 |
+
config = json.load(f)
|
| 94 |
+
model = load_model(cfg['model_path'], config, device)
|
| 95 |
+
|
| 96 |
+
# 2. 打开大图
|
| 97 |
+
with rasterio.open(tif_path) as src:
|
| 98 |
+
h, w, bands = src.height, src.width, src.count
|
| 99 |
+
transform, crs = src.transform, src.crs
|
| 100 |
+
print(f"图像尺寸: {w}×{h} 波段: {bands}")
|
| 101 |
+
|
| 102 |
+
# 3. 计算瓦片信息
|
| 103 |
+
tile_size, overlap = cfg['tile_size'], cfg['overlap']
|
| 104 |
+
stride = tile_size - overlap
|
| 105 |
+
tiles_x = (w - tile_size) // stride + 1 + ((w - tile_size) % stride != 0)
|
| 106 |
+
tiles_y = (h - tile_size) // stride + 1 + ((h - tile_size) % stride != 0)
|
| 107 |
+
total = tiles_x * tiles_y
|
| 108 |
+
print(f"瓦片数量: {total} ({tiles_x}×{tiles_y})")
|
| 109 |
+
|
| 110 |
+
# 4. 创建输出内存文件
|
| 111 |
+
pred_driver = gdal.GetDriverByName('MEM')
|
| 112 |
+
prob_driver = gdal.GetDriverByName('MEM')
|
| 113 |
+
pred_ds = pred_driver.Create('', w, h, 1, gdal.GDT_Byte)
|
| 114 |
+
prob_ds = prob_driver.Create('', w, h, 1, gdal.GDT_Float32)
|
| 115 |
+
for ds in [pred_ds, prob_ds]:
|
| 116 |
+
ds.SetGeoTransform(transform.to_gdal())
|
| 117 |
+
ds.SetProjection(crs.to_wkt())
|
| 118 |
+
|
| 119 |
+
pred_band = pred_ds.GetRasterBand(1)
|
| 120 |
+
prob_band = prob_ds.GetRasterBand(1)
|
| 121 |
+
count = np.zeros((h, w), dtype=np.uint16)
|
| 122 |
+
|
| 123 |
+
# 5. 分块预测
|
| 124 |
+
tile_id = 0
|
| 125 |
+
for y in range(0, h - tile_size + 1, stride):
|
| 126 |
+
for x in range(0, w - tile_size + 1, stride):
|
| 127 |
+
actual_x, actual_y = min(x, w - tile_size), min(y, h - tile_size)
|
| 128 |
+
window = Window(actual_x, actual_y, tile_size, tile_size)
|
| 129 |
+
tile = src.read(window=window) # CHW
|
| 130 |
+
tile = np.transpose(tile, (1, 2, 0)) # HWC
|
| 131 |
+
if tile.shape[2] >= 4:
|
| 132 |
+
tile = tile[:, :, :4]
|
| 133 |
+
else:
|
| 134 |
+
tile = tile[:, :, [2, 1, 0]] if tile.shape[2] >= 3 else tile[:, :, :3]
|
| 135 |
+
|
| 136 |
+
tensor = preprocess_tile(tile.astype(np.float32), config)
|
| 137 |
+
mask, prob = predict_tile(model, tensor, device, config, cfg['threshold'])
|
| 138 |
+
|
| 139 |
+
# 写入内存
|
| 140 |
+
pred_band.WriteArray(mask, actual_x, actual_y)
|
| 141 |
+
prob_band.WriteArray(prob, actual_x, actual_y)
|
| 142 |
+
count[actual_y:actual_y+tile_size, actual_x:actual_x+tile_size] += 1
|
| 143 |
+
tile_id += 1
|
| 144 |
+
if tile_id % 500 == 0:
|
| 145 |
+
print(f" 已预测 {tile_id}/{total}")
|
| 146 |
+
|
| 147 |
+
# 6. 平均化重叠区域
|
| 148 |
+
count[count == 0] = 1
|
| 149 |
+
pred_final = (pred_band.ReadAsArray().astype(np.float32) / count).round().astype(np.uint8)
|
| 150 |
+
prob_final = prob_band.ReadAsArray() / count
|
| 151 |
+
|
| 152 |
+
# 7. 保存 GeoTIFF
|
| 153 |
+
gtiff_path = out_dir / f"{base_name}_prediction.tif"
|
| 154 |
+
drv = gdal.GetDriverByName('GTiff')
|
| 155 |
+
ds_out = drv.Create(str(gtiff_path), w, h, 2, gdal.GDT_Byte,
|
| 156 |
+
options=['COMPRESS=DEFLATE', 'TILED=YES'])
|
| 157 |
+
ds_out.SetGeoTransform(transform.to_gdal())
|
| 158 |
+
ds_out.SetProjection(crs.to_wkt())
|
| 159 |
+
# band1: 二值掩膜
|
| 160 |
+
b1 = ds_out.GetRasterBand(1)
|
| 161 |
+
b1.WriteArray(pred_final * 255)
|
| 162 |
+
b1.SetNoDataValue(0)
|
| 163 |
+
# band2: 概率 [0-255]
|
| 164 |
+
b2 = ds_out.GetRasterBand(2)
|
| 165 |
+
b2.WriteArray((prob_final * 255).astype(np.uint8))
|
| 166 |
+
ds_out = None
|
| 167 |
+
print(f"✅ GeoTIFF 已保存: {gtiff_path}")
|
| 168 |
+
|
| 169 |
+
# 8. 矢量化
|
| 170 |
+
shp_path = out_dir / f"{base_name}_sargassum.shp"
|
| 171 |
+
vectorize(gtiff_path, shp_path, cfg['sieve_pixels'], cfg['min_area'])
|
| 172 |
+
print(f"✅ 矢量文件已保存: {shp_path}")
|
| 173 |
+
|
| 174 |
+
print("🎉 全部完成!")
|
| 175 |
+
|
| 176 |
+
|
| 177 |
+
# ---------- 矢量化 ----------
|
| 178 |
+
def vectorize(tif_path: Path, shp_path: Path, sieve_pixels: int, min_area_m2: float):
|
| 179 |
+
ds = gdal.Open(str(tif_path), gdal.GA_ReadOnly)
|
| 180 |
+
band = ds.GetRasterBand(1)
|
| 181 |
+
data = band.ReadAsArray()
|
| 182 |
+
geo = ds.GetGeoTransform()
|
| 183 |
+
proj = ds.GetProjection()
|
| 184 |
+
|
| 185 |
+
# 碎斑过滤
|
| 186 |
+
if sieve_pixels > 0:
|
| 187 |
+
kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (sieve_pixels//2, sieve_pixels//2))
|
| 188 |
+
data = cv2.morphologyEx(data, cv2.MORPH_OPEN, kernel)
|
| 189 |
+
|
| 190 |
+
mask = (data == 255).astype(np.uint8)
|
| 191 |
+
contours, _ = cv2.findContours(mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
|
| 192 |
+
|
| 193 |
+
driver = ogr.GetDriverByName('ESRI Shapefile')
|
| 194 |
+
if shp_path.exists():
|
| 195 |
+
driver.DeleteDataSource(str(shp_path))
|
| 196 |
+
out_ds = driver.CreateDataSource(str(shp_path))
|
| 197 |
+
srs = osr.SpatialReference()
|
| 198 |
+
srs.ImportFromWkt(proj)
|
| 199 |
+
layer = out_ds.CreateLayer("sargassum", srs, ogr.wkbPolygon)
|
| 200 |
+
fd = ogr.FieldDefn('DN', ogr.OFTInteger)
|
| 201 |
+
layer.CreateField(fd)
|
| 202 |
+
|
| 203 |
+
pixel_area = abs(geo[1] * geo[5])
|
| 204 |
+
for cnt in contours:
|
| 205 |
+
area_pixel = cv2.contourArea(cnt)
|
| 206 |
+
if area_pixel * pixel_area < min_area_m2:
|
| 207 |
+
continue
|
| 208 |
+
ring = ogr.Geometry(ogr.wkbLinearRing)
|
| 209 |
+
for pt in cnt[:, 0, :]:
|
| 210 |
+
x = geo[0] + pt[0] * geo[1] + pt[1] * geo[2]
|
| 211 |
+
y = geo[3] + pt[0] * geo[4] + pt[1] * geo[5]
|
| 212 |
+
ring.AddPoint(x, y)
|
| 213 |
+
ring.CloseRings()
|
| 214 |
+
poly = ogr.Geometry(ogr.wkbPolygon)
|
| 215 |
+
poly.AddGeometry(ring)
|
| 216 |
+
feat = ogr.Feature(layer.GetLayerDefn())
|
| 217 |
+
feat.SetGeometry(poly)
|
| 218 |
+
feat.SetField('DN', 255)
|
| 219 |
+
layer.CreateFeature(feat)
|
| 220 |
+
out_ds = None
|
| 221 |
+
ds = None
|
| 222 |
+
|
| 223 |
+
|
| 224 |
+
# ---------- 入口 ----------
|
| 225 |
+
if __name__ == "__main__":
|
| 226 |
+
# 1. 读 XML(唯一需要改的地方)
|
| 227 |
+
xml_file = r"test.xml" # 也可 sys.argv[1] 传入
|
| 228 |
+
tif_path, out_root = parse_xml(xml_file)
|
| 229 |
+
|
| 230 |
+
# 2. 组装配置字典(其余逻辑零改动)
|
| 231 |
+
base_name = Path(tif_path).stem
|
| 232 |
+
out_dir = Path(out_root) / f"{base_name}_1" # F:/Results/admin/GF1/157/xxx_1
|
| 233 |
+
out_dir.mkdir(parents=True, exist_ok=True)
|
| 234 |
+
|
| 235 |
+
MAIN_CFG = {
|
| 236 |
+
"tif_path" : str(tif_path),
|
| 237 |
+
"model_path" : r"seaweed_segmentation_improved_epoch500\best_checkpoint.pth",
|
| 238 |
+
"config_path" : r"seaweed_segmentation_improved_epoch500\config.json",
|
| 239 |
+
"tile_size" : 256,
|
| 240 |
+
"overlap" : 64,
|
| 241 |
+
"threshold" : 0.5,
|
| 242 |
+
"sieve_pixels" : 10,
|
| 243 |
+
"min_area" : 0.0,
|
| 244 |
+
}
|
| 245 |
+
|
| 246 |
+
# 3. 跑流程
|
| 247 |
+
run_one_shot(MAIN_CFG)
|