Skip to content

Commit bbc4232

Browse files
authored
Merge pull request #214 from siapy/fix
2 parents 6c78cc8 + 34bcab5 commit bbc4232

26 files changed

+805
-227
lines changed

.pre-commit-config.yaml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ repos:
1414
- id: end-of-file-fixer
1515
- id: trailing-whitespace
1616
- repo: https://github.com/astral-sh/ruff-pre-commit
17-
rev: v0.11.4
17+
rev: v0.11.6
1818
hooks:
1919
- id: ruff
2020
args:
@@ -23,7 +23,7 @@ repos:
2323
files: ^(siapy|tests)/
2424
- id: ruff-format
2525
- repo: https://github.com/gitleaks/gitleaks
26-
rev: v8.24.2
26+
rev: v8.24.3
2727
hooks:
2828
- id: gitleaks
2929
- repo: https://github.com/codespell-project/codespell

docs/api/entities/helpers.md

Lines changed: 0 additions & 1 deletion
This file was deleted.

docs/api/utils/image_validators.md

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

docs/api/utils/signatures.md

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

docs/examples/src/spectral_image_02.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@
2323
print("Image shape:", image_np.shape)
2424

2525
# Calculate mean
26-
mean_val = image.mean(axis=(0, 1))
26+
mean_val = image.average_intensity(axis=(0, 1))
2727
print("Mean value per band:", mean_val)
2828

2929
# Create a Pixels object from an iterable with pixels coordinates

mkdocs.yml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -93,7 +93,6 @@ nav:
9393
- Image Sets: api/entities/imagesets.md
9494
- Pixels: api/entities/pixels.md
9595
- Signatures: api/entities/signatures.md
96-
- Helpers: api/entities/helpers.md
9796
- Features:
9897
- Features: api/features/features.md
9998
- Helpers: api/features/helpers.md
@@ -110,7 +109,9 @@ nav:
110109
- Image: api/transformations/image.md
111110
- Utils:
112111
- Images: api/utils/images.md
112+
- Images Validators: api/utils/image_validators.md
113113
- Plots: api/utils/plots.md
114+
- Signatures: api/utils/signatures.md
114115
- Release Notes: changelog.md
115116
- License: permit.md
116117

siapy/datasets/tabular.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +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
12+
from siapy.utils.signatures import get_signatures_within_convex_hull
1313

