Skip to content

Commit b73c3c1

Browse files
committed
added torch interface, first cut, not working.
1 parent 3735f96 commit b73c3c1

4 files changed

Lines changed: 262 additions & 0 deletions

File tree

src/machinevisiontoolbox/ImageCore.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@
4040
from machinevisiontoolbox.ImageMorph import ImageMorphMixin
4141
from machinevisiontoolbox.ImageMultiview import ImageMultiviewMixin
4242
from machinevisiontoolbox.ImagePointFeatures import ImagePointFeaturesMixin
43+
from machinevisiontoolbox.ImageTorch import ImageTorchMixin
4344
from machinevisiontoolbox.ImageProcessing import ImageProcessingMixin
4445
from machinevisiontoolbox.ImageRegionFeatures import ImageRegionFeaturesMixin
4546
from machinevisiontoolbox.ImageReshape import ImageReshapeMixin
@@ -71,6 +72,7 @@ class Image(
7172
ImageLineFeaturesMixin,
7273
ImagePointFeaturesMixin,
7374
ImageMultiviewMixin,
75+
ImageTorchMixin,
7476
):
7577
def __init__(
7678
self,
Lines changed: 186 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,186 @@
1+
"""
2+
PyTorch tensor conversion and integration for Image objects.
3+
"""
4+
5+
from __future__ import annotations
6+
7+
from typing import TYPE_CHECKING
8+
9+
if TYPE_CHECKING:
10+
from machinevisiontoolbox.ImageCore import Image
11+
12+
import numpy as np
13+
14+
try:
15+
import torch
16+
17+
_torch_available = True
18+
except ImportError:
19+
_torch_available = False
20+
21+
22+
# TODO need a resizing option for the torch() method, to allow scaling to a fixed size for model input
23+
24+
25+
class ImageTorchMixin:
26+
"""
27+
PyTorch integration methods for the Image class.
28+
29+
Methods in this mixin require PyTorch to be installed
30+
(``pip install torch``). Each method raises :exc:`ImportError` at
31+
call time if PyTorch is not available.
32+
"""
33+
34+
def torch(self, device="cpu", normalize="imagenet") -> "torch.Tensor":
35+
"""Convert image to a PyTorch tensor.
36+
37+
The returned tensor has shape ``(1, C, H, W)`` for multi-plane images
38+
and ``(1, 1, H, W)`` for single-plane images, with pixel values
39+
preserved in their original dtype.
40+
41+
:param device: target device for the tensor (e.g. "cpu" or "cuda"),
42+
defaults to "cpu"
43+
:type device: str, optional
44+
:param normalize: normalization to apply to pixel values, either
45+
"imagenet" for standard ImageNet scaling or a tuple of
46+
(mean, std) lists for custom scaling; if None, no scaling is
47+
applied and pixel values are preserved in their original range,
48+
defaults to "imagenet"
49+
:type normalize: str or tuple or None, optional
50+
:raises ImportError: if PyTorch is not installed
51+
:return: image as a PyTorch tensor
52+
:rtype: torch.Tensor
53+
54+
.. note:: Pixel values are *not* normalised by default; set
55+
``normalize="imagenet"`` or provide custom mean/std to scale to
56+
zero mean and unit variance if required for model input.
57+
58+
59+
The returned tensor has shape ``(C, H, W)`` for multi-plane images
60+
and ``(1, H, W)`` for single-plane images, with pixel values
61+
preserved in their original dtype.
62+
63+
:raises ImportError: if PyTorch is not installed
64+
:return: image as a PyTorch tensor
65+
:rtype: torch.Tensor
66+
67+
.. note:: Pixel values are *not* normalised; scale to ``[0, 1]``
68+
manually if required for model input.
69+
"""
70+
if not _torch_available:
71+
raise ImportError(
72+
"PyTorch is required for to_tensor(). "
73+
"Install it with: pip install torch"
74+
)
75+
"""
76+
Convert to tensor and apply normalization.
77+
'normalize' can be:
78+
- None: stays 0-1 or 0-255
79+
- "imagenet": applies standard ImageNet mean/std
80+
- (mean, std): a tuple of lists/arrays for custom scaling
81+
"""
82+
# 1. Basic conversion to float 0.0 - 1.0
83+
tensor = torch.from_numpy(self.data).permute(2, 0, 1).float()
84+
if self.data.dtype == np.uint8:
85+
tensor /= 255.0
86+
87+
# 2. Handle Normalization Presets
88+
if normalize == "imagenet":
89+
mean = [0.485, 0.456, 0.406]
90+
std = [0.229, 0.224, 0.225]
91+
elif isinstance(normalize, (tuple, list)) and len(normalize) == 2:
92+
mean, std = normalize
93+
else:
94+
mean, std = None, None
95+
96+
# 3. Apply the Transformation: (x - mean) / std
97+
if mean is not None:
98+
# Convert mean/std to tensors and reshape for broadcasting: [C, 1, 1]
99+
m = torch.tensor(mean).view(-1, 1, 1)
100+
s = torch.tensor(std).view(-1, 1, 1)
101+
tensor = (tensor - m) / s
102+
103+
return tensor.unsqueeze(0).to(device)
104+
105+
@classmethod
106+
def Torch(cls, data, is_mask=False, colororder: str | None = None):
107+
"""Create an Image from a PyTorch tensor.
108+
109+
:param tensor: tensor of shape ``(C, H, W)`` or ``(H, W)``
110+
:type tensor: torch.Tensor
111+
:param colororder: colour plane order, e.g. ``"RGB"`` or ``"BGR"``,
112+
defaults to None
113+
:type colororder: str, optional
114+
:raises ImportError: if PyTorch is not installed
115+
:return: image wrapping the tensor data
116+
:rtype: Image
117+
118+
Accepts either a torch.Tensor or a dictionary containing a tensor.
119+
Create an Image object from a PyTorch tensor.
120+
121+
Handles:
122+
- Moving data from GPU/MPS to CPU
123+
- Detaching from the autograd graph
124+
- Converting (C, H, W) to (H, W, C)
125+
- Handling single-image batches (B, C, H, W)
126+
127+
128+
# Modern "Machine Vision Toolbox" workflow
129+
outputs = model(img.torch())
130+
out = Image.torch(outputs, is_mask=True).disp()
131+
132+
"""
133+
# 1. Handle Dictionary Input (The "Friendly" check)
134+
if isinstance(data, dict):
135+
# Look for the standard 'out' key, otherwise grab the first value
136+
tensor = data.get("out")
137+
if tensor is None:
138+
tensor = next(iter(data.values()))
139+
else:
140+
tensor = data
141+
142+
# 2. Validation
143+
if not torch.is_tensor(tensor):
144+
raise TypeError(f"Expected torch.Tensor or dict, got {type(data)}")
145+
146+
# 1. Boilerplate: Detach, Move to CPU, and convert to NumPy
147+
# We use .float() or .byte() depending on the need, but usually
148+
# keeping the original dtype is safest.
149+
x = tensor.detach().cpu().numpy()
150+
151+
# 2. Handle Batch dimension [B, C, H, W] -> [C, H, W]
152+
if x.ndim == 4:
153+
if x.shape[0] == 1:
154+
x = np.squeeze(x, axis=0)
155+
else:
156+
raise ValueError(
157+
f"Expected a single image batch, but got shape {x.shape}"
158+
)
159+
160+
# 3. Handle Semantic Segmentation Masks (Argmax case)
161+
if is_mask:
162+
# If the user passed raw logits [C, H, W], we take the argmax
163+
# Note: This is done on the NumPy array here for simplicity,
164+
# but could be done on the tensor before conversion for speed.
165+
if x.ndim == 3:
166+
x = np.argmax(x, axis=0)
167+
return cls(x)
168+
169+
# 4. Handle Color/Grayscale Images [C, H, W] -> [H, W, C]
170+
if x.ndim == 3:
171+
x = np.transpose(x, (1, 2, 0))
172+
173+
return cls(x)
174+
175+
176+
if __name__ == "__main__":
177+
from pathlib import Path
178+
179+
import pytest
180+
181+
pytest.main(
182+
[
183+
str(Path(__file__).parent.parent.parent / "tests" / "test_image_torch.py"),
184+
"-v",
185+
]
186+
)

src/machinevisiontoolbox/Sources.py

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,39 @@ class ImageSource(ABC):
4444
def __init__():
4545
pass
4646

47+
def torch(self, device="cpu", normalize=True):
48+
"""
49+
Convert the entire collection into a single 4D PyTorch tensor.
50+
Shape: (N, C, H, W) where N is the number of images.
51+
"""
52+
if not self.images:
53+
return torch.empty(0)
54+
55+
# 1. Consistency Check
56+
# All images in a PyTorch batch MUST have the same dimensions.
57+
first_shape = self.images[0].data.shape
58+
for i, img in enumerate(self.images):
59+
if img.data.shape != first_shape:
60+
raise ValueError(
61+
f"All images must have the same shape for batching. "
62+
f"Image 0 is {first_shape}, but Image {i} is {img.data.shape}."
63+
)
64+
65+
# 2. Efficient Conversion
66+
# We leverage the individual Image.to_pytorch() logic,
67+
# then stack them along a new 'batch' dimension.
68+
# We remove the extra batch dim added by Image.to_pytorch (unsqueeze(0))
69+
# before stacking to avoid a 5D tensor.
70+
tensors = [
71+
img.to_pytorch(device=device, normalize=normalize).squeeze(0)
72+
for img in self.images
73+
]
74+
75+
# 3. Stack into a 4D tensor (N, C, H, W)
76+
batch_tensor = torch.stack(tensors, dim=0)
77+
78+
return batch_tensor
79+
4780

4881
class VideoFile(ImageSource):
4982
"""

tests/test_image_torch.py

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
#!/usr/bin/env python
2+
3+
import unittest
4+
5+
import numpy as np
6+
7+
from machinevisiontoolbox import Image
8+
9+
try:
10+
import torch
11+
12+
_torch_available = True
13+
except ImportError:
14+
_torch_available = False
15+
16+
17+
@unittest.skipUnless(_torch_available, "PyTorch not installed")
18+
class TestImageTorch(unittest.TestCase):
19+
20+
def test_to_tensor_shape_mono(self):
21+
"""Mono image produces tensor of shape (1, H, W)"""
22+
img = Image.Zeros(64, 64, dtype="uint8")
23+
t = img.to_tensor()
24+
self.assertEqual(t.shape, (1, 64, 64))
25+
26+
def test_to_tensor_shape_color(self):
27+
"""Color image produces tensor of shape (C, H, W)"""
28+
img = Image.Zeros(64, 64, colororder="RGB", dtype="uint8")
29+
t = img.to_tensor()
30+
self.assertEqual(t.shape, (3, 64, 64))
31+
32+
def test_from_tensor_roundtrip(self):
33+
"""Round-trip: Image → tensor → Image preserves pixel values"""
34+
img = Image.Read("monalisa.png")
35+
t = img.to_tensor()
36+
img2 = Image.from_tensor(t, colororder=img.colororder_str)
37+
np.testing.assert_array_equal(img2.A, img.A)
38+
39+
40+
if __name__ == "__main__":
41+
unittest.main()

0 commit comments

Comments
 (0)