diff --git a/Documentation/docs/releases/5.4.6.md b/Documentation/docs/releases/5.4.6.md index a98b9b81e93..069e829d7d9 100644 --- a/Documentation/docs/releases/5.4.6.md +++ b/Documentation/docs/releases/5.4.6.md @@ -226,4 +226,3 @@ We remain dedicated to supporting current users through: #### Enhancements - MINC 2025-02-24 (3b8d9c7e) ([a32b73adbc](https://github.com/InsightSoftwareConsortium/ITK/commit/a32b73adbc)) - diff --git a/Modules/Core/TestKernel/src/itkTestDriverIncludeRequiredFactories.cxx b/Modules/Core/TestKernel/src/itkTestDriverIncludeRequiredFactories.cxx index 58ef871b0e1..b05f0a8e4a9 100644 --- a/Modules/Core/TestKernel/src/itkTestDriverIncludeRequiredFactories.cxx +++ b/Modules/Core/TestKernel/src/itkTestDriverIncludeRequiredFactories.cxx @@ -26,6 +26,7 @@ #include "itkTIFFImageIOFactory.h" #include "itkBMPImageIOFactory.h" #include "itkVTKImageIOFactory.h" +#include "itkVTIImageIOFactory.h" #include "itkNrrdImageIOFactory.h" #include "itkGiplImageIOFactory.h" #include "itkNiftiImageIOFactory.h" @@ -85,6 +86,7 @@ RegisterRequiredIOFactories() itk::ObjectFactoryBase::RegisterFactory(itk::GDCMImageIOFactory::New()); itk::ObjectFactoryBase::RegisterFactory(itk::JPEGImageIOFactory::New()); itk::ObjectFactoryBase::RegisterFactory(itk::VTKImageIOFactory::New()); + itk::ObjectFactoryBase::RegisterFactory(itk::VTIImageIOFactory::New()); itk::ObjectFactoryBase::RegisterFactory(itk::PNGImageIOFactory::New()); itk::ObjectFactoryBase::RegisterFactory(itk::TIFFImageIOFactory::New()); itk::ObjectFactoryBase::RegisterFactory(itk::BMPImageIOFactory::New()); diff --git a/Modules/IO/VTK/include/itkVTIImageIO.h b/Modules/IO/VTK/include/itkVTIImageIO.h new file mode 100644 index 00000000000..17afee1431c --- /dev/null +++ b/Modules/IO/VTK/include/itkVTIImageIO.h @@ -0,0 +1,244 @@ +/*========================================================================= + * + * Copyright NumFOCUS + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0.txt + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + *=========================================================================*/ +#ifndef itkVTIImageIO_h +#define itkVTIImageIO_h +#include "ITKIOVTKExport.h" + +#include +#include "itkImageIOBase.h" + +namespace itk +{ +/** + * \class VTIImageIO + * + * \brief ImageIO class for reading and writing VTK XML ImageData (.vti) + * files. + * + * Supported on read (every encoding ParaView 5.x emits by default): + * * attributes: type="ImageData", any version, any byte_order + * (LittleEndian / BigEndian), header_type in {UInt32, UInt64}. + * * attributes: WholeExtent, Origin, Spacing, and + * Direction (VTK 9+; defaults to identity when absent). + * * Single- images. + * * format = "ascii" | "binary" (inline base64) | + * "appended" (raw or base64 AppendedData). + * * compressor = vtkZLibDataCompressor, or absent (uncompressed). + * * Pixel types: Scalar, Vector (3-component), RGB (3), RGBA (4), and + * symmetric second-rank tensor (6 components, VTK canonical + * [XX, YY, ZZ, XY, YZ, XZ] layout remapped to ITK's internal + * [e00, e01, e02, e11, e12, e22] on read). + * + * Single-image semantics: VTIImageIO yields exactly one image per file. + * When declares Scalars/Vectors/Tensors hint attributes, the + * named DataArray is selected; when no hint is given, the first + * child of is used. Sibling DataArrays in the + * same are silently ignored, since ITK's IO model is one + * image per file. Arrays inside , , or other + * containers are not consumed (see F-011). + * + * Supported on write: + * * matching ParaView 5.7+. + * * format = "ascii" and "binary" (inline base64) for every supported + * pixel type except binary symmetric tensor (see deferred list). + * * format = "appended" encoding="raw" + vtkZLibDataCompressor: enabled + * by calling SetUseCompression(true); produces the smallest files on + * disk and matches what ParaView emits by default for large images. + * * Direction is always emitted as a row-major 3x3 Direction attribute, + * padded with identity for images of dimension < 3. + * + * Deferred to the follow-up PR. Items marked (guard) raise an + * itkExceptionMacro tagged with the F-NNN identifier so `git grep + * F-NNN` locates the guard + test + commit; items marked (latent) + * have no public API surface today and so cannot be triggered without + * a code change, but are listed here so the F-NNN identifier is + * findable from the header: + * * F-001 vtkLZ4DataCompressor read (guard) + * * F-002 vtkLZMADataCompressor read (guard) + * * F-003 appended-raw writer (no compression) (latent) + * * F-004 streaming read for appended-raw (latent) + * * F-005 multi- images (guard) + * * F-007 binary symmetric-tensor write (guard) + * * F-008 appended-base64 writer (latent) + * * F-009 MetaDataDictionary round-trip (latent) + * * F-010 catch-all for unknown compressors (guard) + * * F-011 -only images (only consumed) (guard) + * + * Implementation notes: + * * XML header parsing uses expat (ITKExpat); / + * declarations are rejected up-front to mitigate billion-laughs + * and XXE attacks. + * * Expat is fed the file in chunks and suspended (XML_StopParser) at + * the start tag so binary bytes never enter the parser. + * For large files this avoids a full in-memory copy of the binary + * payload during ReadImageInformation(). + * + * \ingroup IOFilters + * \ingroup ITKIOVTK + */ +class ITKIOVTK_EXPORT VTIImageIO : public ImageIOBase +{ +public: + ITK_DISALLOW_COPY_AND_MOVE(VTIImageIO); + + /** Standard class type aliases. */ + using Self = VTIImageIO; + using Superclass = ImageIOBase; + using Pointer = SmartPointer; + using ConstPointer = SmartPointer; + + /** Method for creation through the object factory. */ + itkNewMacro(Self); + + /** \see LightObject::GetNameOfClass() */ + itkOverrideGetNameOfClassMacro(VTIImageIO); + + /*-------- This part of the interface deals with reading data. ------ */ + + /** Determine the file type. Returns true if this ImageIO can read the + * file specified. */ + bool + CanReadFile(const char *) override; + + /** Set the spacing and dimension information for the current filename. */ + void + ReadImageInformation() override; + + /** Reads the data from disk into the memory buffer provided. */ + void + Read(void * buffer) override; + + /*-------- This part of the interfaces deals with writing data. ----- */ + + /** Determine the file type. Returns true if this ImageIO can write the + * file specified. */ + bool + CanWriteFile(const char *) override; + + /** Writes the spacing and dimensions of the image. + * Assumes SetFileName has been called with a valid file name. */ + void + WriteImageInformation() override + {} + + /** Writes the data to disk from the memory buffer provided. Make sure + * that the IORegion has been set properly. */ + void + Write(const void * buffer) override; + + /** Byte-swap \a numComponents values of \a componentSize bytes each in + * place when \a fileByteOrder differs from \a targetByteOrder. The + * implementation reverses bytes within each component unconditionally + * (via std::reverse) rather than going through ByteSwapper's + * system-relative helpers, so it is deterministic on hosts of either + * endianness and therefore unit-testable without a big-endian runner. + * Public so that endianness behaviour can be exercised directly in + * tests. */ + static void + SwapBufferForByteOrder(void * buffer, + std::size_t componentSize, + std::size_t numComponents, + IOByteOrderEnum fileByteOrder, + IOByteOrderEnum targetByteOrder); + +protected: + VTIImageIO(); + ~VTIImageIO() override; + + void + PrintSelf(std::ostream & os, Indent indent) const override; + +private: + /** Map VTK type string to ITK IOComponentEnum. */ + static IOComponentEnum + VTKTypeStringToITKComponent(const std::string & vtkType); + + /** Map ITK IOComponentEnum to VTK type string. */ + static std::string + ITKComponentToVTKTypeString(IOComponentEnum t); + + /** Decode a base64-encoded string into raw bytes. Returns the number + * of decoded bytes. */ + static SizeType + DecodeBase64(const std::string & encoded, std::vector & decoded); + + /** Encode raw bytes as a base64 string. */ + static std::string + EncodeBase64(const unsigned char * data, SizeType numBytes); + + /** Trim leading/trailing whitespace from a string. */ + static std::string + TrimString(const std::string & s); + + // Encoding format of the data array found in the file. + // (Plain comment, not doxygen, to satisfy KWStyle's `\class` rule for + // class-like declarations.) + enum class DataEncoding : std::uint8_t + { + ASCII, + Base64, // binary data encoded in base64 (format="binary") + RawAppended, // raw binary appended data (format="appended" with encoding="raw") + Base64Appended, // base64-encoded appended data (format="appended" with encoding="base64") + ZLibBase64, // zlib-compressed data encoded in base64 (inline) + ZLibAppended, // zlib-compressed raw appended data + ZLibBase64Appended // zlib-compressed base64 appended data + }; + + DataEncoding m_DataEncoding{ DataEncoding::Base64 }; + + /** Cached ASCII data text content captured by the expat parser when the + * active DataArray is in ASCII format. Empty otherwise. */ + std::string m_AsciiDataContent{}; + + /** Cached base64 data text content captured by the expat parser when the + * active DataArray is in base64 ("binary") format. Empty otherwise. */ + std::string m_Base64DataContent{}; + + /** Byte offset into the file where the appended data section begins + * (only relevant when m_DataEncoding == RawAppended). */ + std::streampos m_AppendedDataOffset{ 0 }; + + /** Offset within the appended data block for this DataArray (in bytes, + * not counting the leading block-size UInt32/UInt64). */ + SizeType m_DataArrayOffset{ 0 }; + + /** Whether the header uses 64-bit block-size integers (header_type="UInt64"). */ + bool m_HeaderTypeUInt64{ false }; + + /** Whether the file uses zlib compression (compressor="vtkZLibDataCompressor"). */ + bool m_IsZLibCompressed{ false }; + + /** Whether appended data uses base64 encoding (encoding="base64") vs raw (encoding="raw"). */ + bool m_AppendedDataIsBase64{ false }; + + /** Cached base64 content from appended data section when encoding="base64". */ + std::string m_AppendedBase64Content{}; + + /** Decompress a VTK zlib-compressed block sequence into raw bytes. + * The input buffer begins with the VTK multi-block compression header + * (nblocks / uncompressed_blocksize / last_partial_size / compressed_sizes[]). + * Header integers are UInt32 or UInt64 per \a headerUInt64. */ + static void + DecompressZLib(const unsigned char * compressedData, + std::size_t compressedDataSize, + bool headerUInt64, + std::vector & uncompressed); +}; +} // end namespace itk + +#endif // itkVTIImageIO_h diff --git a/Modules/IO/VTK/include/itkVTIImageIOFactory.h b/Modules/IO/VTK/include/itkVTIImageIOFactory.h new file mode 100644 index 00000000000..d0604cf2f5c --- /dev/null +++ b/Modules/IO/VTK/include/itkVTIImageIOFactory.h @@ -0,0 +1,71 @@ +/*========================================================================= + * + * Copyright NumFOCUS + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0.txt + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + *=========================================================================*/ +#ifndef itkVTIImageIOFactory_h +#define itkVTIImageIOFactory_h +#include "ITKIOVTKExport.h" + +#include "itkObjectFactoryBase.h" +#include "itkImageIOBase.h" + +namespace itk +{ +/** + * \class VTIImageIOFactory + * \brief Create instances of VTIImageIO objects using an object factory. + * \ingroup ITKIOVTK + */ +class ITKIOVTK_EXPORT VTIImageIOFactory : public ObjectFactoryBase +{ +public: + ITK_DISALLOW_COPY_AND_MOVE(VTIImageIOFactory); + + /** Standard class type aliases. */ + using Self = VTIImageIOFactory; + using Superclass = ObjectFactoryBase; + using Pointer = SmartPointer; + using ConstPointer = SmartPointer; + + /** Class Methods used to interface with the registered factories. */ + const char * + GetITKSourceVersion() const override; + + const char * + GetDescription() const override; + + /** Method for class instantiation. */ + itkFactorylessNewMacro(Self); + + /** \see LightObject::GetNameOfClass() */ + itkOverrideGetNameOfClassMacro(VTIImageIOFactory); + + /** Register one factory of this type */ + static void + RegisterOneFactory() + { + auto vtiFactory = VTIImageIOFactory::New(); + + ObjectFactoryBase::RegisterFactoryInternal(vtiFactory); + } + +protected: + VTIImageIOFactory(); + ~VTIImageIOFactory() override; +}; +} // end namespace itk + +#endif diff --git a/Modules/IO/VTK/itk-module.cmake b/Modules/IO/VTK/itk-module.cmake index 9ca8ebbd748..09aac6d3466 100644 --- a/Modules/IO/VTK/itk-module.cmake +++ b/Modules/IO/VTK/itk-module.cmake @@ -1,7 +1,8 @@ set( DOCUMENTATION "This module contains classes for reading and writing image -files in the \"legacy\" (non-XML) VTK file format." +files in the \"legacy\" (non-XML) VTK file format and the VTK XML +ImageData (.vti) file format." ) itk_module( @@ -9,10 +10,15 @@ itk_module( ENABLE_SHARED DEPENDS ITKIOImageBase + PRIVATE_DEPENDS + ITKExpat + ITKZLIB TEST_DEPENDS ITKTestKernel ITKImageSources + ITKZLIB FACTORY_NAMES ImageIO::VTK + ImageIO::VTI DESCRIPTION "${DOCUMENTATION}" ) diff --git a/Modules/IO/VTK/src/CMakeLists.txt b/Modules/IO/VTK/src/CMakeLists.txt index c34014c5dbe..d7fcb92ce3a 100644 --- a/Modules/IO/VTK/src/CMakeLists.txt +++ b/Modules/IO/VTK/src/CMakeLists.txt @@ -2,6 +2,8 @@ set( ITKIOVTK_SRCS itkVTKImageIO.cxx itkVTKImageIOFactory.cxx + itkVTIImageIOFactory.cxx + itkVTIImageIO.cxx ) itk_module_add_library(ITKIOVTK ${ITKIOVTK_SRCS}) diff --git a/Modules/IO/VTK/src/itkVTIImageIO.cxx b/Modules/IO/VTK/src/itkVTIImageIO.cxx new file mode 100644 index 00000000000..a36e9237e1f --- /dev/null +++ b/Modules/IO/VTK/src/itkVTIImageIO.cxx @@ -0,0 +1,1801 @@ +/*========================================================================= + * + * Copyright NumFOCUS + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0.txt + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + *=========================================================================*/ +#include "itkVTIImageIO.h" +#include "itkByteSwapper.h" +#include "itkMakeUniqueForOverwrite.h" + +#include "itk_expat.h" +#include "itk_zlib.h" +#include "itksys/Base64.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace itk +{ + +namespace +{ +// Case-insensitive string compare. +bool +IequalsStr(const std::string & a, const std::string & b) +{ + if (a.size() != b.size()) + { + return false; + } + for (std::size_t i = 0; i < a.size(); ++i) + { + if (std::tolower(static_cast(a[i])) != std::tolower(static_cast(b[i]))) + { + return false; + } + } + return true; +} + +// Lower-case a string. +std::string +ToLower(std::string s) +{ + std::transform(s.begin(), s.end(), s.begin(), [](unsigned char c) { return std::tolower(c); }); + return s; +} + +// Look up an attribute value from an expat-style nullptr-terminated +// (key, value, key, value, ..., nullptr) array. Returns empty string if +// not found. +std::string +FindAttribute(const char ** atts, const char * name) +{ + if (atts == nullptr) + { + return {}; + } + for (int i = 0; atts[i] != nullptr; i += 2) + { + if (std::strcmp(atts[i], name) == 0) + { + return std::string(atts[i + 1] != nullptr ? atts[i + 1] : ""); + } + } + return {}; +} + +// --------------------------------------------------------------------------- +// VTI XML parser state. Populated by expat callbacks. +// --------------------------------------------------------------------------- +struct VTIParseState +{ + // + bool sawVTKFile{ false }; + std::string fileType; + std::string byteOrder; + std::string headerType; + std::string compressor; + + // + bool sawImageData{ false }; + std::string wholeExtent; + std::string origin; + std::string spacing; + std::string direction; + + // Count of elements encountered inside . F-005 + // (multi-Piece support) rejects files with pieceCount > 1 during + // ReadImageInformation rather than risking silent data misassembly. + int pieceCount{ 0 }; + + // + // inPointData tracks whether the parser is currently inside a + // element so that children of , , or any + // other container are ignored. The active{Scalars,Vectors,Tensors} hints + // are scoped to the same nesting level (cleared on ). + bool inPointData{ false }; + std::string activeScalars; + std::string activeVectors; + std::string activeTensors; + + // Set when a element with at least one child is + // seen. Used to distinguish a genuinely empty file (no DataArray + // anywhere) from a CellData-only file that this reader does not yet + // support, so the post-parse diagnostic can name the limitation. + bool cellDataHasArray{ false }; + bool inCellData{ false }; + + // First / active + bool haveDataArray{ false }; + std::string daType; + std::string daName; + std::string daFormat; + std::string daNumberOfComponents; + std::string daOffset; + + // Whether we are currently inside the active DataArray (so character + // data should be appended to either ASCII or base64 buffer). + bool inActiveDataArray{ false }; + bool isAsciiActive{ false }; + bool isBase64Active{ false }; + + std::string asciiContent; + std::string base64Content; + + // Set when the start tag is seen. Used by the caller + // to know that the file has an appended-data block (whose binary + // contents are read directly, not via expat). + bool sawAppendedData{ false }; + std::string appendedDataEncoding; // "raw" or "base64" + + // Handle to the active expat parser, set before the first XML_Parse call. + // Used by VTIStartElement to call XML_StopParser when is + // encountered so we avoid feeding binary bytes to expat. + XML_Parser parserHandle{ nullptr }; +}; + +// Determine whether a DataArray (identified by its Name attribute) is the +// "active" one for this file. If no PointData Scalars/Vectors/Tensors +// attribute is set, the very first DataArray is taken as active. +bool +IsActiveDataArray(const VTIParseState & st, const std::string & daName) +{ + const bool noActive = st.activeScalars.empty() && st.activeVectors.empty() && st.activeTensors.empty(); + if (noActive) + { + return true; + } + return (!st.activeScalars.empty() && daName == st.activeScalars) || + (!st.activeVectors.empty() && daName == st.activeVectors) || + (!st.activeTensors.empty() && daName == st.activeTensors); +} + +extern "C" +{ + static void + VTIStartElement(void * userData, const char * name, const char ** atts) + { + auto * st = static_cast(userData); + + if (std::strcmp(name, "VTKFile") == 0) + { + st->sawVTKFile = true; + st->fileType = FindAttribute(atts, "type"); + st->byteOrder = FindAttribute(atts, "byte_order"); + st->headerType = FindAttribute(atts, "header_type"); + st->compressor = FindAttribute(atts, "compressor"); + } + else if (std::strcmp(name, "ImageData") == 0) + { + st->sawImageData = true; + st->wholeExtent = FindAttribute(atts, "WholeExtent"); + st->origin = FindAttribute(atts, "Origin"); + st->spacing = FindAttribute(atts, "Spacing"); + st->direction = FindAttribute(atts, "Direction"); + } + else if (std::strcmp(name, "Piece") == 0) + { + ++st->pieceCount; + } + else if (std::strcmp(name, "PointData") == 0) + { + st->inPointData = true; + st->activeScalars = FindAttribute(atts, "Scalars"); + st->activeVectors = FindAttribute(atts, "Vectors"); + st->activeTensors = FindAttribute(atts, "Tensors"); + } + else if (std::strcmp(name, "CellData") == 0) + { + st->inCellData = true; + } + else if (std::strcmp(name, "DataArray") == 0) + { + // Only consume DataArray children of . CellData / FieldData + // / Coordinates arrays are silently skipped here; the post-parse F-011 + // / no-DataArray diagnostics distinguish 'CellData-only' from 'truly + // empty file' so the user knows what's wrong. + if (st->inCellData) + { + st->cellDataHasArray = true; + } + if (!st->inPointData) + { + return; + } + const std::string daName = FindAttribute(atts, "Name"); + if (st->haveDataArray) + { + // We have already captured an active DataArray; ignore subsequent + // ones (we read only one image array per file). + return; + } + if (!IsActiveDataArray(*st, daName)) + { + return; + } + st->haveDataArray = true; + st->daType = FindAttribute(atts, "type"); + st->daName = daName; + st->daFormat = ToLower(FindAttribute(atts, "format")); + st->daNumberOfComponents = FindAttribute(atts, "NumberOfComponents"); + st->daOffset = FindAttribute(atts, "offset"); + + st->inActiveDataArray = true; + st->isAsciiActive = (st->daFormat == "ascii"); + st->isBase64Active = (st->daFormat == "binary"); // VTK XML "binary" == base64 + } + else if (std::strcmp(name, "AppendedData") == 0) + { + st->sawAppendedData = true; + st->appendedDataEncoding = ToLower(FindAttribute(atts, "encoding")); + // Suspend expat immediately after this start tag so the parser never + // sees the binary payload that follows the `_` marker. Using + // XML_TRUE (resumable/suspended) rather than XML_FALSE (aborted) + // so that XML_Parse returns XML_STATUS_SUSPENDED, not an error code, + // and XML_GetCurrentByteIndex remains valid. + if (st->parserHandle != nullptr) + { + XML_StopParser(st->parserHandle, XML_TRUE); + } + } + } + + static void + VTIEndElement(void * userData, const char * name) + { + auto * st = static_cast(userData); + if (std::strcmp(name, "DataArray") == 0) + { + st->inActiveDataArray = false; + st->isAsciiActive = false; + st->isBase64Active = false; + } + else if (std::strcmp(name, "PointData") == 0) + { + // Clear the in-PointData flag so any later children of + // sibling / elements are not consumed as image + // data. active{Scalars,Vectors,Tensors} are intentionally not cleared + // — the post-parse pixel-type derivation in ReadImageInformation reads + // them by daName, and the captured DataArray was already gated on + // inPointData at the time it was matched. + st->inPointData = false; + } + else if (std::strcmp(name, "CellData") == 0) + { + st->inCellData = false; + } + } + + static void + VTICharData(void * userData, const char * data, int length) + { + auto * st = static_cast(userData); + if (!st->inActiveDataArray) + { + return; + } + if (st->isAsciiActive) + { + st->asciiContent.append(data, static_cast(length)); + } + else if (st->isBase64Active) + { + st->base64Content.append(data, static_cast(length)); + } + } +} // extern "C" + +} // end anonymous namespace + +// --------------------------------------------------------------------------- +VTIImageIO::VTIImageIO() +{ + this->SetNumberOfDimensions(3); + m_ByteOrder = IOByteOrderEnum::LittleEndian; + m_FileType = IOFileEnum::Binary; + + this->AddSupportedReadExtension(".vti"); + this->AddSupportedWriteExtension(".vti"); +} + +VTIImageIO::~VTIImageIO() = default; + +// --------------------------------------------------------------------------- +void +VTIImageIO::PrintSelf(std::ostream & os, Indent indent) const +{ + Superclass::PrintSelf(os, indent); +} + +// --------------------------------------------------------------------------- +bool +VTIImageIO::CanReadFile(const char * filename) +{ + if (!this->HasSupportedReadExtension(filename)) + { + return false; + } + + std::ifstream file(filename, std::ios::in); + if (!file.is_open()) + { + return false; + } + + // Read first 256 bytes and look for VTKFile + ImageData. + char buf[256]{}; + file.read(buf, 255); + const std::string header(buf); + return header.find("VTKFile") != std::string::npos && header.find("ImageData") != std::string::npos; +} + +// --------------------------------------------------------------------------- +bool +VTIImageIO::CanWriteFile(const char * filename) +{ + return this->HasSupportedWriteExtension(filename); +} + +// --------------------------------------------------------------------------- +// Static helpers +// --------------------------------------------------------------------------- + +std::string +VTIImageIO::TrimString(const std::string & s) +{ + const std::size_t start = s.find_first_not_of(" \t\r\n"); + if (start == std::string::npos) + { + return {}; + } + const std::size_t end = s.find_last_not_of(" \t\r\n"); + return s.substr(start, end - start + 1); +} + +VTIImageIO::SizeType +VTIImageIO::DecodeBase64(const std::string & encoded, std::vector & decoded) +{ + // itksysBase64_Decode stops at any non-Base64 byte (including whitespace and '=' padding), + // so we need to handle that by invoking it multiple times. + + // Allocate output buffer: worst case is (encoded.size() / 4) * 3 bytes + decoded.assign((encoded.size() / 4) * 3 + 3, 0); + + const unsigned char * inputStart = reinterpret_cast(encoded.data()); + const unsigned char * inputEnd = inputStart + encoded.size(); + const unsigned char * inputPtr = inputStart; + unsigned char * outputPtr = decoded.data(); + bool workLeft = true; + while (workLeft) + { + // eat whitespace and '=' padding before the next base64 chunk + while (inputPtr < inputEnd && (std::isspace(*inputPtr) || *inputPtr == '=')) + { + ++inputPtr; + } + const std::size_t produced = itksysBase64_Decode(inputPtr, + 0, // max_output_length ignored when max_input_length is non-zero + outputPtr, + inputEnd - inputPtr); + + // Advance input by the number of Base64 chars that produced the output + inputPtr += produced / 3 * 4; + switch (produced % 3) + { + case 1: + inputPtr += 2; // 1 byte output means we consumed 2 base64 chars + break; + case 2: + inputPtr += 3; // 2 bytes output means we consumed 3 base64 chars + break; + } + outputPtr += produced; + + if (produced == 0 || inputPtr >= inputEnd) + { + workLeft = false; + } + } + + decoded.resize(outputPtr - decoded.data()); + return static_cast(decoded.size()); +} + +std::string +VTIImageIO::EncodeBase64(const unsigned char * data, SizeType numBytes) +{ + // Worst-case output length: ceil(numBytes/3)*4. + std::string out(((static_cast(numBytes) + 2) / 3) * 4, '\0'); + const std::size_t produced = itksysBase64_Encode(data, + static_cast(numBytes), + reinterpret_cast(&out[0]), + /*mark_end=*/0); + out.resize(produced); + return out; +} + +VTIImageIO::IOComponentEnum +VTIImageIO::VTKTypeStringToITKComponent(const std::string & vtkType) +{ + const std::string t = ToLower(vtkType); + if (t == "int8" || t == "char") + { + return IOComponentEnum::CHAR; + } + if (t == "uint8" || t == "unsigned_char") + { + return IOComponentEnum::UCHAR; + } + if (t == "int16" || t == "short") + { + return IOComponentEnum::SHORT; + } + if (t == "uint16" || t == "unsigned_short") + { + return IOComponentEnum::USHORT; + } + if (t == "int32" || t == "int") + { + return IOComponentEnum::INT; + } + if (t == "uint32" || t == "unsigned_int") + { + return IOComponentEnum::UINT; + } + if (t == "int64" || t == "long" || t == "vtktypeint64") + { + return IOComponentEnum::LONGLONG; + } + if (t == "uint64" || t == "unsigned_long" || t == "vtktypeuint64") + { + return IOComponentEnum::ULONGLONG; + } + if (t == "float32" || t == "float") + { + return IOComponentEnum::FLOAT; + } + if (t == "float64" || t == "double") + { + return IOComponentEnum::DOUBLE; + } + return IOComponentEnum::UNKNOWNCOMPONENTTYPE; +} + +std::string +VTIImageIO::ITKComponentToVTKTypeString(IOComponentEnum t) +{ + switch (t) + { + case IOComponentEnum::CHAR: + return "Int8"; + case IOComponentEnum::UCHAR: + return "UInt8"; + case IOComponentEnum::SHORT: + return "Int16"; + case IOComponentEnum::USHORT: + return "UInt16"; + case IOComponentEnum::INT: + return "Int32"; + case IOComponentEnum::UINT: + return "UInt32"; + case IOComponentEnum::LONG: + if (sizeof(long) == 4) + { + return "Int32"; + } + else + { + return "Int64"; + } + case IOComponentEnum::ULONG: + if (sizeof(unsigned long) == 4) + { + return "UInt32"; + } + else + { + return "UInt64"; + } + case IOComponentEnum::LONGLONG: + return "Int64"; + case IOComponentEnum::ULONGLONG: + return "UInt64"; + case IOComponentEnum::FLOAT: + return "Float32"; + case IOComponentEnum::DOUBLE: + return "Float64"; + default: + itkGenericExceptionMacro("Unsupported component type for VTI writing."); + } +} + +// --------------------------------------------------------------------------- +// ReadImageInformation +// --------------------------------------------------------------------------- +void +VTIImageIO::ReadImageInformation() +{ + // Reset cached parser results. + m_AsciiDataContent.clear(); + m_Base64DataContent.clear(); + m_AppendedDataOffset = 0; + m_DataArrayOffset = 0; + m_HeaderTypeUInt64 = false; + m_IsZLibCompressed = false; + m_AppendedDataIsBase64 = false; + m_AppendedBase64Content.clear(); + + // Open the file for streaming. We feed content to expat in chunks rather + // than slurping the whole file. When the start element is + // seen, VTIStartElement calls XML_StopParser so expat halts before touching + // any binary payload. XML_GetCurrentByteIndex then gives the file offset of + // the tag, and we scan forward from there for the `_` data marker. For + // files without an section (ASCII / inline base64) we parse + // to EOF normally. The net effect: large VTI files are read once, not twice. + std::ifstream xmlFile(m_FileName.c_str(), std::ios::in | std::ios::binary); + if (!xmlFile.is_open()) + { + itkExceptionMacro("Cannot open or read file: " << m_FileName); + } + + // Security pre-scan: DOCTYPE / ENTITY declarations must appear before any + // element content, so they will be in the first few hundred bytes. Reading + // 512 bytes is sufficient and avoids loading the full file for this check. + { + char secBuf[512]{}; + xmlFile.read(secBuf, sizeof(secBuf) - 1); + const std::string prefix(secBuf, static_cast(xmlFile.gcount())); + if (prefix.find(" chunk(kChunkSize); + XML_Status parseStatus = XML_STATUS_OK; + bool reachedEOF = false; + + while (!reachedEOF && parseStatus == XML_STATUS_OK) + { + xmlFile.read(chunk.data(), kChunkSize); + const auto bytesRead = static_cast(xmlFile.gcount()); + reachedEOF = (bytesRead < kChunkSize); + parseStatus = XML_Parse(parser, chunk.data(), bytesRead, reachedEOF ? 1 : 0); + } + + // XML_STATUS_SUSPENDED: VTIStartElement called XML_StopParser at . + // XML_STATUS_OK after reachedEOF: normal end-of-file for ASCII/inline-base64 files. + // XML_STATUS_ERROR: genuine parse failure. + if (parseStatus == XML_STATUS_ERROR) + { + const std::string err = XML_ErrorString(XML_GetErrorCode(parser)); + XML_ParserFree(parser); + itkExceptionMacro("XML parse error in " << m_FileName << ": " << err); + } + + // If we stopped at , locate the `_` binary marker and record + // the file offset of the first byte of payload. + if (st.sawAppendedData) + { + const long tagByteIndex = XML_GetCurrentByteIndex(parser); + xmlFile.clear(); + xmlFile.seekg(tagByteIndex, std::ios::beg); + + char c = '\0'; + while (xmlFile.get(c) && c != '_') + { + } + if (c != '_') + { + XML_ParserFree(parser); + itkExceptionMacro("Missing `_` marker in section of: " << m_FileName); + } + m_AppendedDataOffset = xmlFile.tellg(); + + // For base64-encoded appended data, read the text content now so + // Read() doesn't need to re-open the file. + if (IequalsStr(st.appendedDataEncoding, "base64")) + { + std::string b64content; + std::getline(xmlFile, b64content, '<'); // read until + m_AppendedBase64Content = std::move(b64content); + } + } + + XML_ParserFree(parser); + + // ---- Validate captured XML ------------------------------------------ + if (!st.sawVTKFile) + { + itkExceptionMacro("Not a valid VTK XML file (missing element): " << m_FileName); + } + if (!IequalsStr(st.fileType, "ImageData")) + { + itkExceptionMacro("VTK XML file is not of type ImageData: " << m_FileName); + } + if (!st.sawImageData) + { + itkExceptionMacro("Missing element in file: " << m_FileName); + } + // F-005 check runs before the "no DataArray" fallback so a multi-Piece + // file with zero DataArrays still yields the precise guard diagnostic. + if (st.pieceCount > 1) + { + itkExceptionMacro("F-005 Multi-Piece ImageData files not yet supported. File '" + << m_FileName << "' contains " << st.pieceCount + << " elements. Deferred to the follow-up PR; see F-005 " + "in commit history."); + } + if (!st.haveDataArray) + { + if (st.cellDataHasArray) + { + itkExceptionMacro("F-011 CellData-only ImageData files not yet supported. File '" + << m_FileName + << "' has DataArray elements only inside ; VTIImageIO consumes " + " arrays only. Deferred to the follow-up PR; see F-011 in " + "commit history."); + } + itkExceptionMacro("No DataArray element found in file: " << m_FileName); + } + + // Byte order + if (ToLower(st.byteOrder) == "bigendian") + { + m_ByteOrder = IOByteOrderEnum::BigEndian; + } + else + { + m_ByteOrder = IOByteOrderEnum::LittleEndian; + } + + // Header type + m_HeaderTypeUInt64 = (ToLower(st.headerType) == "uint64"); + + // Geometry + if (st.wholeExtent.empty()) + { + itkExceptionMacro("Missing WholeExtent attribute in : " << m_FileName); + } + int extents[6] = { 0, 0, 0, 0, 0, 0 }; + { + std::istringstream extStream(st.wholeExtent); + for (int & ext : extents) + { + extStream >> ext; + } + } + double origin[3] = { 0.0, 0.0, 0.0 }; + if (!st.origin.empty()) + { + std::istringstream os2(st.origin); + os2 >> origin[0] >> origin[1] >> origin[2]; + } + double spacing[3] = { 1.0, 1.0, 1.0 }; + if (!st.spacing.empty()) + { + std::istringstream spStr(st.spacing); + spStr >> spacing[0] >> spacing[1] >> spacing[2]; + } + + // Direction cosines (VTK 9+ optional attribute; row-major 3x3 where + // column j is the direction vector of image axis j in world space). + // Absent => identity, which is ITK's default, so we simply skip the + // SetDirection calls below when the attribute is missing. + double directionMatrix[3][3] = { { 1.0, 0.0, 0.0 }, { 0.0, 1.0, 0.0 }, { 0.0, 0.0, 1.0 } }; + bool directionSpecified = false; + if (!st.direction.empty()) + { + std::istringstream dirStream(st.direction); + for (int r = 0; r < 3; ++r) + { + for (int c = 0; c < 3; ++c) + { + dirStream >> directionMatrix[r][c]; + } + } + if (dirStream.fail()) + { + itkExceptionMacro("Malformed Direction attribute '" + << st.direction << "' (expected 9 space-separated floats) in file: " << m_FileName); + } + // Reject trailing non-whitespace junk after the 9 floats. std::istream + // does not flag failure on a successful 9-token read followed by garbage, + // so check explicitly. + std::string trailing; + if (dirStream >> trailing) + { + itkExceptionMacro("Direction attribute '" << st.direction << "' has unexpected content after the 9 floats: '" + << trailing << "' in file: " << m_FileName); + } + + // Direction matrix should be orthonormal: D^T * D == I within tolerance, + // i.e. each column (the world-space direction vector of an image axis, + // per the docstring above) must be unit-length and distinct columns + // must be orthogonal. ITK's downstream geometry code assumes + // orthonormality (rotations or axis-permutations); a degenerate or + // non-orthogonal Direction silently breaks resampling, registration, + // and physical-space conversions. VTK itself does not validate, and + // ParaView always writes orthonormal matrices, so a non-orthonormal + // value almost always means the file was written by a tool that + // conflates "Direction" with raw column vectors. Emit a warning rather + // than throwing so legacy files still load while making the issue + // visible. + constexpr double kOrthoTol = 1.0e-6; + bool orthonormal = true; + for (int i = 0; i < 3 && orthonormal; ++i) + { + for (int j = 0; j < 3 && orthonormal; ++j) + { + double dot = 0.0; + for (int k = 0; k < 3; ++k) + { + // : dot product of two image-axis world-space + // direction vectors. Equal to (D^T * D)[i][j]. + dot += directionMatrix[k][i] * directionMatrix[k][j]; + } + const double expected = (i == j) ? 1.0 : 0.0; + if (std::abs(dot - expected) > kOrthoTol) + { + orthonormal = false; + } + } + } + if (!orthonormal) + { + itkWarningMacro("Direction attribute '" + << st.direction << "' is not orthonormal (D^T * D != I within " << kOrthoTol + << "); ITK geometry pipelines may behave incorrectly. File: " << m_FileName); + } + directionSpecified = true; + } + + const int nx = extents[1] - extents[0] + 1; + const int ny = extents[3] - extents[2] + 1; + const int nz = extents[5] - extents[4] + 1; + + if (nz <= 1 && ny <= 1) + { + this->SetNumberOfDimensions(1); + } + else if (nz <= 1) + { + this->SetNumberOfDimensions(2); + } + else + { + this->SetNumberOfDimensions(3); + } + + this->SetDimensions(0, static_cast(nx)); + if (this->GetNumberOfDimensions() > 1) + { + this->SetDimensions(1, static_cast(ny)); + } + if (this->GetNumberOfDimensions() > 2) + { + this->SetDimensions(2, static_cast(nz)); + } + for (unsigned int i = 0; i < this->GetNumberOfDimensions(); ++i) + { + this->SetSpacing(i, spacing[i]); + this->SetOrigin(i, origin[i]); + } + if (directionSpecified) + { + const unsigned int nd = this->GetNumberOfDimensions(); + for (unsigned int axis = 0; axis < nd; ++axis) + { + std::vector axisVec(nd); + for (unsigned int r = 0; r < nd; ++r) + { + axisVec[r] = directionMatrix[r][axis]; + } + this->SetDirection(axis, axisVec); + } + } + + // Component type + const IOComponentEnum compType = VTKTypeStringToITKComponent(st.daType); + if (compType == IOComponentEnum::UNKNOWNCOMPONENTTYPE) + { + itkExceptionMacro("Unknown VTK DataArray type '" << st.daType << "' in file: " << m_FileName); + } + this->SetComponentType(compType); + + // Number of components + unsigned int numComp = 1u; + if (!st.daNumberOfComponents.empty()) + { + try + { + numComp = static_cast(std::stoul(st.daNumberOfComponents)); + } + catch (const std::exception & e) + { + itkExceptionMacro("Invalid NumberOfComponents '" << st.daNumberOfComponents << "' in file '" << m_FileName + << "': " << e.what()); + } + } + this->SetNumberOfComponents(numComp); + + // Pixel type, derived from the active PointData attribute and component count + const bool isTensor = !st.activeTensors.empty() && st.daName == st.activeTensors; + const bool isVector = !st.activeVectors.empty() && st.daName == st.activeVectors; + if (isTensor) + { + // VTK canonical symmetric-tensor layout is 6 components per pixel + // in [XX, YY, ZZ, XY, YZ, XZ] order. ITK's in-memory + // SymmetricSecondRankTensor uses [e00, e01, e02, e11, e12, e22] + // (upper-triangular row-major); the actual permutation is applied in + // Read() after the encoding path populates the buffer. + if (numComp != 6) + { + itkExceptionMacro("Active Tensors DataArray has NumberOfComponents=\"" + << numComp + << "\"; expected 6 for VTK-canonical symmetric-tensor layout " + "[XX, YY, ZZ, XY, YZ, XZ] in file: " + << m_FileName); + } + this->SetPixelType(IOPixelEnum::SYMMETRICSECONDRANKTENSOR); + this->SetNumberOfComponents(6); + } + else if (isVector) + { + this->SetPixelType(IOPixelEnum::VECTOR); + } + else if (numComp == 1) + { + this->SetPixelType(IOPixelEnum::SCALAR); + } + else if (numComp == 3) + { + this->SetPixelType(IOPixelEnum::RGB); + } + else if (numComp == 4) + { + this->SetPixelType(IOPixelEnum::RGBA); + } + else + { + this->SetPixelType(IOPixelEnum::VECTOR); + } + + // Compression. Only vtkZLibDataCompressor is currently supported; LZ4 / + // LZMA / any future VTK compressor triggers a tagged exception so users + // get a clear error instead of silently falling through to the + // uncompressed read path. Guard tags (F-NNN) correspond to follow-up + // work items; `git grep F-001` etc. locates the guard + guard test + + // code comment for each. + const std::string compressorLower = ToLower(st.compressor); + if (!compressorLower.empty() && compressorLower.find("zlib") == std::string::npos) + { + if (compressorLower.find("lz4") != std::string::npos) + { + itkExceptionMacro("F-001 LZ4 decompressor not yet implemented. " + "Compressor attribute: '" + << st.compressor << "'. File: " << m_FileName + << ". Deferred to the follow-up PR; see F-001 in commit history."); + } + if (compressorLower.find("lzma") != std::string::npos) + { + itkExceptionMacro("F-002 LZMA decompressor not yet implemented. " + "Compressor attribute: '" + << st.compressor << "'. File: " << m_FileName + << ". Deferred to the follow-up PR; see F-002 in commit history."); + } + itkExceptionMacro("F-010 Unknown VTK compressor '" + << st.compressor + << "'. Only vtkZLibDataCompressor is supported today; LZ4 / LZMA " + "(F-001 / F-002) and any new compressor require an explicit " + "decoder path. File: " + << m_FileName); + } + m_IsZLibCompressed = IequalsStr(st.compressor, "vtkZLibDataCompressor"); + + // Appended data encoding (already read into m_AppendedBase64Content above + // for the base64 case; raw-appended offset is in m_AppendedDataOffset). + m_AppendedDataIsBase64 = IequalsStr(st.appendedDataEncoding, "base64"); + + // Encoding + if (st.daFormat == "ascii") + { + m_DataEncoding = DataEncoding::ASCII; + m_FileType = IOFileEnum::ASCII; + m_AsciiDataContent = std::move(st.asciiContent); + } + else if (st.daFormat == "appended") + { + if (!st.sawAppendedData) + { + itkExceptionMacro( + "DataArray uses format=\"appended\" but no element was found in: " << m_FileName); + } + // Select encoding based on compression and base64 encoding + if (m_IsZLibCompressed) + { + m_DataEncoding = m_AppendedDataIsBase64 ? DataEncoding::ZLibBase64Appended : DataEncoding::ZLibAppended; + } + else + { + m_DataEncoding = m_AppendedDataIsBase64 ? DataEncoding::Base64Appended : DataEncoding::RawAppended; + } + m_FileType = IOFileEnum::Binary; + m_DataArrayOffset = 0u; + if (!st.daOffset.empty()) + { + try + { + m_DataArrayOffset = static_cast(std::stoull(st.daOffset)); + } + catch (const std::exception & e) + { + itkExceptionMacro("Invalid DataArray offset '" << st.daOffset << "' in file '" << m_FileName + << "': " << e.what()); + } + } + } + else // "binary" (base64) or unspecified, defaulting to binary + { + m_DataEncoding = m_IsZLibCompressed ? DataEncoding::ZLibBase64 : DataEncoding::Base64; + m_FileType = IOFileEnum::Binary; + m_Base64DataContent = std::move(st.base64Content); + } +} + +void +VTIImageIO::SwapBufferForByteOrder(void * buffer, + std::size_t componentSize, + std::size_t numComponents, + IOByteOrderEnum fileByteOrder, + IOByteOrderEnum targetByteOrder) +{ + if (fileByteOrder == targetByteOrder || componentSize <= 1) + { + return; + } + const IOByteOrderEnum systemByteOrder = + ByteSwapper::SystemIsBigEndian() ? IOByteOrderEnum::BigEndian : IOByteOrderEnum::LittleEndian; + + // When either the source or the target is the host's native byte order -- + // the only case that occurs in the Read/Write paths -- dispatch to + // itk::ByteSwapper for a correctly-sized, well-tested swap. For the + // uncommon file-to-target swap that does not involve the host order, + // fall back to a direct per-component byte reverse. + if (fileByteOrder == systemByteOrder || targetByteOrder == systemByteOrder) + { + const IOByteOrderEnum nonSystem = (fileByteOrder == systemByteOrder) ? targetByteOrder : fileByteOrder; + const auto swap = [&](auto * typed) { + using T = std::remove_pointer_t; + if (nonSystem == IOByteOrderEnum::LittleEndian) + { + ByteSwapper::SwapRangeFromSystemToLittleEndian(typed, numComponents); + } + else + { + ByteSwapper::SwapRangeFromSystemToBigEndian(typed, numComponents); + } + }; + switch (componentSize) + { + case 2: + swap(static_cast(buffer)); + return; + case 4: + swap(static_cast(buffer)); + return; + case 8: + swap(static_cast(buffer)); + return; + default: + break; // fall through to std::reverse for unusual sizes + } + } + + auto * bytes = static_cast(buffer); + for (std::size_t i = 0; i < numComponents; ++i) + { + unsigned char * c = bytes + i * componentSize; + std::reverse(c, c + componentSize); + } +} + +namespace +{ +// Thin wrapper that defaults targetByteOrder to the host's native order. +// Kept so the existing encoding-path call sites remain untouched. +void +SwapBufferIfNeeded(void * buffer, std::size_t componentSize, std::size_t numComponents, IOByteOrderEnum fileByteOrder) +{ + const IOByteOrderEnum target = + ByteSwapper::SystemIsBigEndian() ? IOByteOrderEnum::BigEndian : IOByteOrderEnum::LittleEndian; + VTIImageIO::SwapBufferForByteOrder(buffer, componentSize, numComponents, fileByteOrder, target); +} +} // anonymous namespace + +// --------------------------------------------------------------------------- +// Read +// --------------------------------------------------------------------------- +void +VTIImageIO::DecompressZLib(const unsigned char * compressedData, + std::size_t compressedDataSize, + bool headerUInt64, + std::vector & uncompressed) +{ + // VTK zlib compressed block layout: + // [nblocks] UInt32 or UInt64 + // [uncompressed_blocksize] UInt32 or UInt64 (size of each full block) + // [last_partial_blocksize] UInt32 or UInt64 (0 means last block is full) + // [compressed_size_0] UInt32 or UInt64 + // [compressed_size_1] UInt32 or UInt64 + // ... + // [compressed_data_0][compressed_data_1]... + const std::size_t hdrItemSize = headerUInt64 ? sizeof(uint64_t) : sizeof(uint32_t); + + auto readHeader = [&](std::size_t offset) -> uint64_t { + if (headerUInt64) + { + uint64_t v; + std::memcpy(&v, compressedData + offset, sizeof(v)); + return v; + } + else + { + uint32_t v; + std::memcpy(&v, compressedData + offset, sizeof(v)); + return static_cast(v); + } + }; + + const uint64_t nblocks = readHeader(0); + const uint64_t uncompBlockSize = readHeader(hdrItemSize); + const uint64_t lastPartialSize = readHeader(2 * hdrItemSize); + + // Compute total uncompressed size + const uint64_t lastBlockUncompSize = (lastPartialSize == 0) ? uncompBlockSize : lastPartialSize; + const uint64_t totalUncompSize = (nblocks > 1 ? (nblocks - 1) * uncompBlockSize : 0) + lastBlockUncompSize; + + uncompressed.resize(static_cast(totalUncompSize)); + + // Compressed block sizes start at offset 3*hdrItemSize + const std::size_t blockSizesOffset = 3 * hdrItemSize; + + // Compressed data starts after the header (3 + nblocks) items + std::size_t dataOffset = static_cast((3 + nblocks) * hdrItemSize); + std::size_t uncompOffset = 0; + + for (uint64_t b = 0; b < nblocks; ++b) + { + const uint64_t compSize = readHeader(blockSizesOffset + b * hdrItemSize); + const uint64_t thisUncompSize = (b == nblocks - 1) ? lastBlockUncompSize : uncompBlockSize; + + if (dataOffset + static_cast(compSize) > compressedDataSize) + { + itkGenericExceptionMacro("ZLib compressed block extends beyond buffer."); + } + + uLongf destLen = static_cast(thisUncompSize); + const int ret = uncompress(reinterpret_cast(uncompressed.data() + uncompOffset), + &destLen, + reinterpret_cast(compressedData + dataOffset), + static_cast(compSize)); + if (ret != Z_OK) + { + itkGenericExceptionMacro("zlib uncompress failed for VTI block (code " << ret << ")."); + } + + dataOffset += static_cast(compSize); + uncompOffset += static_cast(destLen); + } +} + +namespace +{ +// Compress raw bytes into the VTK multi-block zlib appended format. +// +// Output layout (all integers are UInt64, matching header_type="UInt64"): +// [nblocks][uncompressed_blocksize][last_partial_blocksize] +// [compressed_size_0][compressed_size_1]... +// [compressed_data_0][compressed_data_1]... +// +// last_partial_blocksize is 0 when the last block is exactly blockSize bytes. +std::vector +CompressZLibVTK(const unsigned char * data, std::size_t totalBytes, std::size_t blockSize = 65536) +{ + if (totalBytes == 0) + { + // Emit a valid single-block header with zero sizes. + std::vector result(4 * sizeof(uint64_t), 0); + uint64_t one = 1; + std::memcpy(result.data(), &one, sizeof(uint64_t)); // nblocks = 1 + return result; + } + + const uint64_t nblocks = static_cast((totalBytes + blockSize - 1) / blockSize); + const uint64_t lastBlockBytes = + (totalBytes % blockSize == 0) ? static_cast(blockSize) : static_cast(totalBytes % blockSize); + const uint64_t lastPartialSize = (lastBlockBytes == static_cast(blockSize)) ? 0u : lastBlockBytes; + + std::vector> compBlocks(static_cast(nblocks)); + for (uint64_t b = 0; b < nblocks; ++b) + { + const std::size_t offset = static_cast(b) * blockSize; + const std::size_t thisBlockBytes = (b == nblocks - 1) ? static_cast(lastBlockBytes) : blockSize; + + uLongf destLen = compressBound(static_cast(thisBlockBytes)); + compBlocks[static_cast(b)].resize(static_cast(destLen)); + + const int ret = compress2(reinterpret_cast(compBlocks[static_cast(b)].data()), + &destLen, + reinterpret_cast(data + offset), + static_cast(thisBlockBytes), + Z_DEFAULT_COMPRESSION); + if (ret != Z_OK) + { + itkGenericExceptionMacro("zlib compress failed for block " << b << " (code " << ret << ")."); + } + compBlocks[static_cast(b)].resize(static_cast(destLen)); + } + + const std::size_t headerSize = static_cast(3 + nblocks) * sizeof(uint64_t); + std::size_t payloadSize = 0; + for (const auto & block : compBlocks) + { + payloadSize += block.size(); + } + + std::vector result(headerSize + payloadSize); + unsigned char * p = result.data(); + + auto writeU64 = [&](uint64_t v) { + std::memcpy(p, &v, sizeof(uint64_t)); + p += sizeof(uint64_t); + }; + + writeU64(nblocks); + writeU64(static_cast(blockSize)); + writeU64(lastPartialSize); + for (uint64_t b = 0; b < nblocks; ++b) + { + writeU64(static_cast(compBlocks[static_cast(b)].size())); + } + for (uint64_t b = 0; b < nblocks; ++b) + { + const auto & block = compBlocks[static_cast(b)]; + std::memcpy(p, block.data(), block.size()); + p += block.size(); + } + return result; +} + +// Remap a buffer of symmetric-tensor pixels from VTK canonical layout +// [XX, YY, ZZ, XY, YZ, XZ] to ITK's in-memory +// SymmetricSecondRankTensor layout [e00, e01, e02, e11, e12, e22] +// (upper-triangular row-major), in place. Called after each encoding +// path in Read() has populated `buffer`. +void +RemapTensorVTKToITK(void * buffer, std::size_t numPixels, std::size_t componentSize) +{ + std::vector tmp(6 * componentSize); + auto * bufBytes = static_cast(buffer); + for (std::size_t p = 0; p < numPixels; ++p) + { + unsigned char * px = bufBytes + p * 6 * componentSize; + std::memcpy(tmp.data(), px, 6 * componentSize); + std::memcpy(px + 0 * componentSize, tmp.data() + 0 * componentSize, componentSize); // e00 = XX + std::memcpy(px + 1 * componentSize, tmp.data() + 3 * componentSize, componentSize); // e01 = XY + std::memcpy(px + 2 * componentSize, tmp.data() + 5 * componentSize, componentSize); // e02 = XZ + std::memcpy(px + 3 * componentSize, tmp.data() + 1 * componentSize, componentSize); // e11 = YY + std::memcpy(px + 4 * componentSize, tmp.data() + 4 * componentSize, componentSize); // e12 = YZ + std::memcpy(px + 5 * componentSize, tmp.data() + 2 * componentSize, componentSize); // e22 = ZZ + } +} + +// Scope guard that applies RemapTensorVTKToITK when destroyed, so every +// early `return` out of Read() gets the remap without having to edit each +// exit point individually. The guard is armed only after the buffer has +// been populated — call commit() at the end of each successful encoding +// path. If Read() throws before commit() runs (e.g. truncated payload, +// decompression mismatch), the destructor leaves the caller's buffer +// untouched rather than scrambling whatever partial bytes were written. +// Does nothing if the image is not a symmetric tensor. +struct TensorRemapGuard +{ + void * buffer; + std::size_t numPixels; + std::size_t componentSize; + bool active; + bool committed{ false }; + void + commit() + { + committed = true; + } + ~TensorRemapGuard() + { + if (active && committed) + { + RemapTensorVTKToITK(buffer, numPixels, componentSize); + } + } +}; +} // namespace + +void +VTIImageIO::Read(void * buffer) +{ + const SizeType totalComponents = this->GetImageSizeInComponents(); + const SizeType totalBytes = this->GetImageSizeInBytes(); + const SizeType componentSize = this->GetComponentSize(); + + TensorRemapGuard tensorGuard{ + buffer, + static_cast(totalComponents / 6), + static_cast(componentSize), + this->GetPixelType() == IOPixelEnum::SYMMETRICSECONDRANKTENSOR, + }; + + if (m_DataEncoding == DataEncoding::ASCII) + { + if (m_AsciiDataContent.empty()) + { + itkExceptionMacro("ASCII DataArray content is empty in file: " << m_FileName); + } + std::istringstream is(m_AsciiDataContent); + this->ReadBufferAsASCII(is, buffer, this->GetComponentType(), totalComponents); + tensorGuard.commit(); + return; + } + + if (m_DataEncoding == DataEncoding::Base64 || m_DataEncoding == DataEncoding::ZLibBase64) + { + if (m_Base64DataContent.empty()) + { + itkExceptionMacro("Base64 DataArray content is empty in file: " << m_FileName); + } + std::vector decoded; + DecodeBase64(m_Base64DataContent, decoded); + + if (m_DataEncoding == DataEncoding::ZLibBase64) + { + // Compressed: the decoded bytes ARE the compression header + compressed blocks. + std::vector uncompressed; + DecompressZLib(decoded.data(), decoded.size(), m_HeaderTypeUInt64, uncompressed); + if (uncompressed.size() < static_cast(totalBytes)) + { + itkExceptionMacro("Decompressed data size (" << uncompressed.size() << ") is less than expected (" << totalBytes + << ") in file: " << m_FileName); + } + std::memcpy(buffer, uncompressed.data(), static_cast(totalBytes)); + } + else + { + // Uncompressed base64: VTK binary DataArrays are prefixed with a block header + // containing the number of bytes of data. Header is UInt32 or UInt64. + const std::size_t headerBytes = m_HeaderTypeUInt64 ? sizeof(uint64_t) : sizeof(uint32_t); + if (decoded.size() <= headerBytes) + { + itkExceptionMacro("Decoded base64 data is too short in file: " << m_FileName); + } + const unsigned char * dataPtr = decoded.data() + headerBytes; + const std::size_t dataSize = decoded.size() - headerBytes; + if (dataSize < static_cast(totalBytes)) + { + itkExceptionMacro("Decoded data size (" << dataSize << ") is less than expected (" << totalBytes + << ") in file: " << m_FileName); + } + std::memcpy(buffer, dataPtr, static_cast(totalBytes)); + } + SwapBufferIfNeeded(buffer, componentSize, totalComponents, m_ByteOrder); + tensorGuard.commit(); + return; + } + + // Base64Appended or ZLibBase64Appended: appended data is base64-encoded + if (m_DataEncoding == DataEncoding::Base64Appended || m_DataEncoding == DataEncoding::ZLibBase64Appended) + { + if (m_AppendedBase64Content.empty()) + { + itkExceptionMacro("Base64 appended content is empty in file: " << m_FileName); + } + + // Decode the base64 content + std::vector decoded; + DecodeBase64(m_AppendedBase64Content, decoded); + + // The decoded buffer contains all appended data arrays; we need to extract + // the portion at offset m_DataArrayOffset + const std::size_t headerBytes = m_HeaderTypeUInt64 ? sizeof(uint64_t) : sizeof(uint32_t); + const std::size_t arrayStart = static_cast(m_DataArrayOffset); + + if (arrayStart + headerBytes > decoded.size()) + { + itkExceptionMacro("Appended data offset extends beyond decoded buffer in file: " << m_FileName); + } + + if (m_DataEncoding == DataEncoding::ZLibBase64Appended) + { + // Compressed: read nblocks to determine full header size + uint64_t nblocks; + if (m_HeaderTypeUInt64) + { + std::memcpy(&nblocks, decoded.data() + arrayStart, sizeof(uint64_t)); + } + else + { + uint32_t nb32; + std::memcpy(&nb32, decoded.data() + arrayStart, sizeof(uint32_t)); + nblocks = nb32; + } + + // Calculate total header size and extract compressed block + const std::size_t compHeaderSize = static_cast((3 + nblocks) * headerBytes); + if (arrayStart + compHeaderSize > decoded.size()) + { + itkExceptionMacro("Compressed header extends beyond decoded buffer in file: " << m_FileName); + } + + // Sum the compressed block sizes + uint64_t totalCompressed = 0; + for (uint64_t b = 0; b < nblocks; ++b) + { + if (m_HeaderTypeUInt64) + { + uint64_t cs; + std::memcpy(&cs, decoded.data() + arrayStart + (3 + b) * sizeof(uint64_t), sizeof(uint64_t)); + totalCompressed += cs; + } + else + { + uint32_t cs; + std::memcpy(&cs, decoded.data() + arrayStart + (3 + b) * sizeof(uint32_t), sizeof(uint32_t)); + totalCompressed += cs; + } + } + + const std::size_t compDataSize = static_cast(compHeaderSize + totalCompressed); + if (arrayStart + compDataSize > decoded.size()) + { + itkExceptionMacro("Compressed data extends beyond decoded buffer in file: " << m_FileName); + } + + // Decompress + std::vector uncompressed; + DecompressZLib(decoded.data() + arrayStart, compDataSize, m_HeaderTypeUInt64, uncompressed); + if (uncompressed.size() < static_cast(totalBytes)) + { + itkExceptionMacro("Decompressed data size (" << uncompressed.size() << ") is less than expected (" << totalBytes + << ") in file: " << m_FileName); + } + std::memcpy(buffer, uncompressed.data(), static_cast(totalBytes)); + } + else + { + // Uncompressed base64 appended: skip block-size header and read data + if (arrayStart + headerBytes + static_cast(totalBytes) > decoded.size()) + { + itkExceptionMacro("Appended data extends beyond decoded buffer in file: " << m_FileName); + } + const unsigned char * dataPtr = decoded.data() + arrayStart + headerBytes; + std::memcpy(buffer, dataPtr, static_cast(totalBytes)); + } + + SwapBufferIfNeeded(buffer, componentSize, totalComponents, m_ByteOrder); + tensorGuard.commit(); + return; + } + + // RawAppended path (compressed or uncompressed): seek into the file. + std::ifstream file(m_FileName.c_str(), std::ios::in | std::ios::binary); + if (!file.is_open()) + { + itkExceptionMacro("Cannot open file for reading: " << m_FileName); + } + + const std::size_t headerBytes = m_HeaderTypeUInt64 ? sizeof(uint64_t) : sizeof(uint32_t); + const std::streampos readPos = m_AppendedDataOffset + static_cast(m_DataArrayOffset); + + file.seekg(readPos, std::ios::beg); + if (file.fail()) + { + itkExceptionMacro("Failed to seek to data position in file: " << m_FileName); + } + + if (m_DataEncoding == DataEncoding::ZLibAppended) + { + // Read the full compressed block sequence into memory. + // We need to read the nblocks field first to know how big the header is, + // then read all the compressed blocks. + std::vector firstItem(headerBytes); + file.read(reinterpret_cast(firstItem.data()), static_cast(headerBytes)); + if (file.fail()) + { + itkExceptionMacro("Failed to read zlib compression header from file: " << m_FileName); + } + uint64_t nblocks; + if (m_HeaderTypeUInt64) + { + std::memcpy(&nblocks, firstItem.data(), sizeof(uint64_t)); + } + else + { + uint32_t nb32; + std::memcpy(&nb32, firstItem.data(), sizeof(uint32_t)); + nblocks = nb32; + } + + // Read the rest of the header: uncompressed_blocksize, last_partial_blocksize, + // plus nblocks compressed sizes. + const std::size_t remainingHeaderBytes = static_cast((2 + nblocks) * headerBytes); + std::vector headerBuf(headerBytes + remainingHeaderBytes); + std::memcpy(headerBuf.data(), firstItem.data(), headerBytes); + file.read(reinterpret_cast(headerBuf.data() + headerBytes), + static_cast(remainingHeaderBytes)); + if (file.fail()) + { + itkExceptionMacro("Failed to read zlib compression block sizes from file: " << m_FileName); + } + + // Sum the compressed block sizes to know how many bytes of payload to read. + uint64_t totalCompressed = 0; + for (uint64_t b = 0; b < nblocks; ++b) + { + if (m_HeaderTypeUInt64) + { + uint64_t cs; + std::memcpy(&cs, headerBuf.data() + (3 + b) * sizeof(uint64_t), sizeof(uint64_t)); + totalCompressed += cs; + } + else + { + uint32_t cs; + std::memcpy(&cs, headerBuf.data() + (3 + b) * sizeof(uint32_t), sizeof(uint32_t)); + totalCompressed += cs; + } + } + + // Read compressed payload. + std::vector compressedPayload(static_cast(totalCompressed)); + file.read(reinterpret_cast(compressedPayload.data()), static_cast(totalCompressed)); + if (file.fail()) + { + itkExceptionMacro("Failed to read zlib compressed data from file: " << m_FileName); + } + + // Build the full buffer that DecompressZLib expects: header + payload. + std::vector fullBuf(headerBuf.size() + compressedPayload.size()); + std::memcpy(fullBuf.data(), headerBuf.data(), headerBuf.size()); + std::memcpy(fullBuf.data() + headerBuf.size(), compressedPayload.data(), compressedPayload.size()); + + std::vector uncompressed; + DecompressZLib(fullBuf.data(), fullBuf.size(), m_HeaderTypeUInt64, uncompressed); + if (uncompressed.size() < static_cast(totalBytes)) + { + itkExceptionMacro("Decompressed data size (" << uncompressed.size() << ") is less than expected (" << totalBytes + << ") in file: " << m_FileName); + } + std::memcpy(buffer, uncompressed.data(), static_cast(totalBytes)); + SwapBufferIfNeeded(buffer, componentSize, totalComponents, m_ByteOrder); + tensorGuard.commit(); + return; + } + + // Plain RawAppended: skip the block-size header and read directly. + file.seekg(static_cast(headerBytes), std::ios::cur); + if (file.fail()) + { + itkExceptionMacro("Failed to seek past block header in file: " << m_FileName); + } + + file.read(static_cast(buffer), static_cast(totalBytes)); + if (file.fail()) + { + itkExceptionMacro("Failed to read raw appended data from file: " << m_FileName); + } + + SwapBufferIfNeeded(buffer, componentSize, totalComponents, m_ByteOrder); + tensorGuard.commit(); +} + +// --------------------------------------------------------------------------- +// Write +// --------------------------------------------------------------------------- +void +VTIImageIO::Write(const void * buffer) +{ + const unsigned int numDims = this->GetNumberOfDimensions(); + if (numDims < 1 || numDims > 3) + { + itkExceptionMacro("VTIImageIO can only write 1, 2 or 3-dimensional images"); + } + + const auto nx = static_cast(this->GetDimensions(0)); + const auto ny = (numDims > 1) ? static_cast(this->GetDimensions(1)) : 1u; + const auto nz = (numDims > 2) ? static_cast(this->GetDimensions(2)) : 1u; + + const double ox = this->GetOrigin(0); + const double oy = (numDims > 1) ? this->GetOrigin(1) : 0.0; + const double oz = (numDims > 2) ? this->GetOrigin(2) : 0.0; + + const double sx = this->GetSpacing(0); + const double sy = (numDims > 1) ? this->GetSpacing(1) : 1.0; + const double sz = (numDims > 2) ? this->GetSpacing(2) : 1.0; + + const SizeType totalBytes = this->GetImageSizeInBytes(); + const SizeType totalComponents = this->GetImageSizeInComponents(); + + // Determine attribute name and type + const IOPixelEnum pixelType = this->GetPixelType(); + const unsigned int numComp = this->GetNumberOfComponents(); + const IOComponentEnum compType = this->GetComponentType(); + const std::string vtkType = ITKComponentToVTKTypeString(compType); + + std::string attributeElement; + std::string dataArrayName; + std::string pointDataAttr; + if (pixelType == IOPixelEnum::SYMMETRICSECONDRANKTENSOR) + { + dataArrayName = "tensors"; + pointDataAttr = "Tensors=\"tensors\""; + // VTK canonical symmetric-tensor layout: 6 components per pixel in + // [XX, YY, ZZ, XY, YZ, XZ] order. ASCII writer remaps from ITK's + // [e00, e01, e02, e11, e12, e22] layout below. Binary writing is + // deferred to F-007 in the follow-up PR. + attributeElement = "NumberOfComponents=\"6\""; + } + else if (pixelType == IOPixelEnum::VECTOR) + { + dataArrayName = "vectors"; + pointDataAttr = "Vectors=\"vectors\""; + std::ostringstream tmp; + tmp << "NumberOfComponents=\"" << numComp << "\""; + attributeElement = tmp.str(); + } + else + { + dataArrayName = "scalars"; + pointDataAttr = "Scalars=\"scalars\""; + if (numComp > 1) + { + std::ostringstream tmp; + tmp << "NumberOfComponents=\"" << numComp << "\""; + attributeElement = tmp.str(); + } + } + + // F-007: Binary symmetric-tensor writing is deferred to the follow-up + // PR. The ASCII writer below already emits the VTK-canonical + // 6-component [XX, YY, ZZ, XY, YZ, XZ] layout, so when F-007 lands the + // binary path only needs to mirror that remap (no layout re-decision). + if (pixelType == IOPixelEnum::SYMMETRICSECONDRANKTENSOR && m_FileType != IOFileEnum::ASCII) + { + itkExceptionMacro("F-007 Binary symmetric-tensor writer not yet implemented. " + "The ASCII path emits VTK-canonical 6-component layout " + "[XX, YY, ZZ, XY, YZ, XZ]; call SetFileTypeToASCII() for " + "tensor output until the follow-up PR adds binary support."); + } + + // Prepare a byte-swapped copy if the system is big-endian (we always + // write little-endian binary). + const char * dataToWrite = static_cast(buffer); + std::vector swapBuf; + + const bool needsSwap = + ByteSwapper::SystemIsBigEndian() && this->GetComponentSize() > 1 && m_FileType != IOFileEnum::ASCII; + if (needsSwap) + { + swapBuf.resize(static_cast(totalBytes)); + std::memcpy(swapBuf.data(), buffer, static_cast(totalBytes)); + switch (this->GetComponentSize()) + { + case 2: + ByteSwapper::SwapRangeFromSystemToBigEndian(reinterpret_cast(swapBuf.data()), + totalComponents); + break; + case 4: + ByteSwapper::SwapRangeFromSystemToBigEndian(reinterpret_cast(swapBuf.data()), + totalComponents); + break; + case 8: + ByteSwapper::SwapRangeFromSystemToBigEndian(reinterpret_cast(swapBuf.data()), + totalComponents); + break; + default: + break; + } + dataToWrite = reinterpret_cast(swapBuf.data()); + } + + std::ofstream file(m_FileName.c_str(), std::ios::out | std::ios::binary | std::ios::trunc); + if (!file.is_open()) + { + itkExceptionMacro("Cannot open file for writing: " << m_FileName); + } + + file.precision(16); + + const std::string byteOrderStr = ByteSwapper::SystemIsBigEndian() ? "BigEndian" : "LittleEndian"; + + // Compose a 3x3 row-major Direction matrix from ITK's per-axis direction + // vectors, padding with identity rows/columns when image dimensionality + // is less than 3 (VTK always expects a full 3x3 Direction). + const unsigned int nd = this->GetNumberOfDimensions(); + double dm[3][3] = { { 1.0, 0.0, 0.0 }, { 0.0, 1.0, 0.0 }, { 0.0, 0.0, 1.0 } }; + for (unsigned int axis = 0; axis < nd && axis < 3; ++axis) + { + const std::vector & v = this->GetDirection(axis); + for (unsigned int r = 0; r < nd && r < 3; ++r) + { + dm[r][axis] = v[r]; + } + } + + // Determine write mode. When UseCompression is set and the output is not + // ASCII, write appended raw + vtkZLibDataCompressor (smallest on-disk size) + const bool useCompressedAppended = (m_UseCompression && m_FileType != IOFileEnum::ASCII); + + // XML header -- emit the modern VTK 9 / ParaView 5.7+ attribute set so + // round-trip through ParaView does not silently demote the version or + // truncate block headers. header_type="UInt64" is paired with the + // uint64_t block-size prefix used in the binary writer below. + file << "\n"; + file << "\n"; + file << " \n"; + file << " \n"; + file << " \n"; + + if (m_FileType == IOFileEnum::ASCII) + { + file << " \n"; + + if (pixelType == IOPixelEnum::SYMMETRICSECONDRANKTENSOR) + { + // Emit VTK-canonical 6-component symmetric tensor: [XX, YY, ZZ, XY, YZ, XZ]. + // ITK's SymmetricSecondRankTensor is stored as + // [e00, e01, e02, e11, e12, e22] (upper-triangular row-major); remap: + // VTK[0] XX = ITK[0] e00 VTK[3] XY = ITK[1] e01 + // VTK[1] YY = ITK[3] e11 VTK[4] YZ = ITK[4] e12 + // VTK[2] ZZ = ITK[5] e22 VTK[5] XZ = ITK[2] e02 + const SizeType numPixels = totalComponents / 6; + for (SizeType p = 0; p < numPixels; ++p) + { + if (compType == IOComponentEnum::FLOAT) + { + const float * fPtr = static_cast(buffer) + p * 6; + file << fPtr[0] << ' ' << fPtr[3] << ' ' << fPtr[5] << ' ' << fPtr[1] << ' ' << fPtr[4] << ' ' << fPtr[2] + << '\n'; + } + else + { + const double * dPtr = static_cast(buffer) + p * 6; + file << dPtr[0] << ' ' << dPtr[3] << ' ' << dPtr[5] << ' ' << dPtr[1] << ' ' << dPtr[4] << ' ' << dPtr[2] + << '\n'; + } + } + } + else + { + this->WriteBufferAsASCII(file, buffer, compType, totalComponents); + } + + file << "\n \n"; + file << " \n"; + file << " \n"; + file << " \n"; + file << "\n"; + } + else if (useCompressedAppended) + { + // Appended raw + vtkZLibDataCompressor: single DataArray element + // referencing offset 0 in the block. + file << " \n"; + file << " \n"; + file << " \n"; + file << " \n"; + file << " \n_"; + + const std::vector compressed = + CompressZLibVTK(reinterpret_cast(dataToWrite), static_cast(totalBytes)); + file.write(reinterpret_cast(compressed.data()), static_cast(compressed.size())); + + file << "\n \n"; + file << "\n"; + } + else // Binary (inline base64) + { + file << " \n"; + + // Prepend a UInt64 block-size header (number of raw data bytes). The + // attribute declares header_type="UInt64" above, matching + // ParaView 5.7+ defaults and allowing images > 4 GiB without silent + // truncation. + const auto blockSize = static_cast(totalBytes); + std::vector toEncode(sizeof(blockSize) + static_cast(totalBytes)); + std::memcpy(toEncode.data(), &blockSize, sizeof(blockSize)); + std::memcpy(toEncode.data() + sizeof(blockSize), dataToWrite, static_cast(totalBytes)); + + file << " " << EncodeBase64(toEncode.data(), static_cast(toEncode.size())) << "\n"; + file << " \n"; + file << " \n"; + file << " \n"; + file << " \n"; + file << "\n"; + } + + if (file.fail()) + { + itkExceptionMacro("Failed to write VTI file: " << m_FileName); + } +} + +} // end namespace itk diff --git a/Modules/IO/VTK/src/itkVTIImageIOFactory.cxx b/Modules/IO/VTK/src/itkVTIImageIOFactory.cxx new file mode 100644 index 00000000000..0b074d9bb32 --- /dev/null +++ b/Modules/IO/VTK/src/itkVTIImageIOFactory.cxx @@ -0,0 +1,52 @@ +/*========================================================================= + * + * Copyright NumFOCUS + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0.txt + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + *=========================================================================*/ +#include "itkVTIImageIOFactory.h" +#include "itkVTIImageIO.h" +#include "itkVersion.h" + +namespace itk +{ +VTIImageIOFactory::VTIImageIOFactory() +{ + this->RegisterOverride( + "itkImageIOBase", "itkVTIImageIO", "VTI Image IO", true, CreateObjectFunction::New()); +} + +VTIImageIOFactory::~VTIImageIOFactory() = default; + +const char * +VTIImageIOFactory::GetITKSourceVersion() const +{ + return ITK_SOURCE_VERSION; +} + +const char * +VTIImageIOFactory::GetDescription() const +{ + return "VTI ImageIO Factory, allows the loading of VTK XML ImageData (.vti) images into ITK"; +} + +// Undocumented API used to register during static initialization. +// DO NOT CALL DIRECTLY. +void ITKIOVTK_EXPORT +VTIImageIOFactoryRegister__Private() +{ + ObjectFactoryBase::RegisterInternalFactoryOnce(); +} + +} // end namespace itk diff --git a/Modules/IO/VTK/test/CMakeLists.txt b/Modules/IO/VTK/test/CMakeLists.txt index c0f417cd0c0..52bbcded4df 100644 --- a/Modules/IO/VTK/test/CMakeLists.txt +++ b/Modules/IO/VTK/test/CMakeLists.txt @@ -8,10 +8,192 @@ set( itkVTKImageIOTest.cxx itkVTKImageIOTest2.cxx itkVTKImageIOTest3.cxx + itkVTIImageIOTest.cxx + itkVTIImageIOReadWriteTest.cxx ) createtestdriver(ITKIOVTK "${ITKIOVTK-Test_LIBRARIES}" "${ITKIOVTKTests}") +set( + ITKIOVTKGTests + itkVTIImageIODirectionGTest.cxx + itkVTIImageIOFutureFeaturesGTest.cxx + itkVTIImageIOGeneratedFixturesGTest.cxx + itkVTIImageIOSwapBufferGTest.cxx +) +creategoogletestdriver(ITKIOVTK "${ITKIOVTK-Test_LIBRARIES}" "${ITKIOVTKGTests}") + +# The GTest driver reads its fixtures from a single VTI_TEST_INPUT_DIR +# directory. Now that the fixtures are .cid-backed (fetched via +# ExternalData rather than committed in-tree), resolve each fixture's +# build-tree path through ExternalData_Expand_Arguments and stage all +# of them into a single directory the driver can point at. +set( + _vti_gtest_fixtures + VTI_oblique_direction.vti + VTI_oblique_direction.mhd + VTI_oblique_direction.raw + VTI_scalar_u8_appended_raw.vti + VTI_scalar_u8_appended_raw.mhd + VTI_scalar_u8_appended_raw.raw + VTI_scalar_f32_zlib_appended.vti + VTI_scalar_f32_zlib_appended.mhd + VTI_scalar_f32_zlib_appended.raw + VTI_rgba_u8_appended_raw.vti + VTI_rgba_u8_appended_raw.mhd + VTI_rgba_u8_appended_raw.raw + VTI_vector3_f32_zlib_appended.vti + VTI_vector3_f32_zlib_appended.mhd + VTI_vector3_f32_zlib_appended.raw + VTI_tensor_f32_ascii.vti + VTI_tensor_f32_ascii.mhd + VTI_tensor_f32_ascii.raw + VTI_guard_lz4.vti + VTI_guard_lzma.vti + VTI_guard_multipiece.vti + VTI_guard_unknown_compressor.vti +) +set(_vti_gtest_input_dir "${CMAKE_CURRENT_BINARY_DIR}/InputData") +file(MAKE_DIRECTORY "${_vti_gtest_input_dir}") +set(_vti_gtest_staged "") +foreach(_f IN LISTS _vti_gtest_fixtures) + ExternalData_Expand_Arguments( + ITKData + _vti_resolved + DATA{Input/${_f}} + ) + set(_dst "${_vti_gtest_input_dir}/${_f}") + add_custom_command( + OUTPUT + "${_dst}" + COMMAND + ${CMAKE_COMMAND} -E copy_if_different "${_vti_resolved}" "${_dst}" + DEPENDS + "${_vti_resolved}" + COMMENT "Staging VTI GTest fixture ${_f}" + VERBATIM + ) + list(APPEND _vti_gtest_staged "${_dst}") +endforeach() +add_custom_target(ITKIOVTKGTestFixtures DEPENDS ${_vti_gtest_staged}) +add_dependencies(ITKIOVTKGTestDriver ITKIOVTKGTestFixtures) +target_compile_definitions( + ITKIOVTKGTestDriver + PRIVATE + VTI_TEST_INPUT_DIR="${_vti_gtest_input_dir}" +) + +itk_add_test( + NAME itkVTIImageIOTest + COMMAND + ITKIOVTKTestDriver + itkVTIImageIOTest + ${ITK_TEST_OUTPUT_DIR} +) + +itk_add_test( + NAME itkVTIImageIOReadWriteTestCTHead1 + COMMAND + ITKIOVTKTestDriver + --compare + DATA{${ITK_DATA_ROOT}/Input/cthead1.png} + ${ITK_TEST_OUTPUT_DIR}/itkVTIImageIOReadWriteTest_CTHead1.vti + itkVTIImageIOReadWriteTest + DATA{${ITK_DATA_ROOT}/Input/cthead1.png} + ${ITK_TEST_OUTPUT_DIR}/itkVTIImageIOReadWriteTest_CTHead1.vti +) + +itk_add_test( + NAME itkVTIImageIOReadWriteTestVisibleWomanEyeSlice + COMMAND + ITKIOVTKTestDriver + --compare + DATA{${ITK_DATA_ROOT}/Input/VisibleWomanEyeSlice.png} + ${ITK_TEST_OUTPUT_DIR}/itkVTIImageIOReadWriteTest_VisibleWomanEyeSlice.vti + itkVTIImageIOReadWriteTest + DATA{${ITK_DATA_ROOT}/Input/VisibleWomanEyeSlice.png} + ${ITK_TEST_OUTPUT_DIR}/itkVTIImageIOReadWriteTest_VisibleWomanEyeSlice.vti +) + +itk_add_test( + NAME itkVTIImageIOReadWriteTestHeadMRVolume + COMMAND + ITKIOVTKTestDriver + --compare + DATA{${ITK_DATA_ROOT}/Input/HeadMRVolume.mha} + ${ITK_TEST_OUTPUT_DIR}/itkVTIImageIOReadWriteTest_HeadMRVolume.vti + itkVTIImageIOReadWriteTest + DATA{${ITK_DATA_ROOT}/Input/HeadMRVolume.mha} + ${ITK_TEST_OUTPUT_DIR}/itkVTIImageIOReadWriteTest_HeadMRVolume.vti +) + +itk_add_test( + NAME itkVTIImageIOReadWriteTestVHFColor + COMMAND + ITKIOVTKTestDriver + --compare + DATA{${ITK_DATA_ROOT}/Input/VHFColor.mhd,VHFColor.raw} + ${ITK_TEST_OUTPUT_DIR}/itkVTIImageIOReadWriteTest_VHFColor.vti + itkVTIImageIOReadWriteTest + DATA{${ITK_DATA_ROOT}/Input/VHFColor.mhd,VHFColor.raw} + ${ITK_TEST_OUTPUT_DIR}/itkVTIImageIOReadWriteTest_VHFColor.vti +) + +itk_add_test( + NAME itkVTIImageIOReadWriteTest_reco2D_16line + COMMAND + ITKIOVTKTestDriver + --compare + DATA{Input/reco2D_16line.vti} + ${ITK_TEST_OUTPUT_DIR}/itkVTIImageIOReadWriteTest_reco2D_16line.vti + itkVTIImageIOReadWriteTest + DATA{Input/reco2D_16line.mha} + ${ITK_TEST_OUTPUT_DIR}/itkVTIImageIOReadWriteTest_reco2D_16line.vti +) + +itk_add_test( + NAME itkVTIImageIOReadWriteTest_reco2D_16line_compressed + COMMAND + ITKIOVTKTestDriver + --compare + DATA{Input/reco2D_16line.vti} + ${ITK_TEST_OUTPUT_DIR}/itkVTIImageIOReadWriteTest_reco2D_16line_compressed.vti + itkVTIImageIOReadWriteTest + DATA{Input/reco2D_16line.mha} + ${ITK_TEST_OUTPUT_DIR}/itkVTIImageIOReadWriteTest_reco2D_16line_compressed.vti + 1 +) + +# itkVTIImageIOReadWriteTestVHFColorZLib is temporarily disabled until +# Input/VHFColorZLib.vti can be published through ExternalData. The +# ParaView-produced fixture exceeds the 100 KB direct-commit cap and +# content-link-upload.itk.org has been broken since the storacha->Pinata +# migration began; tracked at +# https://github.com/InsightSoftwareConsortium/ITK/issues/4340 and +# https://github.com/InsightSoftwareConsortium/cmake-w3-externaldata-upload/issues/3 . +# +# Equivalent code-path coverage (multi-component Float32 ZLib-compressed +# appended-raw with UInt64 header, via a non-ITK independent writer) is +# provided by itkVTIImageIOGeneratedFixturesTest's +# `vector3_f32_zlib_appended` case, which reads a Python-stdlib-generated +# 4x4x2 Vector fixture and pixel-compares to its MetaIO oracle. +# +# TODO: restore this test once the ExternalData upload tool is operational; +# un-comment the block below and commit the .cid pointer to +# Modules/IO/VTK/test/Input/VHFColorZLib.vti.cid . +# +# itk_add_test( +# NAME itkVTIImageIOReadWriteTestVHFColorZLib +# COMMAND +# ITKIOVTKTestDriver +# --compare +# DATA{${ITK_DATA_ROOT}/Input/VHFColor.mhd,VHFColor.raw} +# ${ITK_TEST_OUTPUT_DIR}/itkVTIImageIOReadWriteTest_VHFColorZLib.vti +# itkVTIImageIOReadWriteTest +# DATA{Input/VHFColorZLib.vti} +# ${ITK_TEST_OUTPUT_DIR}/itkVTIImageIOReadWriteTest_VHFColorZLib.vti +# ) + itk_add_test( NAME itkVTKImageIO2Test COMMAND diff --git a/Modules/IO/VTK/test/Input/VTI_guard_lz4.vti.cid b/Modules/IO/VTK/test/Input/VTI_guard_lz4.vti.cid new file mode 100644 index 00000000000..71723d73bed --- /dev/null +++ b/Modules/IO/VTK/test/Input/VTI_guard_lz4.vti.cid @@ -0,0 +1 @@ +bafkreiexuuji4j3awqujmxtsjr2bvaekroyyyf2ongnuxpqpaosq2airee diff --git a/Modules/IO/VTK/test/Input/VTI_guard_lzma.vti.cid b/Modules/IO/VTK/test/Input/VTI_guard_lzma.vti.cid new file mode 100644 index 00000000000..eaae40d11f9 --- /dev/null +++ b/Modules/IO/VTK/test/Input/VTI_guard_lzma.vti.cid @@ -0,0 +1 @@ +bafkreib7b3zpydx5sjhb5dqv5pzr63vedd5jx7bamxnseswr3xf3yp2gwm diff --git a/Modules/IO/VTK/test/Input/VTI_guard_multipiece.vti.cid b/Modules/IO/VTK/test/Input/VTI_guard_multipiece.vti.cid new file mode 100644 index 00000000000..dc919cad8bb --- /dev/null +++ b/Modules/IO/VTK/test/Input/VTI_guard_multipiece.vti.cid @@ -0,0 +1 @@ +bafkreic7d2fx5tcijxatamrflbes3lrzgnutlurj3cjvnxol6o6e254una diff --git a/Modules/IO/VTK/test/Input/VTI_guard_unknown_compressor.vti.cid b/Modules/IO/VTK/test/Input/VTI_guard_unknown_compressor.vti.cid new file mode 100644 index 00000000000..647d289ada9 --- /dev/null +++ b/Modules/IO/VTK/test/Input/VTI_guard_unknown_compressor.vti.cid @@ -0,0 +1 @@ +bafkreieokgnxcckktvzpuu4hr7jlfp5xlp4hltvgwn7h2sl6us44skb2bi diff --git a/Modules/IO/VTK/test/Input/VTI_oblique_direction.mhd.cid b/Modules/IO/VTK/test/Input/VTI_oblique_direction.mhd.cid new file mode 100644 index 00000000000..0d300e3680b --- /dev/null +++ b/Modules/IO/VTK/test/Input/VTI_oblique_direction.mhd.cid @@ -0,0 +1 @@ +bafkreig5dyrfbuv6bm2qa2dw6zm5r7qgonhvrauplgye4xpfsnitv66pfa diff --git a/Modules/IO/VTK/test/Input/VTI_oblique_direction.raw.cid b/Modules/IO/VTK/test/Input/VTI_oblique_direction.raw.cid new file mode 100644 index 00000000000..0e04788deba --- /dev/null +++ b/Modules/IO/VTK/test/Input/VTI_oblique_direction.raw.cid @@ -0,0 +1 @@ +bafkreiddbxgsszwegntjcesujc53ew2p6qjkjhdtfwzmrk6bxbmbxvyq3u diff --git a/Modules/IO/VTK/test/Input/VTI_oblique_direction.vti.cid b/Modules/IO/VTK/test/Input/VTI_oblique_direction.vti.cid new file mode 100644 index 00000000000..f739357b41c --- /dev/null +++ b/Modules/IO/VTK/test/Input/VTI_oblique_direction.vti.cid @@ -0,0 +1 @@ +bafkreiczucwyaa6ezjkom3a5hq5jwthqr3kvypxxukg55v6es7euykjb4y diff --git a/Modules/IO/VTK/test/Input/VTI_rgba_u8_appended_raw.mhd.cid b/Modules/IO/VTK/test/Input/VTI_rgba_u8_appended_raw.mhd.cid new file mode 100644 index 00000000000..8fd3d5d146e --- /dev/null +++ b/Modules/IO/VTK/test/Input/VTI_rgba_u8_appended_raw.mhd.cid @@ -0,0 +1 @@ +bafkreidrwlapbhyoet3sglg4cdsvrchyvxpmefyhv57byem5maa2qbncou diff --git a/Modules/IO/VTK/test/Input/VTI_rgba_u8_appended_raw.raw.cid b/Modules/IO/VTK/test/Input/VTI_rgba_u8_appended_raw.raw.cid new file mode 100644 index 00000000000..5b33f72c57b --- /dev/null +++ b/Modules/IO/VTK/test/Input/VTI_rgba_u8_appended_raw.raw.cid @@ -0,0 +1 @@ +bafkreihe5nv6gokhst7lwfbvsscfmco4nrahjhyjikyq45k53hcrchwc5m diff --git a/Modules/IO/VTK/test/Input/VTI_rgba_u8_appended_raw.vti.cid b/Modules/IO/VTK/test/Input/VTI_rgba_u8_appended_raw.vti.cid new file mode 100644 index 00000000000..31afe02eed6 --- /dev/null +++ b/Modules/IO/VTK/test/Input/VTI_rgba_u8_appended_raw.vti.cid @@ -0,0 +1 @@ +bafkreie3rhu6gfi2heepoa4lmebuscosfmzs4bgxwjv422mlv4w2elb5gi diff --git a/Modules/IO/VTK/test/Input/VTI_scalar_f32_zlib_appended.mhd.cid b/Modules/IO/VTK/test/Input/VTI_scalar_f32_zlib_appended.mhd.cid new file mode 100644 index 00000000000..c8040f02059 --- /dev/null +++ b/Modules/IO/VTK/test/Input/VTI_scalar_f32_zlib_appended.mhd.cid @@ -0,0 +1 @@ +bafkreiepsw4os4ll5dwg6vudhuxm64rjxc6qnlvkbztdjynsg3kagr23hi diff --git a/Modules/IO/VTK/test/Input/VTI_scalar_f32_zlib_appended.raw.cid b/Modules/IO/VTK/test/Input/VTI_scalar_f32_zlib_appended.raw.cid new file mode 100644 index 00000000000..0b2e2eab0d0 --- /dev/null +++ b/Modules/IO/VTK/test/Input/VTI_scalar_f32_zlib_appended.raw.cid @@ -0,0 +1 @@ +bafkreignnn6gujk4owzytfi654vyhawf36et2l5wpftffj6t7ueim6zbxu diff --git a/Modules/IO/VTK/test/Input/VTI_scalar_f32_zlib_appended.vti.cid b/Modules/IO/VTK/test/Input/VTI_scalar_f32_zlib_appended.vti.cid new file mode 100644 index 00000000000..7a11e803228 --- /dev/null +++ b/Modules/IO/VTK/test/Input/VTI_scalar_f32_zlib_appended.vti.cid @@ -0,0 +1 @@ +bafkreigsgkjhw72dqaas5owkkq4zo2des5fw576lmpb2uch3j4hg2didhu diff --git a/Modules/IO/VTK/test/Input/VTI_scalar_u8_appended_raw.mhd.cid b/Modules/IO/VTK/test/Input/VTI_scalar_u8_appended_raw.mhd.cid new file mode 100644 index 00000000000..d28df5f62f1 --- /dev/null +++ b/Modules/IO/VTK/test/Input/VTI_scalar_u8_appended_raw.mhd.cid @@ -0,0 +1 @@ +bafkreidlyjda6paupfxsbjlv42si3egsu3nxjgzrxttnwt6wsocu746dk4 diff --git a/Modules/IO/VTK/test/Input/VTI_scalar_u8_appended_raw.raw.cid b/Modules/IO/VTK/test/Input/VTI_scalar_u8_appended_raw.raw.cid new file mode 100644 index 00000000000..0e04788deba --- /dev/null +++ b/Modules/IO/VTK/test/Input/VTI_scalar_u8_appended_raw.raw.cid @@ -0,0 +1 @@ +bafkreiddbxgsszwegntjcesujc53ew2p6qjkjhdtfwzmrk6bxbmbxvyq3u diff --git a/Modules/IO/VTK/test/Input/VTI_scalar_u8_appended_raw.vti.cid b/Modules/IO/VTK/test/Input/VTI_scalar_u8_appended_raw.vti.cid new file mode 100644 index 00000000000..4eee4e89ea0 --- /dev/null +++ b/Modules/IO/VTK/test/Input/VTI_scalar_u8_appended_raw.vti.cid @@ -0,0 +1 @@ +bafkreifehufjkhg7aeu77owpk7ylzqldcohkw44hrvbmle3bv5csmogxba diff --git a/Modules/IO/VTK/test/Input/VTI_tensor_f32_ascii.mhd.cid b/Modules/IO/VTK/test/Input/VTI_tensor_f32_ascii.mhd.cid new file mode 100644 index 00000000000..59aa671e94f --- /dev/null +++ b/Modules/IO/VTK/test/Input/VTI_tensor_f32_ascii.mhd.cid @@ -0,0 +1 @@ +bafkreib7w6gmtdv4v7dnq5bw4vuz5e4qdf7cmtlxz5ztr5pl4lteirgkom diff --git a/Modules/IO/VTK/test/Input/VTI_tensor_f32_ascii.raw.cid b/Modules/IO/VTK/test/Input/VTI_tensor_f32_ascii.raw.cid new file mode 100644 index 00000000000..7e57bad5e81 --- /dev/null +++ b/Modules/IO/VTK/test/Input/VTI_tensor_f32_ascii.raw.cid @@ -0,0 +1 @@ +bafkreic4eqroqnfli4vu5vksfjsuvbpy524qs3xeyu6egvup5ocws3mz24 diff --git a/Modules/IO/VTK/test/Input/VTI_tensor_f32_ascii.vti.cid b/Modules/IO/VTK/test/Input/VTI_tensor_f32_ascii.vti.cid new file mode 100644 index 00000000000..4574a4dd406 --- /dev/null +++ b/Modules/IO/VTK/test/Input/VTI_tensor_f32_ascii.vti.cid @@ -0,0 +1 @@ +bafkreig3rlfnlinzd36b5y4tmzpg7bteyf4bhhn3ycxmblvn67mzpvixzq diff --git a/Modules/IO/VTK/test/Input/VTI_vector3_f32_zlib_appended.mhd.cid b/Modules/IO/VTK/test/Input/VTI_vector3_f32_zlib_appended.mhd.cid new file mode 100644 index 00000000000..07c304bd8ad --- /dev/null +++ b/Modules/IO/VTK/test/Input/VTI_vector3_f32_zlib_appended.mhd.cid @@ -0,0 +1 @@ +bafkreiaxtntfusumjnuhvqdi3tnlauvnqtbzoigvszf3zu2m2ra7yjtiru diff --git a/Modules/IO/VTK/test/Input/VTI_vector3_f32_zlib_appended.raw.cid b/Modules/IO/VTK/test/Input/VTI_vector3_f32_zlib_appended.raw.cid new file mode 100644 index 00000000000..1f04d45c498 --- /dev/null +++ b/Modules/IO/VTK/test/Input/VTI_vector3_f32_zlib_appended.raw.cid @@ -0,0 +1 @@ +bafkreicqonrtfxztf43hj5zki5sebf7o3olm3f2u5cbvbr4u72gbfzlc7i diff --git a/Modules/IO/VTK/test/Input/VTI_vector3_f32_zlib_appended.vti.cid b/Modules/IO/VTK/test/Input/VTI_vector3_f32_zlib_appended.vti.cid new file mode 100644 index 00000000000..104ed4b4aa4 --- /dev/null +++ b/Modules/IO/VTK/test/Input/VTI_vector3_f32_zlib_appended.vti.cid @@ -0,0 +1 @@ +bafkreiamcpza6bgsfgjbwne3x2gdfst3uzdvyoph4jzx3fa64mgogdk4tu diff --git a/Modules/IO/VTK/test/Input/reco2D_16line.mha.cid b/Modules/IO/VTK/test/Input/reco2D_16line.mha.cid new file mode 100644 index 00000000000..6c27a6d18c2 --- /dev/null +++ b/Modules/IO/VTK/test/Input/reco2D_16line.mha.cid @@ -0,0 +1 @@ +bafkreieyvb4m2vfdm76avhenhvkww5qvhygm6slvldjf2uviw42bfy7xgq diff --git a/Modules/IO/VTK/test/Input/reco2D_16line.vti.cid b/Modules/IO/VTK/test/Input/reco2D_16line.vti.cid new file mode 100644 index 00000000000..ea3dc7ddc4c --- /dev/null +++ b/Modules/IO/VTK/test/Input/reco2D_16line.vti.cid @@ -0,0 +1 @@ +bafkreic6jtnwdncdth6whkpawixaukcclaoqkey3ly6n5z4cumwlooq6z4 diff --git a/Modules/IO/VTK/test/generate_vti_fixtures.py b/Modules/IO/VTK/test/generate_vti_fixtures.py new file mode 100755 index 00000000000..6e47b1d0f59 --- /dev/null +++ b/Modules/IO/VTK/test/generate_vti_fixtures.py @@ -0,0 +1,459 @@ +#!/usr/bin/env python3 +"""Generate synthetic .vti test fixtures for VTIImageIO regression tests. + +Writes small, deterministic VTK XML ImageData (.vti) files plus MetaIO +(.mhd + .raw) oracles under Modules/IO/VTK/test/Input/. Fixtures are +kept below the 100 KB per-file hook cap so they can live directly in +the git history without relying on ExternalData infrastructure. + +Usage: + python3 Modules/IO/VTK/test/generate_vti_fixtures.py + +Re-running is idempotent: outputs are byte-deterministic for a given +Python version. The generator uses only the Python standard library +(xml, struct, base64, zlib) — no pvpython / vtk / paraview required. + +Two caveats: + 1. Fixtures produced here are NOT from a reference writer; they + only test "our reader agrees with this generator's encoding". + Cross-validation against real ParaView output must happen via + ExternalData once the content-link-upload.itk.org migration + completes (see ITK issue #4340). + 2. Guard fixtures (VTI_guard_*.vti) intentionally carry invalid or + placeholder payloads. They exist only to trigger the + F-NNN guard exceptions and MUST NOT be used in round-trip tests. +""" +from __future__ import annotations + +import base64 +import pathlib +import struct +import zlib + + +# --------------------------------------------------------------------------- +# MetaIO oracle writer +# --------------------------------------------------------------------------- +def write_mhd( + path: pathlib.Path, + dim_size: tuple[int, ...], + spacing: tuple[float, ...], + direction: tuple[float, ...], # row-major 3x3 = 9 floats + element_type: str, # "MET_FLOAT", "MET_UCHAR", etc. + n_components: int, + raw_name: str, + raw_bytes: bytes, +) -> None: + """Write a MetaIO .mhd + companion .raw pair. + + Direction is row-major 3x3 regardless of image dimensionality; + 2D fixtures pad with the identity z-row / z-column. + """ + assert len(dim_size) == 3 + assert len(spacing) == 3 + assert len(direction) == 9 + header = [ + "ObjectType = Image", + "NDims = 3", + "BinaryData = True", + "BinaryDataByteOrderMSB = False", + "CompressedData = False", + f"TransformMatrix = {' '.join(repr(v) for v in direction)}", + "Offset = 0 0 0", + "CenterOfRotation = 0 0 0", + f"ElementSpacing = {' '.join(repr(v) for v in spacing)}", + f"DimSize = {' '.join(str(v) for v in dim_size)}", + ] + if n_components != 1: + header.append(f"ElementNumberOfChannels = {n_components}") + header.append(f"ElementType = {element_type}") + header.append(f"ElementDataFile = {raw_name}") + path.write_text("\n".join(header) + "\n") + (path.parent / raw_name).write_bytes(raw_bytes) + + +# --------------------------------------------------------------------------- +# VTI writers +# --------------------------------------------------------------------------- +def _vti_open( + whole_extent: tuple[int, int, int, int, int, int], + origin: tuple[float, float, float], + spacing: tuple[float, float, float], + direction: tuple[float, ...] | None, + header_type: str = "UInt64", + byte_order: str = "LittleEndian", + compressor: str | None = None, + version: str = "1.0", +) -> str: + attrs = [ + 'type="ImageData"', + f'version="{version}"', + f'byte_order="{byte_order}"', + f'header_type="{header_type}"', + ] + if compressor: + attrs.append(f'compressor="{compressor}"') + img_attrs = [ + f'WholeExtent="{" ".join(str(v) for v in whole_extent)}"', + f'Origin="{" ".join(repr(v) for v in origin)}"', + f'Spacing="{" ".join(repr(v) for v in spacing)}"', + ] + if direction is not None: + img_attrs.append(f'Direction="{" ".join(repr(v) for v in direction)}"') + lines = [ + '', + f'', + f' ', + f' ', + ] + return "\n".join(lines) + "\n" + + +def _vti_close() -> str: + return " \n \n\n" + + +def _header_word(value: int, header_type: str) -> bytes: + return struct.pack(" bytes: + """VTK block layout for uncompressed inline binary: single UInt header = nbytes.""" + return _header_word(len(payload), header_type) + payload + + +def _pack_zlib_single_block(payload: bytes, header_type: str) -> bytes: + """VTK multi-block ZLib header with a single block covering all payload.""" + compressed = zlib.compress(payload, level=6) + return b"".join( + [ + _header_word(1, header_type), # num_blocks + _header_word(len(payload), header_type), # uncompressed_standard_block_size + _header_word(len(payload), header_type), # uncompressed_last_block_size + _header_word(len(compressed), header_type), # compressed_size_of_block_0 + compressed, + ] + ) + + +def write_vti_ascii_scalar( + path: pathlib.Path, + whole_extent, + origin, + spacing, + direction, + vtk_type: str, + name: str, + values, + *, + values_per_line: int = 8, +) -> None: + body = [_vti_open(whole_extent, origin, spacing, direction)] + body.append(f' \n') + body.append( + f' \n' + ) + for i in range(0, len(values), values_per_line): + body.append( + " " + + " ".join(repr(v) for v in values[i : i + values_per_line]) + + "\n" + ) + body.append(" \n") + body.append(" \n \n \n") + body.append(_vti_close()) + path.write_text("".join(body)) + + +def write_vti_appended( + path: pathlib.Path, + whole_extent, + origin, + spacing, + direction, + *, + vtk_type: str, + name: str, + n_components: int, + payload: bytes, + encoding: str, # "raw" or "base64" + header_type: str = "UInt64", + compressor: str | None = None, + active_role: str = "Scalars", +) -> None: + """Write an appended-data .vti with a single DataArray at offset 0.""" + if compressor == "vtkZLibDataCompressor": + block = _pack_zlib_single_block(payload, header_type) + elif compressor is None: + block = _pack_uncompressed_block(payload, header_type) + else: + raise ValueError( + f"Generator does not know how to pack compressor={compressor!r}" + ) + + header_txt = _vti_open( + whole_extent, + origin, + spacing, + direction, + header_type=header_type, + compressor=compressor, + ) + da = ( + f' \n' + f' \n' + f" \n \n \n" + ) + + with path.open("wb") as f: + f.write(header_txt.encode("utf-8")) + f.write(da.encode("utf-8")) + f.write(b" \n \n") + f.write(f' \n _'.encode()) + if encoding == "raw": + f.write(block) + elif encoding == "base64": + f.write(base64.b64encode(block)) + else: + raise ValueError(f"unknown AppendedData encoding: {encoding}") + f.write(b"\n \n\n") + + +def write_vti_guard( + path: pathlib.Path, + *, + compressor: str | None = None, + multipiece: bool = False, +) -> None: + """Emit a minimal .vti whose XML header triggers a guard-exception path. + + Payload bytes are placeholders; the guard fires during header parse + before any decoding runs. + """ + extent = (0, 1, 0, 1, 0, 0) + origin = (0.0, 0.0, 0.0) + spacing = (1.0, 1.0, 1.0) + hdr = _vti_open(extent, origin, spacing, None, compressor=compressor) + if multipiece: + # Emit two tags inside a single ImageData to trigger F-005. + # Replace the single opening line from _vti_open with two + # self-closed pieces, then close ImageData + VTKFile manually (do NOT + # call _vti_close(), which would leave an orphan ). + hdr = hdr.replace( + f' \n', + ' \n' + " \n" + " \n" + ' \n' + " \n" + " \n", + ) + body = hdr + " \n\n" + else: + body = ( + hdr + + ' \n' + + ' \n' + + " \n \n \n" + + " \n \n" + + ' \n _placeholder\n' + + " \n\n" + ) + path.write_text(body) + + +# --------------------------------------------------------------------------- +# Fixture catalogue +# --------------------------------------------------------------------------- +def main() -> None: + out = pathlib.Path(__file__).resolve().parent / "Input" + out.mkdir(exist_ok=True) + + IDENTITY = (1.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0) + + # --- Fixture A: UInt8 scalar, 4x4x2, uncompressed appended-raw, UInt64 header. + extent = (0, 3, 0, 3, 0, 1) + dim_size = (4, 4, 2) + scalar_u8 = bytes(range(32)) + write_vti_appended( + out / "VTI_scalar_u8_appended_raw.vti", + extent, + (0.0, 0.0, 0.0), + (1.0, 1.0, 1.0), + IDENTITY, + vtk_type="UInt8", + name="scalars", + n_components=1, + payload=scalar_u8, + encoding="raw", + ) + write_mhd( + out / "VTI_scalar_u8_appended_raw.mhd", + dim_size, + (1.0, 1.0, 1.0), + IDENTITY, + "MET_UCHAR", + 1, + "VTI_scalar_u8_appended_raw.raw", + scalar_u8, + ) + + # --- Fixture B: Float32 scalar, ZLib-compressed appended-raw, UInt64 header. + scalar_f32 = struct.pack("<32f", *[i * 0.25 for i in range(32)]) + write_vti_appended( + out / "VTI_scalar_f32_zlib_appended.vti", + extent, + (0.0, 0.0, 0.0), + (1.0, 1.0, 1.0), + IDENTITY, + vtk_type="Float32", + name="scalars", + n_components=1, + payload=scalar_f32, + encoding="raw", + compressor="vtkZLibDataCompressor", + ) + write_mhd( + out / "VTI_scalar_f32_zlib_appended.mhd", + dim_size, + (1.0, 1.0, 1.0), + IDENTITY, + "MET_FLOAT", + 1, + "VTI_scalar_f32_zlib_appended.raw", + scalar_f32, + ) + + # --- Fixture C: RGBA, appended-raw, UInt64 header. + rgba_payload = bytes( + b + for i in range(32) + for b in (i * 8 % 256, (i * 8 + 2) % 256, (i * 8 + 4) % 256, 255) + ) + write_vti_appended( + out / "VTI_rgba_u8_appended_raw.vti", + extent, + (0.0, 0.0, 0.0), + (1.0, 1.0, 1.0), + IDENTITY, + vtk_type="UInt8", + name="rgba", + n_components=4, + payload=rgba_payload, + encoding="raw", + ) + write_mhd( + out / "VTI_rgba_u8_appended_raw.mhd", + dim_size, + (1.0, 1.0, 1.0), + IDENTITY, + "MET_UCHAR", + 4, + "VTI_rgba_u8_appended_raw.raw", + rgba_payload, + ) + + # --- Fixture C2: Vector, ZLib appended-raw, UInt64 header. + # Mirrors the shape of ParaView's VHFColorZLib.vti (3-component Float32, + # vtkZLibDataCompressor, appended-raw, UInt64 header) so this test + # covers the same code path as the upstream-broken + # itkVTIImageIOReadWriteTestVHFColorZLib test without depending on an + # ExternalData-hosted ParaView fixture (blocked on ITK #4340). The + # PointData Vectors="vectors" hint makes the reader dispatch to + # IOPixelEnum::VECTOR rather than RGB. + vec3_payload = struct.pack( + f"<{32 * 3}f", + *[(i % 7) * 0.125 + (c + 1) * 0.5 for i in range(32) for c in range(3)], + ) + write_vti_appended( + out / "VTI_vector3_f32_zlib_appended.vti", + extent, + (0.0, 0.0, 0.0), + (1.0, 1.0, 1.0), + IDENTITY, + vtk_type="Float32", + name="vectors", + n_components=3, + payload=vec3_payload, + encoding="raw", + compressor="vtkZLibDataCompressor", + active_role="Vectors", + ) + write_mhd( + out / "VTI_vector3_f32_zlib_appended.mhd", + dim_size, + (1.0, 1.0, 1.0), + IDENTITY, + "MET_FLOAT", + 3, + "VTI_vector3_f32_zlib_appended.raw", + vec3_payload, + ) + + # --- Fixture D: Symmetric tensor Float32, ASCII, 2x2x1 = 4 pixels. + # Per-pixel tensor in matrix form e[i][j] with i,j in [0..2]: + # e[0][0]=i*1.0 e[0][1]=i*2.0 e[0][2]=i*3.0 + # e[1][1]=i*4.0 e[1][2]=i*5.0 e[2][2]=i*6.0 + # VTK canonical order: [XX, YY, ZZ, XY, YZ, XZ] + # ITK internal order: [e00, e01, e02, e11, e12, e22] + tensor_extent = (0, 1, 0, 1, 0, 0) + tensor_dim = (2, 2, 1) + vtk_floats = [] # VTK canonical order for the .vti + itk_floats = [] # ITK internal order for the MHD oracle + for p in range(4): + e00 = p * 1.0 + e01 = p * 2.0 + e02 = p * 3.0 + e11 = p * 4.0 + e12 = p * 5.0 + e22 = p * 6.0 + vtk_floats.extend([e00, e11, e22, e01, e12, e02]) # XX YY ZZ XY YZ XZ + itk_floats.extend([e00, e01, e02, e11, e12, e22]) # upper-triangular row-major + # Inline-write tensor ASCII VTI with Tensors="..." active hint. + # (The generic write_vti_ascii_scalar helper is scalar-only.) + lines = [_vti_open(tensor_extent, (0.0, 0.0, 0.0), (1.0, 1.0, 1.0), IDENTITY)] + lines.append(' \n') + lines.append( + ' \n' + ) + for i in range(0, len(vtk_floats), 6): + lines.append( + " " + " ".join(repr(v) for v in vtk_floats[i : i + 6]) + "\n" + ) + lines.append(" \n") + lines.append(" \n \n \n") + lines.append(_vti_close()) + (out / "VTI_tensor_f32_ascii.vti").write_text("".join(lines)) + write_mhd( + out / "VTI_tensor_f32_ascii.mhd", + tensor_dim, + (1.0, 1.0, 1.0), + IDENTITY, + "MET_FLOAT", + 6, + "VTI_tensor_f32_ascii.raw", + struct.pack(f"<{len(itk_floats)}f", *itk_floats), + ) + + # --- Guard fixtures (no MHD oracle; tests only assert exception). + write_vti_guard(out / "VTI_guard_lz4.vti", compressor="vtkLZ4DataCompressor") + write_vti_guard(out / "VTI_guard_lzma.vti", compressor="vtkLZMADataCompressor") + write_vti_guard( + out / "VTI_guard_unknown_compressor.vti", compressor="vtkBogusDataCompressor" + ) + write_vti_guard(out / "VTI_guard_multipiece.vti", multipiece=True) + + # Summary + print("Generated VTI fixtures under", out) + for f in sorted(out.glob("VTI_*.vti")): + print(f" {f.stat().st_size:>6} {f.name}") + for f in sorted(out.glob("VTI_*.mhd")): + print(f" {f.stat().st_size:>6} {f.name}") + + +if __name__ == "__main__": + main() diff --git a/Modules/IO/VTK/test/itkVTIImageIODirectionGTest.cxx b/Modules/IO/VTK/test/itkVTIImageIODirectionGTest.cxx new file mode 100644 index 00000000000..d1686498355 --- /dev/null +++ b/Modules/IO/VTK/test/itkVTIImageIODirectionGTest.cxx @@ -0,0 +1,86 @@ +/*========================================================================= + * + * Copyright NumFOCUS + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0.txt + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + *=========================================================================*/ + +#include "itkImageFileReader.h" +#include "itkImageFileWriter.h" +#include "itkVTIImageIO.h" +#include "itkVTIImageIOFactory.h" +#include "itkGTest.h" + +#include +#include +#include + +#ifndef VTI_TEST_INPUT_DIR +# error "VTI_TEST_INPUT_DIR must be defined by the build system." +#endif + +namespace +{ +// Expected oblique Direction matrix baked into the VTI_oblique_direction.vti +// fixture: a 45-degree rotation about the Z axis. Row-major 3x3. +constexpr double kExpectedDirection[3][3] = { + { 0.70710678, -0.70710678, 0.0 }, + { 0.70710678, 0.70710678, 0.0 }, + { 0.0, 0.0, 1.0 }, +}; + +constexpr double kDirectionTol = 1.0e-6; + +void +ExpectDirectionMatchesOblique(const itk::ImageIOBase * io) +{ + const unsigned int nd = io->GetNumberOfDimensions(); + for (unsigned int axis = 0; axis < nd && axis < 3; ++axis) + { + const std::vector & v = io->GetDirection(axis); + for (unsigned int r = 0; r < nd && r < 3; ++r) + { + EXPECT_NEAR(v[r], kExpectedDirection[r][axis], kDirectionTol) + << "Direction mismatch at axis " << axis << ", component " << r; + } + } +} +} // namespace + +TEST(VTIImageIODirection, RoundTrip) +{ + using ImageT = itk::Image; + const std::string fixture = std::string(VTI_TEST_INPUT_DIR) + "/VTI_oblique_direction.vti"; + const std::string rtOutput = std::string(::testing::TempDir()) + "/VTI_oblique_direction_rt.vti"; + + // --- Read the fixture and assert Direction was recovered. -------------- + auto reader = itk::ImageFileReader::New(); + reader->SetFileName(fixture); + reader->SetImageIO(itk::VTIImageIO::New()); + ASSERT_NO_THROW(reader->Update()); + ExpectDirectionMatchesOblique(reader->GetImageIO()); + + // --- Round-trip: write, re-read, assert Direction survived. ----------- + auto writer = itk::ImageFileWriter::New(); + writer->SetFileName(rtOutput); + writer->SetInput(reader->GetOutput()); + writer->SetImageIO(itk::VTIImageIO::New()); + ASSERT_NO_THROW(writer->Update()); + + auto reader2 = itk::ImageFileReader::New(); + reader2->SetFileName(rtOutput); + reader2->SetImageIO(itk::VTIImageIO::New()); + ASSERT_NO_THROW(reader2->Update()); + ExpectDirectionMatchesOblique(reader2->GetImageIO()); +} diff --git a/Modules/IO/VTK/test/itkVTIImageIOFutureFeaturesGTest.cxx b/Modules/IO/VTK/test/itkVTIImageIOFutureFeaturesGTest.cxx new file mode 100644 index 00000000000..c49736cc95e --- /dev/null +++ b/Modules/IO/VTK/test/itkVTIImageIOFutureFeaturesGTest.cxx @@ -0,0 +1,89 @@ +/*========================================================================= + * + * Copyright NumFOCUS + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0.txt + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + *=========================================================================*/ + +// Guard tests for Phase-2-deferred VTIImageIO features. +// +// Each test case reads a fixture whose XML header intentionally triggers +// a specific F-NNN guard exception at InternalReadImageInformation +// time. The test asserts the guard fires and that the diagnostic +// contains the expected F-NNN tag so future work items remain +// discoverable via `git grep F-NNN`. +// +// When a feature is implemented in the follow-up PR, each guard test +// flips from "expect exception" to "expect success + pixelwise +// comparison" in the same commit that removes the guard -- producing a +// visible red/green transition in git history. + +#include "itkVTIImageIO.h" +#include "itkGTest.h" + +#include + +#ifndef VTI_TEST_INPUT_DIR +# error "VTI_TEST_INPUT_DIR must be defined by the build system." +#endif + +namespace +{ +// Attempt to ReadImageInformation() on `fname`; return the exception +// description if one was thrown, or an empty string on unexpected success. +std::string +ExpectException(const std::string & fname) +{ + auto io = itk::VTIImageIO::New(); + io->SetFileName(fname); + try + { + io->ReadImageInformation(); + } + catch (const itk::ExceptionObject & e) + { + return e.GetDescription(); + } + return {}; +} + +void +CheckGuard(const std::string & fixture, const std::string & expectedTag) +{ + const std::string fname = std::string(VTI_TEST_INPUT_DIR) + "/" + fixture; + const std::string message = ExpectException(fname); + ASSERT_FALSE(message.empty()) << "no exception thrown for " << fname; + EXPECT_NE(message.find(expectedTag), std::string::npos) + << "exception did not contain '" << expectedTag << "' tag. Message was:\n" + << message; +} +} // namespace + +// F-001: vtkLZ4DataCompressor should raise a tagged exception. +TEST(VTIImageIOFutureFeatures, F001_LZ4) { CheckGuard("VTI_guard_lz4.vti", "F-001 LZ4"); } + +// F-002: vtkLZMADataCompressor should raise a tagged exception. +TEST(VTIImageIOFutureFeatures, F002_LZMA) { CheckGuard("VTI_guard_lzma.vti", "F-002 LZMA"); } + +// F-010: unknown compressor string should raise the catch-all exception. +TEST(VTIImageIOFutureFeatures, F010_UnknownCompressor) +{ + CheckGuard("VTI_guard_unknown_compressor.vti", "F-010 Unknown VTK compressor"); +} + +// F-005: multi-Piece ImageData should raise a tagged exception. +TEST(VTIImageIOFutureFeatures, F005_MultiPiece) { CheckGuard("VTI_guard_multipiece.vti", "F-005 Multi-Piece"); } + +// F-007 (binary symmetric-tensor write) is guarded in Write() and exercised +// in itkVTIImageIOTest's "Binary tensor write correctly rejected" block. diff --git a/Modules/IO/VTK/test/itkVTIImageIOGeneratedFixturesGTest.cxx b/Modules/IO/VTK/test/itkVTIImageIOGeneratedFixturesGTest.cxx new file mode 100644 index 00000000000..0b0fb9716b3 --- /dev/null +++ b/Modules/IO/VTK/test/itkVTIImageIOGeneratedFixturesGTest.cxx @@ -0,0 +1,217 @@ +/*========================================================================= + * + * Copyright NumFOCUS + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0.txt + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + *=========================================================================*/ + +// Pixelwise regression tests against the .vti fixtures produced by +// Modules/IO/VTK/test/generate_vti_fixtures.py. Each fixture has a +// matching MetaIO (.mhd / .raw) oracle containing the same logical +// image; the test reads both files and asserts pixel equality. +// +// These tests exercise the VTIImageIO *reader* against output from an +// independent Python writer (no ITK code is involved in generating the +// fixtures), which catches mismatches between the reader's +// interpretation of the VTK XML format and the format as specified. +// The companion round-trip scenarios in itkVTIImageIOTest only verify +// writer/reader self-consistency; this test closes the loop at the +// format boundary. + +#include "itkImageFileReader.h" +#include "itkImageFileWriter.h" +#include "itkImageRegionConstIteratorWithIndex.h" +#include "itkRGBAPixel.h" +#include "itkSymmetricSecondRankTensor.h" +#include "itkVTIImageIO.h" +#include "itkVector.h" +#include "itkGTest.h" +#include "itkTestDriverIncludeRequiredFactories.h" + +#include +#include +#include + +#ifndef VTI_TEST_INPUT_DIR +# error "VTI_TEST_INPUT_DIR must be defined by the build system." +#endif + +namespace +{ +struct VTIImageIOGeneratedFixtures : public ::testing::Test +{ + void + SetUp() override + { + RegisterRequiredFactories(); + } +}; + +template +::testing::AssertionResult +ImagesEqual(TImage * vti, TImage * mhd, TEqual && pixelEqual) +{ + if (vti->GetLargestPossibleRegion() != mhd->GetLargestPossibleRegion()) + { + return ::testing::AssertionFailure() << "region mismatch " << vti->GetLargestPossibleRegion() << " vs " + << mhd->GetLargestPossibleRegion(); + } + itk::ImageRegionConstIteratorWithIndex vIt(vti, vti->GetLargestPossibleRegion()); + itk::ImageRegionConstIteratorWithIndex mIt(mhd, mhd->GetLargestPossibleRegion()); + for (vIt.GoToBegin(), mIt.GoToBegin(); !vIt.IsAtEnd(); ++vIt, ++mIt) + { + if (!pixelEqual(vIt.Get(), mIt.Get())) + { + return ::testing::AssertionFailure() + << "pixel mismatch at " << vIt.GetIndex() << ": vti=" << vIt.Get() << " mhd=" << mIt.Get(); + } + } + return ::testing::AssertionSuccess(); +} + +template +void +CompareVtiToMhd(const std::string & label, + const std::string & vtiFixture, + const std::string & mhdFixture, + TEqual && pixelEqual, + bool tryCompression = true) +{ + using ReaderType = itk::ImageFileReader; + const std::string vtiPath = std::string(VTI_TEST_INPUT_DIR) + "/" + vtiFixture; + const std::string mhdPath = std::string(VTI_TEST_INPUT_DIR) + "/" + mhdFixture; + + auto vtiReader = ReaderType::New(); + vtiReader->SetFileName(vtiPath); + vtiReader->SetImageIO(itk::VTIImageIO::New()); + ASSERT_NO_THROW(vtiReader->Update()); + + auto mhdReader = ReaderType::New(); + mhdReader->SetFileName(mhdPath); + ASSERT_NO_THROW(mhdReader->Update()); + + auto * vti = vtiReader->GetOutput(); + auto * mhd = mhdReader->GetOutput(); + EXPECT_TRUE(ImagesEqual(vti, mhd, pixelEqual)) << "fixture vs MHD oracle: " << vtiPath << " vs " << mhdPath; + + if (tryCompression) + { + // Write a compressed VTI from the just-read image and compare it to the + // same MHD oracle: exercises the writer's ZLib appended-raw path against + // the same independent MHD-layout reference. + const std::filesystem::path tempDir = std::filesystem::temp_directory_path() / "itkVTICompressed"; + std::filesystem::create_directories(tempDir); + const std::string compressedVtiPath = (tempDir / (label + ".vti")).string(); + ASSERT_NO_THROW(itk::WriteImage(vti, compressedVtiPath, true)); + auto cvti = itk::ReadImage(compressedVtiPath); + EXPECT_TRUE(ImagesEqual(cvti.GetPointer(), mhd, pixelEqual)) + << "compressed-write round-trip: " << compressedVtiPath << " vs " << mhdPath; + } +} + +template +bool +ExactEqual(const T & a, const T & b) +{ + return a == b; +} + +bool +FloatAlmostEqual(float a, float b) +{ + return std::abs(a - b) <= 1.0e-6f * std::max(1.0f, std::abs(a)); +} + +bool +TensorAlmostEqual(const itk::SymmetricSecondRankTensor & a, + const itk::SymmetricSecondRankTensor & b) +{ + for (unsigned int i = 0; i < 6; ++i) + { + if (std::abs(a[i] - b[i]) > 1.0e-6f * std::max(1.0f, std::abs(a[i]))) + { + return false; + } + } + return true; +} + +bool +Vector3AlmostEqual(const itk::Vector & a, const itk::Vector & b) +{ + for (unsigned int i = 0; i < 3; ++i) + { + if (std::abs(a[i] - b[i]) > 1.0e-6f * std::max(1.0f, std::abs(a[i]))) + { + return false; + } + } + return true; +} +} // namespace + +// Fixture A: UInt8 scalar, appended-raw (uncompressed), UInt64 header. +TEST_F(VTIImageIOGeneratedFixtures, ScalarU8AppendedRaw) +{ + CompareVtiToMhd>("scalar_u8_appended_raw", + "VTI_scalar_u8_appended_raw.vti", + "VTI_scalar_u8_appended_raw.mhd", + ExactEqual); +} + +// Fixture B: Float32 scalar, ZLib appended-raw, UInt64 header. +TEST_F(VTIImageIOGeneratedFixtures, ScalarF32ZLibAppended) +{ + CompareVtiToMhd>("scalar_f32_zlib_appended", + "VTI_scalar_f32_zlib_appended.vti", + "VTI_scalar_f32_zlib_appended.mhd", + FloatAlmostEqual); +} + +// Fixture C: RGBA 4-component, appended-raw. +TEST_F(VTIImageIOGeneratedFixtures, RgbaU8AppendedRaw) +{ + CompareVtiToMhd, 3>>("rgba_u8_appended_raw", + "VTI_rgba_u8_appended_raw.vti", + "VTI_rgba_u8_appended_raw.mhd", + ExactEqual>); +} + +// Fixture C2: Vector, ZLib appended-raw, UInt64 header. Covers +// the same code path (multi-component Float32 ZLib decompression of +// appended-raw data) that the upstream-broken +// itkVTIImageIOReadWriteTestVHFColorZLib test would exercise if its +// ParaView-produced fixture were available via ExternalData (blocked on +// ITK #4340). The fixture's hint drives +// the reader to IOPixelEnum::VECTOR dispatch. +TEST_F(VTIImageIOGeneratedFixtures, Vector3F32ZLibAppended) +{ + CompareVtiToMhd, 3>>("vector3_f32_zlib_appended", + "VTI_vector3_f32_zlib_appended.vti", + "VTI_vector3_f32_zlib_appended.mhd", + Vector3AlmostEqual); +} + +// Fixture D: symmetric tensor Float32, ASCII. VTI layout is VTK-canonical +// [XX, YY, ZZ, XY, YZ, XZ]; reader remaps to ITK's [e00, e01, e02, e11, +// e12, e22] so the in-memory tensor matches the MHD oracle's ITK layout. +// Compression skipped because F-007 (binary tensor write) is deferred. +TEST_F(VTIImageIOGeneratedFixtures, TensorF32Ascii) +{ + CompareVtiToMhd, 3>>("tensor_f32_ascii", + "VTI_tensor_f32_ascii.vti", + "VTI_tensor_f32_ascii.mhd", + TensorAlmostEqual, + /*tryCompression=*/false); +} diff --git a/Modules/IO/VTK/test/itkVTIImageIOReadWriteTest.cxx b/Modules/IO/VTK/test/itkVTIImageIOReadWriteTest.cxx new file mode 100644 index 00000000000..7bb22e43d7e --- /dev/null +++ b/Modules/IO/VTK/test/itkVTIImageIOReadWriteTest.cxx @@ -0,0 +1,111 @@ +/*========================================================================= + * + * Copyright NumFOCUS + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0.txt + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + *=========================================================================*/ + +#include "itkImageFileReader.h" +#include "itkImageFileWriter.h" +#include "itkTestingMacros.h" + +#include "itkRGBPixel.h" +#include "itkVectorImage.h" + +namespace +{ +template +int +ReadWrite(const std::string & inputImage, const std::string & outputImage, bool compress) +{ + auto image = itk::ReadImage(inputImage); + ITK_TRY_EXPECT_NO_EXCEPTION(itk::WriteImage(image, outputImage, compress)); + return EXIT_SUCCESS; +} + +template +int +internalMain(const std::string & inputImage, + const std::string & outputImage, + itk::ImageIOBase::Pointer imageIO, + bool compress) +{ + const unsigned int numberOfComponents = imageIO->GetNumberOfComponents(); + using IOPixelType = itk::IOPixelEnum; + const IOPixelType pixelType = imageIO->GetPixelType(); + + switch (pixelType) + { + case IOPixelType::SCALAR: + ITK_TEST_EXPECT_EQUAL(numberOfComponents, 1); + return ReadWrite>(inputImage, outputImage, compress); + + case IOPixelType::RGB: + ITK_TEST_EXPECT_EQUAL(numberOfComponents, 3); + return ReadWrite, Dimension>>(inputImage, outputImage, compress); + + case IOPixelType::VECTOR: + return ReadWrite>(inputImage, outputImage, compress); + + default: + std::cerr << "Test does not support pixel type of " << itk::ImageIOBase::GetPixelTypeAsString(pixelType) + << std::endl; + return EXIT_FAILURE; + } +} + +} // namespace + +int +itkVTIImageIOReadWriteTest(int argc, char * argv[]) +{ + if (argc < 3) + { + std::cerr << "Usage: " << itkNameOfTestExecutableMacro(argv); + std::cerr << " [compress=false]" << std::endl; + return EXIT_FAILURE; + } + const char * inputImage = argv[1]; + const char * outputImage = argv[2]; + bool compress = false; + if (argc > 3) + { + compress = std::stoi(argv[3]); + } + + using ReaderType = itk::ImageFileReader>; + auto reader = ReaderType::New(); + reader->SetFileName(inputImage); + ITK_TRY_EXPECT_NO_EXCEPTION(reader->UpdateOutputInformation()); + + auto imageIO = reader->GetImageIO(); + imageIO->SetFileName(inputImage); + + ITK_TRY_EXPECT_NO_EXCEPTION(imageIO->ReadImageInformation()); + + std::cout << imageIO << std::endl; + + const unsigned int dimension = imageIO->GetNumberOfDimensions(); + + switch (dimension) + { + case 2: + return internalMain<2>(inputImage, outputImage, imageIO, compress); + case 3: + return internalMain<3>(inputImage, outputImage, imageIO, compress); + default: + std::cerr << "Test only supports dimensions 2 and 3. Detected dimension " << dimension << std::endl; + return EXIT_FAILURE; + } +} diff --git a/Modules/IO/VTK/test/itkVTIImageIOSwapBufferGTest.cxx b/Modules/IO/VTK/test/itkVTIImageIOSwapBufferGTest.cxx new file mode 100644 index 00000000000..64366aecb0f --- /dev/null +++ b/Modules/IO/VTK/test/itkVTIImageIOSwapBufferGTest.cxx @@ -0,0 +1,138 @@ +/*========================================================================= + * + * Copyright NumFOCUS + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0.txt + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + *=========================================================================*/ + +// Unit tests for VTIImageIO::SwapBufferForByteOrder. +// +// The old VTIImageIO byte-swap helper hard-coded +// ByteSwapper::SwapRangeFromSystemToBigEndian, which is a no-op when the +// host is big-endian reading a little-endian file -- silently leaving +// file bytes un-swapped. SwapBufferForByteOrder replaces it with an +// unconditional std::reverse-within-component, so all four +// (fileByteOrder, targetByteOrder) combinations produce correct output +// regardless of the host's native endianness. These tests exercise +// every combination without needing a big-endian CI runner. + +#include "itkVTIImageIO.h" +#include "itkGTest.h" + +#include +#include +#include +#include +#include + +namespace +{ +constexpr itk::IOByteOrderEnum LE = itk::IOByteOrderEnum::LittleEndian; +constexpr itk::IOByteOrderEnum BE = itk::IOByteOrderEnum::BigEndian; + +template +std::vector +AsBytes(std::initializer_list values) +{ + std::vector out; + out.reserve(values.size() * sizeof(T)); + for (T v : values) + { + const auto * p = reinterpret_cast(&v); + for (std::size_t i = 0; i < sizeof(T); ++i) + { + out.push_back(p[i]); + } + } + return out; +} + +void +RunCase(std::size_t componentSize, + std::size_t numComponents, + const std::vector & input, + const std::vector & expected, + itk::IOByteOrderEnum fileByteOrder, + itk::IOByteOrderEnum targetByteOrder) +{ + std::vector buffer = input; + itk::VTIImageIO::SwapBufferForByteOrder(buffer.data(), componentSize, numComponents, fileByteOrder, targetByteOrder); + EXPECT_EQ(buffer, expected); +} +} // namespace + +// componentSize == 1: always a no-op regardless of order args. +TEST(VTIImageIOSwapBuffer, ComponentSize1IsNoOp) +{ + const std::vector in{ 0x01, 0x02, 0x03, 0x04 }; + RunCase(1, 4, in, in, LE, LE); + RunCase(1, 4, in, in, BE, BE); + RunCase(1, 4, in, in, LE, BE); + RunCase(1, 4, in, in, BE, LE); +} + +// componentSize == 2: swap each pair when orders differ. +TEST(VTIImageIOSwapBuffer, ComponentSize2) +{ + const std::vector in{ 0x01, 0x02, 0x03, 0x04, 0xAB, 0xCD }; + const std::vector swapped{ 0x02, 0x01, 0x04, 0x03, 0xCD, 0xAB }; + RunCase(2, 3, in, in, LE, LE); + RunCase(2, 3, in, in, BE, BE); + RunCase(2, 3, in, swapped, LE, BE); + RunCase(2, 3, in, swapped, BE, LE); +} + +// componentSize == 4: reverse each 4-byte group when differing. +TEST(VTIImageIOSwapBuffer, ComponentSize4) +{ + const std::vector in{ 0x11, 0x22, 0x33, 0x44, 0xDE, 0xAD, 0xBE, 0xEF }; + const std::vector swapped{ 0x44, 0x33, 0x22, 0x11, 0xEF, 0xBE, 0xAD, 0xDE }; + RunCase(4, 2, in, in, LE, LE); + RunCase(4, 2, in, in, BE, BE); + RunCase(4, 2, in, swapped, LE, BE); + RunCase(4, 2, in, swapped, BE, LE); +} + +// componentSize == 8: reverse each 8-byte group when differing. +TEST(VTIImageIOSwapBuffer, ComponentSize8) +{ + const std::vector in{ 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, + 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88 }; + const std::vector swapped{ 0x08, 0x07, 0x06, 0x05, 0x04, 0x03, 0x02, 0x01, + 0x88, 0x77, 0x66, 0x55, 0x44, 0x33, 0x22, 0x11 }; + RunCase(8, 2, in, in, LE, LE); + RunCase(8, 2, in, in, BE, BE); + RunCase(8, 2, in, swapped, LE, BE); + RunCase(8, 2, in, swapped, BE, LE); +} + +// regression: known-value uint32 reinterpret via LE->LE vs LE->BE. +TEST(VTIImageIOSwapBuffer, KnownValueRegression) +{ + const std::vector in = AsBytes({ 0x01020304, 0xAABBCCDD }); + std::vector swapped = in; + for (std::size_t p = 0; p < 2; ++p) + { + std::reverse(swapped.begin() + p * 4, swapped.begin() + p * 4 + 4); + } + RunCase(4, 2, in, swapped, LE, BE); + RunCase(4, 2, in, swapped, BE, LE); +} + +// zero-element buffer is a no-op (must not dereference the null pointer). +TEST(VTIImageIOSwapBuffer, ZeroComponentBufferIsNoOp) +{ + itk::VTIImageIO::SwapBufferForByteOrder(nullptr, 4, 0, LE, BE); + SUCCEED(); +} diff --git a/Modules/IO/VTK/test/itkVTIImageIOTest.cxx b/Modules/IO/VTK/test/itkVTIImageIOTest.cxx new file mode 100644 index 00000000000..4451641db93 --- /dev/null +++ b/Modules/IO/VTK/test/itkVTIImageIOTest.cxx @@ -0,0 +1,1187 @@ +/*========================================================================= + * + * Copyright NumFOCUS + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0.txt + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + *=========================================================================*/ +#include "itkByteSwapper.h" +#include "itkImage.h" +#include "itkImageFileReader.h" +#include "itkImageFileWriter.h" +#include "itkImageRegionConstIterator.h" +#include "itkImageRegionIterator.h" +#include "itkRGBAPixel.h" +#include "itkRGBPixel.h" +#include "itkSymmetricSecondRankTensor.h" +#include "itkTestingMacros.h" +#include "itkVTIImageIO.h" +#include "itkVTIImageIOFactory.h" +#include "itkVector.h" +#include "itk_zlib.h" + +#include +#include +#include +#include +#include +#include +#include + +namespace +{ + +template +typename TImage::Pointer +MakeRamp(const typename TImage::SizeType & size, double startValue = 0.0) +{ + auto image = TImage::New(); + image->SetRegions(typename TImage::RegionType(size)); + image->Allocate(); + + double v = startValue; + itk::ImageRegionIterator it(image, image->GetLargestPossibleRegion()); + for (it.GoToBegin(); !it.IsAtEnd(); ++it, v += 1.0) + { + it.Set(static_cast(v)); + } + + typename TImage::SpacingType spacing; + typename TImage::PointType origin; + for (unsigned int d = 0; d < TImage::ImageDimension; ++d) + { + spacing[d] = 0.5 + static_cast(d); + origin[d] = 1.0 + static_cast(d); + } + image->SetSpacing(spacing); + image->SetOrigin(origin); + return image; +} + +template +bool +ImagesEqual(const TImage * a, const TImage * b) +{ + if (a->GetLargestPossibleRegion() != b->GetLargestPossibleRegion()) + { + std::cerr << "Region mismatch: " << a->GetLargestPossibleRegion() << " vs " << b->GetLargestPossibleRegion() + << std::endl; + return false; + } + for (unsigned int d = 0; d < TImage::ImageDimension; ++d) + { + if (std::abs(a->GetSpacing()[d] - b->GetSpacing()[d]) > 1e-9) + { + std::cerr << "Spacing mismatch on axis " << d << ": " << a->GetSpacing()[d] << " vs " << b->GetSpacing()[d] + << std::endl; + return false; + } + if (std::abs(a->GetOrigin()[d] - b->GetOrigin()[d]) > 1e-9) + { + std::cerr << "Origin mismatch on axis " << d << ": " << a->GetOrigin()[d] << " vs " << b->GetOrigin()[d] + << std::endl; + return false; + } + } + itk::ImageRegionConstIterator ait(a, a->GetLargestPossibleRegion()); + itk::ImageRegionConstIterator bit(b, b->GetLargestPossibleRegion()); + for (ait.GoToBegin(), bit.GoToBegin(); !ait.IsAtEnd(); ++ait, ++bit) + { + if (ait.Get() != bit.Get()) + { + std::cerr << "Pixel mismatch at " << ait.ComputeIndex() << ": " << ait.Get() << " vs " << bit.Get() << std::endl; + return false; + } + } + return true; +} + +template +int +RoundTripCompressed(const std::string & filename, const typename TImage::SizeType & size) +{ + using ReaderType = itk::ImageFileReader; + using WriterType = itk::ImageFileWriter; + + auto original = MakeRamp(size); + + auto io = itk::VTIImageIO::New(); + io->SetFileType(itk::IOFileEnum::Binary); + io->SetUseCompression(true); + + auto writer = WriterType::New(); + writer->SetFileName(filename); + writer->SetInput(original); + writer->SetImageIO(io); + ITK_TRY_EXPECT_NO_EXCEPTION(writer->Update()); + + auto reader = ReaderType::New(); + reader->SetFileName(filename); + reader->SetImageIO(itk::VTIImageIO::New()); + ITK_TRY_EXPECT_NO_EXCEPTION(reader->Update()); + + if (!ImagesEqual(original, reader->GetOutput())) + { + std::cerr << " COMPRESSED ROUND-TRIP FAILED for " << filename << std::endl; + return EXIT_FAILURE; + } + std::cout << " Compressed round-trip OK: " << filename << std::endl; + return EXIT_SUCCESS; +} + +template +int +RoundTrip(const std::string & filename, const typename TImage::SizeType & size, itk::IOFileEnum fileType) +{ + using ReaderType = itk::ImageFileReader; + using WriterType = itk::ImageFileWriter; + + auto original = MakeRamp(size); + + auto io = itk::VTIImageIO::New(); + io->SetFileType(fileType); + + auto writer = WriterType::New(); + writer->SetFileName(filename); + writer->SetInput(original); + writer->SetImageIO(io); + ITK_TRY_EXPECT_NO_EXCEPTION(writer->Update()); + + auto reader = ReaderType::New(); + reader->SetFileName(filename); + reader->SetImageIO(itk::VTIImageIO::New()); + ITK_TRY_EXPECT_NO_EXCEPTION(reader->Update()); + + if (!ImagesEqual(original, reader->GetOutput())) + { + std::cerr << " ROUND-TRIP FAILED for " << filename + << " (fileType=" << (fileType == itk::IOFileEnum::ASCII ? "ASCII" : "Binary") << ")" << std::endl; + return EXIT_FAILURE; + } + std::cout << " Round-trip OK: " << filename << std::endl; + return EXIT_SUCCESS; +} + +} // namespace + + +int +itkVTIImageIOTest(int argc, char * argv[]) +{ + if (argc < 2) + { + std::cerr << "Usage: " << argv[0] << " " << std::endl; + return EXIT_FAILURE; + } + + // Make sure the factory is registered (also exercises the factory itself). + itk::VTIImageIOFactory::RegisterOneFactory(); + + const std::string outDir = argv[1]; + const std::string sep("/"); + + int status = EXIT_SUCCESS; + + // ---- 2D scalar (uchar) ------------------------------------------------- + { + using ImageType = itk::Image; + ImageType::SizeType size = { { 8, 6 } }; + status |= RoundTrip(outDir + sep + "vti_uchar2d_binary.vti", size, itk::IOFileEnum::Binary); + status |= RoundTrip(outDir + sep + "vti_uchar2d_ascii.vti", size, itk::IOFileEnum::ASCII); + } + + // ---- 3D scalar (short) ------------------------------------------------- + { + using ImageType = itk::Image; + ImageType::SizeType size = { { 5, 4, 3 } }; + status |= RoundTrip(outDir + sep + "vti_short3d_binary.vti", size, itk::IOFileEnum::Binary); + status |= RoundTrip(outDir + sep + "vti_short3d_ascii.vti", size, itk::IOFileEnum::ASCII); + } + + // ---- 3D scalar (float) ------------------------------------------------- + { + using ImageType = itk::Image; + ImageType::SizeType size = { { 4, 4, 4 } }; + status |= RoundTrip(outDir + sep + "vti_float3d_binary.vti", size, itk::IOFileEnum::Binary); + status |= RoundTrip(outDir + sep + "vti_float3d_ascii.vti", size, itk::IOFileEnum::ASCII); + } + + // ---- 3D scalar (double) ------------------------------------------------ + { + using ImageType = itk::Image; + ImageType::SizeType size = { { 4, 4, 4 } }; + status |= RoundTrip(outDir + sep + "vti_double3d_binary.vti", size, itk::IOFileEnum::Binary); + } + + // ---- 2D RGB (3 components) --------------------------------------------- + { + using ImageType = itk::Image, 2>; + ImageType::SizeType size = { { 4, 4 } }; + status |= RoundTrip(outDir + sep + "vti_rgb2d_binary.vti", size, itk::IOFileEnum::Binary); + } + + // ---- 2D RGBA (4 components) -------------------------------------------- + // RGBA round-trip was advertised by the class docstring but not exercised + // before this commit; the binary writer path lands on the generic + // NumberOfComponents="4" branch and the reader infers IOPixelEnum::RGBA + // from that component count. + { + using ImageType = itk::Image, 2>; + ImageType::SizeType size = { { 4, 4 } }; + status |= RoundTrip(outDir + sep + "vti_rgba2d_binary.vti", size, itk::IOFileEnum::Binary); + status |= RoundTrip(outDir + sep + "vti_rgba2d_ascii.vti", size, itk::IOFileEnum::ASCII); + } + + // ---- 3D vector (3 components) ------------------------------------------ + { + using ImageType = itk::Image, 3>; + ImageType::SizeType size = { { 3, 3, 3 } }; + status |= RoundTrip(outDir + sep + "vti_vec3d_binary.vti", size, itk::IOFileEnum::Binary); + } + + // ---- 1D scalar (uchar) ------------------------------------------------- + { + using ImageType = itk::Image; + ImageType::SizeType size = { { 16 } }; + status |= RoundTrip(outDir + sep + "vti_uchar1d_binary.vti", size, itk::IOFileEnum::Binary); + status |= RoundTrip(outDir + sep + "vti_uchar1d_ascii.vti", size, itk::IOFileEnum::ASCII); + } + + // ---- Compressed appended-raw (zlib) round-trips ------------------------ + { + using ImageType = itk::Image; + ImageType::SizeType size = { { 8, 8, 4 } }; + status |= RoundTripCompressed(outDir + sep + "vti_float3d_zlib.vti", size); + } + { + using ImageType = itk::Image; + ImageType::SizeType size = { { 16, 16 } }; + status |= RoundTripCompressed(outDir + sep + "vti_uchar2d_zlib.vti", size); + } + { + using ImageType = itk::Image, 3>; + ImageType::SizeType size = { { 4, 4, 2 } }; + status |= RoundTripCompressed(outDir + sep + "vti_vec3d_zlib.vti", size); + } + + // ---- Symmetric tensor ASCII round-trip --------------------------------- + // On-disk layout is VTK-canonical 6 components per pixel in + // [XX, YY, ZZ, XY, YZ, XZ] order. ITK's in-memory + // SymmetricSecondRankTensor is [e00, e01, e02, e11, e12, e22] + // (upper-triangular row-major). Only ASCII is supported today; the + // binary writer throws an F-007 exception (exercised in the next block). + { + using ImageType = itk::Image, 3>; + ImageType::SizeType size = { { 3, 3, 2 } }; + auto original = ImageType::New(); + original->SetRegions(ImageType::RegionType(size)); + original->Allocate(); + ImageType::PixelType t; + t(0, 0) = 1.0f; + t(0, 1) = 2.0f; + t(0, 2) = 3.0f; + t(1, 1) = 4.0f; + t(1, 2) = 5.0f; + t(2, 2) = 6.0f; + original->FillBuffer(t); + + const std::string fname = outDir + sep + "vti_tensor3d_ascii.vti"; + auto io = itk::VTIImageIO::New(); + io->SetFileType(itk::IOFileEnum::ASCII); + + auto writer = itk::ImageFileWriter::New(); + writer->SetFileName(fname); + writer->SetInput(original); + writer->SetImageIO(io); + ITK_TRY_EXPECT_NO_EXCEPTION(writer->Update()); + + auto reader = itk::ImageFileReader::New(); + reader->SetFileName(fname); + reader->SetImageIO(itk::VTIImageIO::New()); + ITK_TRY_EXPECT_NO_EXCEPTION(reader->Update()); + + if (!ImagesEqual(original, reader->GetOutput())) + { + std::cerr << " ERROR: Tensor ASCII round-trip did not preserve pixel values: " << fname << std::endl; + status = EXIT_FAILURE; + } + else + { + std::cout << " Tensor ASCII round-trip pixel-match OK: " << fname << std::endl; + } + } + + // ---- Binary tensor write must be rejected ------------------------------ + { + using ImageType = itk::Image, 3>; + ImageType::SizeType size = { { 2, 2, 2 } }; + auto original = ImageType::New(); + original->SetRegions(ImageType::RegionType(size)); + original->Allocate(); + auto io = itk::VTIImageIO::New(); + io->SetFileType(itk::IOFileEnum::Binary); + + auto writer = itk::ImageFileWriter::New(); + writer->SetFileName(outDir + sep + "vti_tensor3d_binary.vti"); + writer->SetInput(original); + writer->SetImageIO(io); + bool threw = false; + try + { + writer->Update(); + } + catch (const itk::ExceptionObject &) + { + threw = true; + } + if (!threw) + { + std::cerr << " ERROR: binary tensor write did not throw" << std::endl; + status = EXIT_FAILURE; + } + else + { + std::cout << " Binary tensor write correctly rejected" << std::endl; + } + } + + // ---- XML robustness: comments, attribute reordering, CDATA ------------- + // Hand-craft a tiny ASCII VTI file that exercises features that the + // expat-based parser must handle correctly: XML comments at various + // positions, attribute reordering, optional whitespace, the standalone + // declaration with attributes. + { + const std::string fname = outDir + sep + "vti_xml_robustness.vti"; + { + std::ofstream f(fname.c_str()); + f << "\n"; + f << "\n"; + f << "\n"; + f << " \n"; + f << " \n"; + f << " \n"; + f << " \n"; + f << " \n"; + f << " \n"; + f << " 0 0 0 0 0 0 0 0 0 0 0 0\n"; + f << " \n"; + f << " \n"; + f << " 1.5 2.5 3.5 4.5\n"; + f << " \n"; + f << " \n"; + f << " \n"; + f << " \n"; + f << " \n"; + f << "\n"; + } + + using ImageType = itk::Image; + auto reader = itk::ImageFileReader::New(); + reader->SetFileName(fname); + reader->SetImageIO(itk::VTIImageIO::New()); + ITK_TRY_EXPECT_NO_EXCEPTION(reader->Update()); + + auto out = reader->GetOutput(); + if (out->GetLargestPossibleRegion().GetSize()[0] != 2 || out->GetLargestPossibleRegion().GetSize()[1] != 2) + { + std::cerr << " ERROR: XML robustness test wrong dimensions: " << out->GetLargestPossibleRegion() << std::endl; + status = EXIT_FAILURE; + } + if (std::abs(out->GetSpacing()[0] - 0.5) > 1e-9 || std::abs(out->GetSpacing()[1] - 0.25) > 1e-9) + { + std::cerr << " ERROR: XML robustness test wrong spacing: " << out->GetSpacing() << std::endl; + status = EXIT_FAILURE; + } + if (std::abs(out->GetOrigin()[0] - 10.0) > 1e-9 || std::abs(out->GetOrigin()[1] - 20.0) > 1e-9) + { + std::cerr << " ERROR: XML robustness test wrong origin: " << out->GetOrigin() << std::endl; + status = EXIT_FAILURE; + } + // Verify the active "density" scalar was selected (not the first + // "velocity" DataArray). Pixel values should be 1.5 / 2.5 / 3.5 / 4.5. + ImageType::IndexType idx; + idx[0] = 0; + idx[1] = 0; + if (std::abs(out->GetPixel(idx) - 1.5f) > 1e-5f) + { + std::cerr << " ERROR: XML robustness pixel(0,0) = " << out->GetPixel(idx) << ", expected 1.5" << std::endl; + status = EXIT_FAILURE; + } + idx[0] = 1; + idx[1] = 1; + if (std::abs(out->GetPixel(idx) - 4.5f) > 1e-5f) + { + std::cerr << " ERROR: XML robustness pixel(1,1) = " << out->GetPixel(idx) << ", expected 4.5" << std::endl; + status = EXIT_FAILURE; + } + if (status == EXIT_SUCCESS) + { + std::cout << " XML robustness OK: comments, attribute reordering, multi-DataArray active selector" << std::endl; + } + } + + // ---- Read of an UInt64 header_type base64 file ------------------------- + // Hand-craft a base64-encoded VTI with header_type="UInt64" so we exercise + // the 8-byte block-size header path. + { + const std::string fname = outDir + sep + "vti_uint64_header.vti"; + + // Pixel data: 4 floats + const float pixels[4] = { 0.0f, 1.0f, 2.0f, 3.0f }; + const auto blockSize = static_cast(sizeof(pixels)); + + std::vector raw(sizeof(blockSize) + sizeof(pixels)); + std::memcpy(raw.data(), &blockSize, sizeof(blockSize)); + std::memcpy(raw.data() + sizeof(blockSize), pixels, sizeof(pixels)); + + // base64-encode raw + static const char chars[] = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + std::string b64; + for (std::size_t i = 0; i < raw.size(); i += 3) + { + const unsigned int b0 = raw[i]; + const unsigned int b1 = (i + 1 < raw.size()) ? raw[i + 1] : 0u; + const unsigned int b2 = (i + 2 < raw.size()) ? raw[i + 2] : 0u; + b64 += chars[(b0 >> 2) & 0x3F]; + b64 += chars[((b0 << 4) | (b1 >> 4)) & 0x3F]; + b64 += (i + 1 < raw.size()) ? chars[((b1 << 2) | (b2 >> 6)) & 0x3F] : '='; + b64 += (i + 2 < raw.size()) ? chars[b2 & 0x3F] : '='; + } + + { + std::ofstream f(fname.c_str()); + f << "\n"; + f << "\n"; + f << " \n"; + f << " \n"; + f << " \n"; + f << " \n"; + f << " " << b64 << "\n"; + f << " \n"; + f << " \n"; + f << " \n"; + f << " \n"; + f << "\n"; + } + + using ImageType = itk::Image; + auto reader = itk::ImageFileReader::New(); + reader->SetFileName(fname); + reader->SetImageIO(itk::VTIImageIO::New()); + ITK_TRY_EXPECT_NO_EXCEPTION(reader->Update()); + + auto out = reader->GetOutput(); + ImageType::IndexType idx; + bool ok = true; + for (int i = 0; i < 4; ++i) + { + idx[0] = i % 2; + idx[1] = i / 2; + if (std::abs(out->GetPixel(idx) - static_cast(i)) > 1e-5f) + { + std::cerr << " ERROR: UInt64 header pixel " << idx << " = " << out->GetPixel(idx) << ", expected " << i + << std::endl; + ok = false; + } + } + if (ok) + { + std::cout << " UInt64 header_type base64 read OK" << std::endl; + } + else + { + status = EXIT_FAILURE; + } + } + + // ---- Read of a raw-appended-data file ---------------------------------- + // Build a VTI file with format="appended" and a binary block + // containing a UInt32 block-size header followed by raw little-endian floats. + { + const std::string fname = outDir + sep + "vti_appended.vti"; + + const float pixels[4] = { 10.0f, 20.0f, 30.0f, 40.0f }; + const auto blockSize = static_cast(sizeof(pixels)); + std::vector appended(sizeof(blockSize) + sizeof(pixels)); + std::memcpy(appended.data(), &blockSize, sizeof(blockSize)); + std::memcpy(appended.data() + sizeof(blockSize), pixels, sizeof(pixels)); + // Make sure we are little-endian on disk; swap if the host is big-endian. + if (itk::ByteSwapper::SystemIsBigEndian()) + { + itk::ByteSwapper::SwapRangeFromSystemToBigEndian( + reinterpret_cast(appended.data()), 1); + itk::ByteSwapper::SwapRangeFromSystemToBigEndian( + reinterpret_cast(appended.data() + sizeof(blockSize)), 4); + } + + { + std::ofstream f(fname.c_str(), std::ios::out | std::ios::binary); + f << "\n"; + f << "\n"; + f << " \n"; + f << " \n"; + f << " \n"; + f << " \n"; + f << " \n"; + f << " \n"; + f << " \n"; + f << " \n"; + f << " _"; + f.write(reinterpret_cast(appended.data()), static_cast(appended.size())); + f << "\n \n"; + f << "\n"; + } + + using ImageType = itk::Image; + auto reader = itk::ImageFileReader::New(); + reader->SetFileName(fname); + reader->SetImageIO(itk::VTIImageIO::New()); + ITK_TRY_EXPECT_NO_EXCEPTION(reader->Update()); + + auto out = reader->GetOutput(); + ImageType::IndexType idx; + bool ok = true; + for (int i = 0; i < 4; ++i) + { + idx[0] = i % 2; + idx[1] = i / 2; + const float expected = pixels[i]; + if (std::abs(out->GetPixel(idx) - expected) > 1e-5f) + { + std::cerr << " ERROR: appended-data pixel " << idx << " = " << out->GetPixel(idx) << ", expected " << expected + << std::endl; + ok = false; + } + } + if (ok) + { + std::cout << " Raw-appended-data read OK" << std::endl; + } + else + { + status = EXIT_FAILURE; + } + } + + // ---- Read of a big-endian base64 file (host-side byte-swap path) ------- + // Hand-craft a file declaring byte_order="BigEndian" and store the data + // pre-swapped on disk. The reader should swap to host order on load. + { + const std::string fname = outDir + sep + "vti_bigendian.vti"; + + // Pixel data: 4 little-endian uint16 values 1, 2, 3, 4 swapped to BE. + const std::uint16_t pixelsHost[4] = { 1, 2, 3, 4 }; + std::uint16_t pixelsBE[4]; + std::memcpy(pixelsBE, pixelsHost, sizeof(pixelsHost)); + // Always store as big-endian regardless of host byte order. + if (!itk::ByteSwapper::SystemIsBigEndian()) + { + for (auto & v : pixelsBE) + { + v = static_cast((v << 8) | (v >> 8)); + } + } + + const auto blockSize = static_cast(sizeof(pixelsBE)); + // The block-size header is also stored on disk in the file's byte order. + std::uint32_t blockSizeOnDisk = blockSize; + if (!itk::ByteSwapper::SystemIsBigEndian()) + { + blockSizeOnDisk = ((blockSize & 0xFFu) << 24) | ((blockSize & 0xFF00u) << 8) | ((blockSize & 0xFF0000u) >> 8) | + ((blockSize & 0xFF000000u) >> 24); + } + + std::vector raw(sizeof(blockSizeOnDisk) + sizeof(pixelsBE)); + std::memcpy(raw.data(), &blockSizeOnDisk, sizeof(blockSizeOnDisk)); + std::memcpy(raw.data() + sizeof(blockSizeOnDisk), pixelsBE, sizeof(pixelsBE)); + + static const char chars[] = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + std::string b64; + for (std::size_t i = 0; i < raw.size(); i += 3) + { + const unsigned int b0 = raw[i]; + const unsigned int b1 = (i + 1 < raw.size()) ? raw[i + 1] : 0u; + const unsigned int b2 = (i + 2 < raw.size()) ? raw[i + 2] : 0u; + b64 += chars[(b0 >> 2) & 0x3F]; + b64 += chars[((b0 << 4) | (b1 >> 4)) & 0x3F]; + b64 += (i + 1 < raw.size()) ? chars[((b1 << 2) | (b2 >> 6)) & 0x3F] : '='; + b64 += (i + 2 < raw.size()) ? chars[b2 & 0x3F] : '='; + } + + { + std::ofstream f(fname.c_str()); + f << "\n"; + f << "\n"; + f << " \n"; + f << " \n"; + f << " \n"; + f << " \n"; + f << " " << b64 << "\n"; + f << " \n"; + f << " \n"; + f << " \n"; + f << " \n"; + f << "\n"; + } + + using ImageType = itk::Image; + auto reader = itk::ImageFileReader::New(); + reader->SetFileName(fname); + reader->SetImageIO(itk::VTIImageIO::New()); + ITK_TRY_EXPECT_NO_EXCEPTION(reader->Update()); + + auto out = reader->GetOutput(); + ImageType::IndexType idx; + bool ok = true; + for (int i = 0; i < 4; ++i) + { + idx[0] = i % 2; + idx[1] = i / 2; + if (out->GetPixel(idx) != static_cast(i + 1)) + { + std::cerr << " ERROR: big-endian pixel " << idx << " = " << out->GetPixel(idx) << ", expected " << (i + 1) + << std::endl; + ok = false; + } + } + if (ok) + { + std::cout << " BigEndian byte-swap read OK" << std::endl; + } + else + { + status = EXIT_FAILURE; + } + } + + // ---- CanReadFile / CanWriteFile sanity --------------------------------- + { + auto io = itk::VTIImageIO::New(); + ITK_TEST_EXPECT_TRUE(io->CanWriteFile("foo.vti")); + ITK_TEST_EXPECT_TRUE(!io->CanWriteFile("foo.vtk")); + ITK_TEST_EXPECT_TRUE(!io->CanReadFile("nonexistent.vti")); + // A file that we just wrote should pass CanReadFile. + ITK_TEST_EXPECT_TRUE(io->CanReadFile((outDir + sep + "vti_uchar2d_binary.vti").c_str())); + } + + // ---- Read a zlib-compressed raw-appended VTI file ---------------------- + // Build a hand-crafted VTI with compressor="vtkZLibDataCompressor", + // format="appended", header_type="UInt32". Pixel data: 4 Float32 values. + { + const std::string fname = outDir + sep + "vti_zlib_appended.vti"; + + const float pixels[4] = { 5.0f, 10.0f, 15.0f, 20.0f }; + + // Compress the pixel data using zlib + const uLong srcLen = static_cast(sizeof(pixels)); + uLong destLen = compressBound(srcLen); + std::vector compBuf(static_cast(destLen)); + const int ret = + compress2(compBuf.data(), &destLen, reinterpret_cast(pixels), srcLen, Z_DEFAULT_COMPRESSION); + if (ret != Z_OK) + { + std::cerr << " ERROR: zlib compress2 failed in test setup" << std::endl; + return EXIT_FAILURE; + } + compBuf.resize(static_cast(destLen)); + + // Build VTK compression header (UInt32): nblocks=1, uncompBlockSize=srcLen, + // lastPartialSize=0 (last block is full), compSize0=destLen + const auto nblocks32 = static_cast(1); + const auto uncompBlockSize32 = static_cast(srcLen); + const auto lastPartialSize32 = static_cast(0); + const auto compSize0_32 = static_cast(destLen); + + std::vector appendedData; + appendedData.resize(4 * sizeof(std::uint32_t) + compBuf.size()); + std::size_t pos = 0; + std::memcpy(appendedData.data() + pos, &nblocks32, sizeof(nblocks32)); + pos += sizeof(nblocks32); + std::memcpy(appendedData.data() + pos, &uncompBlockSize32, sizeof(uncompBlockSize32)); + pos += sizeof(uncompBlockSize32); + std::memcpy(appendedData.data() + pos, &lastPartialSize32, sizeof(lastPartialSize32)); + pos += sizeof(lastPartialSize32); + std::memcpy(appendedData.data() + pos, &compSize0_32, sizeof(compSize0_32)); + pos += sizeof(compSize0_32); + std::memcpy(appendedData.data() + pos, compBuf.data(), compBuf.size()); + + { + std::ofstream f(fname.c_str(), std::ios::out | std::ios::binary); + f << "\n"; + f << "\n"; + f << " \n"; + f << " \n"; + f << " \n"; + f << " \n"; + f << " \n"; + f << " \n"; + f << " \n"; + f << " \n"; + f << " _"; + f.write(reinterpret_cast(appendedData.data()), static_cast(appendedData.size())); + f << "\n \n"; + f << "\n"; + } + + using ImageType = itk::Image; + auto reader = itk::ImageFileReader::New(); + reader->SetFileName(fname); + reader->SetImageIO(itk::VTIImageIO::New()); + ITK_TRY_EXPECT_NO_EXCEPTION(reader->Update()); + + auto out = reader->GetOutput(); + ImageType::IndexType idx; + bool ok = true; + for (int i = 0; i < 4; ++i) + { + idx[0] = i % 2; + idx[1] = i / 2; + if (std::abs(out->GetPixel(idx) - pixels[i]) > 1e-5f) + { + std::cerr << " ERROR: zlib appended pixel " << idx << " = " << out->GetPixel(idx) << ", expected " << pixels[i] + << std::endl; + ok = false; + } + } + if (ok) + { + std::cout << " ZLib compressed appended-data read OK" << std::endl; + } + else + { + status = EXIT_FAILURE; + } + } + + // ---- Read a base64-encoded appended VTI file --------------------------- + // Build a hand-crafted VTI with format="appended", encoding="base64". + // Pixel data: 4 Float32 values (25.0f, 30.0f, 35.0f, 40.0f). + { + const std::string fname = outDir + sep + "vti_base64_appended.vti"; + + const float pixels[4] = { 25.0f, 30.0f, 35.0f, 40.0f }; + + // Build the binary payload: UInt32 block size + pixel data + const auto blockSize = static_cast(sizeof(pixels)); + std::vector binaryPayload(sizeof(blockSize) + sizeof(pixels)); + std::memcpy(binaryPayload.data(), &blockSize, sizeof(blockSize)); + std::memcpy(binaryPayload.data() + sizeof(blockSize), pixels, sizeof(pixels)); + + // Base64-encode the payload + static const char chars[] = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + std::string base64; + for (std::size_t i = 0; i < binaryPayload.size(); i += 3) + { + const unsigned int b0 = binaryPayload[i]; + const unsigned int b1 = (i + 1 < binaryPayload.size()) ? binaryPayload[i + 1] : 0u; + const unsigned int b2 = (i + 2 < binaryPayload.size()) ? binaryPayload[i + 2] : 0u; + base64 += chars[(b0 >> 2) & 0x3F]; + base64 += chars[((b0 << 4) | (b1 >> 4)) & 0x3F]; + base64 += (i + 1 < binaryPayload.size()) ? chars[((b1 << 2) | (b2 >> 6)) & 0x3F] : '='; + base64 += (i + 2 < binaryPayload.size()) ? chars[b2 & 0x3F] : '='; + } + + { + std::ofstream f(fname.c_str()); + f << "\n"; + f << "\n"; + f << " \n"; + f << " \n"; + f << " \n"; + f << " \n"; + f << " \n"; + f << " \n"; + f << " \n"; + f << " \n"; + f << " _" << base64 << "\n"; + f << " \n"; + f << "\n"; + } + + using ImageType = itk::Image; + auto reader = itk::ImageFileReader::New(); + reader->SetFileName(fname); + reader->SetImageIO(itk::VTIImageIO::New()); + ITK_TRY_EXPECT_NO_EXCEPTION(reader->Update()); + + auto out = reader->GetOutput(); + ImageType::IndexType idx; + bool ok = true; + for (int i = 0; i < 4; ++i) + { + idx[0] = i % 2; + idx[1] = i / 2; + if (std::abs(out->GetPixel(idx) - pixels[i]) > 1e-5f) + { + std::cerr << " ERROR: base64 appended pixel " << idx << " = " << out->GetPixel(idx) << ", expected " + << pixels[i] << std::endl; + ok = false; + } + } + if (ok) + { + std::cout << " Base64 appended-data read OK" << std::endl; + } + else + { + status = EXIT_FAILURE; + } + } + + // ---- XML entity-expansion hardening: DOCTYPE / ENTITY rejection ------- + // Billion-laughs and XXE mitigation: the reader must refuse any file + // that declares a DOCTYPE or ENTITY. VTK's XML schema has no legitimate + // use for either, so aborting up-front is safe. + { + const std::string fname = outDir + sep + "vti_entity_attack.vti"; + { + std::ofstream f(fname.c_str()); + f << "\n"; + f << "\n"; + f << "]>\n"; + f << "\n"; + f << " \n"; + f << " \n"; + f << " \n"; + f << " 0\n"; + f << " \n"; + f << " \n"; + f << " \n"; + f << "\n"; + } + + auto io = itk::VTIImageIO::New(); + io->SetFileName(fname); + bool threw = false; + try + { + io->ReadImageInformation(); + } + catch (const itk::ExceptionObject & e) + { + threw = true; + if (std::string(e.GetDescription()).find("DOCTYPE") == std::string::npos && + std::string(e.GetDescription()).find("ENTITY") == std::string::npos) + { + std::cerr << " WARNING: DOCTYPE/ENTITY rejection fired for the wrong reason: " << e.GetDescription() + << std::endl; + } + } + if (threw) + { + std::cout << " DOCTYPE/ENTITY rejection OK" << std::endl; + } + else + { + std::cerr << " ERROR: File with and was not rejected: " << fname << std::endl; + status = EXIT_FAILURE; + } + } + + // ---- CellData-only file is rejected ----------------------------------- + // VTI files whose only DataArray children live inside (rather + // than ) are not supported today. The reader must raise an + // exception ("No DataArray element found") rather than silently picking + // up the cell-centered array as if it were point data. + { + const std::string fname = outDir + sep + "vti_celldata_only.vti"; + { + std::ofstream f(fname.c_str()); + f << "\n"; + f << "\n"; + f << " \n"; + f << " \n"; + f << " \n"; + f << " \n"; + f << " \n"; + f << " \n"; + f << " 1.0\n"; + f << " \n"; + f << " \n"; + f << " \n"; + f << " \n"; + f << "\n"; + } + + using ImageType = itk::Image; + auto reader = itk::ImageFileReader::New(); + reader->SetFileName(fname); + reader->SetImageIO(itk::VTIImageIO::New()); + std::string description; + try + { + reader->Update(); + } + catch (const itk::ExceptionObject & e) + { + description = e.GetDescription(); + } + if (description.empty()) + { + std::cerr << " ERROR: File with only arrays was not rejected: " << fname << std::endl; + status = EXIT_FAILURE; + } + else if (description.find("F-011") == std::string::npos) + { + std::cerr << " ERROR: CellData-only rejection diagnostic missing F-011 tag. Message:\n" + << description << std::endl; + status = EXIT_FAILURE; + } + else + { + std::cout << " CellData-only rejection OK (F-011 tag present)" << std::endl; + } + } + + // ---- PointData wins over CellData in mixed file ----------------------- + // When a file contains both and with DataArray + // children, the reader must consume the PointData array and ignore the + // CellData one, regardless of element order in the file. Order the + // CellData block first to exercise the stronger guarantee: the + // PointData-scoping check must reject CellData even if its DataArray + // appears earlier in the document. + { + const std::string fname = outDir + sep + "vti_celldata_then_pointdata.vti"; + { + std::ofstream f(fname.c_str()); + f << "\n"; + f << "\n"; + f << " \n"; + f << " \n"; + f << " \n"; + f << " \n"; + f << " 99.0\n"; + f << " \n"; + f << " \n"; + f << " \n"; + f << " \n"; + f << " 1.0 2.0 3.0 4.0\n"; + f << " \n"; + f << " \n"; + f << " \n"; + f << " \n"; + f << "\n"; + } + + using ImageType = itk::Image; + auto reader = itk::ImageFileReader::New(); + reader->SetFileName(fname); + reader->SetImageIO(itk::VTIImageIO::New()); + ITK_TRY_EXPECT_NO_EXCEPTION(reader->Update()); + + auto out = reader->GetOutput(); + ImageType::IndexType idx; + idx[0] = 0; + idx[1] = 0; + if (std::abs(out->GetPixel(idx) - 1.0f) > 1e-5f) + { + std::cerr << " ERROR: PointData-vs-CellData pixel(0,0) = " << out->GetPixel(idx) + << ", expected 1.0 (PointData), got cell_density value if 99.0" << std::endl; + status = EXIT_FAILURE; + } + idx[0] = 1; + idx[1] = 1; + if (std::abs(out->GetPixel(idx) - 4.0f) > 1e-5f) + { + std::cerr << " ERROR: PointData-vs-CellData pixel(1,1) = " << out->GetPixel(idx) << ", expected 4.0" + << std::endl; + status = EXIT_FAILURE; + } + if (status == EXIT_SUCCESS) + { + std::cout << " PointData wins over CellData OK" << std::endl; + } + } + + // ---- Direction attribute trailing-junk rejection ---------------------- + // 9 valid floats followed by trailing non-whitespace text must be rejected; + // a sloppy `>>`-only parse would silently accept the 9 floats and discard + // the rest. + { + const std::string fname = outDir + sep + "vti_direction_trailing_junk.vti"; + { + std::ofstream f(fname.c_str()); + f << "\n"; + f << "\n"; + f << " \n"; + f << " \n"; + f << " \n"; + f << " \n"; + f << " 1.0 2.0 3.0 4.0\n"; + f << " \n"; + f << " \n"; + f << " \n"; + f << " \n"; + f << "\n"; + } + + using ImageType = itk::Image; + auto reader = itk::ImageFileReader::New(); + reader->SetFileName(fname); + reader->SetImageIO(itk::VTIImageIO::New()); + bool threw = false; + try + { + reader->Update(); + } + catch (const itk::ExceptionObject &) + { + threw = true; + } + if (threw) + { + std::cout << " Direction trailing-junk rejection OK" << std::endl; + } + else + { + std::cerr << " ERROR: Direction with trailing junk was not rejected: " << fname << std::endl; + status = EXIT_FAILURE; + } + } + + // ---- Malformed: without the '_' marker ----------------- + // A valid AppendedData block has a single '_' before the binary payload. + // The reader scans forward from the tag offset for that byte; if the file + // ends first, the read must throw a clear diagnostic rather than reading + // past EOF or returning garbage. + { + const std::string fname = outDir + sep + "vti_appended_no_marker.vti"; + { + std::ofstream f(fname.c_str(), std::ios::out | std::ios::binary); + f << "\n"; + f << "\n"; + f << " \n"; + f << " \n"; + f << " \n"; + f << " \n"; + f << " \n"; + f << " \n"; + f << " \n"; + f << " \n"; + // Intentionally NO '_' marker here. Truncate. + f << "\n"; + f << "\n"; + } + + using ImageType = itk::Image; + auto reader = itk::ImageFileReader::New(); + reader->SetFileName(fname); + reader->SetImageIO(itk::VTIImageIO::New()); + bool threw = false; + try + { + reader->Update(); + } + catch (const itk::ExceptionObject &) + { + threw = true; + } + if (threw) + { + std::cout << " Missing '_' marker rejection OK" << std::endl; + } + else + { + std::cerr << " ERROR: AppendedData file with no '_' marker was not rejected: " << fname << std::endl; + status = EXIT_FAILURE; + } + } + + // ---- Malformed: non-numeric NumberOfComponents ----------------------- + // The reader wraps std::stoul in try/catch and rethrows as + // itk::ExceptionObject; the prior tests had no fixture that hit that + // path. A bogus NumberOfComponents="banana" must surface as an ITK + // exception rather than std::invalid_argument. + { + const std::string fname = outDir + sep + "vti_bogus_components.vti"; + { + std::ofstream f(fname.c_str()); + f << "\n"; + f << "\n"; + f << " \n"; + f << " \n"; + f << " \n"; + f << " \n"; + f << " 1.0 2.0 3.0 4.0\n"; + f << " \n"; + f << " \n"; + f << " \n"; + f << " \n"; + f << "\n"; + } + + using ImageType = itk::Image; + auto reader = itk::ImageFileReader::New(); + reader->SetFileName(fname); + reader->SetImageIO(itk::VTIImageIO::New()); + bool threwITK = false; + try + { + reader->Update(); + } + catch (const itk::ExceptionObject &) + { + threwITK = true; + } + if (threwITK) + { + std::cout << " Bogus NumberOfComponents rejection OK (itk::ExceptionObject)" << std::endl; + } + else + { + std::cerr << " ERROR: NumberOfComponents='banana' did not raise itk::ExceptionObject: " << fname << std::endl; + status = EXIT_FAILURE; + } + } + + // ---- Non-orthonormal Direction: warning, not exception --------------- + // ITK pipelines assume orthonormality for Direction, so the reader emits + // an itkWarningMacro for non-orthonormal matrices. The read must still + // succeed -- the user might be loading legacy data and just wants the + // pixels. Assert no exception, which both proves the warning is + // non-fatal and that ortho-validation didn't accidentally introduce a + // throw. + { + const std::string fname = outDir + sep + "vti_nonortho_direction.vti"; + { + std::ofstream f(fname.c_str()); + f << "\n"; + f << "\n"; + f << " \n"; + f << " \n"; + f << " \n"; + f << " \n"; + f << " 1.0 2.0 3.0 4.0\n"; + f << " \n"; + f << " \n"; + f << " \n"; + f << " \n"; + f << "\n"; + } + + using ImageType = itk::Image; + auto reader = itk::ImageFileReader::New(); + reader->SetFileName(fname); + reader->SetImageIO(itk::VTIImageIO::New()); + ITK_TRY_EXPECT_NO_EXCEPTION(reader->Update()); + std::cout << " Non-orthonormal Direction load OK (warning expected on console)" << std::endl; + } + + return status; +} diff --git a/Modules/IO/VTK/wrapping/itkVTIImageIO.wrap b/Modules/IO/VTK/wrapping/itkVTIImageIO.wrap new file mode 100644 index 00000000000..639791947c5 --- /dev/null +++ b/Modules/IO/VTK/wrapping/itkVTIImageIO.wrap @@ -0,0 +1,2 @@ +itk_wrap_simple_class("itk::VTIImageIO" POINTER) +itk_wrap_simple_class("itk::VTIImageIOFactory" POINTER)