rio-tiler 7.9.1__py3-none-any.whl → 8.0.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.
rio_tiler/models.py CHANGED
@@ -33,6 +33,7 @@ from rio_tiler.types import (
33
33
  ColorMapType,
34
34
  GDALColorMapType,
35
35
  IntervalTuple,
36
+ NoData,
36
37
  NumType,
37
38
  RIOResampling,
38
39
  WarpResampling,
@@ -155,10 +156,14 @@ class PointData:
155
156
 
156
157
  array: numpy.ma.MaskedArray = attr.ib(converter=to_masked)
157
158
  band_names: List[str] = attr.ib(kw_only=True)
159
+ band_descriptions: Optional[List[str]] = attr.ib(kw_only=True)
158
160
  coordinates: Optional[Tuple[float, float]] = attr.ib(default=None, kw_only=True)
159
161
  crs: Optional[CRS] = attr.ib(default=None, kw_only=True)
160
162
  assets: Optional[List] = attr.ib(default=None, kw_only=True)
161
163
  metadata: Optional[Dict] = attr.ib(factory=dict, kw_only=True)
164
+ nodata: Optional[NoData] = attr.ib(default=None, kw_only=True)
165
+ scales: Optional[List[NumType]] = attr.ib(kw_only=True)
166
+ offsets: Optional[List[NumType]] = attr.ib(kw_only=True)
162
167
  pixel_location: Optional[Tuple[NumType, NumType]] = attr.ib(
163
168
  default=None, kw_only=True
164
169
  )
@@ -176,24 +181,36 @@ class PointData:
176
181
  raise ValueError("Coordinates data has to be a 2d list")
177
182
 
178
183
  @band_names.default
179
- def _default_names(self):
184
+ def _default_band_names(self):
180
185
  return [f"b{ix + 1}" for ix in range(self.count)]
181
186
 
182
- ###########################################################################
183
- # For compatibility
187
+ @band_descriptions.default
188
+ def _default_band_descriptions(self):
189
+ return ["" for ix in range(self.count)]
190
+
191
+ @scales.default
192
+ def _default_scales(self):
193
+ return [1.0] * self.count
194
+
195
+ @offsets.default
196
+ def _default_offsets(self):
197
+ return [0.0] * self.count
198
+
184
199
  @property
185
200
  def data(self) -> numpy.ndarray:
186
201
  """Return data part of the masked array."""
187
202
  return self.array.data
188
203
 
204
+ @property
205
+ def _mask(self):
206
+ """Return `inverted/merged` mask from data array."""
207
+ return numpy.array([numpy.logical_and.reduce(~self.array.mask)])
208
+
189
209
  @property
190
210
  def mask(self) -> numpy.ndarray:
191
211
  """Return Mask in form of rasterio dataset mask."""
192
- return numpy.array([numpy.logical_and.reduce(~self.array.mask)]) * numpy.uint8(
193
- 255
194
- )
195
-
196
- ###########################################################################
212
+ minv, maxv = dtype_ranges[str(self.array.dtype)]
213
+ return numpy.where(self._mask, maxv, minv).astype(self.array.dtype)
197
214
 
198
215
  def __iter__(self):
199
216
  """Allow for variable expansion."""
@@ -236,6 +253,16 @@ class PointData:
236
253
  itertools.chain.from_iterable([pt.band_names for pt in data if pt.band_names])
237
254
  )
238
255
 
