opencropphenotyping 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- opencropphenotyping/batch_processing.py +128 -0
- opencropphenotyping/cli.py +218 -0
- opencropphenotyping/coco.py +175 -0
- opencropphenotyping/evaluation.py +352 -0
- opencropphenotyping/indices.py +253 -0
- opencropphenotyping/io.py +540 -0
- opencropphenotyping/pipeline.py +393 -0
- opencropphenotyping/segmentation.py +1320 -0
- opencropphenotyping/statistics.py +17 -0
- opencropphenotyping/traits.py +60 -0
- opencropphenotyping/visualization.py +467 -0
- opencropphenotyping-0.2.0.dist-info/METADATA +695 -0
- opencropphenotyping-0.2.0.dist-info/RECORD +17 -0
- opencropphenotyping-0.2.0.dist-info/WHEEL +5 -0
- opencropphenotyping-0.2.0.dist-info/entry_points.txt +2 -0
- opencropphenotyping-0.2.0.dist-info/licenses/LICENSE +21 -0
- opencropphenotyping-0.2.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,352 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
|
|
3
|
+
import numpy as np
|
|
4
|
+
import pandas as pd
|
|
5
|
+
from PIL import Image
|
|
6
|
+
|
|
7
|
+
from opencropphenotyping.coco import (
|
|
8
|
+
build_image_annotation_index,
|
|
9
|
+
build_plant_annotations_dataframe,
|
|
10
|
+
get_annotation_centers,
|
|
11
|
+
get_image_annotations,
|
|
12
|
+
load_coco_annotations,
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def transform_original_points_to_rotated(
|
|
17
|
+
points: np.ndarray,
|
|
18
|
+
angle: float,
|
|
19
|
+
original_shape: tuple[int, int],
|
|
20
|
+
rotated_shape: tuple[int, int],
|
|
21
|
+
) -> np.ndarray:
|
|
22
|
+
"""
|
|
23
|
+
Transform points from the original image coordinates to the
|
|
24
|
+
rotated image coordinates.
|
|
25
|
+
|
|
26
|
+
The transformation accounts for the different image centers
|
|
27
|
+
before and after rotation. Coordinates are expected in
|
|
28
|
+
``(x, y)`` order, while image shapes are given in
|
|
29
|
+
``(height, width)`` order.
|
|
30
|
+
|
|
31
|
+
Parameters
|
|
32
|
+
----------
|
|
33
|
+
points : np.ndarray
|
|
34
|
+
Array of point coordinates with shape ``(n_points, 2)``.
|
|
35
|
+
Each point is represented as ``(x, y)``.
|
|
36
|
+
angle : float
|
|
37
|
+
Rotation angle in degrees. The rotation convention follows
|
|
38
|
+
the coordinate transformation used for the image rotation.
|
|
39
|
+
original_shape : tuple[int, int]
|
|
40
|
+
Shape of the original image represented as
|
|
41
|
+
``(height, width)``.
|
|
42
|
+
rotated_shape : tuple[int, int]
|
|
43
|
+
Shape of the rotated image represented as
|
|
44
|
+
``(height, width)``.
|
|
45
|
+
|
|
46
|
+
Returns
|
|
47
|
+
-------
|
|
48
|
+
np.ndarray
|
|
49
|
+
Transformed point coordinates with shape ``(n_points, 2)``.
|
|
50
|
+
Coordinates are represented as ``(x, y)``.
|
|
51
|
+
"""
|
|
52
|
+
original_height, original_width = original_shape
|
|
53
|
+
rotated_height, rotated_width = rotated_shape
|
|
54
|
+
|
|
55
|
+
original_center = np.array([
|
|
56
|
+
(original_width - 1) / 2,
|
|
57
|
+
(original_height - 1) / 2,
|
|
58
|
+
])
|
|
59
|
+
|
|
60
|
+
rotated_center = np.array([
|
|
61
|
+
(rotated_width - 1) / 2,
|
|
62
|
+
(rotated_height - 1) / 2,
|
|
63
|
+
])
|
|
64
|
+
|
|
65
|
+
shifted_points = points - original_center
|
|
66
|
+
|
|
67
|
+
theta = np.deg2rad(angle)
|
|
68
|
+
|
|
69
|
+
rotation_matrix = np.array([
|
|
70
|
+
[np.cos(theta), np.sin(theta)],
|
|
71
|
+
[-np.sin(theta), np.cos(theta)],
|
|
72
|
+
])
|
|
73
|
+
|
|
74
|
+
rotated_points = (
|
|
75
|
+
shifted_points @ rotation_matrix.T
|
|
76
|
+
+ rotated_center
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
return rotated_points
|
|
80
|
+
|
|
81
|
+
def match_annotations_to_plant_positions(
|
|
82
|
+
plants_df: pd.DataFrame,
|
|
83
|
+
annotation_points: np.ndarray,
|
|
84
|
+
boundaries: np.ndarray,
|
|
85
|
+
) -> pd.DataFrame:
|
|
86
|
+
"""
|
|
87
|
+
Match annotated plant centres to theoretical planting positions.
|
|
88
|
+
|
|
89
|
+
Parameters
|
|
90
|
+
----------
|
|
91
|
+
plants_df : pd.DataFrame
|
|
92
|
+
Plant detection table containing ``row``, ``x_start`` and
|
|
93
|
+
``x_end`` columns.
|
|
94
|
+
annotation_points : np.ndarray
|
|
95
|
+
Annotated plant centres in rotated image coordinates.
|
|
96
|
+
Expected shape is ``(n_annotations, 2)`` with columns
|
|
97
|
+
``x`` and ``y``.
|
|
98
|
+
boundaries : np.ndarray
|
|
99
|
+
Vertical boundaries defining the crop rows.
|
|
100
|
+
|
|
101
|
+
Returns
|
|
102
|
+
-------
|
|
103
|
+
pd.DataFrame
|
|
104
|
+
Copy of ``plants_df`` with the additional columns
|
|
105
|
+
``annotation_count`` and ``annotation_present``.
|
|
106
|
+
"""
|
|
107
|
+
plants_df = plants_df.copy()
|
|
108
|
+
|
|
109
|
+
annotation_count = []
|
|
110
|
+
annotation_present = []
|
|
111
|
+
|
|
112
|
+
for _, segment in plants_df.iterrows():
|
|
113
|
+
|
|
114
|
+
row_index = int(segment["row"])
|
|
115
|
+
|
|
116
|
+
x_start = segment["x_start"]
|
|
117
|
+
x_end = segment["x_end"]
|
|
118
|
+
|
|
119
|
+
y_start = boundaries[row_index - 1]
|
|
120
|
+
y_end = boundaries[row_index]
|
|
121
|
+
|
|
122
|
+
in_segment = (
|
|
123
|
+
(annotation_points[:, 0] >= x_start)
|
|
124
|
+
& (annotation_points[:, 0] < x_end)
|
|
125
|
+
& (annotation_points[:, 1] >= y_start)
|
|
126
|
+
& (annotation_points[:, 1] < y_end)
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
count = int(np.sum(in_segment))
|
|
130
|
+
|
|
131
|
+
annotation_count.append(count)
|
|
132
|
+
annotation_present.append(count > 0)
|
|
133
|
+
|
|
134
|
+
plants_df["annotation_count"] = annotation_count
|
|
135
|
+
plants_df["annotation_present"] = annotation_present
|
|
136
|
+
|
|
137
|
+
return plants_df
|
|
138
|
+
|
|
139
|
+
def compute_confusion_matrix(
|
|
140
|
+
predicted_occupied: pd.Series,
|
|
141
|
+
actual_occupied: pd.Series,
|
|
142
|
+
) -> dict[str, int]:
|
|
143
|
+
"""
|
|
144
|
+
Compute binary classification confusion matrix.
|
|
145
|
+
|
|
146
|
+
Parameters
|
|
147
|
+
----------
|
|
148
|
+
predicted_occupied : pd.Series
|
|
149
|
+
Boolean predictions indicating whether a planting position
|
|
150
|
+
is considered occupied.
|
|
151
|
+
actual_occupied : pd.Series
|
|
152
|
+
Boolean reference labels indicating whether an annotated plant
|
|
153
|
+
is present.
|
|
154
|
+
|
|
155
|
+
Returns
|
|
156
|
+
-------
|
|
157
|
+
dict[str, int]
|
|
158
|
+
Counts of true positives (TP), false positives (FP),
|
|
159
|
+
false negatives (FN), and true negatives (TN).
|
|
160
|
+
"""
|
|
161
|
+
tp = int(
|
|
162
|
+
(predicted_occupied & actual_occupied).sum()
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
fp = int(
|
|
166
|
+
(predicted_occupied & ~actual_occupied).sum()
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
fn = int(
|
|
170
|
+
(~predicted_occupied & actual_occupied).sum()
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
tn = int(
|
|
174
|
+
(~predicted_occupied & ~actual_occupied).sum()
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
return {
|
|
178
|
+
"tp": tp,
|
|
179
|
+
"fp": fp,
|
|
180
|
+
"fn": fn,
|
|
181
|
+
"tn": tn,
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
def compute_classification_metrics(
|
|
185
|
+
tp: int,
|
|
186
|
+
fp: int,
|
|
187
|
+
fn: int,
|
|
188
|
+
) -> dict[str, float]:
|
|
189
|
+
"""
|
|
190
|
+
Compute precision, recall, and F1 score.
|
|
191
|
+
|
|
192
|
+
Parameters
|
|
193
|
+
----------
|
|
194
|
+
tp : int
|
|
195
|
+
Number of true positives.
|
|
196
|
+
fp : int
|
|
197
|
+
Number of false positives.
|
|
198
|
+
fn : int
|
|
199
|
+
Number of false negatives.
|
|
200
|
+
|
|
201
|
+
Returns
|
|
202
|
+
-------
|
|
203
|
+
dict[str, float]
|
|
204
|
+
Precision, recall, and F1 score.
|
|
205
|
+
"""
|
|
206
|
+
precision = (
|
|
207
|
+
tp / (tp + fp)
|
|
208
|
+
if tp + fp > 0
|
|
209
|
+
else 0.0
|
|
210
|
+
)
|
|
211
|
+
|
|
212
|
+
recall = (
|
|
213
|
+
tp / (tp + fn)
|
|
214
|
+
if tp + fn > 0
|
|
215
|
+
else 0.0
|
|
216
|
+
)
|
|
217
|
+
|
|
218
|
+
f1 = (
|
|
219
|
+
2 * precision * recall / (precision + recall)
|
|
220
|
+
if precision + recall > 0
|
|
221
|
+
else 0.0
|
|
222
|
+
)
|
|
223
|
+
|
|
224
|
+
return {
|
|
225
|
+
"precision": precision,
|
|
226
|
+
"recall": recall,
|
|
227
|
+
"f1": f1,
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
def evaluate_plant_detection(
|
|
231
|
+
plants_df: pd.DataFrame,
|
|
232
|
+
annotation_points: np.ndarray,
|
|
233
|
+
boundaries: np.ndarray,
|
|
234
|
+
) -> tuple[pd.DataFrame, dict]:
|
|
235
|
+
"""
|
|
236
|
+
Evaluate plant detection against annotated plant positions.
|
|
237
|
+
|
|
238
|
+
Parameters
|
|
239
|
+
----------
|
|
240
|
+
plants_df : pd.DataFrame
|
|
241
|
+
Plant detection results.
|
|
242
|
+
annotation_points : np.ndarray
|
|
243
|
+
Annotated plant centres in rotated image coordinates.
|
|
244
|
+
boundaries : np.ndarray
|
|
245
|
+
Vertical boundaries defining the crop rows.
|
|
246
|
+
|
|
247
|
+
Returns
|
|
248
|
+
-------
|
|
249
|
+
tuple[pd.DataFrame, dict]
|
|
250
|
+
Enriched plant detection table and evaluation metrics.
|
|
251
|
+
"""
|
|
252
|
+
plants_df = match_annotations_to_plant_positions(
|
|
253
|
+
plants_df=plants_df,
|
|
254
|
+
annotation_points=annotation_points,
|
|
255
|
+
boundaries=boundaries,
|
|
256
|
+
)
|
|
257
|
+
|
|
258
|
+
predicted_occupied = ~plants_df["missing_candidate"]
|
|
259
|
+
actual_occupied = plants_df["annotation_present"]
|
|
260
|
+
|
|
261
|
+
confusion = compute_confusion_matrix(
|
|
262
|
+
predicted_occupied=predicted_occupied,
|
|
263
|
+
actual_occupied=actual_occupied,
|
|
264
|
+
)
|
|
265
|
+
|
|
266
|
+
metrics = compute_classification_metrics(
|
|
267
|
+
tp=confusion["tp"],
|
|
268
|
+
fp=confusion["fp"],
|
|
269
|
+
fn=confusion["fn"],
|
|
270
|
+
)
|
|
271
|
+
|
|
272
|
+
evaluation = {
|
|
273
|
+
**confusion,
|
|
274
|
+
**metrics,
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
return plants_df, evaluation
|
|
278
|
+
|
|
279
|
+
def prepare_rotated_annotation_points(
|
|
280
|
+
json_paths: list[Path],
|
|
281
|
+
image_path: Path,
|
|
282
|
+
best_angle: float,
|
|
283
|
+
rotated_shape: tuple[int, int],
|
|
284
|
+
) -> np.ndarray:
|
|
285
|
+
"""
|
|
286
|
+
Load and transform plant annotations to rotated image coordinates.
|
|
287
|
+
|
|
288
|
+
COCO annotations are loaded from JSON files, indexed by image,
|
|
289
|
+
converted to a DataFrame, filtered for the target image, and
|
|
290
|
+
transformed from the original image coordinate system to the
|
|
291
|
+
rotated image coordinate system.
|
|
292
|
+
|
|
293
|
+
Parameters
|
|
294
|
+
----------
|
|
295
|
+
json_paths : list[Path]
|
|
296
|
+
Paths to COCO annotation JSON files.
|
|
297
|
+
image_path : Path
|
|
298
|
+
Path to the image whose plant annotations are required.
|
|
299
|
+
best_angle : float
|
|
300
|
+
Rotation angle applied to the image.
|
|
301
|
+
rotated_shape : tuple[int, int]
|
|
302
|
+
Shape of the rotated image as ``(height, width)``.
|
|
303
|
+
|
|
304
|
+
Returns
|
|
305
|
+
-------
|
|
306
|
+
np.ndarray
|
|
307
|
+
Array of annotated plant centres in the rotated image
|
|
308
|
+
coordinate system, with shape ``(n_annotations, 2)``.
|
|
309
|
+
Columns correspond to x and y coordinates.
|
|
310
|
+
"""
|
|
311
|
+
# Load COCO annotations
|
|
312
|
+
coco_datasets = load_coco_annotations(
|
|
313
|
+
json_paths=json_paths,
|
|
314
|
+
)
|
|
315
|
+
|
|
316
|
+
# Link each image to its annotations
|
|
317
|
+
image_annotations = build_image_annotation_index(
|
|
318
|
+
coco_datasets=coco_datasets,
|
|
319
|
+
)
|
|
320
|
+
|
|
321
|
+
# Build annotation DataFrame
|
|
322
|
+
plant_annotations = build_plant_annotations_dataframe(
|
|
323
|
+
image_annotations=image_annotations,
|
|
324
|
+
)
|
|
325
|
+
|
|
326
|
+
# Keep annotations for the target image
|
|
327
|
+
plant_annotations = get_image_annotations(
|
|
328
|
+
plant_annotations=plant_annotations,
|
|
329
|
+
image_path=image_path,
|
|
330
|
+
)
|
|
331
|
+
|
|
332
|
+
# Get annotation centres
|
|
333
|
+
annotation_points = get_annotation_centers(
|
|
334
|
+
plant_annotations=plant_annotations,
|
|
335
|
+
)
|
|
336
|
+
|
|
337
|
+
# Get original image shape
|
|
338
|
+
original_shape = np.array(
|
|
339
|
+
Image.open(image_path)
|
|
340
|
+
).shape[:2]
|
|
341
|
+
|
|
342
|
+
# Transform annotation centres
|
|
343
|
+
annotation_points_rotated = (
|
|
344
|
+
transform_original_points_to_rotated(
|
|
345
|
+
points=annotation_points,
|
|
346
|
+
angle=best_angle,
|
|
347
|
+
original_shape=original_shape,
|
|
348
|
+
rotated_shape=rotated_shape,
|
|
349
|
+
)
|
|
350
|
+
)
|
|
351
|
+
|
|
352
|
+
return annotation_points_rotated
|
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
import numpy as np
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def _prepare_bands(band1: np.ndarray, band2: np.ndarray) -> tuple[np.ndarray, np.ndarray]:
|
|
5
|
+
"""
|
|
6
|
+
Prepare the input bands for index computation.
|
|
7
|
+
|
|
8
|
+
Parameters
|
|
9
|
+
----------
|
|
10
|
+
band1 : np.ndarray
|
|
11
|
+
First band.
|
|
12
|
+
band2 : np.ndarray
|
|
13
|
+
Second band.
|
|
14
|
+
|
|
15
|
+
Returns
|
|
16
|
+
-------
|
|
17
|
+
tuple[np.ndarray, np.ndarray]
|
|
18
|
+
Prepared bands as float32 arrays.
|
|
19
|
+
"""
|
|
20
|
+
# Convert inputs to float32 arrays
|
|
21
|
+
band1 = np.asarray(band1, dtype=np.float32)
|
|
22
|
+
band2 = np.asarray(band2, dtype=np.float32)
|
|
23
|
+
|
|
24
|
+
# Ensure that the input bands have the same shape
|
|
25
|
+
if band1.shape != band2.shape:
|
|
26
|
+
raise ValueError("Input bands must have the same shape.")
|
|
27
|
+
|
|
28
|
+
return band1, band2
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def compute_normalized_difference_index(band1: np.ndarray, band2: np.ndarray) -> np.ndarray:
|
|
32
|
+
"""
|
|
33
|
+
Compute the Normalized Difference Index (NDI) from the given two bands.
|
|
34
|
+
|
|
35
|
+
Parameters
|
|
36
|
+
----------
|
|
37
|
+
band1 : np.ndarray
|
|
38
|
+
First band.
|
|
39
|
+
|
|
40
|
+
band2 : np.ndarray
|
|
41
|
+
Second band.
|
|
42
|
+
|
|
43
|
+
Returns
|
|
44
|
+
-------
|
|
45
|
+
np.ndarray
|
|
46
|
+
NDI image.
|
|
47
|
+
"""
|
|
48
|
+
band1, band2 = _prepare_bands(band1, band2)
|
|
49
|
+
|
|
50
|
+
# Compute NDI using the formula: (band1 - band2) / (band1 + band2)
|
|
51
|
+
denominator = band1 + band2
|
|
52
|
+
numerator = band1 - band2
|
|
53
|
+
ndi = np.divide(numerator, denominator, out=np.zeros_like(denominator), where=denominator != 0)
|
|
54
|
+
|
|
55
|
+
return ndi.astype(np.float32)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def compute_ndvi(red_band: np.ndarray, nir_band: np.ndarray) -> np.ndarray:
|
|
59
|
+
"""
|
|
60
|
+
Compute the Normalized Difference Vegetation Index (NDVI) from the given red and near-infrared (NIR) bands.
|
|
61
|
+
|
|
62
|
+
Parameters
|
|
63
|
+
----------
|
|
64
|
+
red_band : np.ndarray
|
|
65
|
+
Red band.
|
|
66
|
+
|
|
67
|
+
nir_band : np.ndarray
|
|
68
|
+
Near infrared band.
|
|
69
|
+
|
|
70
|
+
Returns
|
|
71
|
+
-------
|
|
72
|
+
np.ndarray
|
|
73
|
+
NDVI image.
|
|
74
|
+
"""
|
|
75
|
+
return compute_normalized_difference_index(nir_band, red_band)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def compute_ndre(nir_band: np.ndarray, red_edge_band: np.ndarray) -> np.ndarray:
|
|
79
|
+
"""
|
|
80
|
+
Compute the Normalized Difference Red Edge Index (NDRE) from the given red and red edge bands.
|
|
81
|
+
|
|
82
|
+
Parameters
|
|
83
|
+
----------
|
|
84
|
+
nir_band : np.ndarray
|
|
85
|
+
Near infrared band.
|
|
86
|
+
|
|
87
|
+
red_edge_band : np.ndarray
|
|
88
|
+
Red edge band.
|
|
89
|
+
|
|
90
|
+
Returns
|
|
91
|
+
-------
|
|
92
|
+
np.ndarray
|
|
93
|
+
NDRE image.
|
|
94
|
+
"""
|
|
95
|
+
return compute_normalized_difference_index(nir_band, red_edge_band)
|
|
96
|
+
|
|
97
|
+
def compute_gndvi(nir_band: np.ndarray, green_band: np.ndarray) -> np.ndarray:
|
|
98
|
+
"""
|
|
99
|
+
Compute the Green Normalized Difference Vegetation Index (GNDVI) from the given nir and green bands.
|
|
100
|
+
|
|
101
|
+
Parameters
|
|
102
|
+
----------
|
|
103
|
+
nir_band : np.ndarray
|
|
104
|
+
Red band.
|
|
105
|
+
|
|
106
|
+
green_band : np.ndarray
|
|
107
|
+
Red edge band.
|
|
108
|
+
|
|
109
|
+
Returns
|
|
110
|
+
-------
|
|
111
|
+
np.ndarray
|
|
112
|
+
GNDVI image.
|
|
113
|
+
"""
|
|
114
|
+
return compute_normalized_difference_index(nir_band, green_band)
|
|
115
|
+
|
|
116
|
+
def compute_savi(red_band: np.ndarray, nir_band: np.ndarray, L_factor : float = 0.5) -> np.ndarray:
|
|
117
|
+
"""
|
|
118
|
+
Compute the Soil Adjusted Vegetation Index (SAVI) from the given red and near-infrared (NIR) bands.
|
|
119
|
+
The L factor is typically set to 0.5 for moderate vegetation cover.
|
|
120
|
+
|
|
121
|
+
Parameters
|
|
122
|
+
----------
|
|
123
|
+
red_band : np.ndarray
|
|
124
|
+
Red band.
|
|
125
|
+
|
|
126
|
+
nir_band : np.ndarray
|
|
127
|
+
Near infrared band.
|
|
128
|
+
|
|
129
|
+
Returns
|
|
130
|
+
-------
|
|
131
|
+
np.ndarray
|
|
132
|
+
SAVI image.
|
|
133
|
+
"""
|
|
134
|
+
# Raise an error if L < 0
|
|
135
|
+
if L_factor < 0:
|
|
136
|
+
raise ValueError("L_factor must be greater than or equal to 0.")
|
|
137
|
+
|
|
138
|
+
prepared_red_band, prepared_nir_band = _prepare_bands(red_band, nir_band)
|
|
139
|
+
|
|
140
|
+
# Compute SAVI index
|
|
141
|
+
denominator = prepared_nir_band + prepared_red_band + L_factor
|
|
142
|
+
numerator = (prepared_nir_band - prepared_red_band) * (1 + L_factor)
|
|
143
|
+
savi = np.divide(numerator, denominator, out=np.zeros_like(denominator), where=denominator != 0)
|
|
144
|
+
|
|
145
|
+
return savi.astype(np.float32)
|
|
146
|
+
|
|
147
|
+
VEGETATION_INDICES = {
|
|
148
|
+
"ndvi": {
|
|
149
|
+
"function": compute_ndvi,
|
|
150
|
+
"bands": ["red_band", "nir_band"],
|
|
151
|
+
},
|
|
152
|
+
"savi": {
|
|
153
|
+
"function": compute_savi,
|
|
154
|
+
"bands": ["red_band", "nir_band"],
|
|
155
|
+
},
|
|
156
|
+
"ndre": {
|
|
157
|
+
"function": compute_ndre,
|
|
158
|
+
"bands": ["nir_band", "red_edge_band"],
|
|
159
|
+
},
|
|
160
|
+
"gndvi": {
|
|
161
|
+
"function": compute_gndvi,
|
|
162
|
+
"bands": ["nir_band", "green_band"],
|
|
163
|
+
},
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
def compute_indexes(
|
|
167
|
+
red_band: np.ndarray | None = None,
|
|
168
|
+
nir_band: np.ndarray | None = None,
|
|
169
|
+
green_band: np.ndarray | None = None,
|
|
170
|
+
red_edge_band: np.ndarray | None = None,
|
|
171
|
+
indices: list[str] | None = None,
|
|
172
|
+
) -> dict[str, np.ndarray]:
|
|
173
|
+
"""Compute vegetation indices from available spectral bands.
|
|
174
|
+
|
|
175
|
+
Parameters
|
|
176
|
+
----------
|
|
177
|
+
red_band : np.ndarray or None, optional
|
|
178
|
+
Red spectral band.
|
|
179
|
+
nir_band : np.ndarray or None, optional
|
|
180
|
+
Near-infrared (NIR) spectral band.
|
|
181
|
+
green_band : np.ndarray or None, optional
|
|
182
|
+
Green spectral band.
|
|
183
|
+
red_edge_band : np.ndarray or None, optional
|
|
184
|
+
Red-edge spectral band.
|
|
185
|
+
indices : list[str] or None, optional
|
|
186
|
+
Names of the vegetation indices to compute. If None, all
|
|
187
|
+
available vegetation indices are requested.
|
|
188
|
+
|
|
189
|
+
Returns
|
|
190
|
+
-------
|
|
191
|
+
dict[str, np.ndarray]
|
|
192
|
+
Dictionary mapping each successfully computed vegetation index
|
|
193
|
+
to its resulting array. An index is skipped when one or more
|
|
194
|
+
of its required spectral bands are unavailable.
|
|
195
|
+
|
|
196
|
+
Raises
|
|
197
|
+
------
|
|
198
|
+
ValueError
|
|
199
|
+
If an unknown vegetation index is requested.
|
|
200
|
+
"""
|
|
201
|
+
results = {}
|
|
202
|
+
|
|
203
|
+
if indices is None:
|
|
204
|
+
indices = ["ndvi", "savi", "ndre", "gndvi"]
|
|
205
|
+
|
|
206
|
+
available_bands = {
|
|
207
|
+
"red_band": red_band,
|
|
208
|
+
"nir_band": nir_band,
|
|
209
|
+
"green_band": green_band,
|
|
210
|
+
"red_edge_band": red_edge_band,
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
for index in indices:
|
|
214
|
+
if index not in VEGETATION_INDICES:
|
|
215
|
+
raise ValueError(f"Unknown vegetation index: {index}")
|
|
216
|
+
|
|
217
|
+
index_config = VEGETATION_INDICES[index]
|
|
218
|
+
required_bands = index_config["bands"]
|
|
219
|
+
|
|
220
|
+
if all(
|
|
221
|
+
available_bands[band] is not None
|
|
222
|
+
for band in required_bands
|
|
223
|
+
):
|
|
224
|
+
compute_function = index_config["function"]
|
|
225
|
+
band_arrays = [
|
|
226
|
+
available_bands[band]
|
|
227
|
+
for band in required_bands
|
|
228
|
+
]
|
|
229
|
+
|
|
230
|
+
results[index] = compute_function(*band_arrays)
|
|
231
|
+
|
|
232
|
+
return results
|
|
233
|
+
|
|
234
|
+
def compute_exg(
|
|
235
|
+
rgb: tuple[np.ndarray, np.ndarray, np.ndarray],
|
|
236
|
+
) -> np.ndarray:
|
|
237
|
+
"""Compute the Excess Green Index (ExG).
|
|
238
|
+
|
|
239
|
+
Parameters
|
|
240
|
+
----------
|
|
241
|
+
rgb : tuple[np.ndarray, np.ndarray, np.ndarray]
|
|
242
|
+
RGB image channels as (red, green, blue).
|
|
243
|
+
|
|
244
|
+
Returns
|
|
245
|
+
-------
|
|
246
|
+
np.ndarray
|
|
247
|
+
ExG image with dtype float32.
|
|
248
|
+
"""
|
|
249
|
+
red = rgb[0].astype(np.float32)
|
|
250
|
+
green = rgb[1].astype(np.float32)
|
|
251
|
+
blue = rgb[2].astype(np.float32)
|
|
252
|
+
|
|
253
|
+
return 2 * green - red - blue
|