Skip to content

Commit 38397c7

Browse files
WeatherGodtdrwenski
authored andcommitted
Many fixes and improvements to geotiff colortable handling
* Make it possible to use colortable regardless of greyscale mode, so long as it is UBYTE * Fix some of the coercion code * Added more tests * Improved documentation and input checking
1 parent 02148bf commit 38397c7

2 files changed

Lines changed: 134 additions & 59 deletions

File tree

cdm/misc/src/main/java/ucar/nc2/geotiff/GeotiffWriter.java

Lines changed: 110 additions & 59 deletions
Original file line numberDiff line numberDiff line change
@@ -65,8 +65,9 @@ public void close() throws IOException {
6565
/**
6666
* Write GridDatatype data to the geotiff file.
6767
*
68-
* This is for backwards-compatibility. Assumes dtype is FLOAT if greyScale is false, and assumes
69-
* dtype is BYTE if greyScale is true.
68+
* Greyscale mode will auto-normalize the data from 1 to 255 and save as unsigned bytes, with 0's used
69+
* for missing data. A color table can be applied if specified via `setColorTable()`.
70+
* Non-greyscale mode will save the data as floats, encoding missing data as the data minimum minus one.
7071
*
7172
* @param dataset grid in contained in this dataset
7273
* @param grid data is in this grid
@@ -75,21 +76,39 @@ public void close() throws IOException {
7576
* @throws IOException on i/o error
7677
*/
7778
public void writeGrid(GridDataset dataset, GridDatatype grid, Array data, boolean greyScale) throws IOException {
78-
writeGrid(dataset, grid, data, greyScale, greyScale ? DataType.BYTE : DataType.FLOAT);
79+
writeGrid(dataset, grid, data, greyScale, greyScale ? DataType.UBYTE : DataType.FLOAT);
7980
}
8081

8182
/**
8283
* Write GridDatatype data to the geotiff file.
8384
*
85+
* Greyscale mode will auto-normalize the data from 1 to 255 and save as unsigned bytes, with 0's used
86+
* for missing data.
87+
* Non-greyscale mode with a floating point dtype will save the data as floats, encoding missing data
88+
* as the data's minimum minus one. Any other dtype will save the data coerced to the specified dtype.
89+
*
90+
* A color table can be applied if specified via `setColorTable()` and the dtype is UBYTE.
91+
*
8492
* @param dataset grid in contained in this dataset
8593
* @param grid data is in this grid
8694
* @param data 2D array in YX order
8795
* @param greyScale if true, write greyScale image, else dataSample.
88-
* @param dtype DataType for the output.
96+
* @param dtype DataType for the output. See other writeGrid() documentation for more details.
8997
* @throws IOException on i/o error
98+
* @throws IllegalArgumentException if above assumptions not valid
9099
*/
91100
public void writeGrid(GridDataset dataset, GridDatatype grid, Array data, boolean greyScale, DataType dtype)
92-
throws IOException {
101+
throws IOException, IllegalArgumentException {
102+
// This check has to be *before* resolving the dtype so that we are only
103+
// checking explicitly specified data types.
104+
if (greyScale && dtype != DataType.UBYTE) {
105+
throw new IllegalArgumentException("When greyScale is true, dtype must be UBYTE");
106+
}
107+
108+
if (colorTable != null && colorTable.length > 0 && dtype != DataType.UBYTE) {
109+
throw new IllegalArgumentException("When using the color table, dtype must be UBYTE");
110+
}
111+
93112
GridCoordSystem gcs = grid.getCoordinateSystem();
94113

95114
if (!gcs.isRegularSpatial()) {
@@ -136,8 +155,9 @@ public void writeGrid(GridDataset dataset, GridDatatype grid, Array data, boolea
136155
* <li>be equally spaced
137156
* </ol>
138157
*
139-
* This is for backwards-compatibility. Assumes dtype is FLOAT if greyScale is false, and assumes
140-
* dtype is BYTE if greyScale is true.
158+
* Greyscale mode will auto-normalize the data from 1 to 255 and save as unsigned bytes, with 0's used
159+
* for missing data. A color table can be applied if specified via `setColorTable()`.
160+
* Non-greyscale mode will save the data as floats, encoding missing data as the data minimum minus one.
141161
*
142162
* @param grid original grid
143163
* @param data 2D array in YX order
@@ -148,12 +168,12 @@ public void writeGrid(GridDataset dataset, GridDatatype grid, Array data, boolea
148168
* @param yInc increment y coord
149169
* @param imageNumber used to write multiple images
150170
* @throws IOException on i/o error
151-
* @throws IllegalArgumentException if above assumptions not valid *
171+
* @throws IllegalArgumentException if above assumptions not valid
152172
*/
153173
void writeGrid(GridDatatype grid, Array data, boolean greyScale, double xStart, double yStart, double xInc,
154174
double yInc, int imageNumber) throws IOException {
155175
writeGrid(grid, data, greyScale, xStart, yStart, xInc, yInc, imageNumber,
156-
greyScale ? DataType.BYTE : DataType.FLOAT);
176+
greyScale ? DataType.UBYTE : DataType.FLOAT);
157177
}
158178

159179
/**
@@ -165,6 +185,13 @@ void writeGrid(GridDatatype grid, Array data, boolean greyScale, double xStart,
165185
* <li>be equally spaced
166186
* </ol>
167187
*
188+
* Greyscale mode will auto-normalize the data from 1 to 255 and save as unsigned bytes, with 0's used
189+
* for missing data.
190+
* Non-greyscale mode with a floating point dtype will save the data as floats, encoding missing data
191+
* as the data's minimum minus one. Any other dtype will save the data coerced to the specified dtype.
192+
*
193+
* A color table can be applied if specified via `setColorTable()` and the dtype is UBYTE.
194+
*
168195
* @param grid original grid
169196
* @param data 2D array in YX order
170197
* @param greyScale if true, normalize the data before writing, otherwise, only handle missing data.
@@ -175,31 +202,28 @@ void writeGrid(GridDatatype grid, Array data, boolean greyScale, double xStart,
175202
* @param imageNumber used to write multiple images
176203
* @param dtype if greyScale is false, then save the data in the given data type.
177204
* Currently, this is a bit hobbled in order to avoid back-compatibility breaks.
178-
* If greyScale is true and this is not BYTE, then an exception is thrown.
205+
* If dtype is DOUBLE, is is currenly downcasted to FLOAT.
206+
* When dtype is floating point, missing data is encoded as the data's minimum minus one.
179207
* If null, then use the datatype of the given array.
180208
* @throws IOException on i/o error
181-
* @throws IllegalArgumentException if above assumptions not valid *
209+
* @throws IllegalArgumentException if above assumptions not valid
182210
*/
183211
void writeGrid(GridDatatype grid, Array data, boolean greyScale, double xStart, double yStart, double xInc,
184-
double yInc, int imageNumber, DataType dtype) throws IOException {
185-
186-
int nextStart;
187-
GridCoordSystem gcs = grid.getCoordinateSystem();
212+
double yInc, int imageNumber, DataType dtype) throws IOException, IllegalArgumentException {
188213

189214
// This check has to be *before* resolving the dtype so that we are only
190215
// checking explicitly specified data types.
191-
if (greyScale && dtype != DataType.BYTE) {
192-
throw new IllegalArgumentException("When greyScale is true, dtype must be BYTE or null");
216+
if (greyScale && dtype != DataType.UBYTE) {
217+
throw new IllegalArgumentException("When greyScale is true, dtype must be UBYTE");
193218
}
194219

195-
if (dtype == null) {
196-
dtype = data.getDataType();
197-
// Need to cap at single precision floats because that's what gets written for floating points
198-
if (dtype == DataType.DOUBLE) {
199-
dtype = DataType.FLOAT;
200-
}
220+
if (colorTable != null && colorTable.length > 0 && dtype != DataType.UBYTE) {
221+
throw new IllegalArgumentException("When using the color table, dtype must be UBYTE");
201222
}
202223

224+
int nextStart;
225+
GridCoordSystem gcs = grid.getCoordinateSystem();
226+
203227
// get rid of this when all projections are implemented
204228
if (!gcs.isLatLon() && !(gcs.getProjection() instanceof LambertConformal)
205229
&& !(gcs.getProjection() instanceof Stereographic) && !(gcs.getProjection() instanceof Mercator)
@@ -209,6 +233,14 @@ void writeGrid(GridDatatype grid, Array data, boolean greyScale, double xStart,
209233
throw new IllegalArgumentException("Unsupported projection = " + gcs.getProjection().getClass().getName());
210234
}
211235

236+
if (dtype == null) {
237+
dtype = data.getDataType();
238+
// Need to cap at single precision floats because that's what gets written for floating points
239+
if (dtype == DataType.DOUBLE) {
240+
dtype = DataType.FLOAT;
241+
}
242+
}
243+
212244
// write the data first
213245
MAMath.MinMax dataMinMax = grid.getMinMaxSkipMissingData(data);
214246
if (greyScale) {
@@ -235,6 +267,20 @@ private void writeMetadata(boolean greyScale, double xStart, double yStart, doub
235267
int width, int imageNumber, int nextStart, MAMath.MinMax dataMinMax, Projection proj, DataType dtype)
236268
throws IOException {
237269

270+
if (dtype == null) {
271+
throw new IllegalArgumentException("dtype can't be null in writeMetadata()");
272+
}
273+
274+
if (greyScale && dtype != DataType.UBYTE) {
275+
throw new IllegalArgumentException("When greyScale is true, dtype must be UBYTE");
276+
}
277+
278+
if (colorTable != null && colorTable.length > 0 && dtype != DataType.UBYTE) {
279+
throw new IllegalArgumentException("When using the color table, the dtype must be UBYTE");
280+
}
281+
282+
int elemSize = dtype.getSize();
283+
238284
geotiff.addTag(new IFDEntry(Tag.ImageWidth, FieldType.SHORT).setValue(width));
239285
geotiff.addTag(new IFDEntry(Tag.ImageLength, FieldType.SHORT).setValue(height));
240286

@@ -257,18 +303,6 @@ private void writeMetadata(boolean greyScale, double xStart, double yStart, doub
257303
* geotiff.addTag( new IFDEntry(Tag.StripOffsets, FieldType.LONG).setValue(nextStart));
258304
*/
259305

260-
if (dtype == null) {
261-
throw new IllegalArgumentException("dtype can't be null in writeMetadata()");
262-
}
263-
264-
// This check has to be *before* resolving the dtype so that we are only
265-
// checking explicitly specified data types.
266-
if (greyScale && dtype != DataType.BYTE) {
267-
throw new IllegalArgumentException("When greyScale is true, dtype must be BYTE");
268-
}
269-
270-
int elemSize = dtype.getSize();
271-
272306
int[] soffset = new int[height];
273307
int[] sbytecount = new int[height];
274308
if (imageNumber == 1) {
@@ -301,15 +335,16 @@ private void writeMetadata(boolean greyScale, double xStart, double yStart, doub
301335
geotiff.addTag(new IFDEntry(Tag.PhotometricInterpretation, FieldType.SHORT).setValue(1));
302336
} else {
303337
if (colorTable != null && colorTable.length > 0) {
338+
// standard tags for Palette-color images ( see TIFF spec, section 5)
304339
geotiff.addTag(new IFDEntry(Tag.PhotometricInterpretation, FieldType.SHORT).setValue(3));
305340
geotiff.addTag(new IFDEntry(Tag.ColorMap, FieldType.SHORT, colorTable.length).setValue(colorTable));
306341
} else {
307-
geotiff.addTag(new IFDEntry(Tag.PhotometricInterpretation, FieldType.SHORT).setValue(1)); // black is zero :
308-
// not used?
342+
geotiff.addTag(new IFDEntry(Tag.PhotometricInterpretation, FieldType.SHORT).setValue(1)); // black is zero
309343
}
310-
// standard tags for SampleFormat ( see TIFF spec, section 19)
344+
311345
geotiff.addTag(new IFDEntry(Tag.BitsPerSample, FieldType.SHORT).setValue(elemSize * 8));
312346

347+
// standard tags for SampleFormat ( see TIFF spec, section 19)
313348
if (dtype.isIntegral()) {
314349
geotiff.addTag(new IFDEntry(Tag.SampleFormat, FieldType.SHORT).setValue(dtype.isUnsigned() ? 1 : 2)); // UINT or
315350
// INT
@@ -408,8 +443,8 @@ public int[] getColorTable() {
408443
* be floored/ceilinged to the [0, 255] range. The color table is also assumed to be for pixel values
409444
* between 0 and 255.
410445
*
411-
* In order for the color table to be properly included in the geotiff, the "greyScale" mode must be false,
412-
* and the output data type must be byte or integer.
446+
* In order for the color table to be properly included in the geotiff, the output data type must be unsigned bytes.
447+
* This works even for greyscale mode.
413448
*/
414449
public void setColorTable(Map<Integer, Color> colorMap) {
415450
setColorTable(colorMap, new Color(0, 0, 0));
@@ -424,18 +459,17 @@ public void setColorTable(Map<Integer, Color> colorMap) {
424459
* For these RGB triplets, 0 is minimum intensity, 255 is maximum intensity. Values outside that range will
425460
* be floored/ceilinged to the [0, 255] range. The color table is also assumed to be for pixel values
426461
* between 0 and 255.
427-
* In order for the color table to be properly included in the geotiff, the "greyScale" mode must be false,
428-
* and the output data type must be byte or integer.
462+
* In order for the color table to be properly included in the geotiff, the output data type must be unsigned bytes.
463+
* This works even for greyscale mode.
429464
*/
430465
public void setColorTable(Map<Integer, Color> colorMap, Color defaultRGB) {
431466
if (colorMap == null) {
432467
colorTable = null;
433468
return;
434469
}
435470

436-
// FIXME: This isn't quite right because this assumes that the data being written is
437-
// unsigned bytes, but the tiff spec says that it should be sized to the width of
438-
// the data type, but I would need to know the data type, which this writer doesn't know.
471+
// TIFF spec allows for 4 or 8 bits per sample (making for 16 or 256 entries).
472+
// Since we don't support saving data as 4 bits per sample, we'll force it to 256.
439473
colorTable = new int[3 * 256];
440474
for (int i = 0; i < 256; i++) {
441475
// Scale it up to [0, 65535], which is needed by the ColorMap tag.
@@ -475,7 +509,7 @@ public static HashMap<Integer, Color> createColorMap(int[] flag_values, String[]
475509
*
476510
* @param data input data array (of any data type)
477511
* @param isUnsigned coerce to unsigned bytes
478-
* @return integer data array
512+
* @return byte data array
479513
*/
480514
static ArrayByte coerceByte(Array data, boolean isUnsigned) {
481515
ArrayByte array = (ArrayByte) Array.factory(isUnsigned ? DataType.UBYTE : DataType.BYTE, data.getShape());
@@ -495,15 +529,15 @@ static ArrayByte coerceByte(Array data, boolean isUnsigned) {
495529
*
496530
* @param data input data array (of any data type)
497531
* @param isUnsigned coerce to unsigned integers
498-
* @return integer data array
532+
* @return short integer data array
499533
*/
500534
static ArrayShort coerceShort(Array data, boolean isUnsigned) {
501535
ArrayShort array = (ArrayShort) Array.factory(isUnsigned ? DataType.USHORT : DataType.SHORT, data.getShape());
502536
IndexIterator dataIter = data.getIndexIterator();
503537
IndexIterator resultIter = array.getIndexIterator();
504538

505539
while (dataIter.hasNext()) {
506-
resultIter.setIntNext(dataIter.getIntNext());
540+
resultIter.setShortNext(dataIter.getShortNext());
507541
}
508542

509543
return array;
@@ -516,7 +550,7 @@ static ArrayShort coerceShort(Array data, boolean isUnsigned) {
516550
*
517551
* @param data input data array (of any data type)
518552
* @param isUnsigned coerce to unsigned integers
519-
* @return integer data array
553+
* @return 32-bit integer data array
520554
*/
521555
static ArrayInt coerceInt(Array data, boolean isUnsigned) {
522556
ArrayInt array = (ArrayInt) Array.factory(isUnsigned ? DataType.UINT : DataType.INT, data.getShape());
@@ -543,7 +577,7 @@ static ArrayFloat coerceFloat(Array data) {
543577
IndexIterator resultIter = array.getIndexIterator();
544578

545579
while (dataIter.hasNext()) {
546-
resultIter.setIntNext(dataIter.getIntNext());
580+
resultIter.setFloatNext(dataIter.getFloatNext());
547581
}
548582

549583
return array;
@@ -574,7 +608,7 @@ private ArrayFloat replaceMissingValues(IsMissingEvaluator grid, Array data, MAM
574608
}
575609

576610
/**
577-
* Replace missing values with 0; scale other values between 1 and 255, return a byte data array.
611+
* Replace missing values with 0; scale other values between 1 and 255, return a ubyte data array.
578612
*
579613
* @param grid GridDatatype
580614
* @param data input data array
@@ -820,29 +854,52 @@ private double geoShiftGetXstart(Array lon, double inc) {
820854
/**
821855
* Write GridCoverage data to the geotiff file.
822856
*
857+
* Greyscale mode will auto-normalize the data from 1 to 255 and save as unsigned bytes, with 0's used
858+
* for missing data. A color table can be applied if specified via `setColorTable()`.
859+
* Non-greyscale mode will save the data as floats, encoding missing data as the data minimum minus one.
860+
*
823861
* @param array GeoReferencedArray array in YX order
824862
* @param greyScale if true, write greyScale image, else dataSample.
825863
* @throws IOException on i/o error
826864
*/
827865
public void writeGrid(GeoReferencedArray array, boolean greyScale) throws IOException {
828-
writeGrid(array, greyScale, greyScale ? DataType.BYTE : DataType.FLOAT);
866+
writeGrid(array, greyScale, greyScale ? DataType.UBYTE : DataType.FLOAT);
829867
}
830868

831869
/**
832870
* Write GridCoverage data to the geotiff file.
833871
*
872+
* Greyscale mode will auto-normalize the data from 1 to 255 and save as unsigned bytes, with 0's used
873+
* for missing data.
874+
* Non-greyscale mode with a floating point dtype will save the data as floats, encoding missing data
875+
* as the data's minimum minus one. Any other dtype will save the data coerced to the specified dtype.
876+
*
877+
* A color table can be applied if specified via `setColorTable()` and the dtype is UBYTE.
878+
*
834879
* @param array GeoReferencedArray array in YX order
835880
* @param greyScale if true, write greyScale image, else dataSample.
836881
* @param dtype if greyScale is false, then save the data in the given data type.
837882
* Currently, this is a bit hobbled in order to avoid back-compatibility breaks.
838-
* If greyScale is true and this is not BYTE, then an exception is thrown.
883+
* If greyScale is true and this is not UBYTE, then an exception is thrown.
884+
* If dtype is DOUBLE, it downcasted to FLOAT instead.
885+
* If using the colorTable and this is not UBYTE, then an exception is thrown.
839886
* If null, then use the datatype of the given array.
840887
* @throws IOException on i/o error
841888
* @throws IllegalArgumentException if data isn't regular or if contradicting the greyScale argument.
842889
*/
843890
public void writeGrid(GeoReferencedArray array, boolean greyScale, DataType dtype)
844891
throws IOException, IllegalArgumentException {
845892

893+
// This check has to be *before* resolving the dtype so that we are only
894+
// checking explicitly specified data types.
895+
if (greyScale && dtype != DataType.UBYTE) {
896+
throw new IllegalArgumentException("When greyScale is true, dtype must be UBYTE");
897+
}
898+
899+
if (colorTable != null && colorTable.length > 0 && dtype != DataType.UBYTE) {
900+
throw new IllegalArgumentException("When using the colorTable, the dtype must be UBYTE");
901+
}
902+
846903
CoverageCoordSys gcs = array.getCoordSysForData();
847904
if (!gcs.isRegularSpatial())
848905
throw new IllegalArgumentException("Must have 1D x and y axes for " + array.getCoverageName());
@@ -866,12 +923,6 @@ public void writeGrid(GeoReferencedArray array, boolean greyScale, DataType dtyp
866923
yStart = yaxis.getCoordEdgeLast() * scaler;
867924
}
868925

869-
// This check has to be *before* resolving the dtype so that we are only
870-
// checking explicitly specified data types.
871-
if (greyScale && dtype != DataType.BYTE) {
872-
throw new IllegalArgumentException("When greyScale is true, dtype must be BYTE or null");
873-
}
874-
875926
if (dtype == null) {
876927
dtype = data.getDataType();
877928
// Need to cap at single precision floats because that's what gets written for floating points

0 commit comments

Comments
 (0)