Repository: rasterio/rasterio

Issue:
CInt16 rasters promoted to np.complex128 dtype
## Expected behavior and actual behavior.

I've noticed rasterio promotes CInt16 to np.complex128 when Tifs are read in, inflating the memory footprint, which is problematic for SAR data. 

This does not happen using GDAL python bindings directly. I'm wondering if would be straightforward to instead map to np.complex64 as GDAL does by default? I'm guessing this is a straightforward fix, in which case I could follow up with a PR...

related issue: https://github.com/qgis/QGIS-Documentation/issues/2966#issuecomment-420665251, and past rasterio discussion of complex floats https://github.com/mapbox/rasterio/issues/97#issuecomment-47406591

## Steps to reproduce the problem.

```python
import rasterio
from osgeo import gdal
import os

os.environ['GDAL_DISABLE_READDIR_ON_OPEN']='EMPTY_DIR' 
os.environ['AWS_NO_SIGN_REQUEST']='YES'

path='/vsis3/my-cog-server/measurement/s1b-iw1-slc-vv-20210401t052624-20210401t052649-026269-032297-004.tiff'

with rasterio.open(path) as src:
    data = src.read()
    print(data.dtype)   
# dtype('complex128')

ds = gdal.Open(path)
test = ds.ReadAsArray() 
print(test.dtype)
# dtype('complex64')
```

I've posted an unzipped version of the original Sentinel-1 SLC Tiff for easy access : 
`GDAL_DISABLE_READDIR_ON_OPEN=EMPTY_DIR AWS_NO_SIGN_REQUEST=YES gdalinfo -nogcp /vsis3/my-cog-server/measurement/s1b-iw1-slc-vv-20210401t052624-20210401t052649-026269-032297-004.tiff`
```
# data from https://datapool.asf.alaska.edu/SLC/SB/S1B_IW_SLC__1SDV_20210401T052622_20210401T052650_026269_032297_EFA4.zip
Driver: GTiff/GeoTIFF
Files: /vsis3/my-cog-server/measurement/s1b-iw1-slc-vv-20210401t052624-20210401t052649-026269-032297-004.tiff
Size is 21632, 13509
Metadata:
  AREA_OR_POINT=Area
  TIFFTAG_DATETIME=2021:04:01 06:57:06
  TIFFTAG_IMAGEDESCRIPTION=Sentinel-1A IW SLC L1
  TIFFTAG_SOFTWARE=Sentinel-1 IPF 003.31
Image Structure Metadata:
  INTERLEAVE=BAND
Corner Coordinates:
Upper Left  (    0.0,    0.0)
Lower Left  (    0.0,13509.0)
Upper Right (21632.0,    0.0)
Lower Right (21632.0,13509.0)
Center      (10816.0, 6754.5)
Band 1 Block=21632x1 Type=CInt16, ColorInterp=Gray
```

## Operating system

MacOS

## Rasterio version and provenance

rasterio 1.2.3 from conda-forge



Discussion:
@scottyhq this project has had a blind spot when it comes to complex integers. Could you check if changing the values in the forward mapping of types from `complex_` to `complex64` in these two lines https://github.com/mapbox/rasterio/blob/master/rasterio/dtypes.py#L38-L39 suffices? You could modify the code in your conda environment.
Thanks for the pointer @sgillies . Yes I've confirmed that simple change fixes the *reading*. 

