Skip to content

Commit dccee33

Browse files
authored
Merge pull request #209 from siapy/fix
2 parents bbecd03 + f3979d2 commit dccee33

20 files changed

+467
-194
lines changed

Makefile

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,10 @@ generate-docs:
5858

5959
.PHONY: serve-docs ## Serve the docs
6060
serve-docs:
61+
pdm run mkdocs serve
62+
63+
.PHONY: serve-docs-mike ## Serve the docs using mike
64+
serve-docs-mike:
6165
pdm run mike serve
6266

6367
.PHONY: version ## Check project version

docs/api/entities/helpers.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
::: siapy.entities.helpers

docs/api/entities/images/mock.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
::: siapy.entities.images.mock

mkdocs.yml

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -87,19 +87,21 @@ nav:
8787
- Interfaces: api/entities/images/interfaces.md
8888
- Rasterio Library: api/entities/images/rasterio_lib.md
8989
- Spectral Library: api/entities/images/spectral_lib.md
90+
- Mock Image: api/entities/images/mock.md
9091
- Spectral Images: api/entities/images/spimage.md
9192
- Shape: api/entities/shapes/shape.md
9293
- Image Sets: api/entities/imagesets.md
9394
- Pixels: api/entities/pixels.md
9495
- Signatures: api/entities/signatures.md
96+
- Helpers: api/entities/helpers.md
9597
- Features:
9698
- Features: api/features/features.md
9799
- Helpers: api/features/helpers.md
98100
- Spectral Indices: api/features/spectral_indices.md
99101
- Optimizers:
100102
- Configs: api/optimizers/configs.md
101103
- Evaluators: api/optimizers/evaluators.md
102-
- Metrics: api/models/metrics.md
104+
- Metrics: api/optimizers/metrics.md
103105
- Optimizers: api/optimizers/optimizers.md
104106
- Parameters: api/optimizers/parameters.md
105107
- Scorers: api/optimizers/scorers.md

siapy/datasets/tabular.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
from siapy.core.types import ImageContainerType
1010
from siapy.datasets.schemas import TabularDatasetData
1111
from siapy.entities import Signatures, SpectralImage, SpectralImageSet
12+
from siapy.entities.helpers import get_signatures_within_convex_hull
1213