1414
__all__ = [
1515
"TabularDataset",

siapy/entities/images/spimage.py

Lines changed: 25 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,13 @@
11
from dataclasses import dataclass
22
from pathlib import Path
3-
from typing import TYPE_CHECKING, Any, Generic, Sequence, TypeVar
3+
from typing import TYPE_CHECKING, Any, Generic, Iterable, Sequence, TypeVar
44

55
import numpy as np
6+
import pandas as pd
67
from numpy.typing import NDArray
78
from PIL import Image
89

10+
from ..pixels import CoordinateInput, Pixels, validate_pixel_input
911
from ..shapes import GeometricShapes, Shape
1012
from ..signatures import Signatures
1113
from .interfaces import ImageBase
@@ -16,8 +18,6 @@
1618
if TYPE_CHECKING:
1719
from siapy.core.types import XarrayType
1820

19-
from ..pixels import Pixels
20-
2121

2222
__all__ = [
2323
"SpectralImage",
@@ -47,6 +47,13 @@ def __eq__(self, other: Any) -> bool:
4747
return NotImplemented
4848
return self.filepath.name == other.filepath.name and self._image == other._image
4949

50+
def __array__(self, dtype: np.dtype | None = None) -> NDArray[np.floating[Any]]:
51+
"""Convert this spectral image to a numpy array when requested by NumPy."""
52+
array = self.to_numpy()
53+
if dtype is not None:
54+
return array.astype(dtype)
55+
return array
56+
5057
@classmethod
5158
def spy_open(
5259
cls, *, header_path: str | Path, image_path: str | Path | None = None
@@ -117,26 +124,30 @@ def to_numpy(self, nan_value: float | None = None) -> NDArray[np.floating[Any]]:
117124
def to_xarray(self) -> "XarrayType":
118125
return self.image.to_xarray()
119126

120-
def to_signatures(self, pixels: "Pixels") -> Signatures:
127+
def to_signatures(self, pixels: Pixels | pd.DataFrame | Iterable[CoordinateInput]) -> Signatures:
128+
pixels = validate_pixel_input(pixels)
121129
image_arr = self.to_numpy()
122130
signatures = Signatures.from_array_and_pixels(image_arr, pixels)
123131
return signatures
124132

125-
def to_subarray(self, pixels: "Pixels") -> NDArray[np.floating[Any]]:
133+
def to_subarray(self, pixels: Pixels | pd.DataFrame | Iterable[CoordinateInput]) -> NDArray[np.floating[Any]]:
134+
pixels = validate_pixel_input(pixels)
126135
image_arr = self.to_numpy()
127-
u_max = pixels.x().max()
128-
u_min = pixels.x().min()
129-
v_max = pixels.y().max()
130-
v_min = pixels.y().min()
136+
x_max = pixels.x().max()
137+
x_min = pixels.x().min()
138+
y_max = pixels.y().max()
139+
y_min = pixels.y().min()
131140
# create new image
132-
image_arr_area = np.nan * np.ones((int(v_max - v_min + 1), int(u_max - u_min + 1), self.bands))
141+
image_arr_area = np.nan * np.ones((int(y_max - y_min + 1), int(x_max - x_min + 1), self.bands))
133142
# convert original coordinates to coordinates for new image
134-
v_norm = pixels.y() - v_min
135-
u_norm = pixels.x() - u_min
143+
y_norm = pixels.y() - y_min
144+
x_norm = pixels.x() - x_min
136145
# write values from original image to new image
137-
image_arr_area[v_norm, u_norm, :] = image_arr[pixels.y(), pixels.x(), :]
146+
image_arr_area[y_norm, x_norm, :] = image_arr[pixels.y(), pixels.x(), :]
138147
return image_arr_area
139148

140-
def mean(self, axis: int | tuple[int, ...] | Sequence[int] | None = None) -> float | NDArray[np.floating[Any]]:
149+
def average_intensity(
150+
self, axis: int | tuple[int, ...] | Sequence[int] | None = None
151+
) -> float | NDArray[np.floating[Any]]:
141152
image_arr = self.to_numpy()
142153
return np.nanmean(image_arr, axis=axis)

siapy/entities/pixels.py

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,9 @@
1313
__all__ = [
1414
"Pixels",
1515
"PixelCoordinate",
16+
"CoordinateInput",
17+
"HomogeneousCoordinate",
18+
"validate_pixel_input",
1619
]
1720

1821

@@ -53,6 +56,13 @@ def __eq__(self, other: Any) -> bool:
5356
return False
5457
return self.df.equals(other.df)
5558

59+
def __array__(self, dtype: np.dtype | None = None) -> NDArray[np.floating[Any]]:
60+
"""Convert this pixels object to a numpy array when requested by NumPy."""
61+
array = self.to_numpy()
62+
if dtype is not None:
63+
return array.astype(dtype)
64+
return array
65+
5666
def __post_init__(self) -> None:
5767
validate_pixel_input_dimensions(self._data)
5868

@@ -128,3 +138,42 @@ def validate_pixel_input_dimensions(df: pd.DataFrame | pd.Series) -> None:
128138
message=f"Invalid column names: expected ['{HomogeneousCoordinate.X}', '{HomogeneousCoordinate.Y}'], got",
129139
input_value=sorted(df.columns),
130140
)
141+
142+
143+
def validate_pixel_input(input_data: Pixels | pd.DataFrame | Iterable[CoordinateInput]) -> Pixels:
144+
"""Validates and converts various input types to Pixels object."""
145+
try:
146+
if isinstance(input_data, Pixels):
147+
return input_data
148+
149+
if isinstance(input_data, pd.DataFrame):
150+
validate_pixel_input_dimensions(input_data)
151+
return Pixels(input_data)
152+
153+
if isinstance(input_data, np.ndarray):
154+
if input_data.ndim != 2 or input_data.shape[1] != 2:
155+
raise InvalidInputError(
156+
input_value=input_data.shape,
157+
message=f"NumPy array must be 2D with shape (n, 2), got shape {input_data.shape}",
158+
)
159+
return Pixels(pd.DataFrame(input_data, columns=[HomogeneousCoordinate.X, HomogeneousCoordinate.Y]))
160+
161+
if isinstance(input_data, Iterable):
162+
return Pixels.from_iterable(input_data) # type: ignore
163+
164+
raise InvalidTypeError(
165+
input_value=input_data,
166+
allowed_types=(Pixels, pd.DataFrame, np.ndarray, Iterable),
167+
message=f"Unsupported input type: {type(input_data).__name__}",
168+
)
169+
170+
except Exception as e:
171+
if isinstance(e, (InvalidTypeError, InvalidInputError)):
172+
raise
173+
174+
raise InvalidInputError(
175+
input_value=input_data,
176+
message=f"Failed to convert input to Pixels: {str(e)}"
177+
f"\nExpected a Pixels instance or an iterable (e.g. list, np.array, tuple, pd.DataFrame)."
178+
f"\nThe input must contain 2D coordinates with x and y values.",
179+
)

siapy/entities/shapes/shape.py

Lines changed: 35 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
from dataclasses import dataclass
22
from enum import Enum
33
from pathlib import Path
4-
from typing import Any, Optional
4+
from typing import Any, Iterable, Optional
55

66
import geopandas as gpd
77
import numpy as np
@@ -11,7 +11,7 @@
1111
from shapely.geometry.base import BaseGeometry
1212

1313
from siapy.core.exceptions import ConfigurationError, InvalidFilepathError, InvalidInputError, InvalidTypeError
14-
from siapy.entities.pixels import PixelCoordinate, Pixels
14+
from siapy.entities.pixels import CoordinateInput, PixelCoordinate, Pixels, validate_pixel_input
1515

1616
__all__ = [
1717
"Shape",
@@ -72,6 +72,13 @@ def __repr__(self) -> str:
7272
def __len__(self) -> int:
7373
return len(self.df)
7474

75+
def __array__(self, dtype: np.dtype | None = None) -> NDArray[np.floating[Any]]:
76+
"""Convert this shape object to a numpy array when requested by NumPy."""
77+
array = self.to_numpy()
78+
if dtype is not None:
79+
return array.astype(dtype)
80+
return array
81+
7582
@classmethod
7683
def open_shapefile(cls, filepath: str | Path, label: str = "") -> "Shape":
7784
filepath = Path(filepath)
@@ -108,30 +115,44 @@ def from_point(cls, x: float, y: float, label: str = "") -> "Shape":
108115
return cls(geometry=Point(x, y), label=label)
109116

110117
@classmethod
111-
def from_multipoint(cls, points: "Pixels", label: str = "") -> "Shape":
118+
def from_multipoint(cls, points: Pixels | pd.DataFrame | Iterable[CoordinateInput], label: str = "") -> "Shape":
119+
points = validate_pixel_input(points)
112120
if len(points) < 1:
113121
raise ConfigurationError("At least one point is required")
114122
coords = points.to_list()
115123
return cls(geometry=MultiPoint(coords), label=label)
116124

117125
@classmethod
118-
def from_line(cls, pixels: Pixels, label: str = "") -> "Shape":
126+
def from_line(cls, pixels: Pixels | pd.DataFrame | Iterable[CoordinateInput], label: str = "") -> "Shape":
127+
pixels = validate_pixel_input(pixels)
119128
if len(pixels) < 2:
120129
raise ConfigurationError("At least two points are required for a line")
121130

122131
return cls(geometry=LineString(pixels.to_list()), label=label)
123132

124133
@classmethod
125-
def from_multiline(cls, line_segments: list[Pixels], label: str = "") -> "Shape":
134+
def from_multiline(
135+
cls, line_segments: list[Pixels | pd.DataFrame | Iterable[CoordinateInput]], label: str = ""
136+
) -> "Shape":
126137
if not line_segments:
127138
raise ConfigurationError("At least one line segment is required")
128139

129-
lines = [LineString(segment.to_list()) for segment in line_segments]
140+
lines = []
141+
for segment in line_segments:
142+
validated_segment = validate_pixel_input(segment)
143+
lines.append(LineString(validated_segment.to_list()))
144+
130145
multi_line = MultiLineString(lines)
131146
return cls(geometry=multi_line, label=label)
132147

133148
@classmethod
134-
def from_polygon(cls, exterior: Pixels, holes: Optional[list[Pixels]] = None, label: str = "") -> "Shape":
149+
def from_polygon(
150+
cls,
151+
exterior: Pixels | pd.DataFrame | Iterable[CoordinateInput],
152+
holes: Optional[list[Pixels | pd.DataFrame | Iterable[CoordinateInput]]] = None,
153+
label: str = "",
154+
) -> "Shape":
155+
exterior = validate_pixel_input(exterior)
135156
if len(exterior) < 3:
136157
raise ConfigurationError("At least three points are required for a polygon")
137158

@@ -144,7 +165,8 @@ def from_polygon(cls, exterior: Pixels, holes: Optional[list[Pixels]] = None, la
144165
# Close each hole if not already closed
145166
closed_holes = []
146167
for hole in holes:
147-
hole_coords = hole.to_list()
168+
validated_hole = validate_pixel_input(hole)
169+
hole_coords = validated_hole.to_list()
148170
if hole_coords[0] != hole_coords[-1]:
149171
hole_coords.append(hole_coords[0])
150172
closed_holes.append(hole_coords)
@@ -155,13 +177,16 @@ def from_polygon(cls, exterior: Pixels, holes: Optional[list[Pixels]] = None, la
155177
return cls(geometry=geometry, label=label)
156178

157179
@classmethod
158-
def from_multipolygon(cls, polygons: list[Pixels], label: str = "") -> "Shape":
180+
def from_multipolygon(
181+
cls, polygons: list[Pixels | pd.DataFrame | Iterable[CoordinateInput]], label: str = ""
182+
) -> "Shape":
159183
if not polygons:
160184
raise ConfigurationError("At least one polygon is required")
161185

162186
polygon_objects = []
163187
for pixels in polygons:
164-
coords = pixels.to_list()
188+
validated_pixels = validate_pixel_input(pixels)
189+
coords = validated_pixels.to_list()
165190
# Close the polygon if not already closed
166191
if coords[0] != coords[-1]:
167192
coords.append(coords[0])

0 commit comments

Comments
 (0)