@@ -1289,7 +1289,8 @@ class Transform(TransformNode):
1289
1289
actually perform a transformation.
1290
1290
1291
1291
All non-affine transformations should be subclasses of this class.
1292
- New affine transformations should be subclasses of `Affine2D`.
1292
+ New affine transformations should be subclasses of `Affine2D` or
1293
+ `Affine3D`.
1293
1294
1294
1295
Subclasses of this class should override the following members (at
1295
1296
minimum):
@@ -1510,7 +1511,7 @@ def transform(self, values):
1510
1511
return res [0 , 0 ]
1511
1512
if ndim == 1 :
1512
1513
return res .reshape (- 1 )
1513
- elif ndim == 2 :
1514
+ elif ndim == 2 or ndim == 3 :
1514
1515
return res
1515
1516
raise ValueError (
1516
1517
"Input values must have shape (N, {dims}) or ({dims},)"
@@ -1839,8 +1840,8 @@ class AffineImmutable(AffineBase):
1839
1840
b d f
1840
1841
0 0 1
1841
1842
1842
- This class provides the read-only interface. For a mutable 2D
1843
- affine transformation, use `Affine2D`.
1843
+ This class provides the read-only interface. For a mutable
1844
+ affine transformation, use `Affine2D` or `Affine3D` .
1844
1845
1845
1846
Subclasses of this class will generally only need to override a
1846
1847
constructor and `~.Transform.get_matrix` that generates a custom matrix
@@ -1920,6 +1921,8 @@ class Affine2DBase(AffineImmutable):
1920
1921
def _affine_factory (mtx , dims , * args , ** kwargs ):
1921
1922
if dims == 2 :
1922
1923
return Affine2D (mtx , * args , ** kwargs )
1924
+ elif dims == 3 :
1925
+ return Affine3D (mtx , * args , ** kwargs )
1923
1926
else :
1924
1927
return NotImplemented
1925
1928
@@ -2147,6 +2150,298 @@ def skew_deg(self, xShear, yShear):
2147
2150
return self .skew (math .radians (xShear ), math .radians (yShear ))
2148
2151
2149
2152
2153
+ class Affine3D (AffineImmutable ):
2154
+ """
2155
+ A mutable 3D affine transformation.
2156
+ """
2157
+
2158
+ def __init__ (self , matrix = None , ** kwargs ):
2159
+ """
2160
+ Initialize an Affine transform from a 4x4 numpy float array::
2161
+
2162
+ a d g j
2163
+ b e h k
2164
+ c f i l
2165
+ 0 0 0 1
2166
+
2167
+ If *matrix* is None, initialize with the identity transform.
2168
+ """
2169
+ super ().__init__ (dims = 3 , ** kwargs )
2170
+ if matrix is None :
2171
+ matrix = np .identity (4 )
2172
+ self ._mtx = matrix .copy ()
2173
+ self ._invalid = 0
2174
+
2175
+ _base_str = _make_str_method ("_mtx" )
2176
+
2177
+ def __str__ (self ):
2178
+ return (self ._base_str ()
2179
+ if (self ._mtx != np .diag (np .diag (self ._mtx ))).any ()
2180
+ else f"Affine3D().scale("
2181
+ f"{ self ._mtx [0 , 0 ]} , "
2182
+ f"{ self ._mtx [1 , 1 ]} , "
2183
+ f"{ self ._mtx [2 , 2 ]} )"
2184
+ if self ._mtx [0 , 0 ] != self ._mtx [1 , 1 ] or
2185
+ self ._mtx [0 , 0 ] != self ._mtx [2 , 2 ]
2186
+ else f"Affine3D().scale({ self ._mtx [0 , 0 ]} )" )
2187
+
2188
+ @staticmethod
2189
+ def from_values (a , b , c , d , e , f , g , h , i , j , k , l ):
2190
+ """
2191
+ Create a new Affine2D instance from the given values::
2192
+
2193
+ a d g j
2194
+ b e h k
2195
+ c f i l
2196
+ 0 0 0 1
2197
+
2198
+ .
2199
+ """
2200
+ return Affine3D (np .array ([
2201
+ a , d , g , j ,
2202
+ b , e , h , k ,
2203
+ c , f , i , l ,
2204
+ 0.0 , 0.0 , 0.0 , 1.0
2205
+ ], float ).reshape ((4 , 4 )))
2206
+
2207
+ def get_matrix (self ):
2208
+ """
2209
+ Get the underlying transformation matrix as a 4x4 array::
2210
+
2211
+ a d g j
2212
+ b e h k
2213
+ c f i l
2214
+ 0 0 0 1
2215
+
2216
+ .
2217
+ """
2218
+ if self ._invalid :
2219
+ self ._inverted = None
2220
+ self ._invalid = 0
2221
+ return self ._mtx
2222
+
2223
+ def set_matrix (self , mtx ):
2224
+ """
2225
+ Set the underlying transformation matrix from a 4x4 array::
2226
+
2227
+ a d g j
2228
+ b e h k
2229
+ c f i l
2230
+ 0 0 0 1
2231
+
2232
+ .
2233
+ """
2234
+ self ._mtx = mtx
2235
+ self .invalidate ()
2236
+
2237
+ def set (self , other ):
2238
+ """
2239
+ Set this transformation from the frozen copy of another
2240
+ `AffineImmutable` object with input and output dimension of 3.
2241
+ """
2242
+ _api .check_isinstance (AffineImmutable , other = other )
2243
+ if (other .input_dims != 3 ):
2244
+ raise TypeError ("Mismatch between dimensions of AffineImmutable"
2245
+ "and Affine3D" )
2246
+ self ._mtx = other .get_matrix ()
2247
+ self .invalidate ()
2248
+
2249
+ def clear (self ):
2250
+ """
2251
+ Reset the underlying matrix to the identity transform.
2252
+ """
2253
+ self ._mtx = np .identity (4 )
2254
+ self .invalidate ()
2255
+ return self
2256
+
2257
+ def rotate (self , theta , dim = 0 ):
2258
+ """
2259
+ Add a rotation (in radians) to this transform in place, along
2260
+ the dimension denoted by *dim*.
2261
+
2262
+ Returns *self*, so this method can easily be chained with more
2263
+ calls to :meth:`rotate`, :meth:`rotate_deg`, :meth:`translate`
2264
+ and :meth:`scale`.
2265
+ """
2266
+ if dim == 0 :
2267
+ return self .rotate_around_vector ([1 , 0 , 0 ], theta )
2268
+ elif dim == 1 :
2269
+ return self .rotate_around_vector ([0 , 1 , 0 ], theta )
2270
+ elif dim == 2 :
2271
+ return self .rotate_around_vector ([0 , 0 , 1 ], theta )
2272
+
2273
+ self .invalidate ()
2274
+ return self
2275
+
2276
+ def rotate_deg (self , degrees , dim = 0 ):
2277
+ """
2278
+ Add a rotation (in degrees) to this transform in place, along
2279
+ the dimension denoted by *dim*.
2280
+
2281
+ Returns *self*, so this method can easily be chained with more
2282
+ calls to :meth:`rotate`, :meth:`rotate_deg`, :meth:`translate`
2283
+ and :meth:`scale`.
2284
+ """
2285
+ return self .rotate (math .radians (degrees ), dim )
2286
+
2287
+ def rotate_around (self , x , y , z , theta , dim = 0 ):
2288
+ """
2289
+ Add a rotation (in radians) around the point (x, y, z) in place,
2290
+ along the dimension denoted by *dim*.
2291
+
2292
+ Returns *self*, so this method can easily be chained with more
2293
+ calls to :meth:`rotate`, :meth:`rotate_deg`, :meth:`translate`
2294
+ and :meth:`scale`.
2295
+ """
2296
+ return self .translate (- x , - y , - z ).rotate (theta , dim ).translate (x , y , z )
2297
+
2298
+ def rotate_deg_around (self , x , y , z , degrees , dim = 0 ):
2299
+ """
2300
+ Add a rotation (in degrees) around the point (x, y, z) in place,
2301
+ along the dimension denoted by *dim*.
2302
+
2303
+ Returns *self*, so this method can easily be chained with more
2304
+ calls to :meth:`rotate`, :meth:`rotate_deg`, :meth:`translate`
2305
+ and :meth:`scale`.
2306
+ """
2307
+ # Cast to float to avoid wraparound issues with uint8's
2308
+ x , y = float (x ), float (y )
2309
+ return self .translate (- x , - y , - z ).rotate_deg (degrees , dim ).translate (x , y , z )
2310
+
2311
+ def rotate_around_vector (self , vector , theta ):
2312
+ """
2313
+ Add a rotation (in radians) around the vector (vx, vy, vz) in place.
2314
+
2315
+ Returns *self*, so this method can easily be chained with more
2316
+ calls to :meth:`rotate`, :meth:`rotate_deg`, :meth:`translate`
2317
+ and :meth:`scale`.
2318
+ """
2319
+ vx , vy , vz = vector / np .linalg .norm (vector )
2320
+ s = np .sin (theta )
2321
+ c = np .cos (theta )
2322
+ t = 2 * np .sin (theta / 2 )** 2 # more numerically stable than t = 1-c
2323
+ rot = [[t * vx * vx + c , t * vx * vy - vz * s , t * vx * vz + vy * s , 0 ],
2324
+ [t * vy * vx + vz * s , t * vy * vy + c , t * vy * vz - vx * s , 0 ],
2325
+ [t * vz * vx - vy * s , t * vz * vy + vx * s , t * vz * vz + c , 0 ],
2326
+ [0 , 0 , 0 , 1 ]]
2327
+ np .matmul (rot , self ._mtx , out = self ._mtx )
2328
+ return self
2329
+
2330
+ def rotate_deg_around_vector (self , vector , degrees ):
2331
+ """
2332
+ Add a rotation (in radians) around the vector (vx, vy, vz) in place.
2333
+
2334
+ Returns *self*, so this method can easily be chained with more
2335
+ calls to :meth:`rotate`, :meth:`rotate_deg`, :meth:`translate`
2336
+ and :meth:`scale`.
2337
+ """
2338
+ return self .rotate_around_vector (vector , math .radians (degrees ))
2339
+
2340
+ def translate (self , tx , ty , tz ):
2341
+ """
2342
+ Add a translation in place.
2343
+
2344
+ Returns *self*, so this method can easily be chained with more
2345
+ calls to :meth:`rotate`, :meth:`rotate_deg`, :meth:`translate`
2346
+ and :meth:`scale`.
2347
+ """
2348
+ self ._mtx [0 , 3 ] += tx
2349
+ self ._mtx [1 , 3 ] += ty
2350
+ self ._mtx [2 , 3 ] += tz
2351
+ self .invalidate ()
2352
+ return self
2353
+
2354
+ def scale (self , sx , sy = None , sz = None ):
2355
+ """
2356
+ Add a scale in place.
2357
+
2358
+ If a scale is not provided in the *y* or *z* directions, *sx*
2359
+ will be applied for that direction.
2360
+
2361
+ Returns *self*, so this method can easily be chained with more
2362
+ calls to :meth:`rotate`, :meth:`rotate_deg`, :meth:`translate`
2363
+ and :meth:`scale`.
2364
+ """
2365
+ if sy is None :
2366
+ sy = sx
2367
+
2368
+ if sz is None :
2369
+ sz = sx
2370
+ # explicit element-wise scaling is fastest
2371
+ self ._mtx [0 , 0 ] *= sx
2372
+ self ._mtx [0 , 1 ] *= sx
2373
+ self ._mtx [0 , 2 ] *= sx
2374
+ self ._mtx [0 , 3 ] *= sx
2375
+ self ._mtx [1 , 0 ] *= sy
2376
+ self ._mtx [1 , 1 ] *= sy
2377
+ self ._mtx [1 , 2 ] *= sy
2378
+ self ._mtx [1 , 3 ] *= sy
2379
+ self ._mtx [2 , 0 ] *= sz
2380
+ self ._mtx [2 , 1 ] *= sz
2381
+ self ._mtx [2 , 2 ] *= sz
2382
+ self ._mtx [2 , 3 ] *= sz
2383
+
2384
+ self .invalidate ()
2385
+ return self
2386
+
2387
+ def skew (self , xyShear , xzShear , yxShear , yzShear , zxShear , zyShear ):
2388
+ """
2389
+ Add a skew in place along for each plane in the 3rd dimension.
2390
+
2391
+ For example *zxShear* is the shear angle along the *zx* plane,
2392
+ in radians.
2393
+
2394
+ Returns *self*, so this method can easily be chained with more
2395
+ calls to :meth:`rotate`, :meth:`rotate_deg`, :meth:`translate`
2396
+ and :meth:`scale`.
2397
+ """
2398
+ rxy = math .tan (xyShear )
2399
+ rxz = math .tan (xzShear )
2400
+ ryx = math .tan (yxShear )
2401
+ ryz = math .tan (yzShear )
2402
+ rzx = math .tan (zxShear )
2403
+ rzy = math .tan (zyShear )
2404
+ mtx = self ._mtx
2405
+ # Operating and assigning one scalar at a time is much faster.
2406
+ (xx , xy , xz , x0 ), (yx , yy , yz , y0 ), (zx , zy , zz , z0 ), _ = mtx .tolist ()
2407
+ # mtx = [[1 rx 0], [ry 1 0], [0 0 1]] * mtx
2408
+
2409
+ mtx [0 , 0 ] += (rxy * yx ) + (rxz * zx )
2410
+ mtx [0 , 1 ] += (rxy * yy ) + (rxz * zy )
2411
+ mtx [0 , 2 ] += (rxy * yz ) + (rxz * zz )
2412
+ mtx [0 , 3 ] += (rxy * y0 ) + (rxz * z0 )
2413
+ mtx [1 , 0 ] = (ryx * xx ) + yx + (ryz * zx )
2414
+ mtx [1 , 1 ] = (ryx * xy ) + yy + (ryz * zy )
2415
+ mtx [1 , 2 ] = (ryx * xz ) + yz + (ryz * zz )
2416
+ mtx [1 , 3 ] = (ryx * x0 ) + y0 + (ryz * z0 )
2417
+ mtx [2 , 0 ] = (rzx * xx ) + (rzy * yx ) + zx
2418
+ mtx [2 , 1 ] = (rzx * xy ) + (rzy * yy ) + zy
2419
+ mtx [2 , 2 ] = (rzx * xz ) + (rzy * yz ) + zz
2420
+ mtx [2 , 3 ] = (rzx * x0 ) + (rzy * y0 ) + z0
2421
+
2422
+ self .invalidate ()
2423
+ return self
2424
+
2425
+ def skew_deg (self , xyShear , xzShear , yxShear , yzShear , zxShear , zyShear ):
2426
+ """
2427
+ Add a skew in place along for each plane in the 3rd dimension.
2428
+
2429
+ For example *zxShear* is the shear angle along the *zx* plane,
2430
+ in radians.
2431
+
2432
+ Returns *self*, so this method can easily be chained with more
2433
+ calls to :meth:`rotate`, :meth:`rotate_deg`, :meth:`translate`
2434
+ and :meth:`scale`.
2435
+ """
2436
+ return self .skew (
2437
+ math .radians (xyShear ),
2438
+ math .radians (xzShear ),
2439
+ math .radians (yxShear ),
2440
+ math .radians (yzShear ),
2441
+ math .radians (zxShear ),
2442
+ math .radians (zyShear ))
2443
+
2444
+
2150
2445
class IdentityTransform (AffineImmutable ):
2151
2446
"""
2152
2447
A special class that does one thing, the identity transform, in a
@@ -2595,6 +2890,8 @@ def composite_transform_factory(a, b):
2595
2890
return a
2596
2891
elif isinstance (a , Affine2D ) and isinstance (b , Affine2D ):
2597
2892
return CompositeAffine (a , b )
2893
+ elif isinstance (a , Affine3D ) and isinstance (b , Affine3D ):
2894
+ return CompositeAffine (a , b )
2598
2895
return CompositeGenericTransform (a , b )
2599
2896
2600
2897
0 commit comments