Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 51 additions & 1 deletion extensions/libtiff/error_handling.h
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/*
* SPDX-FileCopyrightText: Copyright (c) 2022-2023 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
* SPDX-FileCopyrightText: Copyright (c) 2022-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
* SPDX-License-Identifier: Apache-2.0
*
* Licensed under the Apache License, Version 2.0 (the "License");
Expand All @@ -17,6 +17,12 @@

#pragma once

#include <cstdarg>
#include <cstdio>
#include <cstring>
#include <mutex>
#include <tiffio.h>

#define XM_CHECK_NULL(ptr) \
{ \
if (!ptr) \
Expand All @@ -30,3 +36,47 @@
if (LIBTIFF_CALL_SUCCESS != retcode) \
throw std::runtime_error("libtiff call failed with code " + std::to_string(retcode) + ": " #call); \
} while (0)

// GeoTIFF/GDAL tag IDs that libtiff does not recognise natively.
// Decoding is correct; the tags carry only geographic metadata.
static constexpr uint32_t kGeoTIFFTags[] = {
33550, // ModelPixelScaleTag
33922, // ModelTiepointTag
34264, // ModelTransformationTag
34735, // GeoKeyDirectoryTag
34736, // GeoDoubleParamsTag
34737, // GeoAsciiParamsTag
42112, // GDAL_METADATA
42113, // GDAL_NODATA
};

// TIFFWarningHandler that silently drops "Unknown field with tag X" warnings for
// the known GeoTIFF/GDAL tags and forwards all other libtiff warnings to stderr.
inline void SuppressGeoTIFFTagWarnings(const char* module, const char* fmt, va_list ap)
{
if (strstr(fmt, "Unknown field with tag") != nullptr) {
va_list ap_copy;
va_copy(ap_copy, ap);
unsigned int tag = va_arg(ap_copy, unsigned int);
va_end(ap_copy);
for (auto geotiff_tag : kGeoTIFFTags) {
if (tag == geotiff_tag)
return;
}
}
char buf[1024];
vsnprintf(buf, sizeof(buf), fmt, ap);
// libtiff permits a null module name in some code paths
if (module)
fprintf(stderr, "%s: %s\n", module, buf);
else
fprintf(stderr, "%s\n", buf);
}

// Declared inline so the static once_flag is shared across all translation units,
// guaranteeing TIFFSetWarningHandler is called exactly once per process.
inline void InstallGeoTIFFWarningFilter()
{
static std::once_flag flag;
std::call_once(flag, [] { TIFFSetWarningHandler(SuppressGeoTIFFTagWarnings); });
}
1 change: 1 addition & 0 deletions extensions/libtiff/libtiff_ext.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ struct LibtiffImgCodecsExtension
try {
XM_CHECK_NULL(framework)
NVIMGCODEC_LOG_TRACE(framework, "libtiff_ext", "nvimgcodecExtensionCreate");
InstallGeoTIFFWarningFilter();

XM_CHECK_NULL(extension)
*extension = reinterpret_cast<nvimgcodecExtension_t>(new libtiff::LibtiffImgCodecsExtension(framework));
Expand Down
112 changes: 111 additions & 1 deletion test/python/test_decode_tiff.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# SPDX-FileCopyrightText: Copyright (c) 2023 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
# SPDX-FileCopyrightText: Copyright (c) 2023-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
# SPDX-License-Identifier: Apache-2.0
#
# Licensed under the Apache License, Version 2.0 (the "License");
Expand All @@ -15,6 +15,8 @@

from __future__ import annotations
import os
import struct
import tempfile
import numpy as np
from nvidia import nvimgcodec
import pytest as t
Expand Down Expand Up @@ -117,3 +119,111 @@ def test_decode_tiff_too_many_planes():
def test_decode_tiff_oom():
assert None == nvimgcodec.Decoder().read(
os.path.join(img_dir_path, "tiff/error/oom.tiff"))


def _create_geotiff(path, width=4, height=4):
"""Build a minimal GeoTIFF from scratch with all eight GeoTIFF/GDAL metadata tags.

Returns the expected pixel array (HxWx1 uint8) so callers can compare against decoded output.
The file is constructed with struct so no third-party TIFF library is required.
"""
# Image: row-major, values 0..(H*W-1)
image_data = bytes(i % 256 for i in range(width * height))

# Extra tag payloads (appended after the IFD)
model_pixel_scale = struct.pack("<3d", 1.0, 1.0, 0.0) # ModelPixelScaleTag (3 doubles)
model_tiepoint = struct.pack("<6d", *([0.0] * 6)) # ModelTiepointTag (6 doubles)
model_transform = struct.pack("<16d", *([0.0] * 16)) # ModelTransformationTag (16 doubles)
geo_key_dir = struct.pack("<4H", 1, 1, 0, 0) # GeoKeyDirectoryTag (4 shorts)
geo_double = struct.pack("<d", 0.0) # GeoDoubleParamsTag (1 double)
geo_ascii = b"WGS 84|\x00" # GeoAsciiParamsTag (ASCII)
gdal_metadata = b"<GDALMetadata/>\x00" # GDAL_METADATA (ASCII)
gdal_nodata = b"-9999\x00" # GDAL_NODATA (ASCII)

# Compute layout offsets
# 0..7: header
# 8..8+len(image_data)-1: pixel data
# ifd_offset: IFD
num_entries = 17
image_offset = 8
ifd_offset = image_offset + len(image_data)
entries_offset = ifd_offset + 2 # skip num_entries field
extra_start = entries_offset + num_entries * 12 + 4 # after entries + next-IFD pointer

off_mps = extra_start
off_mt = off_mps + len(model_pixel_scale)
off_mtx = off_mt + len(model_tiepoint)
off_gkd = off_mtx + len(model_transform)
off_gdp = off_gkd + len(geo_key_dir)
off_gas = off_gdp + len(geo_double)
off_gmd = off_gas + len(geo_ascii)
off_gnd = off_gmd + len(gdal_metadata)

SHORT, LONG, DOUBLE, ASCII = 3, 4, 12, 2

def ifd_entry(tag, ttype, count, val):
return struct.pack("<HHII", tag, ttype, count, val)

entries = b"".join(
[
ifd_entry(256, SHORT, 1, width), # ImageWidth
ifd_entry(257, SHORT, 1, height), # ImageLength
ifd_entry(258, SHORT, 1, 8), # BitsPerSample
ifd_entry(259, SHORT, 1, 1), # Compression = None
ifd_entry(262, SHORT, 1, 1), # PhotometricInterpretation
ifd_entry(273, LONG, 1, image_offset), # StripOffsets
ifd_entry(277, SHORT, 1, 1), # SamplesPerPixel
ifd_entry(278, SHORT, 1, height), # RowsPerStrip
ifd_entry(279, LONG, 1, len(image_data)), # StripByteCounts
ifd_entry(33550, DOUBLE, 3, off_mps), # ModelPixelScaleTag
ifd_entry(33922, DOUBLE, 6, off_mt), # ModelTiepointTag
ifd_entry(34264, DOUBLE, 16, off_mtx), # ModelTransformationTag
ifd_entry(34735, SHORT, 4, off_gkd), # GeoKeyDirectoryTag
ifd_entry(34736, DOUBLE, 1, off_gdp), # GeoDoubleParamsTag
ifd_entry(34737, ASCII, len(geo_ascii), off_gas), # GeoAsciiParamsTag
ifd_entry(42112, ASCII, len(gdal_metadata), off_gmd), # GDAL_METADATA
ifd_entry(42113, ASCII, len(gdal_nodata), off_gnd), # GDAL_NODATA
]
)
assert len(entries) == num_entries * 12

tiff_bytes = (
struct.pack("<HHI", 0x4949, 42, ifd_offset) # header: LE + magic + IFD offset
+ image_data
+ struct.pack("<H", num_entries)
+ entries
+ struct.pack("<I", 0) # next IFD = none
+ model_pixel_scale
+ model_tiepoint
+ model_transform
+ geo_key_dir
+ geo_double
+ geo_ascii
+ gdal_metadata
+ gdal_nodata
)
with open(path, "wb") as f:
f.write(tiff_bytes)

return np.frombuffer(image_data, dtype=np.uint8).reshape(height, width, 1)


@t.mark.parametrize("backends", [
[nvimgcodec.Backend(nvimgcodec.BackendKind.CPU_ONLY)],
None, # default backend
])
def test_decode_tiff_geotiff(backends):
"""GeoTIFF files with standard geographic metadata tags must decode without errors.

GeoTIFF/GDAL tags are not natively known to libtiff, which emits
"Unknown field with tag X" warnings for them. The fix installs a custom libtiff
warning handler that suppresses those specific warnings while passing through all others.
"""
with tempfile.TemporaryDirectory() as tmpdir:
geo_path = os.path.join(tmpdir, "geo.tif")
expected = _create_geotiff(geo_path)

decoder = nvimgcodec.Decoder(backends=backends)
result = decoder.read(geo_path)
assert result is not None
np.testing.assert_array_equal(np.array(result.cpu()), expected)