Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion sahi/predict.py
Original file line number Diff line number Diff line change
Expand Up @@ -397,7 +397,9 @@ def get_sliced_prediction(

if num_slices > 1 and perform_standard_pred:
prediction_result = get_prediction(
image=image,
# the slicing pass already decoded this, and every slice is a view
# into it, so reusing it costs nothing and saves a second decode
image=slice_image_result.original_image if slice_image_result.original_image is not None else image,
detection_model=detection_model,
shift_amount=[0, 0],
full_shape=[
Expand Down
17 changes: 14 additions & 3 deletions sahi/prediction.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@

from sahi.annotation import ObjectAnnotation
from sahi.utils.coco import CocoPrediction
from sahi.utils.cv import read_image_as_pil, visualize_object_predictions
from sahi.utils.cv import read_image_as_pil, read_image_size, visualize_object_predictions
from sahi.utils.file import Path


Expand Down Expand Up @@ -202,11 +202,22 @@ def __init__(
durations_in_seconds: dict[str, Any]
Elapsed times for profiling (e.g. inference, postprocess).
"""
self.image: Image.Image = read_image_as_pil(image)
self.image_width, self.image_height = self.image.size
# Only the size is needed to build a result, and a path can give that from
# its header. Decoding is deferred to the callers that want the pixels,
# which for a sliced run over a large image is usually none of them.
self._image_source = image
self._image: Image.Image | None = None
self.image_width, self.image_height = read_image_size(image)
self.object_prediction_list: list[ObjectPrediction] = object_prediction_list
self.durations_in_seconds = durations_in_seconds

@property
def image(self) -> Image.Image:
"""The source image, decoded on first access and kept thereafter."""
if self._image is None:
self._image = read_image_as_pil(self._image_source)
return self._image

def export_visuals(
self,
export_dir: str,
Expand Down
16 changes: 14 additions & 2 deletions sahi/slicing.py
Original file line number Diff line number Diff line change
Expand Up @@ -170,18 +170,28 @@ def __init__(self, image: np.ndarray, coco_image: CocoImage, starting_pixel: lis
class SliceImageResult:
"""Container for sliced image results."""

def __init__(self, original_image_size: list[int], image_dir: str | None = None) -> None:
def __init__(
self,
original_image_size: list[int],
image_dir: str | None = None,
original_image: np.ndarray | None = None,
) -> None:
"""Initialize SliceImageResult.

Args:
image_dir: str
Directory of the sliced image exports.
original_image_size: list of int
Size of the unsliced original image in [height, width].
original_image: np.ndarray, optional
The decoded source image. Every slice is a view into it, so it is
alive for as long as this result is; holding it lets callers reuse
the decode instead of reading the file again.
"""
self.original_image_height = original_image_size[0]
self.original_image_width = original_image_size[1]
self.image_dir = image_dir
self.original_image = original_image

self._sliced_image_list: list[SlicedImage] = []

Expand Down Expand Up @@ -376,7 +386,9 @@ def _export_single_slice(image: np.ndarray, output_dir: str, slice_file_name: st
n_ims = 0

# init images and annotations lists
sliced_image_result = SliceImageResult(original_image_size=[image_height, image_width], image_dir=output_dir)
sliced_image_result = SliceImageResult(
original_image_size=[image_height, image_width], image_dir=output_dir, original_image=image_arr
)

suffix = _slice_file_suffix(image, out_ext)

Expand Down
36 changes: 36 additions & 0 deletions tests/test_predict.py
Original file line number Diff line number Diff line change
Expand Up @@ -688,3 +688,39 @@ def test_video_prediction() -> None:
name="exp",
verbose=1,
)


def test_source_image_is_decoded_once_per_sliced_prediction(monkeypatch: pytest.MonkeyPatch) -> None:
"""A sliced run decodes the source file once, not once per result it builds.

Slicing already holds the decoded array and every slice is a view into it,
so the standard pass and both PredictionResults reuse it. Decoding a
gigapixel source four times is the cost this guards against.
"""
import sahi.predict
import sahi.prediction
import sahi.slicing
from sahi.utils.cv import read_image_as_pil

decodes = []

def counting_read(image: Any, *args: Any, **kwargs: Any) -> Any:
# only a path or URL costs a decode; an array or PIL image is already open
if isinstance(image, str):
decodes.append(image)
return read_image_as_pil(image, *args, **kwargs)

for module in (sahi.slicing, sahi.predict, sahi.prediction):
monkeypatch.setattr(module, "read_image_as_pil", counting_read, raising=False)

model = UltralyticsDetectionModel(
model_path=UltralyticsConstants.YOLO11N_MODEL_PATH,
confidence_threshold=CONFIDENCE_THRESHOLD,
device=MODEL_DEVICE,
image_size=IMAGE_SIZE,
)
result = get_sliced_prediction("tests/data/small-vehicles1.jpeg", model, slice_height=256, slice_width=256)

assert len(decodes) == 1, f"expected a single decode, got {len(decodes)}: {decodes}"
# the size still comes back, and the pixels are still reachable on demand
assert (result.image_width, result.image_height) == result.image.size