@@ -299,6 +299,194 @@ def set_mask_polygon(
299299 self ._mask_polygon_is_geo = is_geo
300300 # Clear cached binary mask when polygon changes
301301 self ._mask = None
302+
303+ def convert_to_affine (self ) -> 'GeoTiff' :
304+ """Convert from standard storage to affine rotation storage.
305+
306+ Returns a NEW GeoTiff object with imarray aligned to the mask polygon
307+ rectangle, and transform including rotation. The original object is
308+ not modified.
309+
310+ Requires mask_polygon to be a valid rectangle (4 vertices, 90° angles).
311+
312+ Returns
313+ -------
314+ GeoTiff
315+ New GeoTiff object in affine mode. Returns self if already affine.
316+
317+ Raises
318+ ------
319+ ValueError
320+ If no mask polygon is set or polygon is not a valid rectangle.
321+
322+ Example
323+ -------
324+ .. code-block:: python
325+
326+ >>> gtiff = idp.GeoTiff('input.tif')
327+ >>> gtiff.set_mask_polygon(rect_coords, is_geo=True)
328+ >>> affine_gtiff = gtiff.convert_to_affine()
329+ >>> affine_gtiff.use_affine
330+ True
331+ >>> gtiff.use_affine # Original unchanged
332+ False
333+ """
334+ if self ._use_affine :
335+ logger .warning ("Already in affine mode, returning self" )
336+ return self
337+
338+ if self ._mask_polygon is None :
339+ raise ValueError ("No mask polygon set. Use set_mask_polygon first." )
340+
341+ polygon_geo = self .mask_polygon_geo
342+ is_rect , angle , bounds = self ._is_valid_rectangle (polygon_geo )
343+
344+ if not is_rect :
345+ raise ValueError (
346+ "Polygon is not a valid rectangle. Cannot convert to affine mode."
347+ )
348+
349+ # Perform the transformation
350+ profile = self .header ['profile' ].copy ()
351+ new_imarray , new_profile = self ._prepare_affine_storage (
352+ self ._imarray .copy (), profile , polygon_geo , angle , bounds
353+ )
354+
355+ # Build new header
356+ new_header = self .header .copy ()
357+ new_header ['profile' ] = new_profile
358+ new_header ['width' ] = new_profile ['width' ]
359+ new_header ['height' ] = new_profile ['height' ]
360+ new_header ['transform' ] = new_profile ['transform' ]
361+ new_header ['scale' ] = [abs (new_profile ['transform' ].a ),
362+ abs (new_profile ['transform' ].e )]
363+
364+ # Create new GeoTiff object
365+ new_gtiff = GeoTiff (imarray = new_imarray , header = new_header )
366+ new_gtiff ._mask_polygon = self ._mask_polygon .copy ()
367+ new_gtiff ._mask_polygon_is_geo = self ._mask_polygon_is_geo
368+ new_gtiff ._use_affine = True
369+
370+ logger .info (f"Converted to affine mode with { angle :.1f} ° rotation" )
371+ return new_gtiff
372+
373+ def convert_from_affine (self , target_bounds : tuple | None = None ) -> 'GeoTiff' :
374+ """Convert from affine rotation storage to standard storage.
375+
376+ Returns a NEW GeoTiff object with the rotated imarray transformed back
377+ to axis-aligned coordinates, with a mask for the valid region.
378+ The original object is not modified.
379+
380+ Parameters
381+ ----------
382+ target_bounds : tuple, optional
383+ (min_x, min_y, max_x, max_y) for the output image bounds.
384+ If None, uses the bounding box of mask_polygon.
385+
386+ Returns
387+ -------
388+ GeoTiff
389+ New GeoTiff object in standard mode. Returns self if already standard.
390+
391+ Raises
392+ ------
393+ ValueError
394+ If no mask polygon is available for conversion.
395+
396+ Example
397+ -------
398+ .. code-block:: python
399+
400+ >>> gtiff = idp.GeoTiff('affine_file.tif')
401+ >>> gtiff.use_affine
402+ True
403+ >>> standard_gtiff = gtiff.convert_from_affine()
404+ >>> standard_gtiff.use_affine
405+ False
406+ >>> gtiff.use_affine # Original unchanged
407+ True
408+ """
409+ if not self ._use_affine :
410+ logger .warning ("Already in standard mode, returning self" )
411+ return self
412+
413+ if self ._mask_polygon is None :
414+ raise ValueError ("No mask polygon available for conversion." )
415+
416+ from rasterio .transform import Affine
417+ from scipy .ndimage import map_coordinates
418+
419+ polygon_geo = self .mask_polygon_geo
420+ old_transform = self .header ['profile' ]['transform' ]
421+
422+ # Calculate target bounds
423+ if target_bounds is None :
424+ min_x , max_x = polygon_geo [:, 0 ].min (), polygon_geo [:, 0 ].max ()
425+ min_y , max_y = polygon_geo [:, 1 ].min (), polygon_geo [:, 1 ].max ()
426+ else :
427+ min_x , min_y , max_x , max_y = target_bounds
428+
429+ # Use same pixel size
430+ pixel_size_x = abs (old_transform .a )
431+ pixel_size_y = abs (old_transform .e )
432+
433+ # Calculate output dimensions
434+ out_width = int (np .ceil ((max_x - min_x ) / pixel_size_x ))
435+ out_height = int (np .ceil ((max_y - min_y ) / pixel_size_y ))
436+
437+ # New axis-aligned transform
438+ new_transform = Affine .translation (min_x , max_y ) * Affine .scale (pixel_size_x , - pixel_size_y )
439+
440+ # Create output coordinate grids
441+ out_rows , out_cols = np .mgrid [0 :out_height , 0 :out_width ]
442+
443+ # Output pixels to geo coordinates (axis-aligned)
444+ geo_x = min_x + out_cols * pixel_size_x
445+ geo_y = max_y - out_rows * pixel_size_y
446+
447+ # Geo coordinates to input (rotated) pixel coordinates
448+ inv_old = ~ old_transform
449+ in_cols = inv_old .a * geo_x + inv_old .b * geo_y + inv_old .c
450+ in_rows = inv_old .d * geo_x + inv_old .e * geo_y + inv_old .f
451+
452+ # Sample from rotated image
453+ imarray = self ._imarray
454+ ndim = len (imarray .shape )
455+ if ndim == 2 :
456+ new_imarray = map_coordinates (
457+ imarray , [in_rows , in_cols ], order = 1 , mode = 'constant' , cval = 0
458+ ).astype (imarray .dtype )
459+ else :
460+ bands = imarray .shape [2 ]
461+ new_imarray = np .zeros ((out_height , out_width , bands ), dtype = imarray .dtype )
462+ for b in range (bands ):
463+ new_imarray [:, :, b ] = map_coordinates (
464+ imarray [:, :, b ], [in_rows , in_cols ],
465+ order = 1 , mode = 'constant' , cval = 0
466+ ).astype (imarray .dtype )
467+
468+ # Build new header
469+ new_profile = self .header ['profile' ].copy ()
470+ new_profile ['width' ] = out_width
471+ new_profile ['height' ] = out_height
472+ new_profile ['transform' ] = new_transform
473+
474+ new_header = self .header .copy ()
475+ new_header ['profile' ] = new_profile
476+ new_header ['width' ] = out_width
477+ new_header ['height' ] = out_height
478+ new_header ['transform' ] = new_transform
479+ new_header ['scale' ] = [pixel_size_x , pixel_size_y ]
480+ new_header ['tie_point' ] = [min_x , max_y ]
481+
482+ # Create new GeoTiff object
483+ new_gtiff = GeoTiff (imarray = new_imarray , header = new_header )
484+ new_gtiff ._mask_polygon = self ._mask_polygon .copy ()
485+ new_gtiff ._mask_polygon_is_geo = self ._mask_polygon_is_geo
486+ new_gtiff ._use_affine = False
487+
488+ logger .info ("Converted from affine to standard mode" )
489+ return new_gtiff
302490
303491 def _polygon_pixel_to_geo (self , polygon : np .ndarray ) -> np .ndarray :
304492 """Convert pixel polygon to geo coordinates using transform."""
@@ -743,12 +931,11 @@ def save(
743931 if use_affine :
744932 is_rect , angle , bounds = self ._is_valid_rectangle (polygon_geo )
745933 if is_rect :
746- # Prepare affine rotated storage
934+ # Prepare affine rotated storage (only for saved file)
747935 imarray , profile = self ._prepare_affine_storage (
748936 imarray , profile , polygon_geo , angle , bounds
749937 )
750938 logger .info (f"Affine mode: saved with { angle :.1f} ° rotation" )
751- self ._use_affine = True
752939 apply_mask = False # No mask needed in affine mode
753940 else :
754941 logger .warning (
@@ -811,8 +998,8 @@ def _is_valid_rectangle(
811998 bounds : tuple
812999 (origin_x, origin_y, width, height) of the rectangle.
8131000 """
814- # Remove closure point if present
815- pts = polygon [:- 1 ] if np .allclose (polygon [0 ], polygon [- 1 ]) else polygon
1001+ # Remove closure point if present (use strict atol for geo coords)
1002+ pts = polygon [:- 1 ] if np .allclose (polygon [0 ], polygon [- 1 ], rtol = 0 , atol = 1e-6 ) else polygon
8161003
8171004 if len (pts ) != 4 :
8181005 return False , 0.0 , ()
0 commit comments