If you write a complex64 file back to disk however it becomes CFloat32 instead of the original CInt16, likely because the mapping is not 1:1 (complex64 -> CInt16, CInt32, CFloat32). In anycase, I'm mostly concerned with reading at this point, should I open a PR to make the change?
@scottyhq I'm in the rasterio code this morning and will take care of it. Thanks for the report and the follow up!
@sgillies do you think this is straightforward enough to sneak into the 1.2.4 release (#2184) ?
@scottyhq cint16 read into a complex64 array, yes. The writing side is complicated by some poor choices I made in the past, but I might be able to fix that, too. 

Resolving patch:
diff --git a/rasterio/__init__.py b/rasterio/__init__.py
--- a/rasterio/__init__.py
+++ b/rasterio/__init__.py
@@ -9,8 +9,21 @@
 from rasterio._base import gdal_version
 from rasterio.drivers import driver_from_extension, is_blacklisted
 from rasterio.dtypes import (
-    bool_, ubyte, sbyte, uint8, int8, uint16, int16, uint32, int32, float32, float64,
-    complex_, check_dtype)
+    bool_,
+    ubyte,
+    sbyte,
+    uint8,
+    int8,
+    uint16,
+    int16,
+    uint32,
+    int32,
+    float32,
+    float64,
+    complex_,
+    complex_int16,
+    check_dtype,
+)
 from rasterio.env import ensure_env_with_credentials, Env
 from rasterio.errors import RasterioIOError, DriverCapabilityError
 from rasterio.io import (
diff --git a/rasterio/dtypes.py b/rasterio/dtypes.py
--- a/rasterio/dtypes.py
+++ b/rasterio/dtypes.py
@@ -4,10 +4,6 @@
 Happily strings can be used throughout Numpy and so existing code will
 not break.
 
-Within Rasterio, to test data types, we use Numpy's dtype() factory to
-do something like this:
-
-    if np.dtype(destination.dtype) == np.dtype(rasterio.uint8): ...
 """
 
 bool_ = 'bool'
@@ -23,26 +19,29 @@
 complex64 = 'complex64'
 complex128 = 'complex128'
 
-# Not supported:
-#  GDT_CInt16 = 8, GDT_CInt32 = 9, GDT_CFloat32 = 10, GDT_CFloat64 = 11
+complex_int16 = "complex_int16"
 
 dtype_fwd = {
-    0: None,            # GDT_Unknown
-    1: ubyte,           # GDT_Byte
-    2: uint16,          # GDT_UInt16
-    3: int16,           # GDT_Int16
-    4: uint32,          # GDT_UInt32
-    5: int32,           # GDT_Int32
-    6: float32,         # GDT_Float32
-    7: float64,         # GDT_Float64
-    8: complex_,        # GDT_CInt16
-    9: complex_,        # GDT_CInt32
-    10: complex64,      # GDT_CFloat32
-    11: complex128}    # GDT_CFloat64
+    0: None,  # GDT_Unknown
+    1: ubyte,  # GDT_Byte
+    2: uint16,  # GDT_UInt16
+    3: int16,  # GDT_Int16
+    4: uint32,  # GDT_UInt32
+    5: int32,  # GDT_Int32
+    6: float32,  # GDT_Float32
+    7: float64,  # GDT_Float64
+    8: complex_int16,  # GDT_CInt16
+    9: complex64,  # GDT_CInt32
+    10: complex64,  # GDT_CFloat32
+    11: complex128,  # GDT_CFloat64
+}
 
 dtype_rev = dict((v, k) for k, v in dtype_fwd.items())
-dtype_rev['uint8'] = 1
-dtype_rev['int8'] = 1
+
+dtype_rev["uint8"] = 1
+dtype_rev["int8"] = 1
+dtype_rev["complex"] = 11
+dtype_rev["complex_int16"] = 8
 
 typename_fwd = {
     0: 'Unknown',
@@ -154,7 +153,7 @@ def can_cast_dtype(values, dtype):
     if not is_ndarray(values):
         values = np.array(values)
 
-    if values.dtype.name == np.dtype(dtype).name:
+    if values.dtype.name == _getnpdtype(dtype).name:
         return True
 
     elif values.dtype.kind == 'f':
@@ -185,3 +184,15 @@ def validate_dtype(values, valid_dtypes):
 
     return (values.dtype.name in valid_dtypes or
             get_minimum_dtype(values) in valid_dtypes)
+
+
+def _is_complex_int(dtype):
+    return isinstance(dtype, str) and dtype.startswith("complex_int")
+
+
+def _getnpdtype(dtype):
+    import numpy as np
+    if _is_complex_int(dtype):
+        return np.dtype("complex64")
+    else:
+        return np.dtype(dtype)
diff --git a/rasterio/features.py b/rasterio/features.py
--- a/rasterio/features.py
+++ b/rasterio/features.py
@@ -9,7 +9,7 @@
 import numpy as np
 
 import rasterio
-from rasterio.dtypes import validate_dtype, can_cast_dtype, get_minimum_dtype
+from rasterio.dtypes import validate_dtype, can_cast_dtype, get_minimum_dtype, _getnpdtype
 from rasterio.enums import MergeAlg
 from rasterio.env import ensure_env
 from rasterio.errors import ShapeSkipWarning
@@ -276,7 +276,7 @@ def format_cast_error(param, dtype):
         if dtype is not None and not can_cast_dtype(default_value_array, dtype):
             raise ValueError(format_cast_error('default_vaue', dtype))
 
-    if dtype is not None and np.dtype(dtype).name not in valid_dtypes:
+    if dtype is not None and _getnpdtype(dtype).name not in valid_dtypes:
         raise ValueError(format_invalid_dtype('dtype'))
 
     valid_shapes = []
@@ -332,7 +332,7 @@ def format_cast_error(param, dtype):
         raise ValueError(format_cast_error('shape values', dtype))
 
     if out is not None:
-        if np.dtype(out.dtype).name not in valid_dtypes:
+        if _getnpdtype(out.dtype).name not in valid_dtypes:
             raise ValueError(format_invalid_dtype('out'))
 
         if not can_cast_dtype(shape_values, out.dtype):


Test patch:
diff --git a/tests/test_complex_dtypes.py b/tests/test_complex_dtypes.py
--- a/tests/test_complex_dtypes.py
+++ b/tests/test_complex_dtypes.py
@@ -1,4 +1,5 @@
 import logging
+import subprocess
 import sys
 import uuid
 
@@ -8,9 +9,6 @@
 import rasterio
 
 
-logging.basicConfig(stream=sys.stderr, level=logging.DEBUG)
-
-
 @pytest.fixture(scope='function')
 def tempfile():
     """A temporary filename in the GDAL '/vsimem' filesystem"""
@@ -23,10 +21,8 @@ def complex_image(height, width, dtype):
         [complex(x, x) for x in range(height * width)],
         dtype=dtype).reshape(height, width)
 
-dtypes = ['complex', 'complex64', 'complex128']
-
 
-@pytest.mark.parametrize("dtype", dtypes)
+@pytest.mark.parametrize("dtype", ["complex", "complex64", "complex128"])
 @pytest.mark.parametrize("height,width", [(20, 20)])
 def test_read_array(tempfile, dtype, height, width):
     """_io functions read and write arrays correctly"""
@@ -58,3 +54,45 @@ def test_complex_nodata(tmpdir):
 
     with rasterio.open(tempfile) as dst:
         assert dst.nodata == 0
+
+
+@pytest.mark.gdalbin
+def test_complex_int16(tmpdir):
+    """A cint16 dataset can be created"""
+    import numpy as np
+    import rasterio
+    from rasterio.transform import Affine
+
+    x = np.linspace(-4.0, 4.0, 240)
+    y = np.linspace(-3.0, 3.0, 180)
+    X, Y = np.meshgrid(x, y)
+    Z1 = np.ones_like(X) + 1j
+
+    res = (x[-1] - x[0]) / 240.0
+    transform1 = Affine.translation(x[0] - res / 2, y[-1] - res / 2) * Affine.scale(
+        res, -res
+    )
+
+    tempfile = str(tmpdir.join("test.tif"))
+
+    with rasterio.open(
+        tempfile,
+        "w",
+        driver="GTiff",
+        height=Z1.shape[0],
+        width=Z1.shape[1],
+        nodata=0,
+        count=1,
+        dtype="complex_int16",
+        crs="+proj=latlong",
+        transform=transform1,
+    ) as dst:
+        dst.write(Z1, 1)
+
+    assert "Type=CInt16" in subprocess.check_output(["gdalinfo", tempfile]).decode(
+        "utf-8"
+    )
+
+    with rasterio.open(tempfile) as dst:
+        data = dst.read()
+        assert data.dtype == np.complex64
diff --git a/tests/test_dtypes.py b/tests/test_dtypes.py
--- a/tests/test_dtypes.py
+++ b/tests/test_dtypes.py
@@ -3,10 +3,26 @@
 
 import rasterio
 from rasterio import (
-    ubyte, uint8, uint16, uint32, int16, int32, float32, float64, complex_)
+    ubyte,
+    uint8,
+    uint16,
+    uint32,
+    int16,
+    int32,
+    float32,
+    float64,
+    complex_,
+    complex_int16,
+)
 from rasterio.dtypes import (
-    _gdal_typename, is_ndarray, check_dtype, get_minimum_dtype, can_cast_dtype,
-    validate_dtype
+    _gdal_typename,
+    is_ndarray,
+    check_dtype,
+    get_minimum_dtype,
+    can_cast_dtype,
+    validate_dtype,
+    _is_complex_int,
+    _getnpdtype,
 )
 
 
@@ -28,10 +44,19 @@ def test_check_dtype_invalid():
     assert not check_dtype('foo')
 
 
-def test_gdal_name():
-    assert _gdal_typename(ubyte) == 'Byte'
-    assert _gdal_typename(np.uint8) == 'Byte'
-    assert _gdal_typename(np.uint16) == 'UInt16'
+@pytest.mark.parametrize(
+    ("dtype", "name"),
+    [
+        (ubyte, "Byte"),
+        (np.uint8, "Byte"),
+        (np.uint16, "UInt16"),
+        ("uint8", "Byte"),
+        ("complex_int16", "CInt16"),
+        (complex_int16, "CInt16"),
+    ],
+)
+def test_gdal_name(dtype, name):
+    assert _gdal_typename(dtype) == name
 
 
 def test_get_minimum_dtype():
@@ -89,3 +114,17 @@ def test_complex(tmpdir):
         arr2 = src.read(1)
 
     assert np.array_equal(arr1, arr2)
+
+
+def test_is_complex_int():
+    assert _is_complex_int("complex_int16")
+
+
+def test_not_is_complex_int():
+    assert not _is_complex_int("complex")
+
+
+def test_get_npdtype():
+    npdtype = _getnpdtype("complex_int16")
+    assert npdtype == np.complex64
+    assert npdtype.kind == "c"