Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Fix the ninjotiff writer to provide correct scale and offset #889

Merged
merged 14 commits into from
Sep 30, 2019
Merged
49 changes: 49 additions & 0 deletions satpy/composites/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -750,6 +750,55 @@ def __call__(self, projectables, nonprojectables=None, **info):
return proj


class SingleBandCompositor(CompositeBase):
"""Basic single-band composite builder.

This preserves all the attributes of the dataset it is derived from.
"""

def __init__(self, name, **kwargs):
"""Collect custom configuration values."""
super(SingleBandCompositor, self).__init__(name, **kwargs)

def _get_sensors(self, projectables):
sensor = set()
for projectable in projectables:
current_sensor = projectable.attrs.get("sensor", None)
if current_sensor:
if isinstance(current_sensor, (str, bytes, six.text_type)):
sensor.add(current_sensor)
else:
sensor |= current_sensor
if len(sensor) == 0:
sensor = None
elif len(sensor) == 1:
sensor = list(sensor)[0]
return sensor

def __call__(self, projectables, nonprojectables=None, **attrs):
"""Build the composite."""
num = len(projectables)
if num != 1:
raise ValueError("Can't have more than one band in a single-band composite")
mode = attrs.get('mode', 'L')
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does mode make sense here? This is for non-image composites technically ("physical quantities").

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Well it could be a single band image in the end...

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

True, but that's the default. If someone wants to provide the mode then there is no reason for the default and it should be preserved anyway. Plus this would be adding metadata to a compositor that is "preserving" the metadata. Very image-y.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It could also be P

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

or do you mean that mode is already set in attrs ?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, if the image is an image then it is already set and if not then when this physical quantity gets saved as an image the L mode will be used by default.


data = projectables[0]
new_attrs = data.attrs.copy()

new_attrs.update({key: val
for (key, val) in attrs.items()
if val is not None})
resolution = new_attrs.get('resolution', None)
new_attrs.update(self.attrs)
if resolution is not None:
new_attrs['resolution'] = resolution
new_attrs["sensor"] = self._get_sensors(projectables)
djhoese marked this conversation as resolved.
Show resolved Hide resolved
new_attrs["mode"] = mode

return xr.DataArray(data=data.data, attrs=new_attrs,
dims=data.dims, coords=data.coords)


class GenericCompositor(CompositeBase):
"""Basic colored composite builder."""

Expand Down
19 changes: 10 additions & 9 deletions satpy/tests/writer_tests/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,17 +15,18 @@
#
# You should have received a copy of the GNU General Public License along with
# satpy. If not, see <http://www.gnu.org/licenses/>.
"""The writer tests package.
"""
"""The writer tests package."""

import sys

from satpy.tests.writer_tests import (test_cf, test_geotiff,
from satpy.tests.writer_tests import (test_cf,
test_geotiff,
test_simple_image,
test_scmi, test_mitiff,
test_utils)
# FIXME: pyninjotiff is not xarray/dask friendly
from satpy.tests.writer_tests import test_ninjotiff # noqa
test_scmi,
test_mitiff,
test_utils,
test_ninjotiff,
)

if sys.version_info < (2, 7):
import unittest2 as unittest
Expand All @@ -34,11 +35,11 @@


def suite():
"""Test suite for all writer tests"""
"""Test suite for all writer tests."""
mysuite = unittest.TestSuite()
mysuite.addTests(test_cf.suite())
mysuite.addTests(test_geotiff.suite())
# mysuite.addTests(test_ninjotiff.suite())
mysuite.addTests(test_ninjotiff.suite())
mysuite.addTests(test_simple_image.suite())
mysuite.addTests(test_scmi.suite())
mysuite.addTests(test_mitiff.suite())
Expand Down
60 changes: 52 additions & 8 deletions satpy/tests/writer_tests/test_ninjotiff.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,24 +15,68 @@
#
# You should have received a copy of the GNU General Public License along with
# satpy. If not, see <http://www.gnu.org/licenses/>.
"""Tests for the NinJoTIFF writer.
"""
import sys
"""Tests for the NinJoTIFF writer."""

if sys.version_info < (2, 7):
import unittest2 as unittest
import sys
import unittest
import xarray as xr
from dask.delayed import Delayed
import six
if six.PY3:
from unittest import mock
else:
import unittest
import mock


class FakeImage:
"""Fake image."""

def __init__(self, data, mode):
"""Init fake image."""
self.data = data
self.mode = mode


modules = {'pyninjotiff': mock.Mock(),
'pyninjotiff.ninjotiff': mock.Mock()}