1314
__all__ = [
1415
"TabularDataset",
@@ -62,9 +63,8 @@ def process_image_data(self) -> None:
6263
self.data_entities.clear()
6364
for image_idx, image in enumerate(self.image_set):
6465
for shape_idx, shape in enumerate(image.geometric_shapes.shapes):
65-
convex_hulls = shape.get_pixels_within_convex_hull()
66-
for geometry_idx, pixels in enumerate(convex_hulls):
67-
signatures = image.to_signatures(pixels)
66+
signatures_hull = get_signatures_within_convex_hull(image, shape)
67+
for geometry_idx, signatures in enumerate(signatures_hull):
6868
entity = TabularDataEntity(
6969
image_idx=image_idx,
7070
shape_idx=shape_idx,

siapy/entities/helpers.py

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
import itertools
2+
3+
from shapely.geometry import MultiPoint, Point
4+
from shapely.prepared import prep as shapely_prep
5+
6+
from siapy.core.exceptions import InvalidTypeError
7+
from siapy.entities import Shape, Signatures, SpectralImage
8+
from siapy.entities.pixels import Pixels
9+
from siapy.entities.signatures import Signals
10+
11+
12+
def get_signatures_within_convex_hull(image: SpectralImage, shape: Shape) -> list[Signatures]:
13+
image_xarr = image.to_xarray()
14+
signatures = []
15+
16+
if shape.is_point:
17+
for g in shape.geometry:
18+
if isinstance(g, MultiPoint):
19+
points = list(g.geoms)
20+
elif isinstance(g, Point):
21+
points = [g]
22+
else:
23+
raise InvalidTypeError(
24+
input_value=g,
25+
allowed_types=(Point, MultiPoint),
26+
message="Geometry must be Point or MultiPoint",
27+
)
28+
signals = []
29+
pixels = []
30+
for p in points:
31+
signals.append(image_xarr.sel(x=p.x, y=p.y, method="nearest").values)
32+
pixels.append((p.x, p.y))
33+
34+
signatures.append(Signatures(Pixels.from_iterable(pixels), Signals.from_iterable(signals)))
35+
36+
else:
37+
for hull in shape.convex_hull:
38+
minx, miny, maxx, maxy = hull.bounds
39+
40+
x_coords = image_xarr.x[(image_xarr.x >= minx) & (image_xarr.x <= maxx)].values
41+
y_coords = image_xarr.y[(image_xarr.y >= miny) & (image_xarr.y <= maxy)].values
42+
43+
if len(x_coords) == 0 or len(y_coords) == 0:
44+
continue
45+
46+
# Create a prepared geometry for faster contains check
47+
prepared_hull = shapely_prep(hull)
48+
49+
signals = []
50+
pixels = []
51+
for x, y in itertools.product(x_coords, y_coords):
52+
point = Point(x, y)
53+
# Check if point is: inside the hull or intersects with the hull
54+
if prepared_hull.contains(point) or prepared_hull.intersects(point):
55+
try:
56+
signal = image_xarr.sel(x=x, y=y).values
57+
except (KeyError, IndexError):
58+
continue
59+
signals.append(signal)
60+
pixels.append((x, y))
61+
62+
signatures.append(Signatures(Pixels.from_iterable(pixels), Signals.from_iterable(signals)))
63+
64+
return signatures

siapy/entities/images/mock.py

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
from pathlib import Path
2+
from typing import TYPE_CHECKING, Any
3+
4+
import numpy as np
5+
import xarray as xr
6+
from numpy.typing import NDArray
7+
from PIL import Image
8+
9+
from siapy.core.exceptions import InvalidInputError
10+
from siapy.entities.images.interfaces import ImageBase
11+
12+
if TYPE_CHECKING:
13+
from siapy.core.types import XarrayType
14+
15+
16+
class MockImage(ImageBase):
17+
def __init__(
18+
self,
19+
array: NDArray[np.floating[Any]],
20+
) -> None:
21+
if len(array.shape) != 3:
22+
raise InvalidInputError(
23+
input_value=array.shape,
24+
message="Input array must be 3-dimensional (height, width, bands)",
25+
)
26+
27+
self._array = array.astype(np.float32)
28+
29+
@classmethod
30+
def open(cls, array: NDArray[np.floating[Any]]) -> "MockImage":
31+
return cls(array=array)
32+
33+
@property
34+
def filepath(self) -> Path:
35+
return Path()
36+
37+
@property
38+
def metadata(self) -> dict[str, Any]:
39+
return {}
40+
41+
@property
42+
def shape(self) -> tuple[int, int, int]:
43+
x = self._array.shape[1]
44+
y = self._array.shape[0]
45+
bands = self._array.shape[2]
46+
return (y, x, bands)
47+
48+
@property
49+
def bands(self) -> int:
50+
return self._array.shape[2]
51+
52+
@property
53+
def default_bands(self) -> list[int]:
54+
if self.bands >= 3:
55+
return [0, 1, 2]
56+
return list(range(min(3, self.bands)))
57+
58+
@property
59+
def wavelengths(self) -> list[float]:
60+
return list(range(self.bands))
61+
62+
@property
63+
def camera_id(self) -> str:
64+
return ""
65+
66+
def to_display(self, equalize: bool = True) -> Image.Image:
67+
if self.bands >= 3:
68+
display_bands = self._array[:, :, self.default_bands]
69+
else:
70+
display_bands = np.stack([self._array[:, :, 0]] * 3, axis=2)
71+
72+
if equalize:
73+
for i in range(display_bands.shape[2]):
74+
band = display_bands[:, :, i]
75+
non_nan = ~np.isnan(band)
76+
if np.any(non_nan):
77+
min_val = np.nanmin(band)
78+
max_val = np.nanmax(band)
79+
if max_val > min_val:
80+
band = (band - min_val) / (max_val - min_val) * 255
81+
display_bands[:, :, i] = band
82+
83+
display_array = np.nan_to_num(display_bands).astype(np.uint8)
84+
return Image.fromarray(display_array)
85+
86+
def to_numpy(self, nan_value: float | None = None) -> NDArray[np.floating[Any]]:
87+
if nan_value is not None:
88+
return np.nan_to_num(self._array, nan=nan_value)
89+
return self._array.copy()
90+
91+
def to_xarray(self) -> "XarrayType":
92+
return xr.DataArray(
93+
self._array,
94+
dims=["y", "x", "band"],
95+
coords={
96+
"band": self.wavelengths,
97+
"x": np.arange(self.shape[1]),
98+
"y": np.arange(self.shape[0]),
99+
},
100+
attrs={
101+
"camera_id": self.camera_id,
102+
},
103+
)

siapy/entities/images/spimage.py

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
from ..shapes import GeometricShapes, Shape
1010
from ..signatures import Signatures
1111
from .interfaces import ImageBase
12+
from .mock import MockImage
1213
from .rasterio_lib import RasterioLibImage
1314
from .spectral_lib import SpectralLibImage
1415

@@ -36,10 +37,7 @@ def __init__(
3637
self._geometric_shapes = GeometricShapes(self, geometric_shapes)
3738

3839
def __repr__(self) -> str:
39-
return repr(self._image)
40-
41-
def __str__(self) -> str:
42-
return str(self._image)
40+
return f"SpectralImage(\n{self.image}\n)"
4341

4442
def __lt__(self, other: "SpectralImage[Any]") -> bool:
4543
return self.filepath.name < other.filepath.name
@@ -61,6 +59,11 @@ def rasterio_open(cls, filepath: str | Path) -> "SpectralImage[RasterioLibImage]
6159
image = RasterioLibImage.open(filepath)
6260
return SpectralImage(image)
6361

62+
@classmethod
63+
def from_numpy(cls, array: NDArray[np.floating[Any]]) -> "SpectralImage[MockImage]":
64+
image = MockImage.open(array)
65+
return SpectralImage(image)
66+
6467
@property
6568
def image(self) -> T:
6669
return self._image
@@ -81,6 +84,14 @@ def metadata(self) -> dict[str, Any]:
8184
def shape(self) -> tuple[int, int, int]:
8285
return self.image.shape
8386

87+
@property
88+
def width(self) -> int:
89+
return self.shape[1]
90+
91+
@property
92+
def height(self) -> int:
93+
return self.shape[0]
94+
8495
@property
8596
def bands(self) -> int:
8697
return self.image.bands

siapy/entities/shapes/shape.py

Lines changed: 6 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,14 @@
1+
from dataclasses import dataclass
12
from enum import Enum
23
from pathlib import Path
3-
from typing import Optional, Any
4+
from typing import Any, Optional
45

56
import geopandas as gpd
67
import numpy as np
78
import pandas as pd
89
from numpy.typing import NDArray
910
from shapely.geometry import LineString, MultiLineString, MultiPoint, MultiPolygon, Point, Polygon
1011
from shapely.geometry.base import BaseGeometry
11-
from shapely.prepared import prep as shapely_prep
1212

1313
from siapy.core.exceptions import ConfigurationError, InvalidFilepathError, InvalidInputError, InvalidTypeError
1414
from siapy.entities.pixels import PixelCoordinate, Pixels
@@ -38,6 +38,7 @@ class ShapeGeometryEnum(Enum):
3838
MULTIPOLYGON = "multipolygon"
3939

4040

41+
@dataclass
4142
class Shape:
4243
"""
4344
Unified shape class that can be created from shapefiles or programmatically.
@@ -65,6 +66,9 @@ def __init__(
6566
else:
6667
raise ConfigurationError("Must provide either geometry or geodataframe")
6768

69+
def __repr__(self) -> str:
70+
return f"Shape(label='{self.label}', geometry_type={self.shape_type})"
71+
6872
def __len__(self) -> int:
6973
return len(self.df)
7074

@@ -285,50 +289,3 @@ def to_file(self, filepath: str | Path, driver: str = "ESRI Shapefile") -> None:
285289

286290
def to_numpy(self) -> NDArray[np.floating[Any]]:
287291
return self.df.to_numpy()
288-
289-
def get_pixels_within_convex_hull(self, resolution: float = 1.0) -> list[Pixels]:
290-
pixels: list[Pixels] = []
291-
292-
if self.is_point:
293-
for g in self.geometry:
294-
if isinstance(g, MultiPoint):
295-
points = list(g.geoms)
296-
elif isinstance(g, Point):
297-
points = [g]
298-
else:
299-
raise InvalidTypeError(
300-
input_value=g,
301-
allowed_types=(Point, MultiPoint),
302-
message="Geometry must be Point or MultiPoint",
303-
)
304-
pixels.append(Pixels.from_iterable([(p.x, p.y) for p in points]))
305-
306-
return pixels
307-
308-
if resolution <= 0:
309-
raise InvalidInputError({"resolution": resolution}, "Resolution must be positive")
310-
311-
for hull in self.convex_hull:
312-
minx, miny, maxx, maxy = hull.bounds
313-
314-
u_min = np.ceil(minx / resolution) * resolution
315-
v_min = np.ceil(miny / resolution) * resolution
316-
u_max = np.floor(maxx / resolution) * resolution
317-
v_max = np.floor(maxy / resolution) * resolution
318-
319-
# Creating a prepared geometry improves performance for contains checks
320-
hull_prep = shapely_prep(hull)
321-
322-
u_values = np.arange(u_min, u_max + resolution / 2, resolution)
323-
v_values = np.arange(v_min, v_max + resolution / 2, resolution)
324-
325-
contained_points = []
326-
for u in u_values:
327-
for v in v_values:
328-
point = Point(u, v)
329-
if hull_prep.contains(point) or hull_prep.intersects(point):
330-
contained_points.append((u, v))
331-
332-
pixels.append(Pixels.from_iterable(contained_points))
333-
334-
return pixels

siapy/entities/signatures.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
from dataclasses import dataclass
22
from pathlib import Path
3-
from typing import Any
3+
from typing import Any, Iterable
44

55
import numpy as np
66
import pandas as pd
@@ -32,6 +32,11 @@ def __getitem__(self, indices: Any) -> "Signals":
3232
df_slice = df_slice.to_frame().T
3333
return Signals(df_slice)
3434

35+
@classmethod
36+
def from_iterable(cls, iterable: Iterable) -> "Signals":
37+
df = pd.DataFrame(iterable)
38+
return cls(df)
39+
3540
@classmethod
3641
def load_from_parquet(cls, filepath: str | Path) -> "Signals":
3742
df = pd.read_parquet(filepath)

0 commit comments

Comments
 (0)