dronelytics 1.0.1__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,45 @@
1
+ """
2
+ Dronelytics: Comprehensive package for end-to-end drone orthomosaic analysis
3
+ and agricultural field phenotyping.
4
+
5
+ Features:
6
+ - Load and process multispectral orthomosaics (4-band and 5-band)
7
+ - Calculate 10 vegetation indices with custom formula support
8
+ - Automated plot boundary detection
9
+ - Pixel-level data extraction
10
+ - 3D canopy modeling from point clouds (DTM, DSM, CHM)
11
+ - 3D visualization of point clouds, elevation models, and mesh surfaces
12
+ - CSV/Excel data export
13
+ """
14
+
15
+ __version__ = "1.0.0"
16
+ __author__ = "Research Development"
17
+ __license__ = "MIT"
18
+
19
+ from .core.orthomosaic import Orthomosaic
20
+ from .core.indices import VegetationIndices
21
+ from .core.vegetation_indices_extended import VegetationIndicesExtended
22
+ from .core.segmentation import PlotSegmentation
23
+ from .core.extraction import PixelExtraction
24
+ from .core.pointcloud import PointCloudProcessor
25
+ from .processing.pipeline import AnalysisPipeline
26
+ from .export.csv_export import CSVExporter
27
+ from .export.excel_export import ExcelExporter
28
+ from .utils.logger import setup_logger
29
+ from . import visualization
30
+
31
+ logger = setup_logger(__name__)
32
+
33
+ __all__ = [
34
+ 'Orthomosaic',
35
+ 'VegetationIndices',
36
+ 'VegetationIndicesExtended',
37
+ 'PlotSegmentation',
38
+ 'PixelExtraction',
39
+ 'PointCloudProcessor',
40
+ 'AnalysisPipeline',
41
+ 'CSVExporter',
42
+ 'ExcelExporter',
43
+ 'setup_logger',
44
+ 'visualization',
45
+ ]
@@ -0,0 +1,17 @@
1
+ """Core modules for dronelytics package."""
2
+
3
+ from .orthomosaic import Orthomosaic
4
+ from .indices import VegetationIndices
5
+ from .vegetation_indices_extended import VegetationIndicesExtended
6
+ from .segmentation import PlotSegmentation
7
+ from .extraction import PixelExtraction
8
+ from .pointcloud import PointCloudProcessor
9
+
10
+ __all__ = [
11
+ 'Orthomosaic',
12
+ 'VegetationIndices',
13
+ 'VegetationIndicesExtended',
14
+ 'PlotSegmentation',
15
+ 'PixelExtraction',
16
+ 'PointCloudProcessor',
17
+ ]
@@ -0,0 +1,234 @@
1
+ """Pixel-level data extraction from orthomosaic."""
2
+
3
+ import logging
4
+ import numpy as np
5
+ import pandas as pd
6
+ from ..data.structures import ExtractionResult
7
+
8
+ logger = logging.getLogger(__name__)
9
+
10
+
11
+ class PixelExtraction:
12
+ """Extract pixel-level spectral and derived data."""
13
+
14
+ def __init__(self, orthomosaic):
15
+ """Initialize with orthomosaic."""
16
+ self.ortho = orthomosaic
17
+ self.extracted_data = None
18
+
19
+ def extract_spectra(self, mask=None):
20
+ """Extract spectral values for all bands."""
21
+ try:
22
+ logger.info("Extracting spectral data")
23
+
24
+ if mask is None:
25
+ mask = np.ones(self.ortho.get_shape(), dtype=bool)
26
+
27
+ extracted = {}
28
+ for band_name in self.ortho.band_config.keys():
29
+ band_data = self.ortho.get_band(band_name)
30
+ extracted[band_name] = band_data[mask]
31
+
32
+ logger.info(f"Extracted {mask.sum()} pixels")
33
+ self.extracted_data = extracted
34
+
35
+ return ExtractionResult(extracted, mask, {'method': 'spectra'})
36
+
37
+ except Exception as e:
38
+ logger.error(f"Extraction failed: {e}")
39
+ raise
40
+
41
+ def extract_by_coordinates(self, x_coords, y_coords):
42
+ """Extract values at specific pixel coordinates."""
43
+ try:
44
+ logger.info(f"Extracting {len(x_coords)} pixel locations")
45
+
46
+ extracted = {}
47
+ for band_name in self.ortho.band_config.keys():
48
+ band_data = self.ortho.get_band(band_name)
49
+ extracted[band_name] = band_data[y_coords, x_coords]
50
+
51
+ return ExtractionResult(extracted, None, {'method': 'coordinates', 'count': len(x_coords)})
52
+
53
+ except Exception as e:
54
+ logger.error(f"Coordinate extraction failed: {e}")
55
+ raise
56
+
57
+ def to_dataframe(self):
58
+ """Convert extracted data to pandas DataFrame."""
59
+ if self.extracted_data is None:
60
+ raise ValueError("No data extracted yet")
61
+
62
+ df = pd.DataFrame(self.extracted_data)
63
+ logger.info(f"Created DataFrame with shape {df.shape}")
64
+ return df
65
+
66
+ def get_statistics(self):
67
+ """Get statistics of extracted data."""
68
+ if self.extracted_data is None:
69
+ raise ValueError("No data extracted yet")
70
+
71
+ stats = {}
72
+ for band_name, values in self.extracted_data.items():
73
+ stats[band_name] = {
74
+ 'mean': float(np.mean(values)),
75
+ 'std': float(np.std(values)),
76
+ 'min': float(np.min(values)),
77
+ 'max': float(np.max(values)),
78
+ 'count': int(len(values))
79
+ }
80
+
81
+ return stats
82
+
83
+ def extract_from_segmentation(self, segmentation, segments_array):
84
+ """Extract pixel data from segmented regions.
85
+
86
+ Parameters
87
+ ----------
88
+ segmentation : PlotSegmentation
89
+ PlotSegmentation instance with performed segmentation
90
+ segments_array : np.ndarray
91
+ Labeled segments array
92
+
93
+ Returns
94
+ -------
95
+ ExtractionResult
96
+ Extracted data from segments
97
+ """
98
+ try:
99
+ logger.info("Extracting data from segmented regions")
100
+
101
+ # Extract all spectral data
102
+ extracted = {}
103
+ for band_name in self.ortho.band_config.keys():
104
+ band_data = self.ortho.get_band(band_name)
105
+ extracted[band_name] = band_data.flatten()
106
+
107
+ mask = segments_array.flatten() > 0
108
+ self.extracted_data = extracted
109
+
110
+ return ExtractionResult(
111
+ np.column_stack([extracted[b] for b in extracted.keys()]),
112
+ mask,
113
+ {'method': 'segmentation', 'num_segments': len(np.unique(segments_array)) - 1}
114
+ )
115
+
116
+ except Exception as e:
117
+ logger.error(f"Segmentation extraction failed: {e}")
118
+ raise
119
+
120
+ def summarize_by_plot(self, segmentation, segments_array, vegetation_index=None):
121
+ """Create summary statistics by plot (segment).
122
+
123
+ Parameters
124
+ ----------
125
+ segmentation : PlotSegmentation
126
+ PlotSegmentation instance
127
+ segments_array : np.ndarray
128
+ Labeled segments array
129
+ vegetation_index : np.ndarray, optional
130
+ Vegetation index values for statistics
131
+
132
+ Returns
133
+ -------
134
+ pd.DataFrame
135
+ Summary statistics by plot
136
+ """
137
+ try:
138
+ logger.info("Summarizing data by plot")
139
+
140
+ summaries = []
141
+ unique_segments = np.unique(segments_array)
142
+ unique_segments = unique_segments[unique_segments > 0] # Remove background
143
+
144
+ for seg_id in unique_segments:
145
+ mask = segments_array == seg_id
146
+ stats = self._calculate_plot_stats(mask, seg_id, vegetation_index)
147
+ summaries.append(stats)
148
+
149
+ df = pd.DataFrame(summaries)
150
+ logger.info(f"Created summary for {len(summaries)} plots")
151
+ return df
152
+
153
+ except Exception as e:
154
+ logger.error(f"Plot summarization failed: {e}")
155
+ raise
156
+
157
+ def _calculate_plot_stats(self, mask, plot_id, vegetation_index=None):
158
+ """Calculate statistics for a single plot.
159
+
160
+ Parameters
161
+ ----------
162
+ mask : np.ndarray
163
+ Boolean mask for plot pixels
164
+ plot_id : int
165
+ Plot identifier
166
+ vegetation_index : np.ndarray, optional
167
+ Vegetation index array
168
+
169
+ Returns
170
+ -------
171
+ dict
172
+ Statistics dictionary
173
+ """
174
+ stats = {
175
+ 'plot_id': int(plot_id),
176
+ 'pixel_count': int(mask.sum()),
177
+ }
178
+
179
+ # Band statistics
180
+ for band_name in self.ortho.band_config.keys():
181
+ band_data = self.ortho.get_band(band_name)
182
+ pixels = band_data[mask]
183
+ if len(pixels) > 0:
184
+ stats[f'{band_name}_mean'] = float(np.mean(pixels))
185
+ stats[f'{band_name}_std'] = float(np.std(pixels))
186
+ stats[f'{band_name}_min'] = float(np.min(pixels))
187
+ stats[f'{band_name}_max'] = float(np.max(pixels))
188
+
189
+ # Vegetation index statistics if provided
190
+ if vegetation_index is not None:
191
+ vi_pixels = vegetation_index[mask]
192
+ if len(vi_pixels) > 0:
193
+ stats['vi_mean'] = float(np.mean(vi_pixels))
194
+ stats['vi_std'] = float(np.std(vi_pixels))
195
+ stats['vi_min'] = float(np.min(vi_pixels))
196
+ stats['vi_max'] = float(np.max(vi_pixels))
197
+
198
+ return stats
199
+
200
+ def get_plot_statistics(self, plot_id, segmentation, segments_array):
201
+ """Get statistics for a specific plot.
202
+
203
+ Parameters
204
+ ----------
205
+ plot_id : int
206
+ Plot identifier
207
+ segmentation : PlotSegmentation
208
+ PlotSegmentation instance
209
+ segments_array : np.ndarray
210
+ Labeled segments array
211
+
212
+ Returns
213
+ -------
214
+ dict
215
+ Statistics for the plot
216
+ """
217
+ mask = segments_array == plot_id
218
+ return self._calculate_plot_stats(mask, plot_id)
219
+
220
+ def to_csv(self, filepath):
221
+ """Export extracted data to CSV file.
222
+
223
+ Parameters
224
+ ----------
225
+ filepath : str
226
+ Output CSV file path
227
+ """
228
+ try:
229
+ df = self.to_dataframe()
230
+ df.to_csv(filepath, index=False)
231
+ logger.info(f"Data exported to {filepath}")
232
+ except Exception as e:
233
+ logger.error(f"CSV export failed: {e}")
234
+ raise
@@ -0,0 +1,75 @@
1
+ """Standard vegetation indices (legacy, use vegetation_indices_extended instead)."""
2
+
3
+ import logging
4
+ import numpy as np
5
+ from ..data.structures import VegetationIndexData
6
+
7
+ logger = logging.getLogger(__name__)
8
+
9
+
10
+ class VegetationIndices:
11
+ """Calculate standard vegetation indices (4-band support)."""
12
+
13
+ def __init__(self, orthomosaic):
14
+ """Initialize with orthomosaic."""
15
+ self.ortho = orthomosaic
16
+ self.results = {}
17
+
18
+ def _safe_divide(self, numerator, denominator):
19
+ """Safely divide avoiding division by zero."""
20
+ epsilon = 1e-10 + np.abs(denominator).max() * 1e-12
21
+ return np.divide(numerator, denominator + epsilon, where=denominator != 0)
22
+
23
+ def _check_bands(self, required_bands):
24
+ """Check if required bands exist."""
25
+ missing = [b for b in required_bands if b not in self.ortho.band_config]
26
+ if missing:
27
+ raise ValueError(f"Missing bands: {missing}")
28
+
29
+ def ndvi(self):
30
+ """Normalized Difference Vegetation Index."""
31
+ self._check_bands(['red', 'nir'])
32
+ nir = self.ortho.get_band('nir')
33
+ red = self.ortho.get_band('red')
34
+ ndvi = self._safe_divide(nir - red, nir + red)
35
+ self.results['ndvi'] = VegetationIndexData('NDVI', ndvi, ndvi.mean(), ndvi.std(), ndvi.min(), ndvi.max(), ndvi.size)
36
+ return self.results['ndvi']
37
+
38
+ def ndre(self):
39
+ """Normalized Difference Red Edge Index."""
40
+ self._check_bands(['nir', 'red_edge'])
41
+ nir = self.ortho.get_band('nir')
42
+ red_edge = self.ortho.get_band('red_edge')
43
+ ndre = self._safe_divide(nir - red_edge, nir + red_edge)
44
+ self.results['ndre'] = VegetationIndexData('NDRE', ndre, ndre.mean(), ndre.std(), ndre.min(), ndre.max(), ndre.size)
45
+ return self.results['ndre']
46
+
47
+ def gndvi(self):
48
+ """Green Normalized Difference Vegetation Index."""
49
+ self._check_bands(['nir', 'green'])
50
+ nir = self.ortho.get_band('nir')
51
+ green = self.ortho.get_band('green')
52
+ gndvi = self._safe_divide(nir - green, nir + green)
53
+ self.results['gndvi'] = VegetationIndexData('GNDVI', gndvi, gndvi.mean(), gndvi.std(), gndvi.min(), gndvi.max(), gndvi.size)
54
+ return self.results['gndvi']
55
+
56
+ # Alias methods for consistency with documentation
57
+ def calculate_ndvi(self):
58
+ """Alias for ndvi()."""
59
+ return self.ndvi()
60
+
61
+ def calculate_ndre(self):
62
+ """Alias for ndre()."""
63
+ return self.ndre()
64
+
65
+ def calculate_gndvi(self):
66
+ """Alias for gndvi()."""
67
+ return self.gndvi()
68
+
69
+ def get(self, name):
70
+ """Get calculated index by name."""
71
+ return self.results.get(name.lower())
72
+
73
+ def get_all(self):
74
+ """Get all calculated indices."""
75
+ return self.results
@@ -0,0 +1,77 @@
1
+ """Orthomosaic loading and management."""
2
+
3
+ import logging
4
+ import numpy as np
5
+ import rasterio
6
+ from pathlib import Path
7
+
8
+ logger = logging.getLogger(__name__)
9
+
10
+
11
+ class Orthomosaic:
12
+ """Load and manage multispectral orthomosaic data."""
13
+
14
+ def __init__(self, filepath, band_config=None):
15
+ """
16
+ Initialize orthomosaic.
17
+
18
+ Parameters
19
+ ----------
20
+ filepath : str
21
+ Path to GeoTIFF file
22
+ band_config : dict
23
+ Band configuration mapping (e.g., {'red': 1, 'nir': 4})
24
+ """
25
+ self.filepath = Path(filepath)
26
+ if not self.filepath.exists():
27
+ raise FileNotFoundError(f"File not found: {filepath}")
28
+
29
+ self.band_config = band_config or {}
30
+ self.data = None
31
+ self.metadata = {}
32
+ self._load()
33
+
34
+ def _load(self):
35
+ """Load GeoTIFF file."""
36
+ try:
37
+ with rasterio.open(self.filepath) as src:
38
+ self.data = src.read()
39
+ self.metadata = src.meta
40
+ self.transform = src.transform
41
+ self.crs = src.crs
42
+ logger.info(f"Loaded {self.filepath}: shape {self.data.shape}")
43
+ except Exception as e:
44
+ logger.error(f"Failed to load {self.filepath}: {e}")
45
+ raise
46
+
47
+ def get_band(self, band_name):
48
+ """Get band data by name."""
49
+ if band_name not in self.band_config:
50
+ raise ValueError(f"Band '{band_name}' not in config")
51
+
52
+ band_idx = self.band_config[band_name]
53
+ return self.data[band_idx - 1].astype(np.float32)
54
+
55
+ def get_shape(self):
56
+ """Get data shape."""
57
+ return self.data.shape[1:]
58
+
59
+ def get_transform(self):
60
+ """Get geospatial transform."""
61
+ return self.transform
62
+
63
+ def get_crs(self):
64
+ """Get coordinate reference system."""
65
+ return self.crs
66
+
67
+ def close(self):
68
+ """Close and cleanup."""
69
+ self.data = None
70
+ logger.info("Orthomosaic closed")
71
+
72
+ def clear_cache(self):
73
+ """Clear cache."""
74
+ self.data = None
75
+
76
+ def __repr__(self):
77
+ return f"Orthomosaic({self.filepath.name}, shape={self.data.shape})"