256
+ band_descriptions = list(
257
+ itertools.chain.from_iterable(
258
+ [pt.band_descriptions for pt in data if pt.band_descriptions]
259
+ )
260
+ )
261
+
262
+ scales = list(itertools.chain.from_iterable([pt.scales for pt in data]))
263
+
264
+ offsets = list(itertools.chain.from_iterable([pt.offsets for pt in data]))
265
+
239
266
  metadata = dict(
240
267
  itertools.chain.from_iterable(
241
268
  [pt.metadata.items() for pt in data if pt.metadata]
@@ -246,6 +273,9 @@ class PointData:
246
273
  arr,
247
274
  assets=assets,
248
275
  band_names=band_names,
276
+ band_descriptions=band_descriptions,
277
+ offsets=offsets,
278
+ scales=scales,
249
279
  coordinates=data[0].coordinates,
250
280
  crs=data[0].crs,
251
281
  metadata=metadata,
@@ -259,6 +289,8 @@ class PointData:
259
289
  # Using numexpr do not preserve mask info
260
290
  data.mask = False
261
291
 
292
+ # TODO: Update band descriptions
293
+
262
294
  return PointData(
263
295
  data,
264
296
  assets=self.assets,
@@ -297,7 +329,7 @@ class ImageData:
297
329
  bounds (BoundingBox, optional): bounding box of the data.
298
330
  crs (rasterio.crs.CRS, optional): Coordinates Reference System of the bounds.
299
331
  metadata (dict, optional): Additional metadata. Defaults to `{}`.
300
- band_names (list, optional): name of each band. Defaults to `["1", "2", "3"]` for 3 bands image.
332
+ band_names (list, optional): name of each band. Defaults to `["b1", "b2", "b3"]` for 3 bands image.
301
333
  dataset_statistics (list, optional): dataset statistics `[(min, max), (min, max)]`
302
334
 
303
335
  Note: `mask` should be considered as `PER_BAND` so shape should be similar as the data
@@ -311,18 +343,51 @@ class ImageData:
311
343
  )
312
344
  crs: Optional[CRS] = attr.ib(default=None, kw_only=True)
313
345
  metadata: Optional[Dict] = attr.ib(factory=dict, kw_only=True)
346
+ nodata: Optional[NoData] = attr.ib(default=None, kw_only=True)
347
+ scales: Optional[List[NumType]] = attr.ib(kw_only=True)
348
+ offsets: Optional[List[NumType]] = attr.ib(kw_only=True)
314
349
  band_names: Optional[List[str]] = attr.ib(kw_only=True)
350
+ band_descriptions: Optional[List[str]] = attr.ib(kw_only=True)
315
351
  dataset_statistics: Optional[Sequence[Tuple[float, float]]] = attr.ib(
316
352
  default=None, kw_only=True
317
353
  )
318
354
  cutline_mask: Optional[numpy.ndarray] = attr.ib(default=None)
355
+ alpha_mask: Optional[numpy.ndarray] = attr.ib(default=None)
319
356
 
320
357
  @band_names.default
321
- def _default_names(self):
358
+ def _default_band_names(self):
322
359
  return [f"b{ix + 1}" for ix in range(self.count)]
323
360
 
324
- ###########################################################################
325
- # For compatibility
361
+ @band_descriptions.default
362
+ def _default_band_descriptions(self):
363
+ return ["" for ix in range(self.count)]
364
+
365
+ @scales.default
366
+ def _default_scales(self):
367
+ return [1.0] * self.count
368
+
369
+ @offsets.default
370
+ def _default_offsets(self):
371
+ return [0.0] * self.count
372
+
373
+ @alpha_mask.validator
374
+ def _check_alpha_mask(self, attribute, value):
375
+ """Make sure alpha mask has valid shame and datatype."""
376
+ if value is not None:
377
+ if (
378
+ len(value.shape) != 2
379
+ or value.shape[0] != self.height
380
+ or value.shape[1] != self.width
381
+ ):
382
+ raise ValueError(
383
+ f"Invalide shape {value.shape} for AlphaMask, should be of shape {self.height}x{self.width}"
384
+ )
385
+
386
+ if not value.dtype == self.array.dtype:
387
+ raise ValueError(
388
+ f"Invalide dtype {value.dtype} for AlphaMask, should be of {self.array.dtype}"
389
+ )
390
+
326
391
  @property
327
392
  def data(self) -> numpy.ndarray:
328
393
  """Return data part of the masked array."""
@@ -331,9 +396,17 @@ class ImageData:
331
396
  @property
332
397
  def mask(self) -> numpy.ndarray:
333
398
  """Return Mask in form of rasterio dataset mask."""
334
- return numpy.logical_or.reduce(~self.array.mask) * numpy.uint8(255)
399
+ # NOTE: if available we return the alpha_mask
400
+ if self.alpha_mask is not None:
401
+ return self.alpha_mask
335
402
 
336
- ###########################################################################
403
+ minv, maxv = dtype_ranges[str(self.array.dtype)]
404
+ return numpy.where(self._mask, maxv, minv).astype(self.array.dtype)
405
+
406
+ @property
407
+ def _mask(self):
408
+ """Return `inverted/merged` mask from data array."""
409
+ return numpy.logical_or.reduce(~self.array.mask)
337
410
 
338
411
  def __iter__(self):
339
412
  """Allow for variable expansion (``arr, mask = ImageData``)"""
@@ -386,7 +459,15 @@ class ImageData:
386
459
  array,
387
460
  crs=dataset.crs,
388
461
  bounds=dataset.bounds,
462
+ band_names=[f"b{idx}" for idx in indexes],
463
+ band_descriptions=[
464
+ dataset.descriptions[ix - 1] or "" for idx in indexes
465
+ ],
389
466
  dataset_statistics=dataset_statistics,
467
+ nodata=dataset.nodata,
468
+ scales=list(dataset.scales),
469
+ offsets=list(dataset.offsets),
470
+ metadata=dataset.tags(),
390
471
  )
391
472
 
392
473
  @classmethod
@@ -439,6 +520,16 @@ class ImageData:
439
520
  )
