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.
- dronelytics/__init__.py +45 -0
- dronelytics/core/__init__.py +17 -0
- dronelytics/core/extraction.py +234 -0
- dronelytics/core/indices.py +75 -0
- dronelytics/core/orthomosaic.py +77 -0
- dronelytics/core/pointcloud.py +301 -0
- dronelytics/core/segmentation.py +170 -0
- dronelytics/core/vegetation_indices_extended.py +206 -0
- dronelytics/data/__init__.py +3 -0
- dronelytics/data/structures.py +47 -0
- dronelytics/export/__init__.py +4 -0
- dronelytics/export/csv_export.py +11 -0
- dronelytics/export/excel_export.py +94 -0
- dronelytics/processing/__init__.py +3 -0
- dronelytics/processing/pipeline.py +194 -0
- dronelytics/utils/__init__.py +5 -0
- dronelytics/utils/logger.py +16 -0
- dronelytics/visualization/__init__.py +11 -0
- dronelytics/visualization/vis3d.py +203 -0
- dronelytics-1.0.1.dist-info/METADATA +298 -0
- dronelytics-1.0.1.dist-info/RECORD +30 -0
- dronelytics-1.0.1.dist-info/WHEEL +5 -0
- dronelytics-1.0.1.dist-info/licenses/LICENSE +21 -0
- dronelytics-1.0.1.dist-info/top_level.txt +3 -0
- examples/__init__.py +1 -0
- examples/advanced_workflow.py +151 -0
- examples/basic_workflow.py +98 -0
- examples/visualization_example.py +164 -0
- tests/__init__.py +1 -0
- tests/test_core.py +225 -0
dronelytics/__init__.py
ADDED
|
@@ -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})"
|