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,128 @@
1
+ from pathlib import Path
2
+
3
+ from opencropphenotyping.pipeline import export_results, export_uav_results, process_sentinel2, process_uav_segmentation
4
+
5
+
6
+ def process_batch(
7
+ input_dir: Path,
8
+ output_dir: Path,
9
+ processed_dir: Path,
10
+ indices: list[str] | None = None,
11
+ ndvi_threshold: float = 0.3,
12
+ resolution: int = 10,
13
+ ) -> None:
14
+ product_dirs = [
15
+ path for path in input_dir.iterdir()
16
+ if path.is_dir()
17
+ ]
18
+
19
+ for product_dir in product_dirs:
20
+ product_output_dir = output_dir / product_dir.name
21
+ product_processed_dir = processed_dir / product_dir.name
22
+
23
+ print(f"Processing product: {product_dir.name}")
24
+
25
+ try:
26
+ result_product = process_sentinel2(
27
+ input_dir=product_dir,
28
+ output_dir=product_processed_dir,
29
+ indices=indices,
30
+ ndvi_threshold=ndvi_threshold,
31
+ resolution=resolution,
32
+ )
33
+
34
+ export_results(
35
+ result_product,
36
+ product_output_dir,
37
+ )
38
+
39
+ print(f"Successfully processed: {product_dir.name}")
40
+
41
+ except Exception as error:
42
+ print(
43
+ f"Error processing product '{product_dir.name}': {error}"
44
+ )
45
+
46
+ def process_uav_batch(
47
+ input_dir: Path,
48
+ output_dir: Path,
49
+ threshold: float = 25,
50
+ vegetation_fraction_threshold: float = 0.04,
51
+ export_intermediate: bool = False,
52
+ ) -> None:
53
+ """Process multiple UAV image datasets from a parent directory."""
54
+
55
+ dataset_dirs = [
56
+ path for path in input_dir.iterdir()
57
+ if path.is_dir()
58
+ ]
59
+
60
+ for dataset_dir in dataset_dirs:
61
+ dataset_output_dir = output_dir / dataset_dir.name
62
+
63
+ image_files = [
64
+ path for path in dataset_dir.iterdir()
65
+ if path.suffix.lower() in {".jpg", ".jpeg", ".png"}
66
+ and "georef" not in path.stem.lower()
67
+ ]
68
+
69
+ geotiff_files = [
70
+ path for path in dataset_dir.iterdir()
71
+ if path.suffix.lower() in {".tif", ".tiff"}
72
+ ]
73
+
74
+ geopackage_files = [
75
+ path for path in dataset_dir.iterdir()
76
+ if path.suffix.lower() == ".gpkg"
77
+ ]
78
+
79
+ if len(image_files) != 1:
80
+ print(
81
+ f"Error processing '{dataset_dir.name}': "
82
+ f"expected 1 RGB image, found {len(image_files)}."
83
+ )
84
+ continue
85
+
86
+ if len(geotiff_files) != 1:
87
+ print(
88
+ f"Error processing '{dataset_dir.name}': "
89
+ f"expected 1 GeoTIFF, found {len(geotiff_files)}."
90
+ )
91
+ continue
92
+
93
+ if len(geopackage_files) != 1:
94
+ print(
95
+ f"Error processing '{dataset_dir.name}': "
96
+ f"expected 1 GeoPackage, found {len(geopackage_files)}."
97
+ )
98
+ continue
99
+
100
+ image_path = image_files[0]
101
+ geotiff_path = geotiff_files[0]
102
+ geopackage_path = geopackage_files[0]
103
+
104
+ print(f"Processing UAV dataset: {dataset_dir.name}")
105
+
106
+ try:
107
+ result = process_uav_segmentation(
108
+ image_path=image_path,
109
+ geotiff_path=geotiff_path,
110
+ geopackage_path=geopackage_path,
111
+ threshold=threshold,
112
+ vegetation_fraction_threshold=vegetation_fraction_threshold,
113
+ export_intermediate=export_intermediate,
114
+ )
115
+
116
+ export_uav_results(
117
+ result,
118
+ dataset_output_dir,
119
+ export_intermediate=export_intermediate
120
+ )
121
+
122
+ print(f"Successfully processed: {dataset_dir.name}")
123
+
124
+ except Exception as error:
125
+ print(
126
+ f"Error processing UAV dataset "
127
+ f"'{dataset_dir.name}': {error}"
128
+ )
@@ -0,0 +1,218 @@
1
+ from pathlib import Path
2
+
3
+ import typer
4
+
5
+ from opencropphenotyping.batch_processing import process_batch, process_uav_batch
6
+ from opencropphenotyping.pipeline import export_results, export_uav_results, process_sentinel2, process_uav_segmentation
7
+
8
+ app = typer.Typer()
9
+
10
+
11
+ @app.command()
12
+ def process(
13
+ input_dir: Path = typer.Option(..., help="Path to the Sentinel-2 product."),
14
+ output_dir: Path = typer.Option(..., help="Directory where results will be saved."),
15
+ processed_dir: Path = typer.Option(..., help="Directory for processed files."),
16
+ indices: list[str] = typer.Option(
17
+ ["ndvi"],
18
+ help="Vegetation indices to compute.",
19
+ ),
20
+ ndvi_threshold: float = typer.Option(
21
+ 0.3,
22
+ help="NDVI threshold used for vegetation masking.",
23
+ ),
24
+ resolution: int = typer.Option(
25
+ 10,
26
+ help="Target spatial resolution in meters.",
27
+ ),
28
+ ):
29
+ """Process a single Sentinel-2 product."""
30
+
31
+ try:
32
+ result = process_sentinel2(
33
+ input_dir=input_dir,
34
+ output_dir=processed_dir,
35
+ indices=indices,
36
+ ndvi_threshold=ndvi_threshold,
37
+ resolution=resolution,
38
+ )
39
+
40
+ export_results(
41
+ result,
42
+ output_dir,
43
+ )
44
+ except Exception as e:
45
+ typer.echo(f"Error during processing: {e}", err=True)
46
+ raise typer.Exit(code=1)
47
+
48
+ typer.echo("Processing completed successfully.")
49
+
50
+ @app.command()
51
+ def batch(
52
+ input_dir: Path = typer.Option(..., help="Path to the directory containing Sentinel-2 products."),
53
+ output_dir: Path = typer.Option(..., help="Directory where results will be saved."),
54
+ processed_dir: Path = typer.Option(..., help="Directory for processed files."),
55
+ indices: list[str] = typer.Option(
56
+ ["ndvi"],
57
+ help="Vegetation indices to compute.",
58
+ ),
59
+ ndvi_threshold: float = typer.Option(
60
+ 0.3,
61
+ help="NDVI threshold used for vegetation masking.",
62
+ ),
63
+ resolution: int = typer.Option(
64
+ 10,
65
+ help="Target spatial resolution in meters.",
66
+ ),
67
+ ):
68
+ """Process multiple Sentinel-2 products from a directory."""
69
+
70
+ try:
71
+ process_batch(
72
+ input_dir=input_dir,
73
+ output_dir=output_dir,
74
+ processed_dir=processed_dir,
75
+ indices=indices,
76
+ ndvi_threshold=ndvi_threshold,
77
+ resolution=resolution,
78
+ )
79
+ except Exception as e:
80
+ typer.echo(f"Error during processing: {e}", err=True)
81
+ raise typer.Exit(code=1)
82
+
83
+ typer.echo("Batch processing completed successfully.")
84
+
85
+ if __name__ == "__main__":
86
+ app()
87
+
88
+ @app.command()
89
+ def segment_uav(
90
+ image: Path = typer.Option(
91
+ ...,
92
+ help="Path to the UAV RGB image.",
93
+ ),
94
+ geotiff: Path = typer.Option(
95
+ ...,
96
+ help="Path to the georeferenced GeoTIFF.",
97
+ ),
98
+ geopackage: Path = typer.Option(
99
+ ...,
100
+ help="Path to the experimental plot GeoPackage.",
101
+ ),
102
+ output_dir: Path = typer.Option(
103
+ ...,
104
+ help="Directory where UAV segmentation results will be saved.",
105
+ ),
106
+ best_angle: float | None = typer.Option(
107
+ None,
108
+ help="Crop-row orientation angle. If omitted, it is estimated automatically.",
109
+ ),
110
+ threshold: float = typer.Option(
111
+ 25.0,
112
+ help="ExG threshold used for vegetation segmentation.",
113
+ ),
114
+ vegetation_fraction_threshold: float = typer.Option(
115
+ 0.04,
116
+ help="Vegetation fraction threshold used to classify planting positions.",
117
+ ),
118
+ export_intermediate: bool = typer.Option(
119
+ False,
120
+ help="If True, all intermediate results will be exported.",
121
+ )
122
+ ):
123
+ """Process a UAV RGB image and detect crop plants."""
124
+
125
+ try:
126
+ result = process_uav_segmentation(
127
+ image_path=image,
128
+ geotiff_path=geotiff,
129
+ geopackage_path=geopackage,
130
+ best_angle=best_angle,
131
+ threshold=threshold,
132
+ vegetation_fraction_threshold=vegetation_fraction_threshold,
133
+ export_intermediate=export_intermediate,
134
+ )
135
+
136
+ export_uav_results(
137
+ result,
138
+ output_dir,
139
+ export_intermediate=export_intermediate
140
+ )
141
+
142
+ except Exception as e:
143
+ typer.echo(
144
+ f"Error during UAV segmentation: {e}",
145
+ err=True,
146
+ )
147
+ raise typer.Exit(code=1)
148
+
149
+ typer.echo(
150
+ "UAV segmentation completed successfully."
151
+ )
152
+
153
+ if(export_intermediate):
154
+ typer.echo(
155
+ f"Best angle: {result.best_angle:.1f}°"
156
+ )
157
+
158
+ typer.echo(
159
+ f"Detected rows: {len(result.row_positions)}"
160
+ )
161
+
162
+ typer.echo(
163
+ f"Planting positions: {len(result.plants)}"
164
+ )
165
+
166
+ typer.echo(
167
+ "Occupied positions: "
168
+ f"{(~result.plants['missing_candidate']).sum()}"
169
+ )
170
+
171
+ typer.echo(
172
+ "Potentially missing positions: "
173
+ f"{result.plants['missing_candidate'].sum()}"
174
+ )
175
+
176
+ @app.command()
177
+ def batch_uav(
178
+ input_dir: Path = typer.Option(
179
+ ...,
180
+ help="Parent directory containing UAV datasets.",
181
+ ),
182
+ output_dir: Path = typer.Option(
183
+ ...,
184
+ help="Directory where UAV results will be saved.",
185
+ ),
186
+ threshold: float = typer.Option(
187
+ 25,
188
+ help="ExG threshold used for vegetation segmentation.",
189
+ ),
190
+ vegetation_fraction_threshold: float = typer.Option(
191
+ 0.04,
192
+ help="Threshold used to classify missing plants.",
193
+ ),
194
+ export_intermediate: bool = typer.Option(
195
+ False,
196
+ help="If True, all intermediate results will be exported.",
197
+ ),
198
+ ):
199
+ """Process multiple UAV datasets from a parent directory."""
200
+ try:
201
+ process_uav_batch(
202
+ input_dir=input_dir,
203
+ output_dir=output_dir,
204
+ threshold=threshold,
205
+ vegetation_fraction_threshold=vegetation_fraction_threshold,
206
+ export_intermediate=export_intermediate,
207
+ )
208
+ except Exception as error:
209
+ typer.echo(
210
+ f"Error during UAV batch processing: {error}",
211
+ err=True,
212
+ )
213
+ raise typer.Exit(code=1)
214
+
215
+ typer.echo("UAV batch processing completed successfully.")
216
+
217
+ if __name__ == "__main__":
218
+ app()
@@ -0,0 +1,175 @@
1
+ import json
2
+ from pathlib import Path
3
+
4
+ import numpy as np
5
+ import pandas as pd
6
+
7
+
8
+ def load_coco_annotations(
9
+ json_paths: list[Path],
10
+ ) -> dict[str, dict]:
11
+ """
12
+ Load COCO annotation files from JSON files.
13
+
14
+ Parameters
15
+ ----------
16
+ json_paths : list[Path]
17
+ Paths to COCO annotation JSON files.
18
+
19
+ Returns
20
+ -------
21
+ dict[str, dict]
22
+ Dictionary mapping each JSON filename to its decoded
23
+ annotation data.
24
+ """
25
+ annotations = {}
26
+
27
+ for path in json_paths:
28
+ with open(path, encoding="utf-8") as f:
29
+ annotations[path.name] = json.load(f)
30
+
31
+ return annotations
32
+
33
+ def build_image_annotation_index(
34
+ coco_datasets: dict[str, dict],
35
+ ) -> dict[str, dict]:
36
+ """
37
+ Build an index linking each image to its COCO annotations.
38
+
39
+ Parameters
40
+ ----------
41
+ coco_datasets : dict[str, dict]
42
+ COCO datasets loaded from JSON files.
43
+
44
+ Returns
45
+ -------
46
+ dict[str, dict]
47
+ Dictionary indexed by image filename. Each entry contains
48
+ the image metadata under ``"image"`` and the corresponding
49
+ annotations under ``"annotations"``.
50
+ """
51
+ image_annotations = {}
52
+
53
+ for data in coco_datasets.values():
54
+
55
+ for image in data["images"]:
56
+
57
+ image_annotations[image["file_name"]] = {
58
+ "image": image,
59
+ "annotations": [
60
+ annotation
61
+ for annotation in data["annotations"]
62
+ if annotation["image_id"] == image["id"]
63
+ ],
64
+ }
65
+
66
+ return image_annotations
67
+
68
+ def build_plant_annotations_dataframe(
69
+ image_annotations: dict[str, dict],
70
+ ) -> pd.DataFrame:
71
+ """
72
+ Convert indexed COCO plant annotations into a DataFrame.
73
+
74
+ Parameters
75
+ ----------
76
+ image_annotations : dict[str, dict]
77
+ Image annotation index produced by
78
+ :func:`build_image_annotation_index`.
79
+
80
+ Returns
81
+ -------
82
+ pd.DataFrame
83
+ DataFrame containing one row per annotated plant.
84
+ Bounding-box coordinates and plant centre coordinates are
85
+ expressed in the original image coordinate system.
86
+ """
87
+ plant_annotations = []
88
+
89
+ columns = [
90
+ "image_id",
91
+ "image_name",
92
+ "category_id",
93
+ "bbox_x",
94
+ "bbox_y",
95
+ "bbox_width",
96
+ "bbox_height",
97
+ "center_x",
98
+ "center_y",
99
+ ]
100
+
101
+ for image_name, info in image_annotations.items():
102
+
103
+ image_id = info["image"]["id"]
104
+
105
+ for annotation in info["annotations"]:
106
+
107
+ x, y, width, height = annotation["bbox"]
108
+
109
+ plant_annotations.append(
110
+ {
111
+ "image_id": image_id,
112
+ "image_name": image_name,
113
+ "category_id": annotation["category_id"],
114
+ "bbox_x": x,
115
+ "bbox_y": y,
116
+ "bbox_width": width,
117
+ "bbox_height": height,
118
+ "center_x": x + width / 2,
119
+ "center_y": y + height / 2,
120
+ }
121
+ )
122
+
123
+ return pd.DataFrame(plant_annotations, columns=columns)
124
+
125
+ def get_image_annotations(
126
+ plant_annotations: pd.DataFrame,
127
+ image_path: Path,
128
+ ) -> pd.DataFrame:
129
+ """
130
+ Return plant annotations associated with a given image.
131
+
132
+ Parameters
133
+ ----------
134
+ plant_annotations : pd.DataFrame
135
+ DataFrame containing plant annotations for all images.
136
+ Must contain an ``image_name`` column.
137
+ image_path : Path
138
+ Path to the image for which annotations are requested.
139
+
140
+ Returns
141
+ -------
142
+ pd.DataFrame
143
+ DataFrame containing only the annotations associated with
144
+ ``image_path``.
145
+ """
146
+ return plant_annotations[
147
+ plant_annotations["image_name"] == image_path.name
148
+ ].copy()
149
+
150
+ def get_annotation_centers(
151
+ plant_annotations: pd.DataFrame,
152
+ ) -> np.ndarray:
153
+ """
154
+ Extract annotated plant centres as an array of x/y coordinates.
155
+
156
+ Parameters
157
+ ----------
158
+ plant_annotations : pd.DataFrame
159
+ Plant annotations containing ``center_x`` and ``center_y``.
160
+
161
+ Returns
162
+ -------
163
+ np.ndarray
164
+ Array of plant centres with shape ``(n_annotations, 2)``.
165
+ Columns correspond to x and y coordinates.
166
+ """
167
+ if len(plant_annotations) == 0:
168
+ return np.empty(
169
+ (0, 2),
170
+ dtype=float,
171
+ )
172
+
173
+ return plant_annotations[
174
+ ["center_x", "center_y"]
175
+ ].to_numpy(dtype=float)