@mock.patch.dict(sys.modules, modules)
class TestNinjoTIFFWriter(unittest.TestCase):
"""The ninjo tiff writer tests."""

def test_init(self):
"""Test the init."""
from satpy.writers.ninjotiff import NinjoTIFFWriter
ninjo_tags = {40000: 'NINJO'}
ntw = NinjoTIFFWriter(tags=ninjo_tags)
self.assertDictEqual(ntw.tags, ninjo_tags)

@mock.patch('satpy.writers.ninjotiff.ImageWriter.save_dataset')
@mock.patch('satpy.writers.ninjotiff.convert_units')
def test_dataset(self, uconv, iwsd):
"""Test saving a dataset."""
from satpy.writers.ninjotiff import NinjoTIFFWriter
ntw = NinjoTIFFWriter()
dataset = xr.DataArray([1, 2, 3], attrs={'units': 'K'})
ntw.save_dataset(dataset, physic_unit='CELSIUS')
uconv.assert_called_once_with(dataset, 'K', 'CELSIUS')
self.assertEqual(iwsd.call_count, 1)

@mock.patch('satpy.writers.ninjotiff.NinjoTIFFWriter.save_dataset')
@mock.patch('satpy.writers.ninjotiff.ImageWriter.save_image')
def test_image(self, iwsi, save_dataset):
"""Test saving an image."""
from satpy.writers.ninjotiff import NinjoTIFFWriter
NinjoTIFFWriter()
ntw = NinjoTIFFWriter()
dataset = xr.DataArray([1, 2, 3], attrs={'units': 'K'})
img = FakeImage(dataset, 'L')
ret = ntw.save_image(img, filename='bla.tif', compute=False)
self.assertIsInstance(ret, Delayed)


def suite():
"""The test suite for this writer's tests."""
"""Test suite for this writer's tests."""
loader = unittest.TestLoader()
mysuite = unittest.TestSuite()
mysuite.addTest(loader.loadTestsFromTestCase(TestNinjoTIFFWriter))
Expand Down
157 changes: 153 additions & 4 deletions satpy/writers/ninjotiff.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,19 +17,105 @@
# satpy. If not, see <http://www.gnu.org/licenses/>.
"""Writer for TIFF images compatible with the NinJo visualization tool (NinjoTIFFs).

NinjoTIFFs can be color images or monochromatic. For monochromatic images, the
physical units and scale and offsets to retrieve the physical values are
provided. Metadata is also recorded in the file.

In order to write ninjotiff files, some metadata needs to be provided to the
writer. Here is an example on how to write a color image::

chn = "airmass"
ninjoRegion = load_area("areas.def", "nrEURO3km")

filenames = glob("data/*__")
global_scene = Scene(reader="hrit_msg", filenames=filenames)
global_scene.load([chn])
local_scene = global_scene.resample(ninjoRegion)
local_scene.save_dataset(chn, filename="airmass.tif", writer='ninjotiff',
sat_id=6300014,
chan_id=6500015,
data_cat='GPRN',
data_source='EUMCAST',
nbits=8)

Here is an example on how to write a color image::

chn = "IR_108"
ninjoRegion = load_area("areas.def", "nrEURO3km")

filenames = glob("data/*__")
global_scene = Scene(reader="hrit_msg", filenames=filenames)
global_scene.load([chn])
local_scene = global_scene.resample(ninjoRegion)
local_scene.save_dataset(chn, filename="msg.tif", writer='ninjotiff',
sat_id=6300014,
chan_id=900015,
data_cat='GORN',
data_source='EUMCAST',
physic_unit='K',
nbits=8)

The metadata to provide to the writer can also be stored in a configuration file
(see pyninjotiff), so that the previous example can be rewritten as::

chn = "IR_108"
ninjoRegion = load_area("areas.def", "nrEURO3km")

filenames = glob("data/*__")
global_scene = Scene(reader="hrit_msg", filenames=filenames)
global_scene.load([chn])
local_scene = global_scene.resample(ninjoRegion)
local_scene.save_dataset(chn, filename="msg.tif", writer='ninjotiff',
# ninjo product name to look for in .cfg file
ninjo_product_name="IR_108",
# custom configuration file for ninjo tiff products
# if not specified PPP_CONFIG_DIR is used as config file directory
ninjo_product_file="/config_dir/ninjotiff_products.cfg")


.. _ninjotiff: http://www.ssec.wisc.edu/~davidh/polar2grid/misc/NinJo_Satellite_Import_Formats.html

"""