440
521
  )
441
522
 
523
+ band_descriptions = list(
524
+ itertools.chain.from_iterable(
525
+ [img.band_descriptions for img in data if img.band_descriptions]
526
+ )
527
+ )
528
+
529
+ scales = list(itertools.chain.from_iterable([img.scales for img in data]))
530
+
531
+ offsets = list(itertools.chain.from_iterable([img.offsets for img in data]))
532
+
442
533
  stats = list(
443
534
  itertools.chain.from_iterable(
444
535
  [img.dataset_statistics for img in data if img.dataset_statistics]
@@ -458,9 +549,12 @@ class ImageData:
458
549
  crs=crs,
459
550
  bounds=bounds,
460
551
  band_names=band_names,
552
+ band_descriptions=band_descriptions,
461
553
  dataset_statistics=dataset_statistics,
462
554
  cutline_mask=cutline_mask,
463
555
  metadata=metadata,
556
+ scales=scales,
557
+ offsets=offsets,
464
558
  )
465
559
 
466
560
  def data_as_image(self) -> numpy.ndarray:
@@ -503,21 +597,65 @@ class ImageData:
503
597
  ) -> Self:
504
598
  """Rescale data in place."""
505
599
  self.array = rescale_image(
506
- self.array.copy(),
600
+ self.array,
507
601
  in_range=in_range,
508
602
  out_range=out_range,
509
603
  out_dtype=out_dtype,
510
604
  )
605
+ if self.alpha_mask is not None:
606
+ self.alpha_mask = linear_rescale(
607
+ self.alpha_mask,
608
+ in_range=dtype_ranges[str(self.alpha_mask.dtype)],
609
+ out_range=dtype_ranges[out_dtype],
610
+ ).astype(out_dtype)
611
+
612
+ # reset scales/offsets
613
+ self.scales = [1.0] * self.count
614
+ self.offsets = [0.0] * self.count
615
+
616
+ return self
617
+
618
+ def apply_color_formula(self, color_formula: Optional[str]) -> Self:
619
+ """Apply color-operations formula in place."""
620
+ out = self.array.data
621
+ out[out < 0] = 0
622
+
623
+ for ops in parse_operations(color_formula):
624
+ out = scale_dtype(ops(to_math_type(out)), numpy.uint8)
625
+
626
+ data = numpy.ma.MaskedArray(out)
627
+ data.mask = self.array.mask
628
+ self.array = data
629
+
630
+ # NOTE: we need to rescale the alpha mask if not in Uint8
631
+ if self.alpha_mask is not None and self.alpha_mask.dtype != "uint8":
632
+ self.alpha_mask = linear_rescale(
633
+ self.alpha_mask, in_range=dtype_ranges[str(self.alpha_mask.dtype)]
634
+ ).astype("uint8")
635
+
636
+ # reset scales/offsets
637
+ self.scales = [1.0] * self.count
638
+ self.offsets = [0.0] * self.count
639
+
511
640
  return self
512
641
 
513
642
  def apply_colormap(self, colormap: ColorMapType) -> "ImageData":
514
643
  """Apply colormap to the image data."""
515
644
  data, alpha = apply_cmap(self.array.data, colormap)
516
645
 
517
- # Use Dataset Mask which is fine
518
- # because in theory self.array should be a 1 band image
519
646
  array = numpy.ma.MaskedArray(data)
520
- array.mask = numpy.bitwise_and(alpha, self.mask) == 0
647
+
648
+ # `inValid/Valid` values for colormaped datatype (e.g 0 for uint8)
649
+ invalid, valid = dtype_ranges[str(alpha.dtype)]
650
+
651
+ # Combine both dataset mask and alpha from colormap
652
+ array.mask = numpy.bitwise_or(alpha != valid, ~self._mask)
653
+
654
+ if self.alpha_mask is not None:
655
+ warnings.warn(
656
+ "Alpha Mask value ignored from the input ImageData object when applying colormap.",
657
+ UserWarning,
658
+ )
521
659
 
522
660
  return ImageData(
523
661
  array,
@@ -525,21 +663,10 @@ class ImageData:
525
663
  crs=self.crs,
526
664
  bounds=self.bounds,
527
665
  metadata=self.metadata,
666
+ # NOTE: make sure masked part from initial data are also masked in alpha band
667
+ alpha_mask=numpy.where(~self._mask, invalid, alpha),
528
668
  )
