ialdev-img 0.1.0__tar.gz
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.
- ialdev_img-0.1.0/PKG-INFO +21 -0
- ialdev_img-0.1.0/README.md +5 -0
- ialdev_img-0.1.0/pyproject.toml +30 -0
- ialdev_img-0.1.0/src/iad/img/__init__.py +5 -0
- ialdev_img-0.1.0/src/iad/img/camera.py +337 -0
- ialdev_img-0.1.0/src/iad/img/regions.py +176 -0
- ialdev_img-0.1.0/src/iad/img/tools.py +242 -0
- ialdev_img-0.1.0/src/iad/img/transforms.py +158 -0
- ialdev_img-0.1.0/tests/test_camera.py +16 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: ialdev-img
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: iad.img — image transforms, regions, and stereo camera models
|
|
5
|
+
Requires-Python: >=3.10
|
|
6
|
+
Description-Content-Type: text/markdown
|
|
7
|
+
Requires-Dist: ialdev-core
|
|
8
|
+
Requires-Dist: ialdev-io
|
|
9
|
+
Requires-Dist: ialdev-maths
|
|
10
|
+
Requires-Dist: numpy>=1.20.0
|
|
11
|
+
Requires-Dist: xxhash>=3.0.0
|
|
12
|
+
Requires-Dist: toolz>=0.11.0
|
|
13
|
+
Requires-Dist: pytest>=7.0.0 ; extra == "dev"
|
|
14
|
+
Provides-Extra: dev
|
|
15
|
+
|
|
16
|
+
# ialdev-img (`imgtools/`)
|
|
17
|
+
|
|
18
|
+
**PyPI:** `ialdev-img` **Imports:** `iad.img`
|
|
19
|
+
|
|
20
|
+
Former `algutils.image` plus stereo camera types moved from I/O.
|
|
21
|
+
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["flit_core>=3.11,<4"]
|
|
3
|
+
build-backend = "flit_core.buildapi"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "ialdev-img"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "iad.img — image transforms, regions, and stereo camera models"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.10"
|
|
11
|
+
license = {text = "MIT"}
|
|
12
|
+
dependencies = [
|
|
13
|
+
"ialdev-core",
|
|
14
|
+
"ialdev-io",
|
|
15
|
+
"ialdev-maths",
|
|
16
|
+
"numpy>=1.20.0",
|
|
17
|
+
"xxhash>=3.0.0",
|
|
18
|
+
"toolz>=0.11.0",
|
|
19
|
+
]
|
|
20
|
+
|
|
21
|
+
[project.optional-dependencies]
|
|
22
|
+
dev = ["pytest>=7.0.0"]
|
|
23
|
+
|
|
24
|
+
[tool.flit.module]
|
|
25
|
+
name = "iad.img"
|
|
26
|
+
|
|
27
|
+
[tool.pytest.ini_options]
|
|
28
|
+
testpaths = ["tests"]
|
|
29
|
+
python_files = ["test_*.py"]
|
|
30
|
+
pythonpath = ["src"]
|
|
@@ -0,0 +1,337 @@
|
|
|
1
|
+
from dataclasses import dataclass, InitVar
|
|
2
|
+
from datetime import datetime
|
|
3
|
+
from enum import Enum
|
|
4
|
+
from math import tan, pi
|
|
5
|
+
from typing import Union, NamedTuple, TYPE_CHECKING
|
|
6
|
+
from warnings import warn
|
|
7
|
+
|
|
8
|
+
import numpy as np
|
|
9
|
+
|
|
10
|
+
from iad.core.param import TBox
|
|
11
|
+
from iad.maths.geom import Vec2d, Pose
|
|
12
|
+
|
|
13
|
+
if TYPE_CHECKING:
|
|
14
|
+
from iad.core.units import Quantity
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class Resolution(Enum):
|
|
18
|
+
HQVGA = (240, 160)
|
|
19
|
+
QVGA = (320, 240)
|
|
20
|
+
VGA = (645, 480)
|
|
21
|
+
WGA = (800, 480)
|
|
22
|
+
SVGA = (800, 600)
|
|
23
|
+
DVGA = (960, 640)
|
|
24
|
+
WXGA = (1280, 800)
|
|
25
|
+
QFD = (960, 540)
|
|
26
|
+
HD = (1280, 720)
|
|
27
|
+
FHD = (1920, 1080)
|
|
28
|
+
QHD = (2560, 1440)
|
|
29
|
+
UHD4K = (3840, 2160)
|
|
30
|
+
UHD8K = (7680, 4320)
|
|
31
|
+
|
|
32
|
+
def __init__(self, x, y):
|
|
33
|
+
self.x = x
|
|
34
|
+
self.y = y
|
|
35
|
+
|
|
36
|
+
def __getitem__(self, item):
|
|
37
|
+
return (self.x, self.y)[item]
|
|
38
|
+
|
|
39
|
+
def __repr__(self):
|
|
40
|
+
return f'Resolution {self.name}: {self.x}x{self.y}'
|
|
41
|
+
|
|
42
|
+
@property
|
|
43
|
+
def total_pixels(self):
|
|
44
|
+
return self.x * self.y
|
|
45
|
+
|
|
46
|
+
@property
|
|
47
|
+
def xy(self):
|
|
48
|
+
return self.x, self.y
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@dataclass(repr=False)
|
|
52
|
+
class Sensor:
|
|
53
|
+
pixels: Union[Vec2d, Resolution]
|
|
54
|
+
center: Vec2d = 0 # [pix], relative to the sensor center
|
|
55
|
+
colors: str = 'RGB'
|
|
56
|
+
bits: int = 10
|
|
57
|
+
pix_size: Vec2d = 1 # in microns
|
|
58
|
+
|
|
59
|
+
def __post_init__(self):
|
|
60
|
+
xy = self.pixels.xy if isinstance(self.pixels, Resolution) else self.pixels
|
|
61
|
+
self.pixels = Vec2d(xy) # to support initialization with iterable
|
|
62
|
+
if self.center == 0:
|
|
63
|
+
self.center = Vec2d(0, 0)
|
|
64
|
+
if not hasattr(self.pix_size, '__len__'):
|
|
65
|
+
self.pix_size = Vec2d(1, 1)
|
|
66
|
+
|
|
67
|
+
@property
|
|
68
|
+
def size(self):
|
|
69
|
+
""" Return sensor size in mm"""
|
|
70
|
+
return Vec2d(*self.pixels / np.array(self.pix_size) / 1000)
|
|
71
|
+
|
|
72
|
+
def __repr__(self):
|
|
73
|
+
def dims(v): return f'{v.x}\u2A09{v.y}'
|
|
74
|
+
|
|
75
|
+
px_sz = (np.array(self.pix_size) * 10).round() / 10 # with 0.1um accuracy
|
|
76
|
+
px_sz = dims(Vec2d(*px_sz)) if np.diff(px_sz) else f'{px_sz[0]}'
|
|
77
|
+
|
|
78
|
+
return f'Sensor {self.bits}b[{self.colors}] {dims(self.pixels)} pix({px_sz}\u03BCm)'
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
@dataclass(repr=False)
|
|
82
|
+
class Shot:
|
|
83
|
+
time: datetime = None
|
|
84
|
+
pose: Pose = None
|
|
85
|
+
exposure: float = 0
|
|
86
|
+
|
|
87
|
+
def __repr__(self):
|
|
88
|
+
if self.pose is None and self.time is None and not self.exposure:
|
|
89
|
+
return ''
|
|
90
|
+
time = self.time.strftime('%y/%d %H:%M:%S.%f') if self.time else ''
|
|
91
|
+
exps = f'expos:{self.exposure * 1000:.1}ms' if self.exposure is None else ''
|
|
92
|
+
pose = self.pose if self.pose else ''
|
|
93
|
+
return f'[{time}] {exps} {pose}'
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
@dataclass(repr=False)
|
|
97
|
+
class Camera:
|
|
98
|
+
"""
|
|
99
|
+
Camera information class.
|
|
100
|
+
Contains both geometrical and physical aspects of the camera
|
|
101
|
+
"""
|
|
102
|
+
pixels: InitVar[Vec2d] = None
|
|
103
|
+
sensor: Sensor = None
|
|
104
|
+
focal: float = np.nan # in mm, along z-axis from the center
|
|
105
|
+
name: str = ''
|
|
106
|
+
|
|
107
|
+
def __post_init__(self, pixels):
|
|
108
|
+
if self.sensor is None:
|
|
109
|
+
self.sensor = Sensor(pixels=pixels)
|
|
110
|
+
|
|
111
|
+
if np.isnan(self.focal):
|
|
112
|
+
self.focal = max(*self.sensor.size) # 90 degrees
|
|
113
|
+
|
|
114
|
+
def angles(self, measure='rad'):
|
|
115
|
+
measure = measure.lower()
|
|
116
|
+
assert measure in ('rad', 'deg')
|
|
117
|
+
rad = np.arctan(self.sensor.size / self.focal / 2) * 2
|
|
118
|
+
return rad if measure == 'rad' else rad * 180 / np.pi
|
|
119
|
+
|
|
120
|
+
def __repr__(self):
|
|
121
|
+
def dims(v): return f'{v.x}\u2A09{v.y}'
|
|
122
|
+
|
|
123
|
+
angles = dims(Vec2d(*np.array(self.angles('deg'), dtype=int)))
|
|
124
|
+
name = f'<{self.name}>' if self.name else ''
|
|
125
|
+
return f'Camera{name}: {self.sensor}, angles: {angles}\u00B0'
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def bin_x(im, bin_factor):
|
|
129
|
+
""" Binning along x direction by given (integer) factor
|
|
130
|
+
|
|
131
|
+
:param im:
|
|
132
|
+
:param bin_factor:
|
|
133
|
+
:return: binned image
|
|
134
|
+
"""
|
|
135
|
+
return im.reshape(im.shape[0], im.shape[1] // bin_factor, bin_factor).sum(2)
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def bin_y(im, bin_factor):
|
|
139
|
+
""" Binning along y direction by given (integer) factor
|
|
140
|
+
|
|
141
|
+
:param im:
|
|
142
|
+
:param bin_factor:
|
|
143
|
+
:return: binned image
|
|
144
|
+
"""
|
|
145
|
+
return im.reshape(im.shape[0] // bin_factor, bin_factor, im.shape[1]).sum(1)
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def bin_xy(im, x_factor, y_factor):
|
|
149
|
+
""" Binning along x and y directions by given (integer) factor for each
|
|
150
|
+
Make sure that image doesn't overflow during sum (for example cast to 16 bits)
|
|
151
|
+
:param im: input image
|
|
152
|
+
:param x_factor: binning factor for x axis
|
|
153
|
+
:param y_factor: binning factor for y axis
|
|
154
|
+
:return: binned image
|
|
155
|
+
"""
|
|
156
|
+
return im.reshape(im.shape[0] // y_factor, y_factor, im.shape[1] // x_factor, x_factor).sum(3).sum(1)
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def to_10bit_gray(img):
|
|
160
|
+
""" Convert possible rgb image to gray; convert 8-bits image to 10 bits
|
|
161
|
+
|
|
162
|
+
:param img: gray or rgb image with 8 or 10 bits
|
|
163
|
+
:return: gray image with 10 bits
|
|
164
|
+
"""
|
|
165
|
+
if len(img.shape) == 3:
|
|
166
|
+
img = img[:, :, 0]
|
|
167
|
+
if np.max(img) < 256:
|
|
168
|
+
img = img.astype('uint16') << 2
|
|
169
|
+
return img
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
class StereoCam(TBox):
|
|
173
|
+
class XY(NamedTuple):
|
|
174
|
+
x: float
|
|
175
|
+
y: float
|
|
176
|
+
|
|
177
|
+
views = ('left', 'right')
|
|
178
|
+
|
|
179
|
+
@staticmethod
|
|
180
|
+
def camera_matrix(cam):
|
|
181
|
+
C = np.identity(4)
|
|
182
|
+
C[0, 0] = cam.focal.x
|
|
183
|
+
C[1, 1] = cam.focal.y
|
|
184
|
+
C[0:2, 2] = cam.center
|
|
185
|
+
return C
|
|
186
|
+
|
|
187
|
+
def rescale_resolution(self, scale):
|
|
188
|
+
"""
|
|
189
|
+
Apply resolution rescaling to the calibration parameters to change those measured in pixels.
|
|
190
|
+
scale * resolution MUST lead to an integer new resolution - otherwise ValueError
|
|
191
|
+
:param scale: coefficient to calculate new resolution
|
|
192
|
+
Note: If cameras have different resolutions they are still scaled by this same factor.
|
|
193
|
+
"""
|
|
194
|
+
for side in self.views:
|
|
195
|
+
if 'resolution' in self[side]:
|
|
196
|
+
new_resolution = self.XY(*(v * scale for v in self[side].resolution))
|
|
197
|
+
if not all(v.is_integer() for v in new_resolution):
|
|
198
|
+
raise ValueError(f'Rescaling must keep resolution integer! Instead received: {new_resolution}')
|
|
199
|
+
self[side].resolution = self.XY(*(int(v) for v in new_resolution))
|
|
200
|
+
|
|
201
|
+
pix_keys = ('left.focal', 'right.focal', # TODO: use views to define
|
|
202
|
+
'left.center', 'right.center')
|
|
203
|
+
for key in pix_keys:
|
|
204
|
+
self[key] = self.XY(*(v * scale for v in self[key]))
|
|
205
|
+
self.scale *= scale if self.scale else scale
|
|
206
|
+
|
|
207
|
+
def __init__(self, obj=None, *, resolution=(640, 480), baseline='60mm', center=None,
|
|
208
|
+
focal=None, angle=90, units: Union[int, str, 'Quantity'] = 1, **kwargs):
|
|
209
|
+
"""
|
|
210
|
+
Stereo (dual) cameras calibration parameters,
|
|
211
|
+
is accessible as attributes or as dict as following structure:
|
|
212
|
+
Par:
|
|
213
|
+
obj: dict or Box kind of object - if provided all the named arguments are ignored (except of kwargs)
|
|
214
|
+
resolution: (x, y) [pix]
|
|
215
|
+
baseline: b [mm]
|
|
216
|
+
left:
|
|
217
|
+
focal: (fx, fy) [pix]
|
|
218
|
+
center: (cx, xy) [pix]
|
|
219
|
+
right:
|
|
220
|
+
focal: (fx, fy) [pix]
|
|
221
|
+
center: (cx, xy) [pix]
|
|
222
|
+
Args:
|
|
223
|
+
resolution: (x, y) [pixels] - integer
|
|
224
|
+
center: (cx, cy) [pixels] - center of the lenses from corner (0, 0) (default - sensor center)
|
|
225
|
+
focal: (fx, fy) [pixels] - focal length in pixels
|
|
226
|
+
angle: view angle - used to calculate focal only if its not provided - ignored otherwise
|
|
227
|
+
units: str or pint-units object describing baseline units
|
|
228
|
+
"""
|
|
229
|
+
from iad.core.units import assign_units
|
|
230
|
+
# process special initialization cases
|
|
231
|
+
if obj is not None:
|
|
232
|
+
super().__init__(self._convert_xy(obj), **kwargs)
|
|
233
|
+
if 'baseline' in self:
|
|
234
|
+
self.baseline = assign_units(self.baseline, units)
|
|
235
|
+
return
|
|
236
|
+
|
|
237
|
+
super().__init__(**kwargs)
|
|
238
|
+
if '__box_heritage' in kwargs:
|
|
239
|
+
return
|
|
240
|
+
# -------------------------------
|
|
241
|
+
#
|
|
242
|
+
# kwargs.setdefault('box_it_up', True)
|
|
243
|
+
# kwargs.setdefault('default_box', True)
|
|
244
|
+
|
|
245
|
+
resolution = self.XY(*resolution)
|
|
246
|
+
self.baseline = assign_units(baseline, units)
|
|
247
|
+
|
|
248
|
+
if focal is None:
|
|
249
|
+
fsc = tan(angle / 360 * pi)
|
|
250
|
+
focal = [v / 2 / fsc for v in resolution]
|
|
251
|
+
elif not hasattr(focal, '__len__'):
|
|
252
|
+
focal = [focal, focal]
|
|
253
|
+
self.left = dict(focal=self.XY(*focal),
|
|
254
|
+
resolution=resolution)
|
|
255
|
+
|
|
256
|
+
if center is None:
|
|
257
|
+
center = [(v - 1) / 2 for v in resolution]
|
|
258
|
+
self.left.center = self.XY(*center)
|
|
259
|
+
|
|
260
|
+
self.right = dict(self.left)
|
|
261
|
+
|
|
262
|
+
def validate(self):
|
|
263
|
+
"""
|
|
264
|
+
Validate the configuration structure
|
|
265
|
+
:return: True if OK
|
|
266
|
+
"""
|
|
267
|
+
from numbers import Number
|
|
268
|
+
ref_keys = self.__class__().keys(deep=True)
|
|
269
|
+
|
|
270
|
+
dif = set(ref_keys).difference(self.keys(deep=True))
|
|
271
|
+
invalids = [k for k, v in TBox(self.to_dict()).items(deep=True) if not isinstance(v, Number) or np.isnan(v)]
|
|
272
|
+
|
|
273
|
+
if dif: warn(f'Missing keys: {dif}')
|
|
274
|
+
if invalids: warn(f'Invalid values: {invalids}')
|
|
275
|
+
|
|
276
|
+
return not (dif or invalids)
|
|
277
|
+
|
|
278
|
+
def to_dict(self, *, xy=True):
|
|
279
|
+
""" Recursively converts to dict
|
|
280
|
+
:param xy: if True all the XY namedtuple fieled are also converted to dict
|
|
281
|
+
:return recursive dictionary
|
|
282
|
+
"""
|
|
283
|
+
XY = StereoCam.XY
|
|
284
|
+
return {k: (dict(XY._asdict(v)) if isinstance(v, XY) and xy
|
|
285
|
+
else (self.__class__.to_dict(v, xy=xy) if hasattr(v, 'keys')
|
|
286
|
+
else v)) for k, v in self.items()}
|
|
287
|
+
|
|
288
|
+
def to_yaml(self, filename=None, **kw_box):
|
|
289
|
+
"""
|
|
290
|
+
Convert to YAML format.
|
|
291
|
+
:param filename: optional - to save yaml
|
|
292
|
+
:param kw_box: yaml, box keyword arguments
|
|
293
|
+
:return: str if filename=None or None
|
|
294
|
+
"""
|
|
295
|
+
from iad.core.units import un
|
|
296
|
+
d = self.to_dict()
|
|
297
|
+
if isinstance(d['baseline'], un.Quantity):
|
|
298
|
+
d['baseline'] = d['baseline'].to_tuple()
|
|
299
|
+
return TBox(d).to_yaml(filename=filename, **kw_box)
|
|
300
|
+
|
|
301
|
+
@classmethod
|
|
302
|
+
def _convert_xy(cls, obj):
|
|
303
|
+
XY = cls.XY
|
|
304
|
+
for k in obj:
|
|
305
|
+
item = obj[k]
|
|
306
|
+
if hasattr(item, 'items'):
|
|
307
|
+
obj[k] = XY(**item) if not set(XY._fields).symmetric_difference(item) else cls._convert_xy(item)
|
|
308
|
+
return obj
|
|
309
|
+
|
|
310
|
+
@classmethod
|
|
311
|
+
def from_yaml(cls, yaml_string=None, filename=None, **kwargs):
|
|
312
|
+
from iad.core.units import un
|
|
313
|
+
d = TBox.from_yaml(yaml_string=yaml_string, filename=filename, **kwargs)
|
|
314
|
+
if isinstance(d['baseline'], str):
|
|
315
|
+
d['baseline'] = un(d['baseline'])
|
|
316
|
+
return cls._convert_xy(d)
|
|
317
|
+
|
|
318
|
+
@classmethod
|
|
319
|
+
def from_tiff(cls, file_name: str):
|
|
320
|
+
from iad.io.inu.utils.imread import tiff_inu_cam
|
|
321
|
+
return tiff_inu_cam(file_name)
|
|
322
|
+
|
|
323
|
+
def center_crop(self, width, height):
|
|
324
|
+
"""Return camera parameters for sensor symmetrically cropped around center
|
|
325
|
+
|
|
326
|
+
:param width: new width in pixels
|
|
327
|
+
:param height: new height in pixels
|
|
328
|
+
"""
|
|
329
|
+
if self.left != self.right:
|
|
330
|
+
raise ValueError("Central crop on Camera Params is defined only if left == right")
|
|
331
|
+
par = self.left
|
|
332
|
+
xsz, ysz = par.resolution
|
|
333
|
+
return StereoCam(baseline=self.baseline,
|
|
334
|
+
focal=par.focal,
|
|
335
|
+
center=(par.center.x - (xsz - width) // 2,
|
|
336
|
+
par.center.y - (ysz - height) // 2),
|
|
337
|
+
resolution=(width, height))
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
from typing import Union, Optional
|
|
2
|
+
|
|
3
|
+
import numpy as np
|
|
4
|
+
import toolz as tz
|
|
5
|
+
|
|
6
|
+
from iad.core import nptools as npt
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class Regions(dict):
|
|
10
|
+
"""
|
|
11
|
+
Collection labeling regions in 2D array.
|
|
12
|
+
|
|
13
|
+
Organized as a dict of binary masks (of same shape) for every kind of regions.
|
|
14
|
+
Support arithmetic on the regions as logical operation on the masks.
|
|
15
|
+
|
|
16
|
+
Overrides iteration protocol to yield Regions object built around every region
|
|
17
|
+
item in the container (instead of keys, as in dict)
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
def __init__(self, regions: Union[dict, np.array] = None,
|
|
21
|
+
rgn_codes: Optional[dict] = None, **kw_rgs):
|
|
22
|
+
"""
|
|
23
|
+
Create from a dict of maps or a single map and dict of codes
|
|
24
|
+
:param regions: dict of maps - then just built upon it, or
|
|
25
|
+
map of values - then dict of masks is formed using
|
|
26
|
+
`rgn_codes` dict mapping codes in the map into keys
|
|
27
|
+
:param rgn_codes: mapping of names into values in `regions` array
|
|
28
|
+
:param kw_rgs: regions maps as kw arguments
|
|
29
|
+
"""
|
|
30
|
+
if rgn_codes:
|
|
31
|
+
assert isinstance(regions, np.ndarray)
|
|
32
|
+
regions = tz.valmap(regions.__eq__, rgn_codes)
|
|
33
|
+
regions = {**(regions or {}), **kw_rgs}
|
|
34
|
+
super().__init__(regions)
|
|
35
|
+
|
|
36
|
+
def __and__(self, other):
|
|
37
|
+
# print('====> [&]', list(self.keys()),
|
|
38
|
+
# list(other.keys()) if isinstance(other, dict) else '[M]')
|
|
39
|
+
|
|
40
|
+
def _and(r1, m1, r2, m2):
|
|
41
|
+
t1, t2 = m1.dtype.kind, m2.dtype.kind
|
|
42
|
+
if t1 == 'b':
|
|
43
|
+
if t2 == 'b':
|
|
44
|
+
return f"{r1} & {r2}", m1 & m2
|
|
45
|
+
else:
|
|
46
|
+
return r1, npt.fill_mask(m2, ~m1)
|
|
47
|
+
elif t2 == 'b':
|
|
48
|
+
return r1, npt.fill_mask(m1, ~m2)
|
|
49
|
+
return r1, m1
|
|
50
|
+
|
|
51
|
+
if isinstance(other, dict):
|
|
52
|
+
return Regions(dict(_and(*it1, *it2) for it1 in self.items()
|
|
53
|
+
for it2 in other.items()))
|
|
54
|
+
else:
|
|
55
|
+
return Regions({r: (m & other) for r, m in self.items()})
|
|
56
|
+
|
|
57
|
+
def __iter__(self):
|
|
58
|
+
return (Regions({k: self[k]}) for k in self.keys())
|
|
59
|
+
|
|
60
|
+
def items(self):
|
|
61
|
+
"""Iterator over regions (name, data)"""
|
|
62
|
+
return ((k, self[k]) for k in self.keys())
|
|
63
|
+
|
|
64
|
+
def __or__(self, other):
|
|
65
|
+
return Regions({f"{r1} | {r2}": m1 | m2
|
|
66
|
+
for r1, m1 in self.items() for r2, m2 in other.items()})
|
|
67
|
+
|
|
68
|
+
def __xor__(self, other):
|
|
69
|
+
return Regions({f"{r1} ^ {r2}": m1 ^ m2
|
|
70
|
+
for r1, m1 in self.items() for r2, m2 in other.items()})
|
|
71
|
+
|
|
72
|
+
def __add__(self, other):
|
|
73
|
+
return Regions({**self, **other})
|
|
74
|
+
|
|
75
|
+
def __sub__(self, other):
|
|
76
|
+
return
|
|
77
|
+
|
|
78
|
+
def __repr__(self):
|
|
79
|
+
if len(self):
|
|
80
|
+
shapes = set(map(lambda x: x.shape, self.values()))
|
|
81
|
+
types = set(map(lambda x: x.dtype, self.values()))
|
|
82
|
+
if len(shapes) == 1 and len(types) == 1:
|
|
83
|
+
h, w = shapes.pop()
|
|
84
|
+
tp = types.pop()
|
|
85
|
+
return ', '.join(self.keys()) + f": [{h}×{w}]{tp.kind}{tp.itemsize}"
|
|
86
|
+
return "\n".join(f"{k}: [{m.shape[0]}×{m.shape[1]}]"
|
|
87
|
+
f"{m.dtype.kind}{m.dtype.itemsize}"
|
|
88
|
+
for k, m in self.items())
|
|
89
|
+
|
|
90
|
+
def __str__(self):
|
|
91
|
+
return self.__repr__()
|
|
92
|
+
|
|
93
|
+
def __iadd__(self, other: 'Regions'):
|
|
94
|
+
self.update(other)
|
|
95
|
+
return self
|
|
96
|
+
|
|
97
|
+
def __call__(self, *kinds):
|
|
98
|
+
"""Return subset of the regions as a new Regions object"""
|
|
99
|
+
return Regions({kind: self[kind] for kind in kinds})
|
|
100
|
+
|
|
101
|
+
def __invert__(self):
|
|
102
|
+
return Regions({'~' + k: ~m if m.dtype is np.dtype(bool) else m
|
|
103
|
+
for k, m in self.items()})
|
|
104
|
+
|
|
105
|
+
@property
|
|
106
|
+
def key(self):
|
|
107
|
+
"""Assuming there is only one region returns its name, or None"""
|
|
108
|
+
return next(iter(self.keys())) if len(self) == 1 else None
|
|
109
|
+
|
|
110
|
+
@property
|
|
111
|
+
def mask(self):
|
|
112
|
+
"""Assuming there is only one region returns its mask, or None"""
|
|
113
|
+
return next(iter(self.values())) if len(self) == 1 else None
|
|
114
|
+
|
|
115
|
+
@property
|
|
116
|
+
def masks(self):
|
|
117
|
+
return Regions({k: m for k, m in self.items() if m.dtype.kind == 'b'})
|
|
118
|
+
|
|
119
|
+
@property
|
|
120
|
+
def scores(self):
|
|
121
|
+
return Regions({k: m for k, m in self.items() if m.dtype.kind != 'b'})
|
|
122
|
+
|
|
123
|
+
@property
|
|
124
|
+
def areas(self):
|
|
125
|
+
return tz.valmap(np.sum, self)
|
|
126
|
+
|
|
127
|
+
@property
|
|
128
|
+
def area(self):
|
|
129
|
+
return self.mask.sum() if len(self) == 1 else None
|
|
130
|
+
|
|
131
|
+
@property
|
|
132
|
+
def shape(self):
|
|
133
|
+
"""Return shape if all shapes are same or None"""
|
|
134
|
+
shape = None
|
|
135
|
+
for k, m in self.items():
|
|
136
|
+
if shape is None:
|
|
137
|
+
shape = m.shape
|
|
138
|
+
elif m.shape != shape:
|
|
139
|
+
return None
|
|
140
|
+
return shape
|
|
141
|
+
|
|
142
|
+
@staticmethod
|
|
143
|
+
def from_larger_than(tag: str, score, thr, out):
|
|
144
|
+
"""
|
|
145
|
+
Helper function to use when creating regions calculators
|
|
146
|
+
:param tag: name of this regions kind
|
|
147
|
+
:param score: score 2d array - base for the output
|
|
148
|
+
:param thr: threshold to convert score into binary mask: score > thr
|
|
149
|
+
:param out: format and content of the output:
|
|
150
|
+
- '<tag>' - binary mask score > thr or score < thr
|
|
151
|
+
- 'score' - float score (before threshold application)
|
|
152
|
+
- 'region' - Regions obj with '<tag>' mask
|
|
153
|
+
- 'all' - Regions obj with '<tag>' mask and '<tag>_score'
|
|
154
|
+
Use thr=None & 'region' for Regions obj with '<tag>_score'
|
|
155
|
+
:return:
|
|
156
|
+
"""
|
|
157
|
+
score_rgn = Regions({f'{tag}_score': score})
|
|
158
|
+
if thr is None:
|
|
159
|
+
if out in {'region', 'all'}:
|
|
160
|
+
return score_rgn
|
|
161
|
+
elif out == 'score':
|
|
162
|
+
return score
|
|
163
|
+
raise ValueError(f"Can't calculate {tag} mask without `thr`!")
|
|
164
|
+
else:
|
|
165
|
+
if out == 'score':
|
|
166
|
+
return score
|
|
167
|
+
|
|
168
|
+
with np.errstate(invalid='ignore'):
|
|
169
|
+
mask = score > thr
|
|
170
|
+
|
|
171
|
+
if out == tag:
|
|
172
|
+
return mask
|
|
173
|
+
if out == 'all':
|
|
174
|
+
return Regions({tag: mask}) + score_rgn
|
|
175
|
+
if out == 'region':
|
|
176
|
+
return Regions({tag: mask})
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
import numpy as np
|
|
2
|
+
|
|
3
|
+
from .regions import Regions
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def center_crop(im, width, height):
|
|
7
|
+
from iad.img.camera import StereoCam
|
|
8
|
+
|
|
9
|
+
def slice_(sz, lim):
|
|
10
|
+
offs = (sz - lim) // 2
|
|
11
|
+
return slice(offs, offs + lim) if offs > 0 else slice(None)
|
|
12
|
+
|
|
13
|
+
if isinstance(im, Regions):
|
|
14
|
+
assert im.shape # shape must be defined (all regions share same)
|
|
15
|
+
for k in im.keys():
|
|
16
|
+
im[k] = center_crop(im[k], width, height)
|
|
17
|
+
return im
|
|
18
|
+
elif isinstance(im, StereoCam):
|
|
19
|
+
return im.center_crop(width, height)
|
|
20
|
+
|
|
21
|
+
if im.ndim == 3:
|
|
22
|
+
h, w, _ = im.shape
|
|
23
|
+
return im[slice_(h, height), slice_(w, width), :]
|
|
24
|
+
else:
|
|
25
|
+
h, w = im.shape
|
|
26
|
+
return im[slice_(h, height), slice_(w, width)]
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _crop_slices(rect, shape):
|
|
30
|
+
""" Return slices to crop a `rect` shape from an image of given `shape`.
|
|
31
|
+
|
|
32
|
+
:param rect: [i0, j0, height, width] - may exceed the range of the shape
|
|
33
|
+
:param shape: shape of 2D array to crop
|
|
34
|
+
:return src, trg: 2d indexers to broadcast src image into the trg image of rect[2,3] shape
|
|
35
|
+
|
|
36
|
+
:Example:
|
|
37
|
+
- src, trg = crop_crop_slices([-10, 40, 100, 160], im.shape]
|
|
38
|
+
- zeros([100, 160])[trg] = im[src]
|
|
39
|
+
"""
|
|
40
|
+
src = np.array(rect)
|
|
41
|
+
src[2:] += src[:2] # [i0, j0, i_end, j_end
|
|
42
|
+
trg = np.array([0, 0, *rect[2:]])
|
|
43
|
+
for i in [0, 1]:
|
|
44
|
+
if src[i + 2] > shape[i]:
|
|
45
|
+
src[i + 2], trg[i + 2] = shape[i], shape[i] - src[i]
|
|
46
|
+
if src[i] < 0:
|
|
47
|
+
src[i], trg[i] = trg[i], -src[i]
|
|
48
|
+
return tuple(np.s_[x[0]:x[2], x[1]:x[3]] for x in (src, trg))
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def crop(im, rect):
|
|
52
|
+
""" Crop rect from the image. rect may exceed the image are (filled wth 0).
|
|
53
|
+
|
|
54
|
+
:param im: 2D array
|
|
55
|
+
:param rect: rect: [i0, j0, height, width] - may exceed the range of the shape
|
|
56
|
+
:return cropped_image: negative margins will be filled with 0
|
|
57
|
+
|
|
58
|
+
:Example: crop_im = _crop_slices(im, [-10, 40, 100, 160])
|
|
59
|
+
"""
|
|
60
|
+
new_im = np.zeros(rect[2:], dtype=im.dtype)
|
|
61
|
+
src, trg = _crop_slices(rect, im.shape)
|
|
62
|
+
new_im[trg] = im[src]
|
|
63
|
+
return new_im
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def image_2D(im: np.ndarray):
|
|
67
|
+
"""Squeeze dimensions or collapse colors to produce 2D image"""
|
|
68
|
+
if im.ndim == 2:
|
|
69
|
+
return im
|
|
70
|
+
im = im.squeeze()
|
|
71
|
+
if im.ndim != 2:
|
|
72
|
+
if im.ndim == 3 and im.shape[2] == 3:
|
|
73
|
+
from skimage.color import rgb2gray
|
|
74
|
+
im = rgb2gray(im)
|
|
75
|
+
else:
|
|
76
|
+
raise ValueError('Invalid input image shape')
|
|
77
|
+
return im
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def to_3d(im: np.ndarray):
|
|
81
|
+
"""Add if needed 3-rd dimension to the 2d array"""
|
|
82
|
+
if im.ndim == 3:
|
|
83
|
+
return im
|
|
84
|
+
if im.ndim == 2:
|
|
85
|
+
return im[..., None]
|
|
86
|
+
raise ValueError('Input must be 2 or 3 dimensional!')
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def im2col_sliding_broadcasting(A, BSZ, stepsize=1):
|
|
90
|
+
""" # TODO: [DOC]
|
|
91
|
+
|
|
92
|
+
:param A:
|
|
93
|
+
:param BSZ:
|
|
94
|
+
:param stepsize:
|
|
95
|
+
:return:
|
|
96
|
+
"""
|
|
97
|
+
# Parameters
|
|
98
|
+
M, N = A.shape
|
|
99
|
+
col_extent = N - BSZ[1] + 1
|
|
100
|
+
row_extent = M - BSZ[0] + 1
|
|
101
|
+
|
|
102
|
+
# Get Starting block indices
|
|
103
|
+
start_idx = np.arange(BSZ[0])[:, None] * N + np.arange(BSZ[1])
|
|
104
|
+
|
|
105
|
+
# Get offsetted indices across the height and width of input array
|
|
106
|
+
offset_idx = np.arange(row_extent)[:, None] * N + np.arange(col_extent)
|
|
107
|
+
|
|
108
|
+
# Get all actual indices & index into input array for final output
|
|
109
|
+
return np.take(A, start_idx.ravel()[:, None] + offset_idx.ravel()[::stepsize])
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def im2col_sliding_strided(A, BSZ, stepsize=1):
|
|
113
|
+
""" # TODO: [DOC]
|
|
114
|
+
|
|
115
|
+
:param A:
|
|
116
|
+
:param BSZ:
|
|
117
|
+
:param stepsize:
|
|
118
|
+
:return:
|
|
119
|
+
"""
|
|
120
|
+
# Parameters
|
|
121
|
+
m, n = A.shape
|
|
122
|
+
s0, s1 = A.strides
|
|
123
|
+
nrows = m - BSZ[0] + 1
|
|
124
|
+
ncols = n - BSZ[1] + 1
|
|
125
|
+
shp = BSZ[0], BSZ[1], nrows, ncols
|
|
126
|
+
strd = s0, s1, s0, s1
|
|
127
|
+
|
|
128
|
+
out_view = np.lib.stride_tricks.as_strided(A, shape=shp, strides=strd)
|
|
129
|
+
return out_view.reshape(BSZ[0] * BSZ[1], -1)[:, ::stepsize]
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def im2col_sliding_strided_v2(A, BSZ, stepsize=1):
|
|
133
|
+
""" # TODO: [DOC]
|
|
134
|
+
|
|
135
|
+
:param A:
|
|
136
|
+
:param BSZ:
|
|
137
|
+
:param stepsize:
|
|
138
|
+
:return:
|
|
139
|
+
"""
|
|
140
|
+
from skimage.util import view_as_windows as viewW
|
|
141
|
+
return viewW(A, (BSZ[0], BSZ[1])).reshape(-1, BSZ[0] * BSZ[1]).T[:, ::stepsize]
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def wind_center_proc(imc, func, wnd, *, imw=None, shape=None, fill=None, out=None):
|
|
145
|
+
""" Apply custom rolling window processing on the image.
|
|
146
|
+
|
|
147
|
+
The processing sums outputs of the provided func(wind_center, pixel) over the window pixels
|
|
148
|
+
to produce a single pixel in the output.
|
|
149
|
+
|
|
150
|
+
The shape of the output may be either 'same' as the input or limited only to the 'valid' area.
|
|
151
|
+
The ill-defined boundary of the 'same' shape can be filled by:
|
|
152
|
+
- a provided numeric value (dafault is 0)
|
|
153
|
+
- 'mirror' the valid results along the boundary
|
|
154
|
+
- 'extend' the valid values on the boundary
|
|
155
|
+
|
|
156
|
+
:param im: the input image
|
|
157
|
+
:param oper: function(c,p) to be applied on each pixel p in the window
|
|
158
|
+
:param wnd: (height, width) of the window - must be even
|
|
159
|
+
:param shape: shape of the result, one of: ['same'] | 'valid'
|
|
160
|
+
:param fill: [number=0] | 'mirror' | 'extend'
|
|
161
|
+
"""
|
|
162
|
+
# process arguments
|
|
163
|
+
hy, hx = tuple(w // 2 for w in wnd)
|
|
164
|
+
assert tuple(w * 2 + 1 for w in (hy, hx)) == wnd
|
|
165
|
+
wsz = wnd[0] * wnd[1]
|
|
166
|
+
|
|
167
|
+
valid_area = np.s_[hy:-hy, hx:-hx]
|
|
168
|
+
imc_valid = imc[valid_area]
|
|
169
|
+
|
|
170
|
+
if imw is None:
|
|
171
|
+
imw = imc
|
|
172
|
+
|
|
173
|
+
if out is None:
|
|
174
|
+
if shape is None:
|
|
175
|
+
shape = 'same'
|
|
176
|
+
else:
|
|
177
|
+
if out.shape == imc.shape:
|
|
178
|
+
assert shape == 'same' or shape is None
|
|
179
|
+
shape = 'same'
|
|
180
|
+
elif out.shape == imc_valid.shape:
|
|
181
|
+
assert shape == 'valid' or shape is None
|
|
182
|
+
same = 'valid'
|
|
183
|
+
else:
|
|
184
|
+
raise ValueError('out.shape %s must be either ''same'' %s or ''valid'' %s!' %
|
|
185
|
+
(out.shape, imc.shape, imc_valid.shape))
|
|
186
|
+
|
|
187
|
+
# calculate
|
|
188
|
+
imc_stride = imc_valid.reshape(-1).repeat(wsz, axis=0).reshape(imc_valid.size, wsz).T
|
|
189
|
+
imw_stride = im2col_sliding_strided_v2(imw, wnd)
|
|
190
|
+
res = func(imc_stride, imw_stride).sum(axis=0).reshape(imc_valid.shape)
|
|
191
|
+
|
|
192
|
+
# pack output
|
|
193
|
+
if shape == 'valid':
|
|
194
|
+
if out is None:
|
|
195
|
+
out = res
|
|
196
|
+
else:
|
|
197
|
+
out[:, :] = res
|
|
198
|
+
else:
|
|
199
|
+
if out is None:
|
|
200
|
+
out = np.zeros_like(imc, dtype=res.dtype)
|
|
201
|
+
out[valid_area] = res
|
|
202
|
+
fill_bounds(out, margs=(hx, hy), fill=fill)
|
|
203
|
+
return out
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def fill_bounds(im, margs, *, fill='extend', out=True):
|
|
207
|
+
""" Fill (in place) boundary area of the image according to the given rule
|
|
208
|
+
|
|
209
|
+
:param im: the image with boundaries to fill
|
|
210
|
+
:param margs: (top, left, bot, right) or
|
|
211
|
+
(top, left) or # then right == left and bottom = top
|
|
212
|
+
marg # then all (top, left, bot, right) == marg
|
|
213
|
+
:param fill: ['extend'] | 'mirror' | None - do nothing | number to fill
|
|
214
|
+
:param out: [True] | False - return filled image
|
|
215
|
+
"""
|
|
216
|
+
import numbers
|
|
217
|
+
if isinstance(margs, numbers.Number):
|
|
218
|
+
margs = (margs,) * 4
|
|
219
|
+
elif len(margs) == 2:
|
|
220
|
+
margs = margs * 2
|
|
221
|
+
top, left, bot, right = margs
|
|
222
|
+
|
|
223
|
+
if fill is None:
|
|
224
|
+
pass
|
|
225
|
+
elif fill == 'extend':
|
|
226
|
+
im[:top, :] = im[top, :].reshape(1, im.shape[1])
|
|
227
|
+
im[-bot:, :] = im[-bot, :].reshape(1, im.shape[1])
|
|
228
|
+
im[:, :left] = im[:, left].reshape(im.shape[0], 1)
|
|
229
|
+
im[:, -right:] = im[:, -right].reshape(im.shape[0], 1)
|
|
230
|
+
elif fill == 'mirror':
|
|
231
|
+
im[:top, :] = im[2 * top:top:-1, :]
|
|
232
|
+
im[-bot:, :] = im[-2 * bot:-bot:-1, :]
|
|
233
|
+
im[:, :left] = im[:, 2 * left:left:-1]
|
|
234
|
+
im[:, -right:] = im[:, -2 * right:-right:-1]
|
|
235
|
+
else:
|
|
236
|
+
im[:top, :] = fill
|
|
237
|
+
im[-bot:, :] = fill
|
|
238
|
+
im[:, :left] = fill
|
|
239
|
+
im[:, -right:] = fill
|
|
240
|
+
|
|
241
|
+
if out:
|
|
242
|
+
return im
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Includes transforms for images
|
|
3
|
+
"""
|
|
4
|
+
import numpy as np
|
|
5
|
+
from xxhash import xxh32_intdigest
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def norm(im, scale=1, offset=0, dtype=None, inverse=False):
|
|
9
|
+
res = (im / scale + offset) if inverse else (im - offset) * scale
|
|
10
|
+
return res.astype(dtype) if dtype else res
|
|
11
|
+
|
|
12
|
+
def take_ch(im, channel):
|
|
13
|
+
mapping = {'red': 0, 'green': 1, 'blue': 2}
|
|
14
|
+
return im[:, :, mapping[channel.lower()]]
|
|
15
|
+
|
|
16
|
+
def alpha_blend(im, channel):
|
|
17
|
+
from skimage.color.colorconv import rgba2rgb
|
|
18
|
+
return rgba2rgb(im, channel_axis=channel)
|
|
19
|
+
|
|
20
|
+
def gamma(im: np.array, g=1.0, inp_bits=8, out_bits=8):
|
|
21
|
+
inp_range = 2 ** inp_bits - 1 # 8 -> 255 ; 10 -> 1023
|
|
22
|
+
out_range = 2 ** out_bits - 1
|
|
23
|
+
|
|
24
|
+
# build a lookup table mapping the pixel values [0, inp_range+1] to
|
|
25
|
+
# their adjusted gamma values using the range [0, out_range+1]
|
|
26
|
+
inp_range = float(inp_range)
|
|
27
|
+
inv_g = 1.0 / g
|
|
28
|
+
return ((im / inp_range) ** inv_g) * out_range
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def shot_noise(im, *, max_electrons, num_bits, norm_pool=None, clip: float | bool = True, stable=True,
|
|
32
|
+
detect_overflow: bool | float = True, dtype=None):
|
|
33
|
+
"""
|
|
34
|
+
Add shot noise to image.
|
|
35
|
+
Uses internal normal generator unless pool is provided.
|
|
36
|
+
|
|
37
|
+
:param im: input image to add the noise to
|
|
38
|
+
:param norm_pool: 1D array of pre-generated random numbers with normal distribution sigma=1
|
|
39
|
+
:param max_electrons: Sensor parameter - electrons / charge for full well. MUST include
|
|
40
|
+
:param num_bits: Data parameter - number of bits the images are made of. MUST include.
|
|
41
|
+
Both max_electrons and num_bits create the gl2el (gray_level->electrons) conversion.
|
|
42
|
+
:param detect_overflow: do not add noise to the pixels with overflow (dtype.max or given number)
|
|
43
|
+
:param clip: clip result into range (0, dtype.max or clip value)
|
|
44
|
+
:param dtype: output must fit into this dtype, if None - use im.dtype
|
|
45
|
+
"""
|
|
46
|
+
import torch
|
|
47
|
+
if dtype is None:
|
|
48
|
+
dtype = im.dtype
|
|
49
|
+
|
|
50
|
+
rnd = np.random
|
|
51
|
+
|
|
52
|
+
if isinstance(im, torch.Tensor):
|
|
53
|
+
if norm_pool or stable or detect_overflow:
|
|
54
|
+
raise ValueError("Normal pool, stable output or overflow not supported for torch tensor input.")
|
|
55
|
+
create_norm = lambda : torch.normal(0,1, im.shape).to(device)
|
|
56
|
+
device = im.device
|
|
57
|
+
sqrt = lambda _ : torch.sqrt(_)
|
|
58
|
+
max_val = torch.iinfo(dtype).max if clip is True else clip
|
|
59
|
+
clip = lambda _ : torch.clamp(_, 0, max_val)
|
|
60
|
+
|
|
61
|
+
elif isinstance(im, np.ndarray):
|
|
62
|
+
if dtype.kind == 'f' and True in (clip, detect_overflow):
|
|
63
|
+
raise ValueError("Clipping or overflow are defied only for integer types")
|
|
64
|
+
create_norm = lambda : rnd.normal(0, 1, im.shape)
|
|
65
|
+
sqrt = lambda _ : np.sqrt(_)
|
|
66
|
+
max_val = np.iinfo(dtype).max if clip is True else clip
|
|
67
|
+
clip = lambda _ : np.clip(_, 0, max_val)
|
|
68
|
+
else:
|
|
69
|
+
raise ValueError(f'Type {type(im)} for input is not supported')
|
|
70
|
+
|
|
71
|
+
if stable is True:
|
|
72
|
+
if not im.flags.c_contiguous:
|
|
73
|
+
im = np.ascontiguousarray(im)
|
|
74
|
+
seed = xxh32_intdigest(im)
|
|
75
|
+
rnd = np.random.RandomState(seed)
|
|
76
|
+
|
|
77
|
+
if norm_pool is None:
|
|
78
|
+
rn = create_norm()
|
|
79
|
+
else:
|
|
80
|
+
rn = rnd.choice(norm_pool, im.size).reshape(im.shape)
|
|
81
|
+
|
|
82
|
+
gl2el = max_electrons / (2 ** num_bits)
|
|
83
|
+
scaler = sqrt(im / gl2el) # noise = [sqrt(im * gl2el)] / gl2el , returned as gray levels
|
|
84
|
+
noise = scaler * rn
|
|
85
|
+
|
|
86
|
+
if detect_overflow:
|
|
87
|
+
max_val = np.iinfo(dtype).max if detect_overflow is True else detect_overflow
|
|
88
|
+
noise[im >= max_val] = 0
|
|
89
|
+
|
|
90
|
+
res = im + noise
|
|
91
|
+
if clip:
|
|
92
|
+
res = clip(res)
|
|
93
|
+
return res
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def gamma_lut(im: np.array, g=1.0, inp_bits=8, out_bits=8):
|
|
97
|
+
"""
|
|
98
|
+
Using gamma transformation to change dynamic range of input image.
|
|
99
|
+
Also enables to switch between number of bits - only if input bits are higher than outputs'.
|
|
100
|
+
|
|
101
|
+
Gamma values < 1 will shift the image towards the darker end of the spectrum,
|
|
102
|
+
while gamma values > 1 will make the image appear lighter.
|
|
103
|
+
A gamma value of g == 1 will have no effect on the input image.
|
|
104
|
+
|
|
105
|
+
Using a Look-up table to create results in O(1) time.
|
|
106
|
+
The built-in function of LUT in cv2 works with either 1 or more channels,
|
|
107
|
+
and for 10 bit images we create our own LUT.
|
|
108
|
+
|
|
109
|
+
:param im: input image. Can either be uint array with 1 channel, or uint8 with 1 or more channels
|
|
110
|
+
:param g: gamma value.
|
|
111
|
+
:param inp_bits: input image bits to calculate range
|
|
112
|
+
:param out_bits: output image bits to calculate range
|
|
113
|
+
:return:
|
|
114
|
+
"""
|
|
115
|
+
import cv2
|
|
116
|
+
if im.dtype.kind == 'f' and im.max() <= 1.0: # image with range 0-1
|
|
117
|
+
raise NotImplementedError("Please use only integer types as input."
|
|
118
|
+
"to not lose information in the transformation")
|
|
119
|
+
inp_range = 2 ** inp_bits - 1 # 8 -> 255 ; 10 -> 1023
|
|
120
|
+
out_range = 2 ** out_bits - 1
|
|
121
|
+
|
|
122
|
+
# build a lookup table mapping the pixel values [0, inp_range+1] to
|
|
123
|
+
# their adjusted gamma values using the range [0, out_range+1]
|
|
124
|
+
inp_range = float(inp_range)
|
|
125
|
+
inv_g = 1.0 / g
|
|
126
|
+
table = np.array([((i / inp_range) ** inv_g) * out_range
|
|
127
|
+
for i in np.arange(0, inp_range + 1)])
|
|
128
|
+
|
|
129
|
+
table = np.round(table)
|
|
130
|
+
# special case
|
|
131
|
+
if out_bits > inp_bits:
|
|
132
|
+
raise ValueError("Please pre process your input image to have "
|
|
133
|
+
"equal or more bits as your output image bits")
|
|
134
|
+
|
|
135
|
+
# op1 - 10 bit input image (with 1 channel)
|
|
136
|
+
elif inp_bits > 8:
|
|
137
|
+
table = table.astype("uint8") if out_bits == 8 else table.astype("uint16")
|
|
138
|
+
return table[im] # apply gamma correction using the lookup table
|
|
139
|
+
|
|
140
|
+
# op2 - 8 bit input image - with 1 or more channels - use cv2
|
|
141
|
+
else:
|
|
142
|
+
table = table.astype("uint8")
|
|
143
|
+
return cv2.LUT(im, table)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def conv(a, b, bound='extend'):
|
|
147
|
+
""" Wraps signal.convolve2d and adds bound 'extend' to extend the boundary values into the margins"""
|
|
148
|
+
from scipy.signal import convolve2d
|
|
149
|
+
from .tools import fill_bounds
|
|
150
|
+
|
|
151
|
+
if bound == 'extend':
|
|
152
|
+
marg = (b.shape[0] // 2, b.shape[1] // 2)
|
|
153
|
+
am = np.empty([da + db - 1 for da, db in zip(a.shape, b.shape)], a.dtype)
|
|
154
|
+
am[marg[0]:-marg[0], marg[1]:-marg[1]] = a
|
|
155
|
+
fill_bounds(am, marg, fill=bound, out=False)
|
|
156
|
+
return convolve2d(am, b, mode='valid')
|
|
157
|
+
else:
|
|
158
|
+
return convolve2d(a, b, mode='same', boundary=bound)
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import numpy as np
|
|
2
|
+
|
|
3
|
+
from iad.img.camera import Camera, Resolution, Vec2d
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def test_create_camera():
|
|
7
|
+
resolution = Resolution.HD
|
|
8
|
+
cam = Camera(name='HD', pixels=resolution)
|
|
9
|
+
assert cam.sensor.pixels == Vec2d(resolution)
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def test_binning():
|
|
13
|
+
from iad.img import camera
|
|
14
|
+
a = np.arange(3 * 5 * 2 * 3).reshape(10, 9)
|
|
15
|
+
assert np.all(
|
|
16
|
+
(camera.bin_xy(a, 3, 2) == camera.bin_x(camera.bin_y(a, 2), 3)))
|