cuibinge commited on
Commit
c2b1b26
·
verified ·
1 Parent(s): dd0ae11

Sync YOLO training and evaluation utilities (part 2)

Browse files
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)