529
669
 
530
- def apply_color_formula(self, color_formula: Optional[str]) -> Self:
531
- """Apply color-operations formula in place."""
532
- out = self.array.data.copy()
533
- out[out < 0] = 0
534
-
535
- for ops in parse_operations(color_formula):
536
- out = scale_dtype(ops(to_math_type(out)), numpy.uint8)
537
-
538
- data = numpy.ma.MaskedArray(out)
539
- data.mask = self.array.mask
540
- self.array = data
541
- return self
542
-
543
670
  def apply_expression(self, expression: str) -> "ImageData":
544
671
  """Apply expression to the image data."""
545
672
  blocks = get_expression_blocks(expression)
@@ -557,10 +684,12 @@ class ImageData:
557
684
  )
558
685
  )
559
686
 
560
- data = apply_expression(blocks, self.band_names, self.array)
687
+ data = apply_expression(blocks, self.band_names, self.array.copy())
561
688
  # NOTE: We use dataset mask when mixing bands
562
689
  data.mask = numpy.logical_or.reduce(self.array.mask)
563
690
 
691
+ # TODO: update band descriptions
692
+
564
693
  return ImageData(
565
694
  data,
566
695
  assets=self.assets,
@@ -569,6 +698,7 @@ class ImageData:
569
698
  band_names=blocks,
570
699
  metadata=self.metadata,
571
700
  dataset_statistics=stats,
701
+ alpha_mask=self.alpha_mask,
572
702
  )
573
703
 
574
704
  def resize(
@@ -582,6 +712,9 @@ class ImageData:
582
712
  mask = resize_array(self.array.mask * 1, height, width, resampling_method).astype(
583
713
  "bool"
584
714
  )
715
+ alpha_mask = self.alpha_mask
716
+ if alpha_mask is not None:
717
+ alpha_mask = resize_array(alpha_mask.copy(), height, width, resampling_method)
585
718
 
586
719
  return ImageData(
587
720
  numpy.ma.MaskedArray(data, mask=mask),
@@ -589,8 +722,13 @@ class ImageData:
589
722
  crs=self.crs,
590
723
  bounds=self.bounds,
591
724
  band_names=self.band_names,
725
+ band_descriptions=self.band_descriptions,
726
+ nodata=self.nodata,
727
+ scales=self.scales,
728
+ offsets=self.offsets,
592
729
  metadata=self.metadata,
593
730
  dataset_statistics=self.dataset_statistics,
731
+ alpha_mask=alpha_mask,
594
732
  )
595
733
 
596
734
  def clip(self, bbox: BBox) -> "ImageData":
@@ -605,8 +743,15 @@ class ImageData:
605
743
  crs=self.crs,
606
744
  bounds=bbox,
607
745
  band_names=self.band_names,
746
+ band_descriptions=self.band_descriptions,
747
+ nodata=self.nodata,
748
+ scales=self.scales,
749
+ offsets=self.offsets,
608
750
  metadata=self.metadata,
609
751
  dataset_statistics=self.dataset_statistics,
752
+ alpha_mask=self.alpha_mask[row_slice, col_slice].copy()
753
+ if self.alpha_mask is not None
754
+ else None,
610
755
  )
611
756
 
