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.
@@ -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