ialdev-img 0.1.0__py3-none-any.whl

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.
iad/img/__init__.py ADDED
@@ -0,0 +1,5 @@
1
+ import numpy as np
2
+
3
+ from .regions import Regions
4
+
5
+ Image = np.ndarray
iad/img/camera.py ADDED
@@ -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))
iad/img/regions.py ADDED
@@ -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})
iad/img/tools.py ADDED
@@ -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
iad/img/transforms.py ADDED
@@ -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,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,8 @@
1
+ iad/img/__init__.py,sha256=nJWLsstryFVhoBmGCYvYifZJ9QYAC20dA6EbaiRBT08,69
2
+ iad/img/camera.py,sha256=WRKoVBkP_W_H1mR_UjSI1IufKg_S5fTrDvp5Hoj-6ts,11453
3
+ iad/img/regions.py,sha256=ZRfEYFMu6KZ9v15GPwurMpKSgaU16lUWvavkT9rDHa4,6179
4
+ iad/img/tools.py,sha256=41UmGsB6P8ijNPe7TOF_KuPA0laxx3VL1oo5yPRGTnA,7655
5
+ iad/img/transforms.py,sha256=vifnMgShUR_J8xSNAuoJz-KDeDj9nzpp21u362ByYRk,6342
6
+ ialdev_img-0.1.0.dist-info/WHEEL,sha256=G2gURzTEtmeR8nrdXUJfNiB3VYVxigPQ-bEQujpNiNs,82
7
+ ialdev_img-0.1.0.dist-info/METADATA,sha256=Ql2pwQSWPeAQhd5GY5CD7tlOOyPdmvdgbXu3oX4tIUc,566
8
+ ialdev_img-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: flit 3.12.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any