diff --git a/include/geode/io/image/detail/gdal_file.hpp b/include/geode/io/image/detail/gdal_file.hpp index 185e7251..1b1217e9 100644 --- a/include/geode/io/image/detail/gdal_file.hpp +++ b/include/geode/io/image/detail/gdal_file.hpp @@ -23,6 +23,8 @@ #pragma once +#include + #include #include @@ -39,16 +41,39 @@ namespace geode { namespace detail { + /*! + * While alive, GDAL warnings are logged and GDAL errors are not + * printed: they should be reported with last_error() + */ + class opengeode_io_image_api GDALErrorScope + { + public: + GDALErrorScope(); + ~GDALErrorScope(); + GDALErrorScope( const GDALErrorScope& ) = delete; + GDALErrorScope& operator=( const GDALErrorScope& ) = delete; + GDALErrorScope( GDALErrorScope&& ) = delete; + GDALErrorScope& operator=( GDALErrorScope&& ) = delete; + + std::string last_error() const; + }; + class opengeode_io_image_api GDALFile { public: - GDALFile( std::string_view filename ); + /*! + * @param[in] allowed_drivers Names of the GDAL drivers which can + * open the file, all the drivers if empty + */ + explicit GDALFile( std::string_view filename, + absl::Span< const char* const > allowed_drivers = {} ); ~GDALFile(); CoordinateSystem2D read_coordinate_system(); bool is_coordinate_system_loadable(); + // Existing files used with the main file (e.g. world files) template < typename AdditionalFiles > AdditionalFiles additional_files() { @@ -56,7 +81,7 @@ namespace geode for( auto& file : associated_files() ) { files.optional_files.emplace_back( - std::move( file ), true ); + std::move( file ), false ); } return files; } diff --git a/include/geode/io/image/detail/vtk_output.hpp b/include/geode/io/image/detail/vtk_output.hpp index 60f4f441..b862985e 100644 --- a/include/geode/io/image/detail/vtk_output.hpp +++ b/include/geode/io/image/detail/vtk_output.hpp @@ -29,6 +29,7 @@ #include #include #include +#include #include #include #include @@ -193,17 +194,37 @@ namespace geode { write_with_appended_data( root ); } - file_.flush(); - OpenGeodeIOImageException::check_exception( file_.good(), + file_.close(); + OpenGeodeIOImageException::check_exception( !file_.fail(), nullptr, OpenGeodeException::TYPE::data, "[VTKOutput] Error while writing file: ", filename_ ); + written_ = true; + if( temporary_filename_.empty() ) + { + return; + } + std::error_code error; + std::filesystem::rename( + temporary_filename_, target_filename_, error ); + if( error ) + { + std::filesystem::remove( temporary_filename_, error ); + throw OpenGeodeIOImageException{ nullptr, + OpenGeodeException::TYPE::data, + "[VTKOutput] Error while writing file: ", filename_, + " (", error.message(), ")" }; + } } protected: VTKOutputImpl( std::string_view filename, const Mesh& mesh, const char* type ) : filename_{ filename }, - file_{ to_string( filename ), std::ios::binary }, + target_filename_{ target_file( filename ) }, + temporary_filename_{ temporary_file( target_filename_ ) }, + file_{ temporary_filename_.empty() ? target_filename_ + : temporary_filename_, + std::ios::binary }, mesh_( mesh ), type_{ type } { @@ -212,7 +233,49 @@ namespace geode "[VTKOutput] Error while writing file: ", filename ); } - virtual ~VTKOutputImpl() {} + // The file is written in a temporary file which replaces the + // output file once complete: a failure never leaves a truncated + // file nor erases an existing one + virtual ~VTKOutputImpl() + { + if( written_ || temporary_filename_.empty() ) + { + return; + } + file_.close(); + std::error_code error; + std::filesystem::remove( temporary_filename_, error ); + } + + // Symbolic links are followed to replace their target + static std::string target_file( std::string_view filename ) + { + const std::filesystem::path path{ to_string( filename ) }; + std::error_code error; + if( std::filesystem::is_symlink( path, error ) ) + { + const auto target = + std::filesystem::canonical( path, error ); + if( !error ) + { + return target.string(); + } + } + return path.string(); + } + + // Special files (e.g. devices) cannot be replaced: they are written + // directly, without temporary file + static std::string temporary_file( const std::string& target ) + { + std::error_code error; + const auto status = std::filesystem::status( target, error ); + if( std::filesystem::is_other( status ) ) + { + return {}; + } + return absl::StrCat( target, ".tmp" ); + } const Mesh& mesh() const { @@ -298,6 +361,22 @@ namespace geode std::string_view name, std::vector< T >&& values, local_index_t nb_components = 1 ) + { + auto owner = + std::make_shared< std::vector< T > >( std::move( values ) ); + auto data_array = write_external_data_array< T >( + parent, name, *owner, nb_components ); + appended_blocks_.back().owner = std::move( owner ); + return data_array; + } + + // Same as above, the values are neither copied nor owned: they + // must stay alive until the file is written + template < typename T > + pugi::xml_node write_external_data_array( pugi::xml_node& parent, + std::string_view name, + absl::Span< const T > values, + local_index_t nb_components = 1 ) { auto data_array = parent.append_child( "DataArray" ); data_array.append_attribute( "type" ).set_value( @@ -307,16 +386,13 @@ namespace geode data_array.append_attribute( "format" ).set_value( "appended" ); data_array.append_attribute( "NumberOfComponents" ) .set_value( nb_components ); - write_range( data_array, absl::Span< const T >{ values } ); + write_range( data_array, values ); data_array.append_attribute( "offset" ) .set_value( appended_size_ ); - auto owner = - std::make_shared< std::vector< T > >( std::move( values ) ); AppendedBlock block; - block.nb_bytes = owner->size() * sizeof( T ); + block.nb_bytes = values.size() * sizeof( T ); // NOLINTNEXTLINE(cppcoreguidelines-pro-type-reinterpret-cast) - block.data = reinterpret_cast< const char* >( owner->data() ); - block.owner = std::move( owner ); + block.data = reinterpret_cast< const char* >( values.data() ); appended_size_ += sizeof( HeaderType ) + block.nb_bytes; appended_blocks_.push_back( std::move( block ) ); return data_array; @@ -583,7 +659,10 @@ namespace geode private: std::string_view filename_; + std::string target_filename_; + std::string temporary_filename_; std::ofstream file_; + bool written_{ false }; const Mesh& mesh_; pugi::xml_document document_; const char* type_; diff --git a/include/geode/io/image/internal/raster_image_input.hpp b/include/geode/io/image/internal/raster_image_input.hpp index c24ea3df..ee7ea8b1 100644 --- a/include/geode/io/image/internal/raster_image_input.hpp +++ b/include/geode/io/image/internal/raster_image_input.hpp @@ -44,9 +44,8 @@ namespace geode RasterImage2D read_file(); - RasterImage2D read_reversed_y_axis_file(); - - Percentage is_loadable(); + // Unreadable files are not loadable + static Percentage is_loadable( std::string_view filename ); }; } // namespace internal } // namespace geode \ No newline at end of file diff --git a/src/geode/io/image/bmp_input.cpp b/src/geode/io/image/bmp_input.cpp index 6a710814..51f10801 100644 --- a/src/geode/io/image/bmp_input.cpp +++ b/src/geode/io/image/bmp_input.cpp @@ -45,8 +45,7 @@ namespace geode Percentage BMPInput::is_loadable() const { - ImageInputImpl reader{ filename() }; - return reader.is_loadable(); + return ImageInputImpl::is_loadable( filename() ); } } // namespace internal } // namespace geode diff --git a/src/geode/io/image/gdal_file.cpp b/src/geode/io/image/gdal_file.cpp index 6013ceb8..7fdc1b6b 100644 --- a/src/geode/io/image/gdal_file.cpp +++ b/src/geode/io/image/gdal_file.cpp @@ -23,26 +23,59 @@ #include +#include + #include +#include #include #include +namespace +{ + void CPL_STDCALL log_gdal_warnings( + CPLErr type, CPLErrorNum /*unused*/, const char* message ) + { + if( type == CE_Warning ) + { + geode::Logger::warning( "[GDAL] ", message ); + } + } +} // namespace + namespace geode { namespace detail { + GDALErrorScope::GDALErrorScope() + { + CPLPushErrorHandler( log_gdal_warnings ); + CPLErrorReset(); + } + + GDALErrorScope::~GDALErrorScope() + { + CPLPopErrorHandler(); + } + + std::string GDALErrorScope::last_error() const + { + return CPLGetLastErrorMsg(); + } + class GDALFile::Impl { public: - Impl( std::string_view filename ) - : dataset_{ GDALDataset::Open( - geode::to_string( filename ).c_str(), GDAL_OF_READONLY ) } + Impl( std::string_view filename, + absl::Span< const char* const > allowed_drivers ) { + const GDALErrorScope errors; + dataset_.reset( open( filename, allowed_drivers ) ); OpenGeodeIOImageException::check_exception( dataset_ != nullptr, nullptr, OpenGeodeException::TYPE::data, - "[GDALFile] Failed to open file ", filename ); + "[GDALFile] Failed to open file ", filename, ": ", + errors.last_error() ); } GDALDataset& dataset() @@ -85,20 +118,52 @@ namespace geode { return {}; } + // The list starts with the main file, which is not associated + const std::filesystem::path main_file{ + dataset_->GetDescription() + }; std::vector< std::string > files; for( int i = 0; papszFileList[i] != nullptr; i++ ) { + std::error_code error; + if( papszFileList[i] == main_file + || std::filesystem::equivalent( + papszFileList[i], main_file, error ) ) + { + continue; + } files.emplace_back( papszFileList[i] ); } CSLDestroy( papszFileList ); return files; } + private: + static GDALDataset* open( std::string_view filename, + absl::Span< const char* const > allowed_drivers ) + { + if( allowed_drivers.empty() ) + { + return GDALDataset::Open( + geode::to_string( filename ).c_str(), + GDAL_OF_READONLY | GDAL_OF_VERBOSE_ERROR ); + } + std::vector< const char* > drivers{ allowed_drivers.begin(), + allowed_drivers.end() }; + drivers.push_back( nullptr ); + return GDALDataset::Open( geode::to_string( filename ).c_str(), + GDAL_OF_READONLY | GDAL_OF_VERBOSE_ERROR, drivers.data() ); + } + private: GDALDatasetUniquePtr dataset_; }; - GDALFile::GDALFile( std::string_view filename ) : impl_{ filename } {} + GDALFile::GDALFile( std::string_view filename, + absl::Span< const char* const > allowed_drivers ) + : impl_{ filename, allowed_drivers } + { + } GDALFile::~GDALFile() = default; diff --git a/src/geode/io/image/jpg_input.cpp b/src/geode/io/image/jpg_input.cpp index b7627ba0..1d8ce74b 100644 --- a/src/geode/io/image/jpg_input.cpp +++ b/src/geode/io/image/jpg_input.cpp @@ -34,7 +34,7 @@ namespace geode RasterImage2D JPGInput::read() { ImageInputImpl reader{ filename() }; - return reader.read_reversed_y_axis_file(); + return reader.read_file(); } auto JPGInput::additional_files() const -> AdditionalFiles @@ -45,8 +45,7 @@ namespace geode Percentage JPGInput::is_loadable() const { - ImageInputImpl reader{ filename() }; - return reader.is_loadable(); + return ImageInputImpl::is_loadable( filename() ); } } // namespace internal } // namespace geode diff --git a/src/geode/io/image/png_input.cpp b/src/geode/io/image/png_input.cpp index 3be2872d..1f271023 100644 --- a/src/geode/io/image/png_input.cpp +++ b/src/geode/io/image/png_input.cpp @@ -34,7 +34,7 @@ namespace geode RasterImage2D PNGInput::read() { ImageInputImpl reader{ filename() }; - return reader.read_reversed_y_axis_file(); + return reader.read_file(); } auto PNGInput::additional_files() const -> AdditionalFiles @@ -45,8 +45,7 @@ namespace geode Percentage PNGInput::is_loadable() const { - ImageInputImpl reader{ filename() }; - return reader.is_loadable(); + return ImageInputImpl::is_loadable( filename() ); } } // namespace internal } // namespace geode diff --git a/src/geode/io/image/raster_image_input.cpp b/src/geode/io/image/raster_image_input.cpp index 1d0ecbf5..03bac312 100644 --- a/src/geode/io/image/raster_image_input.cpp +++ b/src/geode/io/image/raster_image_input.cpp @@ -23,109 +23,266 @@ #include +#include +#include +#include +#include + #include -#include +#include +#include + #include #include +#include #include #include namespace { - absl::FixedArray< GByte > read_color_component( - const geode::RasterImage2D& raster, - GDALDataset& gdal_data, - int component ) + // Image formats only: other GDAL formats (e.g. VRT) can reference any + // other file or URL + constexpr std::array< const char*, 4 > IMAGE_DRIVERS{ "PNG", "JPEG", "BMP", + "GTiff" }; + + static_assert( sizeof( geode::RGBColor ) == 3, + "[ImageInputImpl] Colors should be stored as 3 bytes" ); + + // Image files store their top row first whereas raster images start from + // the bottom row: GDAL writes the rows backward from the last one + GByte* last_row_start( absl::Span< geode::RGBColor > colors, int width ) + { + // NOLINTNEXTLINE(cppcoreguidelines-pro-type-reinterpret-cast) + return reinterpret_cast< GByte* >( colors.data() + colors.size() ) + - static_cast< std::ptrdiff_t >( 3 ) * width; + } + + void check_read( CPLErr status ) { - const auto width = raster.nb_cells_in_direction( 0 ); - const auto height = raster.nb_cells_in_direction( 1 ); - absl::FixedArray< GByte > values( raster.nb_cells() ); - const auto status = - gdal_data.GetRasterBand( component ) - ->RasterIO( GF_Read, 0, 0, width, height, values.data(), width, - height, GDT_Byte, 0, 0 ); geode::OpenGeodeIOImageException::check_exception( status == CE_None, nullptr, geode::OpenGeodeException::TYPE::data, - "[ImageInputImpl] Failed to read color component" ); - return values; + "[ImageInputImpl] Failed to read image pixels: ", + CPLGetLastErrorMsg() ); } - std::array< geode::index_t, 3 > get_rgb_indices( GDALDataset& gdal_data ) + + void read_grey_band( GDALRasterBand& band, + absl::Span< geode::RGBColor > colors, + int width, + int height ) { - std::array< geode::index_t, 3 > rgb_indices{ 0, 0, 0 }; - const auto nb_color_components = - static_cast< geode::index_t >( gdal_data.GetRasterCount() ); - for( const auto id : geode::Range( nb_color_components ) ) + check_read( band.RasterIO( GF_Read, 0, 0, width, height, + last_row_start( colors, width ), width, height, GDT_Byte, 3, + -static_cast< GSpacing >( 3 ) * width ) ); + for( auto& color : colors ) { - const auto band_id = id + 1; - GDALColorInterp colorInterp = - gdal_data.GetRasterBand( band_id )->GetColorInterpretation(); - if( colorInterp == GCI_RedBand ) - { - rgb_indices[0] = band_id; - } - else if( colorInterp == GCI_GreenBand ) + color.set_green( color.red() ); + color.set_blue( color.red() ); + } + } + + // Colors of the palette entries when the band is a palette index + std::optional< std::vector< geode::RGBColor > > palette_colors( + GDALRasterBand& band ) + { + if( band.GetColorInterpretation() != GCI_PaletteIndex ) + { + return std::nullopt; + } + const auto* table = band.GetColorTable(); + if( table == nullptr ) + { + return std::nullopt; + } + const auto interpretation = table->GetPaletteInterpretation(); + if( interpretation != GPI_RGB && interpretation != GPI_Gray ) + { + geode::Logger::warning( "[ImageInputImpl] Palette is neither RGB " + "nor grey: palette indices are read as " + "grey levels" ); + return std::nullopt; + } + std::vector< geode::RGBColor > colors( + static_cast< size_t >( table->GetColorEntryCount() ) ); + for( auto entry_id = 0; entry_id < table->GetColorEntryCount(); + entry_id++ ) + { + const auto& entry = *table->GetColorEntry( entry_id ); + const auto red = geode::detail::color_channel( entry.c1 ); + if( interpretation == GPI_Gray ) { - rgb_indices[1] = band_id; + colors[entry_id] = { red, red, red }; + continue; } - else if( colorInterp == GCI_BlueBand ) + colors[entry_id] = { red, geode::detail::color_channel( entry.c2 ), + geode::detail::color_channel( entry.c3 ) }; + } + return colors; + } + + void read_palette_band( GDALRasterBand& band, + absl::Span< const geode::RGBColor > palette, + absl::Span< geode::RGBColor > colors, + int width, + int height ) + { + std::vector< GUInt16 > indices( colors.size() ); + constexpr auto INDEX_SIZE = + static_cast< GSpacing >( sizeof( GUInt16 ) ); + check_read( band.RasterIO( GF_Read, 0, 0, width, height, + indices.data() + indices.size() - width, width, height, GDT_UInt16, + INDEX_SIZE, -INDEX_SIZE * width ) ); + for( const auto pixel : geode::Indices{ indices } ) + { + const auto index = indices[pixel]; + // Indices without palette entry are black + colors[pixel] = + index < palette.size() ? palette[index] : geode::RGBColor{}; + } + } + + void read_rgb_bands( GDALDataset& dataset, + std::array< int, 3 > bands, + absl::Span< geode::RGBColor > colors, + int width, + int height ) + { + check_read( dataset.RasterIO( GF_Read, 0, 0, width, height, + last_row_start( colors, width ), width, height, GDT_Byte, 3, + bands.data(), 3, -static_cast< GSpacing >( 3 ) * width, 1, + nullptr ) ); + } + + bool are_byte_bands( GDALDataset& dataset, absl::Span< const int > bands ) + { + return absl::c_all_of( bands, [&dataset]( int band ) { + return dataset.GetRasterBand( band )->GetRasterDataType() + == GDT_Byte; + } ); + } + + struct ValueRange + { + double min{ std::numeric_limits< double >::max() }; + double max{ std::numeric_limits< double >::lowest() }; + }; + + // Range of the band values, nodata values excluded + ValueRange bands_range( + GDALDataset& dataset, absl::Span< const int > bands ) + { + ValueRange range; + for( const auto band : bands ) + { + std::array< double, 2 > min_max; + if( dataset.GetRasterBand( band )->ComputeRasterMinMax( + false, min_max.data() ) + == CE_None ) { - rgb_indices[2] = band_id; + range.min = std::min( range.min, min_max[0] ); + range.max = std::max( range.max, min_max[1] ); } } - return rgb_indices; + return range; + } + + void set_channel( + geode::RGBColor& color, size_t channel, geode::local_index_t value ) + { + if( channel == 0 ) + { + color.set_red( value ); + } + else if( channel == 1 ) + { + color.set_green( value ); + } + else + { + color.set_blue( value ); + } } - template < typename SpecificRange > - geode::RasterImage2D read_file( GDALDataset& gdal_data ) + // Values which are not 8-bit are linearly rescaled from the range of the + // values of all the bands to the color range, nodata values are black + void read_rescaled_bands( GDALDataset& dataset, + std::array< int, 3 > bands, + absl::Span< geode::RGBColor > colors, + int width, + int height ) { - const auto width = gdal_data.GetRasterXSize(); - const auto height = gdal_data.GetRasterYSize(); - geode::RasterImage2D raster{ { static_cast< geode::index_t >( width ), - static_cast< geode::index_t >( height ) } }; - const auto nb_color_components = - static_cast< geode::index_t >( gdal_data.GetRasterCount() ); - if( nb_color_components <= 2 ) - { - const auto grey_scale = - read_color_component( raster, gdal_data, 1 ); - geode::index_t cell{ 0 }; - for( const auto image_j : - SpecificRange{ raster.nb_cells_in_direction( 1 ) } ) + const auto range = bands_range( dataset, bands ); + const auto scale = + range.max > range.min + ? std::numeric_limits< geode::local_index_t >::max() + / ( range.max - range.min ) + : 0.; + std::vector< double > values( colors.size() ); + constexpr auto VALUE_SIZE = static_cast< GSpacing >( sizeof( double ) ); + for( const auto channel : geode::Indices{ bands } ) + { + if( absl::c_find( bands, bands[channel] ) + != bands.begin() + channel ) + { + // Band already read for a previous channel + continue; + } + auto& band = *dataset.GetRasterBand( bands[channel] ); + check_read( band.RasterIO( GF_Read, 0, 0, width, height, + values.data() + values.size() - width, width, height, + GDT_Float64, VALUE_SIZE, -VALUE_SIZE * width ) ); + int has_nodata{ 0 }; + const auto nodata = band.GetNoDataValue( &has_nodata ); + for( const auto pixel : geode::Indices{ values } ) { - for( const auto image_i : - geode::Range{ raster.nb_cells_in_direction( 0 ) } ) + const auto value = values[pixel]; + const auto is_nodata = + std::isnan( value ) || ( has_nodata && value == nodata ); + const auto channel_value = + is_nodata ? geode::local_index_t{ 0 } + : geode::detail::color_channel( + ( value - range.min ) * scale ); + for( const auto other : geode::Indices{ bands } ) { - const auto pixel = image_i + width * image_j; - const auto value = grey_scale[pixel]; - raster.set_color( cell++, { value, value, value } ); + if( bands[other] == bands[channel] ) + { + set_channel( colors[pixel], other, channel_value ); + } } } } - else if( nb_color_components <= 4 ) - { - const auto rgb_indices = get_rgb_indices( gdal_data ); - const auto red = - read_color_component( raster, gdal_data, rgb_indices[0] ); - const auto green = - read_color_component( raster, gdal_data, rgb_indices[1] ); - const auto blue = - read_color_component( raster, gdal_data, rgb_indices[2] ); - geode::index_t cell{ 0 }; - for( const auto image_j : - SpecificRange{ raster.nb_cells_in_direction( 1 ) } ) + } + + std::array< int, 3 > get_rgb_indices( GDALDataset& gdal_data ) + { + std::array< int, 3 > rgb_indices{ 0, 0, 0 }; + const auto nb_color_components = gdal_data.GetRasterCount(); + for( int band_id = 1; band_id <= nb_color_components; band_id++ ) + { + GDALColorInterp colorInterp = + gdal_data.GetRasterBand( band_id )->GetColorInterpretation(); + if( colorInterp == GCI_RedBand ) { - for( const auto image_i : - geode::Range{ raster.nb_cells_in_direction( 0 ) } ) - { - const auto pixel = image_i + width * image_j; - raster.set_color( - cell++, { red[pixel], green[pixel], blue[pixel] } ); - } + rgb_indices[0] = band_id; + } + else if( colorInterp == GCI_GreenBand ) + { + rgb_indices[1] = band_id; } + else if( colorInterp == GCI_BlueBand ) + { + rgb_indices[2] = band_id; + } + } + if( absl::c_linear_search( rgb_indices, 0 ) ) + { + geode::Logger::warning( "[ImageInputImpl] Red, green and blue " + "bands are not identified: the first " + "three bands are used" ); + return { 1, 2, 3 }; } - return raster; + return rgb_indices; } } // namespace @@ -134,27 +291,67 @@ namespace geode namespace internal { ImageInputImpl::ImageInputImpl( std::string_view filename ) - : detail::GDALFile{ filename } + : detail::GDALFile{ filename, IMAGE_DRIVERS } { } RasterImage2D ImageInputImpl::read_file() { - return ::read_file< Range >( dataset() ); - } - - RasterImage2D ImageInputImpl::read_reversed_y_axis_file() - { - return ::read_file< ReverseRange >( dataset() ); + const detail::GDALErrorScope errors; + auto& gdal_data = dataset(); + OpenGeodeIOImageException::check_exception( + gdal_data.GetRasterCount() > 0, nullptr, + OpenGeodeException::TYPE::data, + "[ImageInputImpl] No raster band in image file" ); + const auto width = gdal_data.GetRasterXSize(); + const auto height = gdal_data.GetRasterYSize(); + RasterImage2D raster{ { static_cast< index_t >( width ), + static_cast< index_t >( height ) } }; + const auto colors = raster.modifiable_colors(); + const auto nb_color_components = gdal_data.GetRasterCount(); + auto& first_band = *gdal_data.GetRasterBand( 1 ); + if( const auto palette = palette_colors( first_band ) ) + { + read_palette_band( + first_band, palette.value(), colors, width, height ); + } + else + { + const auto bands = nb_color_components <= 2 + ? std::array< int, 3 >{ 1, 1, 1 } + : get_rgb_indices( gdal_data ); + if( !are_byte_bands( gdal_data, bands ) ) + { + read_rescaled_bands( + gdal_data, bands, colors, width, height ); + } + else if( nb_color_components <= 2 ) + { + read_grey_band( first_band, colors, width, height ); + } + else + { + read_rgb_bands( gdal_data, bands, colors, width, height ); + } + } + return raster; } - Percentage ImageInputImpl::is_loadable() + Percentage ImageInputImpl::is_loadable( std::string_view filename ) { - if( dataset().GetRasterCount() == 0 ) + try + { + ImageInputImpl reader{ filename }; + if( reader.dataset().GetRasterCount() == 0 ) + { + return Percentage{ 0 }; + } + return Percentage{ 1 }; + } + catch( const OpenGeodeException& ) { return Percentage{ 0 }; } - return Percentage{ 1 }; } } // namespace internal -} // namespace geode \ No newline at end of file +} // namespace geode diff --git a/src/geode/io/image/tiff_input.cpp b/src/geode/io/image/tiff_input.cpp index 3ed84fff..09c44416 100644 --- a/src/geode/io/image/tiff_input.cpp +++ b/src/geode/io/image/tiff_input.cpp @@ -34,7 +34,7 @@ namespace geode RasterImage2D TIFFInput::read() { ImageInputImpl reader{ filename() }; - return reader.read_reversed_y_axis_file(); + return reader.read_file(); } auto TIFFInput::additional_files() const -> AdditionalFiles @@ -45,8 +45,7 @@ namespace geode Percentage TIFFInput::is_loadable() const { - ImageInputImpl reader{ filename() }; - return reader.is_loadable(); + return ImageInputImpl::is_loadable( filename() ); } } // namespace internal } // namespace geode diff --git a/src/geode/io/image/vti_raster_image_output.cpp b/src/geode/io/image/vti_raster_image_output.cpp index 0a56a5f4..201af875 100644 --- a/src/geode/io/image/vti_raster_image_output.cpp +++ b/src/geode/io/image/vti_raster_image_output.cpp @@ -62,18 +62,15 @@ namespace { auto point_data = piece.append_child( "PointData" ); point_data.append_attribute( "Scalars" ).set_value( "Color" ); - std::vector< uint8_t > values; - values.reserve( - static_cast< size_t >( this->mesh().nb_cells() ) * 3 ); - for( const auto c : geode::Range{ this->mesh().nb_cells() } ) - { - const auto& color = this->mesh().color( c ); - values.push_back( color.red() ); - values.push_back( color.green() ); - values.push_back( color.blue() ); - } - this->template write_data_array< uint8_t >( - point_data, "Color", std::move( values ), 3 ); + static_assert( sizeof( geode::RGBColor ) == 3, + "[VTIRasterImageOutput] Colors should be stored as 3 bytes" ); + const auto colors = this->mesh().colors(); + // Colors are written from the image storage, without copy + // NOLINTNEXTLINE(cppcoreguidelines-pro-type-reinterpret-cast) + const auto* values = + reinterpret_cast< const uint8_t* >( colors.data() ); + this->template write_external_data_array< uint8_t >( + point_data, "Color", { values, 3 * colors.size() }, 3 ); } }; } // namespace diff --git a/tests/data/image_five_bands.tif b/tests/data/image_five_bands.tif new file mode 100644 index 00000000..35d1fa35 Binary files /dev/null and b/tests/data/image_five_bands.tif differ diff --git a/tests/data/image_float.tif b/tests/data/image_float.tif new file mode 100644 index 00000000..d1a0bb28 Binary files /dev/null and b/tests/data/image_float.tif differ diff --git a/tests/data/image_garbage.png b/tests/data/image_garbage.png new file mode 100644 index 00000000..c93adf7e --- /dev/null +++ b/tests/data/image_garbage.png @@ -0,0 +1 @@ +this is not an image diff --git a/tests/data/image_grey16.png b/tests/data/image_grey16.png new file mode 100644 index 00000000..69ff37a5 Binary files /dev/null and b/tests/data/image_grey16.png differ diff --git a/tests/data/image_palette.png b/tests/data/image_palette.png new file mode 100644 index 00000000..5cf82b3d Binary files /dev/null and b/tests/data/image_palette.png differ diff --git a/tests/data/image_rgb.bmp b/tests/data/image_rgb.bmp new file mode 100644 index 00000000..29c35273 Binary files /dev/null and b/tests/data/image_rgb.bmp differ diff --git a/tests/data/image_rgb.png b/tests/data/image_rgb.png new file mode 100644 index 00000000..b7f59902 Binary files /dev/null and b/tests/data/image_rgb.png differ diff --git a/tests/data/image_rgb16.tif b/tests/data/image_rgb16.tif new file mode 100644 index 00000000..9a0de9b2 Binary files /dev/null and b/tests/data/image_rgb16.tif differ diff --git a/tests/data/image_untagged_bands.tif b/tests/data/image_untagged_bands.tif new file mode 100644 index 00000000..1d7af4ca Binary files /dev/null and b/tests/data/image_untagged_bands.tif differ diff --git a/tests/data/image_vrt.png b/tests/data/image_vrt.png new file mode 100644 index 00000000..c754c381 --- /dev/null +++ b/tests/data/image_vrt.png @@ -0,0 +1,8 @@ + + + + image_rgb.png + 1 + + + diff --git a/tests/data/image_world_file.pgw b/tests/data/image_world_file.pgw new file mode 100644 index 00000000..956580e1 --- /dev/null +++ b/tests/data/image_world_file.pgw @@ -0,0 +1,6 @@ +1.0 +0.0 +0.0 +-1.0 +0.5 +2.5 diff --git a/tests/data/image_world_file.png b/tests/data/image_world_file.png new file mode 100644 index 00000000..b7f59902 Binary files /dev/null and b/tests/data/image_world_file.png differ diff --git a/tests/image/CMakeLists.txt b/tests/image/CMakeLists.txt index 2dc2b510..5a80bfd6 100755 --- a/tests/image/CMakeLists.txt +++ b/tests/image/CMakeLists.txt @@ -18,11 +18,18 @@ # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE # SOFTWARE. +# The test uses the VTK writer, which needs pugixml, also when the tests are +# built against an installed OpenGeode-IO +if(NOT TARGET pugixml::pugixml) + find_package(pugixml REQUIRED CONFIG) +endif() + add_geode_test( SOURCE "test-raster-image.cpp" DEPENDENCIES OpenGeode::basic OpenGeode::mesh ${PROJECT_NAME}::image + pugixml::pugixml ESSENTIAL ) \ No newline at end of file diff --git a/tests/image/test-raster-image.cpp b/tests/image/test-raster-image.cpp index e307a9f4..8b317087 100644 --- a/tests/image/test-raster-image.cpp +++ b/tests/image/test-raster-image.cpp @@ -23,7 +23,11 @@ #include +#include +#include +#include #include +#include #include #include @@ -42,6 +46,7 @@ #include #include +#include #include void test_jpg_from_gimp_input() @@ -146,6 +151,170 @@ void test_tiff_input() geode::save_raster_image( raster, "cea.vti" ); } +// Test images are 4x3 pixels, image row 0 being the top row +constexpr geode::index_t TEST_IMAGE_WIDTH = 4; +constexpr geode::index_t TEST_IMAGE_HEIGHT = 3; + +std::array< geode::index_t, 2 > test_image_pixel( geode::index_t cell ) +{ + return { cell % TEST_IMAGE_WIDTH, + TEST_IMAGE_HEIGHT - 1 - cell / TEST_IMAGE_WIDTH }; +} + +geode::RasterImage2D load_test_image( std::string_view filename ) +{ + auto raster = geode::load_raster_image< 2 >( + absl::StrCat( geode::DATA_PATH, filename ) ); + geode::OpenGeodeIOImageException::test( + raster.nb_cells_in_direction( 0 ) == TEST_IMAGE_WIDTH + && raster.nb_cells_in_direction( 1 ) == TEST_IMAGE_HEIGHT, + "[TEST] Wrong size for ", filename ); + return raster; +} + +// Colors of the bands of image_rgb files +geode::RGBColor test_image_rgb( geode::index_t cell ) +{ + const auto pixel = test_image_pixel( cell ); + return { static_cast< geode::local_index_t >( 20 * pixel[0] ), + static_cast< geode::local_index_t >( 40 * pixel[1] ), 130 }; +} + +void check_test_image_colors( std::string_view filename, + const std::function< geode::RGBColor( geode::index_t ) >& expected ) +{ + const auto raster = load_test_image( filename ); + for( const auto cell : geode::Range{ raster.nb_cells() } ) + { + const auto& color = raster.color( cell ); + const auto expected_color = expected( cell ); + geode::OpenGeodeIOImageException::test( color == expected_color, + "[TEST] Wrong color for cell ", cell, " of ", filename, ": (", + color.red(), ", ", color.green(), ", ", color.blue(), + ") instead of (", expected_color.red(), ", ", + expected_color.green(), ", ", expected_color.blue(), ")" ); + } +} + +void test_bmp_input() +{ + check_test_image_colors( "image_rgb.png", test_image_rgb ); + check_test_image_colors( "image_rgb.bmp", test_image_rgb ); +} + +void test_untagged_bands_input() +{ + check_test_image_colors( "image_untagged_bands.tif", test_image_rgb ); +} + +void test_five_bands_input() +{ + check_test_image_colors( "image_five_bands.tif", test_image_rgb ); +} + +void test_palette_input() +{ + check_test_image_colors( "image_palette.png", []( geode::index_t cell ) { + const auto pixel = test_image_pixel( cell ); + const auto index = pixel[0] + TEST_IMAGE_WIDTH * pixel[1]; + return geode::RGBColor{ static_cast< geode::local_index_t >( + 20 * index ), + static_cast< geode::local_index_t >( 255 - 20 * index ), + static_cast< geode::local_index_t >( 5 * index ) }; + } ); +} + +geode::local_index_t rescaled( double value, double min, double max ) +{ + return static_cast< geode::local_index_t >( + std::round( ( value - min ) * 255. / ( max - min ) ) ); +} + +void test_non_8_bit_input() +{ + const auto index = []( geode::index_t cell ) { + const auto pixel = test_image_pixel( cell ); + return pixel[0] + TEST_IMAGE_WIDTH * pixel[1]; + }; + check_test_image_colors( "image_grey16.png", [&index]( + geode::index_t cell ) { + const auto grey = rescaled( 1000. + 100. * index( cell ), 1000, 2100 ); + return geode::RGBColor{ grey, grey, grey }; + } ); + check_test_image_colors( + "image_rgb16.tif", [&index]( geode::index_t cell ) { + const auto value = 100. * index( cell ); + return geode::RGBColor{ rescaled( 1000. + value, 1000, 4100 ), + rescaled( 2000. + value, 1000, 4100 ), + rescaled( 3000. + value, 1000, 4100 ) }; + } ); + // Pixel 5 has the nodata value and pixel 6 is NaN + check_test_image_colors( + "image_float.tif", [&index]( geode::index_t cell ) { + const auto pixel = index( cell ); + const auto grey = pixel == 5 || pixel == 6 + ? geode::local_index_t{ 0 } + : rescaled( 0.5 * pixel, 0, 5.5 ); + return geode::RGBColor{ grey, grey, grey }; + } ); +} + +void test_additional_files() +{ + const auto no_files = geode::raster_image_additional_files< 2 >( + absl::StrCat( geode::DATA_PATH, "image_rgb.png" ) ); + geode::OpenGeodeIOImageException::test( + no_files.optional_files.empty() && !no_files.has_additional_files(), + "[TEST] Image without associated file should have no additional " + "file" ); + const auto files = geode::raster_image_additional_files< 2 >( + absl::StrCat( geode::DATA_PATH, "image_world_file.png" ) ); + geode::OpenGeodeIOImageException::test( + files.optional_files.size() == 1 && !files.has_additional_files(), + "[TEST] Wrong additional files of image with world file" ); + geode::OpenGeodeIOImageException::test( + absl::EndsWith( + files.optional_files.front().filename, "image_world_file.pgw" ), + "[TEST] World file should be an additional file" ); +} + +void test_non_image_format_input() +{ + // A VRT file, referencing image_rgb.png, named as a PNG file + bool thrown{ false }; + try + { + const auto raster = geode::load_raster_image< 2 >( + absl::StrCat( geode::DATA_PATH, "image_vrt.png" ) ); + } + catch( const geode::OpenGeodeException& ) + { + thrown = true; + } + geode::OpenGeodeIOImageException::test( + thrown, "[TEST] Non image formats should not be loaded" ); +} + +void test_unreadable_input() +{ + const auto filename = absl::StrCat( geode::DATA_PATH, "image_garbage.png" ); + geode::OpenGeodeIOImageException::test( + geode::is_raster_image_loadable< 2 >( filename ).value() == 0, + "[TEST] Unreadable image should not be loadable" ); + std::string message; + try + { + const auto raster = geode::load_raster_image< 2 >( filename ); + } + catch( const geode::OpenGeodeException& exception ) + { + message = exception.what(); + } + geode::OpenGeodeIOImageException::test( + absl::StrContains( message, "not recognized" ), + "[TEST] Load error should give the GDAL reason, got: ", message ); +} + void test_empty_image_output() { const geode::RasterImage2D raster; @@ -158,6 +327,109 @@ void test_empty_image_output() "[TEST] Wrong extent written for empty image" ); } +void test_vti_colors_output() +{ + geode::RasterImage2D raster{ { 3, 2 } }; + for( const auto cell : geode::Range{ raster.nb_cells() } ) + { + const auto value = static_cast< geode::local_index_t >( 10 * cell ); + raster.set_color( + cell, { value, static_cast< geode::local_index_t >( value + 1 ), + static_cast< geode::local_index_t >( value + 2 ) } ); + } + geode::save_raster_image( raster, "colors.vti" ); + std::ifstream file{ "colors.vti", std::ios::binary }; + const std::string content{ std::istreambuf_iterator< char >{ file }, + std::istreambuf_iterator< char >{} }; + const auto start = content.find( "" ); + geode::OpenGeodeIOImageException::test( + start != std::string::npos, "[TEST] No appended data in VTI file" ); + const auto data = content.find( '_', start ) + 1; + std::uint64_t nb_bytes{ 0 }; + std::memcpy( &nb_bytes, content.data() + data, sizeof( nb_bytes ) ); + geode::OpenGeodeIOImageException::test( + nb_bytes == 3 * raster.nb_cells(), "[TEST] Wrong VTI colors size" ); + for( const auto cell : geode::Range{ raster.nb_cells() } ) + { + const auto* color = + content.data() + data + sizeof( nb_bytes ) + 3 * cell; + const auto& expected = raster.color( cell ); + geode::OpenGeodeIOImageException::test( + static_cast< geode::local_index_t >( color[0] ) == expected.red() + && static_cast< geode::local_index_t >( color[1] ) + == expected.green() + && static_cast< geode::local_index_t >( color[2] ) + == expected.blue(), + "[TEST] Wrong VTI color for cell ", cell ); + } +} + +class FailingVTIOutput + : public geode::detail::VTIOutputImpl< geode::RasterImage2D > +{ +public: + FailingVTIOutput( + const geode::RasterImage2D& raster, std::string_view filename ) + : geode::detail::VTIOutputImpl< geode::RasterImage2D >{ raster, + filename } + { + } + +private: + void write_piece( pugi::xml_node& /*unused*/ ) final + { + throw geode::OpenGeodeIOImageException{ nullptr, + geode::OpenGeodeException::TYPE::data, "Failure while writing" }; + } +}; + +void test_failed_output_keeps_existing_file() +{ + const auto filename = "existing.vti"; + std::ofstream{ filename } << "previous content"; + const geode::RasterImage2D raster{ { 2, 2 } }; + bool thrown{ false }; + try + { + FailingVTIOutput output{ raster, filename }; + output.write_file(); + } + catch( const geode::OpenGeodeException& ) + { + thrown = true; + } + geode::OpenGeodeIOImageException::test( + thrown, "[TEST] Failing output should throw" ); + std::ifstream file{ filename }; + const std::string content{ std::istreambuf_iterator< char >{ file }, + std::istreambuf_iterator< char >{} }; + geode::OpenGeodeIOImageException::test( content == "previous content", + "[TEST] Failing output should keep the existing file" ); + geode::OpenGeodeIOImageException::test( + !std::filesystem::exists( absl::StrCat( filename, ".tmp" ) ), + "[TEST] Failing output should not leave a temporary file" ); +} + +void test_output_through_symbolic_link() +{ +#ifdef __linux__ + std::filesystem::remove( "link.vti" ); + std::ofstream{ "link_target.vti" } << "previous content"; + std::filesystem::create_symlink( "link_target.vti", "link.vti" ); + const geode::RasterImage2D raster{ { 2, 2 } }; + geode::save_raster_image( raster, "link.vti" ); + geode::OpenGeodeIOImageException::test( + std::filesystem::is_symlink( "link.vti" ), + "[TEST] Output should keep the symbolic link" ); + std::ifstream file{ "link_target.vti" }; + const std::string content{ std::istreambuf_iterator< char >{ file }, + std::istreambuf_iterator< char >{} }; + geode::OpenGeodeIOImageException::test( + absl::StrContains( content, "VTKFile" ), + "[TEST] Output should be written in the symbolic link target" ); +#endif +} + int main() { try @@ -169,7 +441,18 @@ int main() test_jpg_from_paraview_input(); test_png_input(); test_tiff_input(); + test_bmp_input(); + test_untagged_bands_input(); + test_five_bands_input(); + test_palette_input(); + test_non_8_bit_input(); + test_additional_files(); + test_non_image_format_input(); + test_unreadable_input(); test_empty_image_output(); + test_failed_output_keeps_existing_file(); + test_output_through_symbolic_link(); + test_vti_colors_output(); geode::Logger::info( "TEST SUCCESS" ); return 0; }