612
757
  def post_process(
@@ -633,25 +778,24 @@ class ImageData:
633
778
  >>> img.post_process(color_formula="Gamma RGB 4.1")
634
779
 
635
780
  """
636
- array = self.array.copy()
637
-
638
- if in_range:
639
- array = rescale_image(array, in_range, out_dtype=out_dtype, **kwargs)
640
-
641
- if color_formula:
642
- array[array < 0] = 0
643
- for ops in parse_operations(color_formula):
644
- array = scale_dtype(ops(to_math_type(array)), numpy.uint8)
645
- array.mask = self.array.mask
646
-
647
- return ImageData(
648
- array,
781
+ img = ImageData(
782
+ self.array.copy(),
649
783
  crs=self.crs,
650
784
  bounds=self.bounds,
651
785
  assets=self.assets,
652
786
  metadata=self.metadata,
787
+ dataset_statistics=self.dataset_statistics,
788
+ alpha_mask=self.alpha_mask.copy() if self.alpha_mask is not None else None,
653
789
  )
654
790
 
791
+ if in_range:
792
+ img.rescale(in_range, out_dtype=out_dtype, **kwargs)
793
+
794
+ if color_formula:
795
+ img.apply_color_formula(color_formula)
796
+
797
+ return img
798
+
655
799
  def render(
656
800
  self,
657
801
  add_mask: bool = True,
@@ -680,39 +824,31 @@ class ImageData:
680
824
  kwargs.update({"crs": self.crs})
681
825
 
682
826
  array = self.array.copy()
827
+ mask = self.mask
683
828
 
684
829
  datatype_range = self.dataset_statistics or (dtype_ranges[str(array.dtype)],)
685
830
 
686
831
  if not colormap:
687
- if img_format in ["PNG"] and array.dtype not in ["uint8", "uint16"]:
688
- warnings.warn(
689
- f"Invalid type: `{array.dtype}` for the `{img_format}` driver. Data will be rescaled using min/max type bounds or dataset_statistics.",
690
- InvalidDatatypeWarning,
691
- )
692
- array = rescale_image(array, in_range=datatype_range)
693
-
694
- elif img_format in ["JPEG", "WEBP"] and array.dtype not in ["uint8"]:
695
- warnings.warn(
696
- f"Invalid type: `{array.dtype}` for the `{img_format}` driver. Data will be rescaled using min/max type bounds or dataset_statistics.",
697
- InvalidDatatypeWarning,
698
- )
699
- array = rescale_image(array, in_range=datatype_range)
700
-
701
- elif img_format in ["JP2OPENJPEG"] and array.dtype not in [
702
- "uint8",
703
- "int16",
704
- "uint16",
705
- ]:
832
+ format_dtypes = {
833
+ "PNG": ["uint8", "uint16"],
834
+ "JPEG": ["uint8"],
835
+ "WEBP": ["uint8"],
836
+ "JP2OPENJPEG": ["uint8", "int16", "uint16"],
837
+ }
838
+ valid_dtypes = format_dtypes.get(img_format, [])
839
+ if valid_dtypes and array.dtype not in valid_dtypes:
706
840
  warnings.warn(
707
841
  f"Invalid type: `{array.dtype}` for the `{img_format}` driver. Data will be rescaled using min/max type bounds or dataset_statistics.",
708
842
  InvalidDatatypeWarning,
709
843
  )
710
844
  array = rescale_image(array, in_range=datatype_range)
845
+ if mask is not None:
846
+ mask = linear_rescale(mask, in_range=dtype_ranges[str(array.dtype)])
711
847
 
712
848
  if add_mask:
713
849
  return render(
714
850
  array.data,
715
- self.mask, # We use dataset mask for rendering
851
+ mask,
716
852
  img_format=img_format,
717
853
  colormap=colormap,
718
854
  **kwargs,
@@ -728,7 +864,7 @@ class ImageData:
728
864
  if "crs" not in kwargs and self.crs:
729
865
  kwargs.update({"crs": self.crs})
730
866
 
731
- write_nodata = "nodata" in kwargs
867
+ write_nodata = self.nodata is not None
732
868
  count, height, width = self.array.shape
733
869
 
734
870
  output_profile = {
@@ -736,6 +872,7 @@ class ImageData:
736
872
  "count": count if write_nodata else count + 1,
737
873
  "height": height,
738
874
  "width": width,
875
+ "nodata": self.nodata,
739
876
  }
740
877
  output_profile.update(kwargs)
741
878
 
@@ -858,6 +995,21 @@ class ImageData:
858
995
  resampling=Resampling[reproject_method],
859
996
  )
860
997
 
998
+ alpha_mask = self.alpha_mask
999
+ if self.alpha_mask is not None:
1000
+ alpha_mask = numpy.ma.masked_array(
1001
+ numpy.zeros((h, w), dtype=self.alpha_mask.dtype),
1002
+ )
1003
+ alpha_mask, _ = reproject(
1004
+ self.alpha_mask,
1005
+ alpha_mask,
1006
+ src_transform=self.transform,
1007
+ src_crs=self.crs,
1008
+ dst_transform=dst_transform,
1009
+ dst_crs=dst_crs,
1010
+ resampling=Resampling[reproject_method],
1011
+ )
1012
+
861
1013
  bounds = array_bounds(h, w, dst_transform)
862
1014
 
863
1015
  return ImageData(
@@ -866,6 +1018,11 @@ class ImageData:
866
1018
  crs=dst_crs,
867
1019
  bounds=bounds,
868
1020
  band_names=self.band_names,
1021
+ band_descriptions=self.band_descriptions,
1022
+ nodata=self.nodata,
1023
+ scales=self.scales,
1024
+ offsets=self.offsets,
869
1025
  metadata=self.metadata,
870
1026
  dataset_statistics=self.dataset_statistics,
1027
+ alpha_mask=alpha_mask,
871
1028
  )