import logging

from dask import delayed

import pyninjotiff.ninjotiff as nt
from satpy.writers import ImageWriter

LOG = logging.getLogger(__name__)

logger = logging.getLogger(__name__)


def convert_units(dataset, in_unit, out_unit):
"""Convert units of *dataset*."""
from pint import UnitRegistry

ureg = UnitRegistry()
# Commented because buggy: race condition ?
# ureg.define("degree_Celsius = degC = Celsius = C = CELSIUS")
in_unit = ureg.parse_expression(in_unit, False)
if out_unit in ['CELSIUS', 'C', 'Celsius', 'celsius']:
dest_unit = ureg.degC
else:
dest_unit = ureg.parse_expression(out_unit, False)
data = ureg.Quantity(dataset, in_unit)
attrs = dataset.attrs
dataset = data.to(dest_unit).magnitude
dataset.attrs = attrs
dataset.attrs["units"] = out_unit
return dataset


class NinjoTIFFWriter(ImageWriter):
"""Writer for NinjoTiff files."""

def __init__(self, tags=None, **kwargs):
ImageWriter.__init__(self, default_config_filename="writers/ninjotiff.yaml", **kwargs)
"""Inititalize the writer."""
ImageWriter.__init__(
self, default_config_filename="writers/ninjotiff.yaml", **kwargs
)

self.tags = self.info.get("tags", None) if tags is None else tags
if self.tags is None:
Expand All @@ -38,10 +124,73 @@ def __init__(self, tags=None, **kwargs):
# if it's coming from a config file
self.tags = dict(tuple(x.split("=")) for x in self.tags.split(","))

def save_image(self, img, filename=None, **kwargs): # floating_point=False,
def save_image(self, img, filename=None, compute=True, **kwargs): # floating_point=False,
"""Save the image to the given *filename* in ninjotiff_ format.

.. _ninjotiff: http://www.ssec.wisc.edu/~davidh/polar2grid/misc/NinJo_Satellite_Import_Formats.html
"""
filename = filename or self.get_filename(**img.data.attrs)
nt.save(img, filename, **kwargs)
if img.mode.startswith("L") and (
"ch_min_measurement_unit" not in kwargs
or "ch_max_measurement_unit" not in kwargs
):
try:
history = img.data.attrs["enhancement_history"]
except KeyError:
logger.warning("Cannot find information on previous scaling for ninjo.")
else:
if len(history) > 1:
raise NotImplementedError(
"Don't know how to process large enhancement_history yet"
)
try:
scale = history[0]["scale"]
offset = history[0]["offset"]
dmin = -offset / scale
dmax = (1 - offset) / scale
if dmin > dmax:
dmin, dmax = dmax, dmin
ch_min_measurement_unit, ch_max_measurement_unit = (
dmin.values,
dmax.values,
)
kwargs["ch_min_measurement_unit"] = ch_min_measurement_unit
kwargs["ch_max_measurement_unit"] = ch_max_measurement_unit
except KeyError:
raise NotImplementedError(
"Don't know how to handle non-scale/offset-based enhancements yet."
)
if compute:
return nt.save(img, filename, data_is_scaled_01=True, **kwargs)
else:
return delayed(nt.save)(img, filename, data_is_scaled_01=True, **kwargs)

def save_dataset(
self, dataset, filename=None, fill_value=None, compute=True, **kwargs
):
"""Save a dataset to ninjotiff format.

This calls `save_image` in turn, but first preforms some unit conversion
if necessary.
"""
nunits = kwargs.get("physic_unit", None)
if nunits is None:
try:
options = nt.get_product_config(
kwargs["ninjo_product_name"], True, kwargs["ninjo_product_file"]
)
nunits = options["physic_unit"]
except KeyError:
pass
if nunits is not None:
try:
units = dataset.attrs["units"]
except KeyError:
logger.warning(
"Saving to physical ninjo file without units defined in dataset!"
)
else:
dataset = convert_units(dataset, units, nunits)
return super(NinjoTIFFWriter, self).save_dataset(
dataset, filename=filename, compute=compute, fill_value=fill_value, **kwargs
)
1 change: 1 addition & 0 deletions setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@
'scmi': ['netCDF4 >= 1.1.8'],
'geotiff': ['rasterio', 'trollimage[geotiff]'],
'mitiff': ['libtiff'],
'ninjo': ['pyninjotiff', 'pint'],
# MultiScene:
'animations': ['imageio'],
# Documentation:
Expand Down