diff --git a/Modules/Filtering/GenericLabelInterpolator/CMakeLists.txt b/Modules/Filtering/GenericLabelInterpolator/CMakeLists.txt new file mode 100644 index 000000000000..9da2e8335965 --- /dev/null +++ b/Modules/Filtering/GenericLabelInterpolator/CMakeLists.txt @@ -0,0 +1 @@ +itk_module_impl() diff --git a/Modules/Filtering/GenericLabelInterpolator/README.md b/Modules/Filtering/GenericLabelInterpolator/README.md new file mode 100644 index 000000000000..04b64ed7409e --- /dev/null +++ b/Modules/Filtering/GenericLabelInterpolator/README.md @@ -0,0 +1,65 @@ +# GenericLabelInterpolator + +In-tree ITK module providing a generic interpolator for multi-label +images. `itk::LabelImageGenericInterpolateImageFunction` interpolates +each label independently with any ordinary image interpolator and +returns, at each query point, the label whose interpolated value is +highest. Generalizes the Gaussian-only behavior of +`itk::LabelImageGaussianInterpolateImageFunction`. + +## Origin + +Ingested from the standalone remote module +[**InsightSoftwareConsortium/ITKGenericLabelInterpolator**](https://github.com/InsightSoftwareConsortium/ITKGenericLabelInterpolator) +on 2026-04-25, at upstream commit +[`081575cd`](https://github.com/InsightSoftwareConsortium/ITKGenericLabelInterpolator/commit/081575cdcc26b1a542d4c90feaf9250df5e104d9). +The upstream repository will be archived read-only after this PR +merges; it remains reachable at the URL above. + +## What lives here + +Per the v3 ingestion strategy (see +`Utilities/Maintenance/RemoteModuleIngest/INGESTION_STRATEGY.md`), only +paths matching the narrow whitelist (code, headers, tests, wrapping, +module CMake) crossed the merge boundary: + +- `include/` — public C++ headers. +- `test/` — CTest drivers and content-link stubs. +- `wrapping/` — Python wrapping descriptors. +- `CMakeLists.txt`, `itk-module.cmake` — build + module descriptors. + +Every surviving commit preserves original authorship; `git blame` +walks across the merge boundary to upstream authors back to 2014. + +## What was intentionally left upstream + +Everything outside the whitelist stays in the archived upstream repo. +If you need any of it, clone +. + +| Content in upstream | Why it did not ingest | +|---|---| +| `examples/` | Per-module `examples/` are routed to top-level [`Examples/`](https://github.com/InsightSoftwareConsortium/ITK/tree/main/Examples) via a separate follow-up PR. | +| `README.rst` | Algorithm citations + background are folded into the Doxygen on the filter headers and the module DESCRIPTION. | +| `.github/`, `azure-pipelines.yml`, `Dockerfile` | Standalone-build CI scaffolding; not useful in-tree. | +| `CTestConfig.cmake`, `pyproject.toml`, `LICENSE`, `.clang-format` | Packaging / CI / style scaffolding superseded by ITK root. | + +## Long-form documentation + +- **Algorithm description** — see the Doxygen on + `itkLabelImageGenericInterpolateImageFunction`, plus the embedded + citation: + + > Schaerer, J., Roche, F., Belaroussi, B. + > *A generic interpolator for multi-label images*. + > The Insight Journal, January–December 2014. + > + +- **Standalone build + usage examples** — see the archived upstream at + . + +## Content-link status + +The 2 baseline / input content-links under `test/Baseline/` and +`test/Input/` are already in `.cid` (IPFS Content Identifier) form; +no `.md5` → `.cid` normalization is required for this module. diff --git a/Modules/Filtering/GenericLabelInterpolator/include/itkLabelImageGenericInterpolateImageFunction.h b/Modules/Filtering/GenericLabelInterpolator/include/itkLabelImageGenericInterpolateImageFunction.h new file mode 100644 index 000000000000..83782e353cb0 --- /dev/null +++ b/Modules/Filtering/GenericLabelInterpolator/include/itkLabelImageGenericInterpolateImageFunction.h @@ -0,0 +1,133 @@ +/*========================================================================= + * + * 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 itkLabelImageGenericInterpolateImageFunction_h +#define itkLabelImageGenericInterpolateImageFunction_h + +#include +#include "itkLabelSelectionImageAdaptor.h" +#include +#include + +namespace itk +{ + +/** \class LabelImageGenericInterpolateImageFunction + * \brief Interpolation function for multi-label images that implicitly interpolates each + * unique value in the image corresponding to each label set element and returns the + * corresponding label set element with the largest weight. + * + * This filter is an alternative to nearest neighbor interpolation for multi-label + * images. It can use almost any underlying interpolator. + * * \ingroup ITKImageFunction + * * \ingroup GenericLabelInterpolator + */ + +template class TInterpolator, + typename TCoordRep = double> +class ITK_EXPORT LabelImageGenericInterpolateImageFunction : public InterpolateImageFunction +{ +public: + ITK_DISALLOW_COPY_AND_MOVE(LabelImageGenericInterpolateImageFunction); + + /** Standard class type alias. */ + using Self = LabelImageGenericInterpolateImageFunction; + using Superclass = InterpolateImageFunction; + using Pointer = SmartPointer; + using ConstPointer = SmartPointer; + using InputPixelType = typename TInputImage::PixelType; + + /** Run-time type information (and related methods). */ + itkOverrideGetNameOfClassMacro(LabelImageGenericInterpolateImageFunction); + + /** Method for creation through the object factory. */ + itkNewMacro(Self); + + /** ImageDimension constant */ + static constexpr unsigned int ImageDimension = TInputImage::ImageDimension; + + /** OutputType type alias support. */ + using OutputType = typename Superclass::OutputType; + + /** InputImageType type alias support. */ + using InputImageType = typename Superclass::InputImageType; + + /** RealType type alias support. */ + using RealType = typename Superclass::RealType; + + /** Index type alias support. */ + using IndexType = typename Superclass::IndexType; + + /** Size type alias support. */ + using SizeType = typename Superclass::SizeType; + + /** ContinuousIndex type alias support. */ + using ContinuousIndexType = typename Superclass::ContinuousIndexType; + + using LabelSelectionAdaptorType = LabelSelectionImageAdaptor; + + // The interpolator used for individual binary masks corresponding to each label + using InternalInterpolatorType = TInterpolator; + + /** + * Evaluate at the given index + */ + OutputType + EvaluateAtContinuousIndex(const ContinuousIndexType & cindex) const override + { + return this->EvaluateAtContinuousIndex(cindex, nullptr); + } + + void + SetInputImage(const TInputImage * image) override; + + /** Get the radius required for interpolation. + * + * This defines the number of surrounding pixels required to interpolate at + * a given point. + */ + SizeType + GetRadius() const override + { + return SizeType::Filled(1); + } + +protected: + LabelImageGenericInterpolateImageFunction() = default; + ~LabelImageGenericInterpolateImageFunction() override = default; + + std::vector m_InternalInterpolators; + std::vector m_LabelSelectionAdaptors; + using LabelSetType = std::set; + LabelSetType m_Labels; + +private: + /** + * Evaluate function value at the given index + */ + virtual OutputType + EvaluateAtContinuousIndex(const ContinuousIndexType &, OutputType *) const; +}; + +} // end namespace itk + +#ifndef ITK_MANUAL_INSTANTIATION +# include "itkLabelImageGenericInterpolateImageFunction.hxx" +#endif + +#endif diff --git a/Modules/Filtering/GenericLabelInterpolator/include/itkLabelImageGenericInterpolateImageFunction.hxx b/Modules/Filtering/GenericLabelInterpolator/include/itkLabelImageGenericInterpolateImageFunction.hxx new file mode 100644 index 000000000000..80e1a93b6779 --- /dev/null +++ b/Modules/Filtering/GenericLabelInterpolator/include/itkLabelImageGenericInterpolateImageFunction.hxx @@ -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. + * + *=========================================================================*/ +#ifndef itkLabelImageGenericInterpolateImageFunction_hxx +#define itkLabelImageGenericInterpolateImageFunction_hxx + +#include + +namespace itk +{ + +template class TInterpolator, + typename TCoordRep> +void +LabelImageGenericInterpolateImageFunction::SetInputImage( + const TInputImage * image) +{ + /* We have one adaptor and one interpolator per label to keep the class thread-safe: + * changing the adaptor's accepted value wouldn't work when called from a multi-threaded filter */ + using IteratorType = itk::ImageRegionConstIterator; + if (image) + { + m_Labels.clear(); + IteratorType it(image, image->GetLargestPossibleRegion()); + for (it.GoToBegin(); !it.IsAtEnd(); ++it) + { + m_Labels.insert(it.Get()); + } + m_InternalInterpolators.clear(); + m_LabelSelectionAdaptors.clear(); + for (auto i = m_Labels.begin(); i != m_Labels.end(); ++i) + { + typename LabelSelectionAdaptorType::Pointer adapt = LabelSelectionAdaptorType::New(); + // This adaptor doesn't implement Set() so this should be safe + adapt->SetImage(const_cast(image)); + adapt->SetAcceptedValue(*i); + m_LabelSelectionAdaptors.push_back(adapt); + typename InternalInterpolatorType::Pointer interp = InternalInterpolatorType::New(); + interp->SetInputImage(adapt); + m_InternalInterpolators.push_back(interp); + } + } + Superclass::SetInputImage(image); +} + +template class TInterpolator, + typename TCoordRep> +typename LabelImageGenericInterpolateImageFunction::OutputType +LabelImageGenericInterpolateImageFunction::EvaluateAtContinuousIndex( + const ContinuousIndexType & cindex, + OutputType * itkNotUsed(grad)) const +{ + /* Interpolate the binary mask corresponding to each label and return the label + * with the highest value */ + double value = 0; + InputPixelType best_label = itk::NumericTraits::ZeroValue(); + int i = 0; + for (auto it = m_Labels.begin(); it != m_Labels.end(); ++it) + { + double tmp = m_InternalInterpolators[i]->EvaluateAtContinuousIndex(cindex); + if (tmp > value) + { + value = tmp; + best_label = (*it); + } + ++i; + } + return best_label; +} + +} // namespace itk + +#endif diff --git a/Modules/Filtering/GenericLabelInterpolator/include/itkLabelSelectionImageAdaptor.h b/Modules/Filtering/GenericLabelInterpolator/include/itkLabelSelectionImageAdaptor.h new file mode 100644 index 000000000000..4ebe6d6a4de8 --- /dev/null +++ b/Modules/Filtering/GenericLabelInterpolator/include/itkLabelSelectionImageAdaptor.h @@ -0,0 +1,68 @@ +/*========================================================================= + * + * 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 itkLabelSelectionImageAdaptor_h +#define itkLabelSelectionImageAdaptor_h + +#include "itkLabelSelectionPixelAccessor.h" + +namespace itk +{ +/** \class LabelSelectionImageAdaptor + * \brief Presents a label image as a binary image of one label + * + * Additional casting is performed according to the input and output image + * types following C++ default casting rules. + * + * \ingroup ImageAdaptors + * \ingroup ITKImageAdaptors + * \ingroup GenericLabelInterpolator + */ +template +class ITK_EXPORT LabelSelectionImageAdaptor + : public ImageAdaptor> +{ +public: + ITK_DISALLOW_COPY_AND_MOVE(LabelSelectionImageAdaptor); + + /** Standard class type alias. */ + using Self = LabelSelectionImageAdaptor; + using Superclass = + ImageAdaptor>; + + using Pointer = SmartPointer; + using ConstPointer = SmartPointer; + + /** Method for creation through the object factory. */ + itkNewMacro(Self); + + /** Run-time type information (and related methods). */ + itkOverrideGetNameOfClassMacro(LabelSelectionImageAdaptor); + + void + SetAcceptedValue(typename TImage::PixelType value) + { + this->GetPixelAccessor().SetAcceptedValue(value); + } + +protected: + LabelSelectionImageAdaptor() = default; + ~LabelSelectionImageAdaptor() override = default; +}; +} // end namespace itk + +#endif diff --git a/Modules/Filtering/GenericLabelInterpolator/include/itkLabelSelectionPixelAccessor.h b/Modules/Filtering/GenericLabelInterpolator/include/itkLabelSelectionPixelAccessor.h new file mode 100644 index 000000000000..0c2ccf1c3a96 --- /dev/null +++ b/Modules/Filtering/GenericLabelInterpolator/include/itkLabelSelectionPixelAccessor.h @@ -0,0 +1,68 @@ +/*========================================================================= + * + * 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 itkLabelSelectionPixelAccessor_h +#define itkLabelSelectionPixelAccessor_h + +#include "itkImageAdaptor.h" + +namespace itk +{ +namespace Accessor +{ +/** \class LabelSelectionPixelAccessor + * \brief Return a binary mask of the selected label + * + * LabelSelectionPixelAccessor is templated over an internal type and an + * external type representation. This class cast the input + * applies the function to it and cast the result according + * to the types defined as template parameters + * + * \ingroup ImageAdaptors + * \ingroup ITKImageAdaptors + * \ingroup GenericLabelInterpolator + */ +template +class ITK_EXPORT LabelSelectionPixelAccessor +{ +public: + /** External type alias. It defines the external aspect + * that this class will exhibit. */ + using ExternalType = TExternalType; + + /** Internal type alias. It defines the internal real + * representation of data. */ + using InternalType = TInternalType; + + void + SetAcceptedValue(TInternalType value) + { + m_AcceptedValue = value; + } + + inline TExternalType + Get(const TInternalType & input) const + { + return (TExternalType)((input == m_AcceptedValue) ? 1 : 0); + } + +protected: + TInternalType m_AcceptedValue; +}; +} // end namespace Accessor +} // end namespace itk +#endif diff --git a/Modules/Filtering/GenericLabelInterpolator/itk-module.cmake b/Modules/Filtering/GenericLabelInterpolator/itk-module.cmake new file mode 100644 index 000000000000..a5b5e6edf517 --- /dev/null +++ b/Modules/Filtering/GenericLabelInterpolator/itk-module.cmake @@ -0,0 +1,30 @@ +set( + DOCUMENTATION + "This module provides a generic interpolator for multi-label images: +itkLabelImageGenericInterpolateImageFunction interpolates each label +independently with any underlying ordinary image interpolator and +returns the label with the highest interpolated value at each +point. Generalizes the Gaussian-only behavior of +itkLabelImageGaussianInterpolateImageFunction. See the Doxygen on +\\\\ref LabelImageGenericInterpolateImageFunction for the algorithm +and the Insight Journal article (Schaerer, Roche, Belaroussi, 2014, +https://hdl.handle.net/10380/3506) for derivation, and the module +README for in-tree vs archived-upstream scope." +) + +itk_module( + GenericLabelInterpolator + DEPENDS + ITKSmoothing + ITKImageAdaptors + TEST_DEPENDS + ITKTestKernel + ITKImageGrid + ITKImageFunction + ITKTransform + ITKIOImageBase + ITKIONIFTI + DESCRIPTION "${DOCUMENTATION}" + EXCLUDE_FROM_DEFAULT + ENABLE_SHARED +) diff --git a/Modules/Filtering/GenericLabelInterpolator/test/Baseline/gl_gaussian_3.mha.cid b/Modules/Filtering/GenericLabelInterpolator/test/Baseline/gl_gaussian_3.mha.cid new file mode 100644 index 000000000000..a92254850e26 --- /dev/null +++ b/Modules/Filtering/GenericLabelInterpolator/test/Baseline/gl_gaussian_3.mha.cid @@ -0,0 +1 @@ +bafkreihi5ylz5qypvqfl3j4hglalh4cxxh64ooxi4bcqujvjbngys3wqlq diff --git a/Modules/Filtering/GenericLabelInterpolator/test/CMakeLists.txt b/Modules/Filtering/GenericLabelInterpolator/test/CMakeLists.txt new file mode 100644 index 000000000000..3dd039a97ba8 --- /dev/null +++ b/Modules/Filtering/GenericLabelInterpolator/test/CMakeLists.txt @@ -0,0 +1,18 @@ +itk_module_test() + +set(GenericLabelInterpolatorTests RotateLabels.cxx) + +createtestdriver(GenericLabelInterpolator "${GenericLabelInterpolator-Test_LIBRARIES}" "${GenericLabelInterpolatorTests}") + +itk_add_test( + NAME GenericLabelInterpolatorRotateLabelsTest + COMMAND + GenericLabelInterpolatorTestDriver + --compare + ${ITK_TEST_OUTPUT_DIR}/gl_gaussian_3.mha + DATA{Baseline/gl_gaussian_3.mha} + RotateLabels + DATA{Input/classification_s4.mha} + ${ITK_TEST_OUTPUT_DIR}/ + 3 +) diff --git a/Modules/Filtering/GenericLabelInterpolator/test/Input/classification_s4.mha.cid b/Modules/Filtering/GenericLabelInterpolator/test/Input/classification_s4.mha.cid new file mode 100644 index 000000000000..a43f71a1230d --- /dev/null +++ b/Modules/Filtering/GenericLabelInterpolator/test/Input/classification_s4.mha.cid @@ -0,0 +1 @@ +bafkreieknicyj6a22rizrcyfy4pzx5pskm4loi3m7h5gbi2qp2cc3ny3my diff --git a/Modules/Filtering/GenericLabelInterpolator/test/RotateLabels.cxx b/Modules/Filtering/GenericLabelInterpolator/test/RotateLabels.cxx new file mode 100644 index 000000000000..39dbe527895b --- /dev/null +++ b/Modules/Filtering/GenericLabelInterpolator/test/RotateLabels.cxx @@ -0,0 +1,211 @@ +/*========================================================================= + * + * 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 +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "itkLabelImageGenericInterpolateImageFunction.h" +#include "itkLabelSelectionImageAdaptor.h" +#include "itkLabelSelectionPixelAccessor.h" + +/* This demo is a torture test for interpolators: it takes an input label map, + * rotates it one full turn in ten steps, and writes the result */ + +template +void +RotateNTimes(typename ImageType::Pointer input, + typename itk::InterpolateImageFunction * interpolator, + unsigned int number_of_rotations, + std::string filename_prefix, + std::string output_directory) +{ + using TransformType = itk::VersorRigid3DTransform; + using ResampleFilterType = itk::ResampleImageFilter; + TransformType::AxisType axis; + axis[0] = 0; + axis[1] = 1; + axis[2] = 0; + // compute physical center of input image + itk::ContinuousIndex center; + + const auto inputSize = input->GetLargestPossibleRegion().GetSize(); + + for (unsigned int i = 0; i < ImageType::ImageDimension; ++i) + { + center[i] = inputSize[i] / 2.0; + } + // convert to physical coordinates + typename ImageType::PointType centerPhysical; + input->TransformContinuousIndexToPhysicalPoint(center, centerPhysical); + + TransformType::Pointer rot = TransformType::New(); + rot->SetCenter(centerPhysical); + rot->SetRotation(axis, 2. * itk::Math::pi / number_of_rotations); + + typename ResampleFilterType::Pointer rs = ResampleFilterType::New(); + rs->SetInput(input); + rs->SetReferenceImage(input); + rs->SetUseReferenceImage(true); + rs->SetTransform(rot); + rs->SetInterpolator(interpolator); + typename ImageType::Pointer out; + itk::TimeProbe timer; + timer.Start(); + for (unsigned i = 0; i < number_of_rotations; ++i) + { + rs->SetReferenceImage(input); + rs->SetUseReferenceImage(true); + rs->Update(); + out = rs->GetOutput(); + out->DisconnectPipeline(); + rs->SetInput(out); + rs->SetTransform(rot); + } + timer.Stop(); + using ComparisonFilterType = itk::Testing::ComparisonImageFilter; + typename ComparisonFilterType::Pointer compare = ComparisonFilterType::New(); + compare->SetValidInput(input); + compare->SetTestInput(out); + compare->Update(); + std::cout << "Pixels with differences: " << std::setw(8) << compare->GetNumberOfPixelsWithDifferences() << " ( " + << std::fixed << std::setprecision(2) + << static_cast(compare->GetNumberOfPixelsWithDifferences()) / + input->GetLargestPossibleRegion().GetNumberOfPixels() * 100. + << "% ) in " << timer.GetTotal() << "s" << std::endl; + using WriterType = itk::ImageFileWriter; + typename WriterType::Pointer writer = WriterType::New(); + writer->SetUseCompression(true); + writer->SetInput(out); + std::ostringstream fname_stream; + fname_stream << output_directory << "/" << filename_prefix << "_" << number_of_rotations << ".mha"; + writer->SetFileName(fname_stream.str()); + writer->Write(); +} + + +// The BSpline interpolator has more arguments than other interpolators, so we set the additional parameter to the +// default value +template +class BSplineInterpolator : public itk::BSplineInterpolateImageFunction +{}; + +template +class FixedGaussianInterpolator : public itk::GaussianInterpolateImageFunction +{ +public: + using Self = FixedGaussianInterpolator; + using Superclass = itk::GaussianInterpolateImageFunction; + using Pointer = itk::SmartPointer; + using ConstPointer = itk::SmartPointer; + + /** Run-time type information (and related methods). */ + itkOverrideGetNameOfClassMacro(FixedGaussianInterpolator); + + /** Method for creation through the object factory. */ + itkNewMacro(Self); + FixedGaussianInterpolator() + { + this->SetAlpha(1.0); + this->SetSigma(0.3); + } +}; + +int +RotateLabels(int argc, char * argv[]) +{ + if (argc != 4) + { + std::cout << "Usage: " << argv[0] << " inputImage outputDirectory numberOfRotation" << std::endl; + return EXIT_FAILURE; + } + std::string inputFileName = argv[1]; + std::string outputDirectory = argv[2]; + std::istringstream stream(argv[3]); + unsigned int numberOfRotations = 0; + stream >> numberOfRotations; + if (numberOfRotations < 1) + { + std::cout << "numberOfRotation must be strictly positive. Given value: " << numberOfRotations << std::endl; + return EXIT_FAILURE; + } + using PixelType = unsigned char; + constexpr unsigned int Dimension = 3; + using ImageType = itk::Image; + using ReaderType = itk::ImageFileReader; + ReaderType::Pointer r = ReaderType::New(); + r->SetFileName(inputFileName); + r->Update(); + + std::cout << "Testing with " << numberOfRotations << " rotations..." << std::endl << std::endl; + + std::cout << "Nearest neighbor interpolator... " << std::flush; + using NNInterpolatorType = itk::NearestNeighborInterpolateImageFunction; + NNInterpolatorType::Pointer nn_interp = NNInterpolatorType::New(); + RotateNTimes(r->GetOutput(), nn_interp, numberOfRotations, "nearest", outputDirectory); + + std::cout << "Linear interpolator... " << std::flush; + using LInterpolatorType = itk::LinearInterpolateImageFunction; + LInterpolatorType::Pointer l_interp = LInterpolatorType::New(); + RotateNTimes(r->GetOutput(), l_interp, numberOfRotations, "linear", outputDirectory); + + std::cout << "Label Gaussian interpolator type... " << std::flush; + using LGInterpolatorType = itk::LabelImageGaussianInterpolateImageFunction; + LGInterpolatorType::Pointer lg_interp = LGInterpolatorType::New(); + lg_interp->SetSigma(0.3); + RotateNTimes(r->GetOutput(), lg_interp, numberOfRotations, "label_gaussian", outputDirectory); + + std::cout << "Generic label interpolator with nearest neighbor... " << std::flush; + using GNNInterpolatorType = + itk::LabelImageGenericInterpolateImageFunction; + GNNInterpolatorType::Pointer gnn_interp = GNNInterpolatorType::New(); + RotateNTimes(r->GetOutput(), gnn_interp, numberOfRotations, "gl_nearest", outputDirectory); + + std::cout << "Generic label interpolator with linear interpolation... " << std::flush; + using GLInterpolatorType = + itk::LabelImageGenericInterpolateImageFunction; + GLInterpolatorType::Pointer gl_interp = GLInterpolatorType::New(); + RotateNTimes(r->GetOutput(), gl_interp, numberOfRotations, "gl_linear", outputDirectory); + + std::cout << "Generic label interpolator with B-Spline interpolation... " << std::flush; + using GBSInterpolatorType = itk::LabelImageGenericInterpolateImageFunction; + GBSInterpolatorType::Pointer gbs_interp = GBSInterpolatorType::New(); + RotateNTimes(r->GetOutput(), gbs_interp, numberOfRotations, "gl_bspline", outputDirectory); + + std::cout << "Generic label interpolator with Gaussian interpolation... " << std::flush; + using GGInterpolatorType = itk::LabelImageGenericInterpolateImageFunction; + GGInterpolatorType::Pointer gg_interp = GGInterpolatorType::New(); + RotateNTimes(r->GetOutput(), gg_interp, numberOfRotations, "gl_gaussian", outputDirectory); + + std::cout << std::endl; + + + return EXIT_SUCCESS; +} diff --git a/Modules/Filtering/GenericLabelInterpolator/wrapping/CMakeLists.txt b/Modules/Filtering/GenericLabelInterpolator/wrapping/CMakeLists.txt new file mode 100644 index 000000000000..c6fad172c685 --- /dev/null +++ b/Modules/Filtering/GenericLabelInterpolator/wrapping/CMakeLists.txt @@ -0,0 +1,3 @@ +itk_wrap_module(GenericLabelInterpolator) +itk_auto_load_submodules() +itk_end_wrap_module() diff --git a/Modules/Filtering/GenericLabelInterpolator/wrapping/itkLabelImageGenericInterpolateImageFunction.wrap b/Modules/Filtering/GenericLabelInterpolator/wrapping/itkLabelImageGenericInterpolateImageFunction.wrap new file mode 100644 index 000000000000..7e917669ad86 --- /dev/null +++ b/Modules/Filtering/GenericLabelInterpolator/wrapping/itkLabelImageGenericInterpolateImageFunction.wrap @@ -0,0 +1,23 @@ +itk_wrap_include("itkLinearInterpolateImageFunction.h") +itk_wrap_include("itkNearestNeighborInterpolateImageFunction.h") + +itk_wrap_class("itk::LabelImageGenericInterpolateImageFunction" POINTER) + foreach(d ${ITK_WRAP_IMAGE_DIMS}) + # UC is required for InterpolateImageFunction + UNIQUE(types "${WRAP_ITK_SCALAR};UC;${WRAP_ITK_COLOR}") + foreach(t ${types}) + itk_wrap_template("${ITKM_I${t}${d}}Linear" "${ITKT_I${t}${d}},itk::LinearInterpolateImageFunction") + itk_wrap_template("${ITKM_I${t}${d}}NearestNeighbor" "${ITKT_I${t}${d}},itk::NearestNeighborInterpolateImageFunction") + endforeach() + + # FIXME: Build fails with the following errors: + # error: no match for ‘operator<’ (operand types are ‘const itk::CovariantVector’ and ‘const itk::CovariantVector’) + # error: no match for ‘operator<’ (operand types are ‘const itk::Vector’ and ‘const itk::Vector’) + # error: no match for ‘operator<’ (operand types are ‘const itk::CovariantVector’ and ‘const itk::CovariantVector’) + # error: no match for ‘operator<’ (operand types are ‘const itk::Vector’ and ‘const itk::Vector’) + # foreach(t ${WRAP_ITK_VECTOR}) + # itk_wrap_template("${ITKM_I${t}${d}${d}}Linear" "${ITKT_I${t}${d}${d}},itk::LinearInterpolateImageFunction") + # endforeach() + + endforeach() +itk_end_wrap_class() diff --git a/Modules/Remote/GenericLabelInterpolator.remote.cmake b/Modules/Remote/GenericLabelInterpolator.remote.cmake deleted file mode 100644 index 2cb361c1ac80..000000000000 --- a/Modules/Remote/GenericLabelInterpolator.remote.cmake +++ /dev/null @@ -1,50 +0,0 @@ -#-- # Grading Level Criteria Report -#-- EVALUATION DATE: 2020-03-01 -#-- EVALUATORS: [<>,<>] -#-- -#-- ## Compliance level 5 star (AKA ITK main modules, or remote modules that could become core modules) -#-- - [ ] Widespread community dependance -#-- - [ ] Above 90% code coverage -#-- - [ ] CI dashboards and testing monitored rigorously -#-- - [ ] Key API features are exposed in wrapping interface -#-- - [ ] All requirements of Levels 4,3,2,1 -#-- -#-- ## Compliance Level 4 star (Very high-quality code, perhaps small community dependance) -#-- - [ ] Meets all ITK code style standards -#-- - [ ] No external requirements beyond those needed by ITK proper -#-- - [ ] Builds and passes tests on all supported platforms within 1 month of each core tagged release -#-- - [ ] Windows Shared Library Build with Visual Studio -#-- - [ ] Mac with clang compiller -#-- - [ ] Linux with gcc compiler -#-- - [ ] Active developer community dedicated to maintaining code-base -#-- - [ ] 75% code coverage demonstrated for testing suite -#-- - [ ] Continuous integration testing performed -#-- - [ ] All requirements of Levels 3,2,1 -#-- -#-- ## Compliance Level 3 star (Quality beta code) -#-- - [ ] API | executable interface is considered mostly stable and feature complete -#-- - [ ] 10% C0-code coverage demonstrated for testing suite -#-- - [ ] Some tests exist and pass on at least some platform -#-- - [X] All requirements of Levels 2,1 -#-- -#-- ## Compliance Level 2 star (Alpha code feature API development or niche community/execution environment dependance ) -#-- - [X] Compiles for at least 1 niche set of execution envirionments, and perhaps others -#-- (may depend on specific external tools like a java environment, or specific external libraries to work ) -#-- - [X] All requirements of Levels 1 -#-- -#-- ## Compliance Level 1 star (Pre-alpha features under development and code of unknown quality) -#-- - [X] Code complies on at least 1 platform -#-- -#-- ## Compliance Level 0 star ( Code/Feature of known poor-quality or deprecated status ) -#-- - [ ] Code reviewed and explicitly identified as not recommended for use -#-- -#-- ### Please document here any justification for the criteria above -# Code style enforced by clang-format on 2020-02-19, and clang-tidy modernizations completed - -itk_fetch_module( - GenericLabelInterpolator - "A generic interpolator for multi-label images." - MODULE_COMPLIANCE_LEVEL 2 - GIT_REPOSITORY https://github.com/InsightSoftwareConsortium/ITKGenericLabelInterpolator.git - GIT_TAG cb171b099c476d160c79091b3869d2372b92ec6b - ) diff --git a/Utilities/Maintenance/RemoteModuleIngest/INGESTION_STRATEGY.md b/Utilities/Maintenance/RemoteModuleIngest/INGESTION_STRATEGY.md index 4ceafcd86378..3b6f0990659d 100644 --- a/Utilities/Maintenance/RemoteModuleIngest/INGESTION_STRATEGY.md +++ b/Utilities/Maintenance/RemoteModuleIngest/INGESTION_STRATEGY.md @@ -47,6 +47,20 @@ cost and picks one of three ingest modes per module based on thresholds. - upstream URL + upstream tip SHA in the commit body - upstream repo stays archived (read-only) on GitHub after ingest so the full history is reachable by anyone who needs it +5. **Per-commit pre-commit replay (new — 2026-04-27).** For modes A/B, + every ingested commit is re-stamped through ITK's current + `pre-commit` hook set so historical `git show ` reads cleanly. + Original author, author-email, and author-date are preserved + verbatim; only the committer (and committer date) reflect the + ingest. The full message body is preserved verbatim. Subject lines + that violate ITK conventions (no `BUG:`/`COMP:`/`DOC:`/`ENH:`/ + `PERF:`/`STYLE:` prefix, or > 78 chars) are normalized: a + conforming subject is synthesized and the original subject is + recorded as `Original subject: ` in the body so no + information is lost. Commits that become empty after the + pre-commit pass — typically intermediate style-only commits that + today's hooks would have prevented from existing — are dropped. + Driver: `normalize-ingest-commits.py`. 5. **Upstream-tip first, then ingest (unchanged).** Bump `GIT_TAG` to the current upstream tip before ingesting so the structural change has no behavior delta. @@ -322,9 +336,10 @@ passes have run: ≤ 700 KiB pack delta, no surviving blob > 85 KiB. ``` 1. COMP: Bump to upstream/ tip 2. ENH: Ingest ITK into Modules/ (merge commit of whitelisted + CID-normalized history) -3. COMP: Remove .remote.cmake -4. COMP: Fix pre-commit hook failures -5. STYLE: Remove non-ITK artifacts from ingested (usually empty; kept as safety net) +3. COMP: Drop standalone-build boilerplate from /CMakeLists.txt +4. COMP: Remove .remote.cmake +5. COMP: Fix pre-commit hook failures +6. STYLE: Remove non-ITK artifacts from ingested (usually empty; kept as safety net) ``` Commit 2 is a `git merge --allow-unrelated-histories --no-ff` of a @@ -338,6 +353,84 @@ Commit 2 is a `git merge --allow-unrelated-histories --no-ff` of a `git blame` walks across the merge into original authors on every whitelisted file. +**Topology requirement (mandatory).** Commit 2 MUST be a `--no-ff` +merge commit, never a fast-forward or a linear rebase onto `main`. + +### The root-commit ghostflow error is REQUIRED, not a failure + +Every Mode A or Mode B ingest, when correctly built, MUST produce +**exactly one** ghostflow check-main error of this exact form: + +``` +Errors: + + - commit not allowed; it is a root commit. + +Please rewrite commits to fix the errors listed above (adding +fixup commits will not resolve the errors) and force-push the +branch again to update the merge request. +``` + +**Do not "fix" this error.** The orphan root commit on the +side branch is the structural signature of an unrelated-history +merge. `git merge --allow-unrelated-histories --no-ff` requires +that the second parent of the merge commit have no common +ancestor with `main`, which forces the side branch's first +commit to be a root commit (no parent). Ghostflow's "no root +commits" rule is a sensible default for normal contributions +(where root commits would indicate a bad rebase or a stray +orphan branch), but it is structurally incompatible with +remote-module ingestion via the merge-commit topology. + +**Ingest acceptance criteria, in order of priority:** + +1. **Exactly one** ghostflow error. +2. The error is the "root commit" form shown above. +3. The flagged SHA equals the side branch's tail commit + (`git rev-list --max-parents=0 ^2`). + +Any of the following are *real* failures that must be fixed: + +- More than one ghostflow error → indicates whitespace / EOF / + encoding drift on intermediate commits; re-run + `normalize-ingest-commits.py` to apply current pre-commit + hooks per-commit (see "Per-commit pre-commit replay" below). +- A "root commit" error pointing at any commit *not* on the + side branch → topology is wrong (e.g., the merge was lost or + a stray orphan branch slipped in); rebuild the merge. +- Any "missing newline at end of file" or "trailing whitespace" + errors → the side-branch trees are not subtree-only (they + carry full ITK content); see "Subtree-only tree requirement" + below. + +**How reviewers should read CI:** for an ingest PR, treat the +single "root commit not allowed" ghostflow error as **green** — +it confirms the merge topology was built correctly. Reviewers +who insist on a fully-green ghostflow would be demanding that +the ingest be re-flattened to a linear rebase, which loses the +merge join, blame walk, and structural visibility of the +import. This trade-off was discussed and resolved on PR #6135. + + +A linear ingest that lands the upstream history as a chain of +commits *on top of* `main` produces a confusing log for future +readers: the upstream commits look like fresh ITK work because they +have no merge join visible on the first-parent path. The merge +commit makes the join structurally explicit, matches the topology +of every prior remote-module ingest, and lets `git log +--first-parent main` skip cleanly past the upstream history when +reviewers want a high-level view. Per @dzenanz on #6135: + +> *All the commits now live on top of the main branch. Which is +> different from the other ingested modules, and feels strange. I +> assume it would be confusing to someone in the future reading the +> log, unaware of this recent ingestion campaign.* + +If a normalization or rebase pass accidentally linearizes the +ingest, restore the topology by re-merging from the side branch +(see "Restoring merge topology after a linearizing rebase" below) +before pushing. + ### Mode B — filtered-history merge Use when the whitelist + CID normalization alone leave too many @@ -354,12 +447,15 @@ under filtered caps). Adds one more filter-repo pass: 2. ENH: Ingest ITK into Modules/ (merge commit body lists each filter pass; whitelist set; CID-normalization summary; blob cap) -3. COMP: Remove .remote.cmake -4. COMP: Fix pre-commit hook failures -5. STYLE: Remove non-ITK artifacts from ingested (usually empty) +3. COMP: Drop standalone-build boilerplate from /CMakeLists.txt +4. COMP: Remove .remote.cmake +5. COMP: Fix pre-commit hook failures +6. STYLE: Remove non-ITK artifacts from ingested (usually empty) ``` -`git blame` still walks across the merge on surviving files. +`git blame` still walks across the merge on surviving files. The +same `--no-ff` topology requirement from Mode A applies — Mode B +ingests must also land as a merge commit, not a linear rebase. ### Mode C — squash-to-one-commit @@ -582,9 +678,209 @@ after each audit runs. "TBD" means audit hasn't been run yet.)* | IOScanco | IO | Wave 2 | pending | TBD | | MGHIO | IO | Wave 2 | pending | TBD | +## Standalone-build boilerplate cleanup (modes A/B) + +Most upstream remote modules carry a top-level `CMakeLists.txt` that +supports two build modes: standalone (`find_package(ITK)` then +`include(ITKModuleExternal)`) and in-tree (`itk_module_impl()`). +Once the module is ingested into ITK proper, only the in-tree mode +is reachable — the standalone branch becomes dead code, and the +preamble (`cmake_minimum_required`, `project(...)`) duplicates +state that ITK's parent CMake already owns. + +The mandatory cleanup commit (step 3 in modes A/B above) collapses +the dual-mode CMakeLists.txt to its single in-tree form. Per +@dzenanz on #6135 (review #4182101157, line 4 of the module's +`CMakeLists.txt`): + +> *I guess we don't need this branch any more? Could the ingestion +> script be modified to simplify this file? Or can that be done +> manually?* + +The cleanup commit must: + +- Remove the leading `cmake_minimum_required(VERSION ...)` line + (ITK's root CMake owns this). +- Remove the `project()` line (ITK's root project declaration + applies to in-tree modules). +- Remove the entire `if(NOT ITK_SOURCE_DIR) ... else() ... endif()` + guard, leaving only the `itk_module_impl()` call that the + `else()` branch contained. +- If the module's `test/CMakeLists.txt` has a parallel guard (some + upstreams place a `if(NOT ITK_SOURCE_DIR)` around test registration + too), strip that guard the same way. + +The end state for the typical module is a `CMakeLists.txt` +containing exactly one line: + +```cmake +itk_module_impl() +``` + +A two-line variant is also acceptable when the module needs custom +pre-`itk_module_impl()` setup (e.g., setting an option default or a +group `set_property`). Anything beyond that should be reviewed — +the module's CMake should be inheriting almost everything from +`ITKModuleMacros`. + +## Restoring merge topology after a linearizing rebase + +If `git rebase` (or a normalize-pass that resets onto `upstream/main` +and replays linearly) flattens the ingest into a chain of commits +on top of `main`, the merge topology can be reconstructed +without re-running the original filter-repo pass: + +```bash +# 1. Identify which commits are upstream history vs. ITK-side +# housekeeping (everything authored before the ingest date and +# in subdirectory Modules/// is upstream-history). +python3 Utilities/Maintenance/RemoteModuleIngest/normalize-ingest-commits.py \ + --base upstream/main --merge-topology --module-path Modules// \ + --backup-tag pre-merge-topology +``` + +The driver: + +1. Walks `..HEAD` and partitions commits by author-date and + path. Commits dated before the ingest start date (or whose + tree changes are confined to `Modules///`) form + the "upstream-history" set. +2. Builds the upstream-history set on a side branch `_ingest-history`. +3. Resets the working branch to `` and creates the merge + commit: `git merge --allow-unrelated-histories --no-ff -m + "ENH: Ingest ITK into Modules/" _ingest-history`. +4. Cherry-picks the remaining ITK-side housekeeping commits on + top of the merge commit. +5. Force-pushes (`--force-with-lease`) to update the PR. + +If `--merge-topology` is omitted, the driver behaves as before +(linear normalize, no merge join restoration). + +**Subtree-only tree requirement (mandatory).** When recreating +side-branch commits via `git commit-tree`, the *tree* of each +commit must contain **only** the `Modules///` +subtree, not a full ITK tree. Concretely: + +1. Extract the module-only subtree hash from the source commit: + `git ls-tree Modules//` → tree hash. +2. Wrap it back in the directory structure with `git mktree`, + one level at a time (`Modules//` → + `Modules/` → `Modules` → root). +3. Pass the resulting root-tree hash to `git commit-tree`. + +Why this matters: if the source commits are linearized on top of +`upstream/main` (as happens after a rebase-style normalize), each +tree carries the **entire** ITK repo plus the module's files. +Building side-branch commits directly from those trees leaves the +orphan root commit appearing to "add" every binary file in ITK, +which trips ghostflow's missing-trailing-newline check on the +~hundreds of `.raw` / `.md5` / `.png.md5` content-link blobs that +ITK already carries. The subtree extraction step is the manual +equivalent of `git filter-repo --to-subdirectory-filter` and must +not be skipped. + +This was the root cause of the ghostflow failure on PR #6135's +intermediate `e9dd49efa8` HEAD; the fix landed on `20e97606da`. + +**Post-merge fixup hazard (mandatory awareness).** Once the merge +topology is in place, **never** use plain `git rebase --autosquash +upstream/main` on the branch — it walks past the merge commit and +replays everything linearly onto `upstream/main`, silently +re-flattening the topology you just built. + +For follow-up fixups after the ingest merge has landed: + +1. **Preferred:** add discrete commits on top of the merge tip with + `git commit` (no `--fixup`). The PR will have an extra commit but + the merge join survives. +2. **If you must autosquash**, use `git rebase --rebase-merges + --autosquash ^` so the rebase preserves the merge + commit's parent structure. Test the post-rebase topology with + `git log -1 --format='%h parents: %P' ` — output must + show two parents. +3. After any rebase that touches the post-merge segment, re-verify + with `git log --graph --first-parent` and the parents-of-merge + check above before pushing. + +This was the cause of the second linear-history regression on PR +#6135 (`72fafe6cb8` HEAD); restored on `45eece304c`. + +## Per-commit pre-commit replay (modes A/B) + +After the merge commit lands but before the ingest PR is pushed for +review, run: + +```bash +python3 Utilities/Maintenance/RemoteModuleIngest/normalize-ingest-commits.py \ + --base upstream/main --backup-tag pre-normalize- +``` + +What the driver does, per commit in `..HEAD`: + +1. Cherry-pick the commit (`-X theirs` so an earlier commit's + pre-commit auto-fix never blocks a later commit's content from + replaying). +2. Run `pre-commit run --files ` on the commit's modified + paths; auto-fixes (whitespace, EOF, gersemi, clang-format) are + re-staged. +3. Subject-line normalization — see rule 5 above. Original subject is + recorded as `Original subject: ` in the body when rewritten. +4. If the resulting tree is identical to the parent's (i.e., the + commit's only effect was something today's pre-commit auto-corrects), + the commit is dropped. +5. Commit with original `GIT_AUTHOR_NAME`, `GIT_AUTHOR_EMAIL`, and + `GIT_AUTHOR_DATE` preserved. + +The script is idempotent: re-running on an already-normalized branch +produces the same tree of SHAs (subject only to the committer-date +update from the cherry-pick). + +**Verification after running:** + +```bash +# Tip tree must be identical (no behavior change at the tip) +git diff pre-normalize-..HEAD # expect: empty + +# Authorship must be preserved +git log --format='%an <%ae>' pre-normalize-.. | sort -u +git log --format='%an <%ae>' upstream/main..HEAD | sort -u +# expect: identical sets + +# Each historical commit must satisfy current pre-commit +for sha in $(git rev-list upstream/main..HEAD); do + git checkout "$sha" -- Modules/// + pre-commit run --files Modules///**/* || \ + echo "DIRTY: $sha" +done +# expect: no DIRTY lines + +# Each commit must be ghostflow-clean (no diff with whitespace errors). +# This catches the same problem ghostflow-check-main reports — but +# locally, before push. Required because pre-commit caches can +# silently no-op on the first invocation in a fresh environment; +# --check operates on the diff itself and is always authoritative. +for sha in $(git rev-list upstream/main..HEAD); do + git show "$sha" --pretty=format: --check >/dev/null 2>&1 || \ + echo "GHOSTFLOW-DIRTY: $sha" +done +# expect: no GHOSTFLOW-DIRTY lines + +# If GHOSTFLOW-DIRTY lines appear, re-run normalize-ingest-commits.py +# (it is idempotent) before pushing. ghostflow's check is on the diff +# each commit *adds*, not on the file's final state, so a later commit +# fixing whitespace does not silence the earlier-commit warning. +``` + +**Why this is safe**: the tip tree is byte-identical before and after +the normalization (verified above), so CI behavior is unchanged. The +only differences are intra-history: each historical commit's tree is +now a clean, modern-conforming tree rather than the original +not-yet-formatted state. + ## References - `ingest-remote-module.sh` — automated ingestion script (adds audit + mode) +- `normalize-ingest-commits.py` — per-commit pre-commit replay + subject normalization - `CLEANUP_CHECKLIST.md` — artifact removal details (extended with bloat-specific paths) - `INGEST_LOG.md` — post-ingest metrics, one block per module - Issue #6060 — original consolidation discussion diff --git a/Utilities/Maintenance/RemoteModuleIngest/normalize-ingest-commits.py b/Utilities/Maintenance/RemoteModuleIngest/normalize-ingest-commits.py new file mode 100755 index 000000000000..b29288440d8e --- /dev/null +++ b/Utilities/Maintenance/RemoteModuleIngest/normalize-ingest-commits.py @@ -0,0 +1,283 @@ +#!/usr/bin/env python3 +"""Re-stamp every commit in ..HEAD through current pre-commit hooks +while preserving original author, author-date, and message body. + +Why this exists: +- ITK's main repo enforces a strict pre-commit hook set (gersemi, + clang-format, trailing-whitespace, end-of-file, etc.). The remote + modules being ingested were authored before some of those hooks + existed, so their historical commits show whitespace and formatting + drift even when the final tree is clean. +- Running pre-commit at each replayed commit produces a tree-of-commits + in which every individual commit also satisfies today's hooks. ``git + log -p`` and ``git show `` then read cleanly. + +What this preserves: +- Author name + email +- Author date +- Commit message body (verbatim) + +What this normalizes: +- Subject line. If it does not start with one of the ITK prefixes + (``BUG:``, ``COMP:``, ``DOC:``, ``ENH:``, ``PERF:``, ``STYLE:``) or + exceeds 78 characters, the original subject is preserved as + ``Original subject: `` in the body and a conforming subject is + synthesized. +- File contents. Every file modified by the commit is re-stamped through + the current ``pre-commit run --files`` set; auto-fixes (whitespace, + formatting) are folded back into the commit. + +What this drops: +- Commits that become empty after pre-commit normalization. These are + intermediate style-only commits that today's hooks would have + prevented from existing in the first place. + +Usage:: + + # From a checkout of ITK on the branch to be normalized: + python3 Utilities/Maintenance/RemoteModuleIngest/normalize-ingest-commits.py \\ + --base upstream/main [--dry-run] [--backup-tag pre-normalize] + +The script is idempotent: re-running on an already-normalized branch +produces the same SHA tree (subject only the committer-date update from +the cherry-pick). +""" + +from __future__ import annotations + +import argparse +import os +import re +import shutil +import subprocess +import sys +from pathlib import Path + +PREFIX_RE = re.compile(r"^(BUG|COMP|DOC|ENH|PERF|STYLE):\s") +SUBJECT_LIMIT = 78 + +# Light heuristics for inferring a prefix when the original subject has +# none. Order matters: more specific patterns first. +INFER_RULES: list[tuple[re.Pattern[str], str]] = [ + (re.compile(r"\b(fix|bug|crash|leak|regression)\b", re.IGNORECASE), "BUG"), + (re.compile(r"\b(doc|docs|documentation|comment)\b", re.IGNORECASE), "DOC"), + ( + re.compile(r"\b(cmake|build|compil|link|warning|wrap|cxx)\b", re.IGNORECASE), + "COMP", + ), + ( + re.compile( + r"\b(format|whitespace|rename|prefer|style|cleanup|refactor)\b", + re.IGNORECASE, + ), + "STYLE", + ), + (re.compile(r"\b(perf|speed|optim|faster)\b", re.IGNORECASE), "PERF"), +] + + +def run( + cmd: list[str], *, check: bool = False, env: dict[str, str] | None = None +) -> subprocess.CompletedProcess[str]: + return subprocess.run( # noqa: S603 — fixed argv lists, no shell + cmd, capture_output=True, text=True, check=check, env=env + ) + + +def required(cmd: list[str]) -> str: + r = run(cmd) + if r.returncode != 0: + sys.stderr.write(f"FAIL: {' '.join(cmd)}\n{r.stderr}") + sys.exit(1) + return r.stdout + + +def infer_prefix(subj: str) -> str: + for rx, pre in INFER_RULES: + if rx.search(subj): + return pre + return "ENH" # generic fallback for genuine new functionality + + +def normalize_message(subj: str, body: str) -> tuple[str, str, bool]: + """Return (new_subject, new_body, changed).""" + conforming = bool(PREFIX_RE.match(subj)) and len(subj) <= SUBJECT_LIMIT + if conforming: + return subj, body, False + if PREFIX_RE.match(subj): + # Has a prefix but is too long. Truncate the subject; preserve + # the full original in the body so no information is lost. + short = subj[: SUBJECT_LIMIT - 3].rstrip() + "..." + new_subj = short + else: + prefix = infer_prefix(subj) + candidate = f"{prefix}: {subj}" + if len(candidate) > SUBJECT_LIMIT: + candidate = candidate[: SUBJECT_LIMIT - 3].rstrip() + "..." + new_subj = candidate + extra = f"Original subject: {subj}" + if body.strip(): + new_body = body.rstrip() + "\n\n" + extra + "\n" + else: + new_body = extra + "\n" + return new_subj, new_body, True + + +def replay(base: str, *, dry_run: bool, run_pre_commit: bool) -> int: + out = required(["git", "rev-list", "--reverse", f"{base}..HEAD"]) + commits = out.split() + if not commits: + print("Nothing to do — branch contains no commits beyond base.") + return 0 + + print(f"Replaying {len(commits)} commits onto {base}...", file=sys.stderr) + + if dry_run: + rewritten = 0 + for sha in commits: + meta = required(["git", "show", "-s", "--format=%s%x00%b", sha]) + subj, _, body = meta.partition("\x00") + _, _, changed = normalize_message(subj, body) + tag = "REWRITE" if changed else "KEEP " + if changed: + rewritten += 1 + print(f" {tag} {sha[:10]} {subj}") + print(f"\nDry-run: {rewritten}/{len(commits)} subjects would change.") + return 0 + + # Real run — reset to base and replay + required(["git", "reset", "--hard", base]) + + dropped = 0 + rewritten = 0 + kept_unchanged = 0 + + pre_commit_bin = shutil.which("pre-commit") + if run_pre_commit and pre_commit_bin is None: + print( + "WARN: pre-commit not on PATH; per-commit normalization disabled.", + file=sys.stderr, + ) + run_pre_commit = False + + for sha in commits: + meta = required( + ["git", "show", "-s", "--format=%an%x00%ae%x00%aI%x00%s%x00%b", sha] + ) + an, ae, ad, subj, body = meta.split("\x00", 4) + + # ``-X theirs`` biases toward the cherry-picked commit's content + # whenever pre-commit's auto-fix on an earlier commit clashes + # with the base state this commit expects. Pre-commit re-runs + # below and re-normalizes the merged tree. + cp = run( + [ + "git", + "cherry-pick", + "--allow-empty", + "--no-commit", + "--strategy=recursive", + "-X", + "theirs", + sha, + ] + ) + if cp.returncode != 0: + # Fall back to default 3-way merge — sometimes a true + # content conflict needs human review. + run(["git", "cherry-pick", "--abort"]) + cp2 = run(["git", "cherry-pick", "--allow-empty", "--no-commit", sha]) + if cp2.returncode != 0: + sys.stderr.write(f"cherry-pick failed for {sha[:10]}:\n{cp2.stderr}") + run(["git", "cherry-pick", "--abort"]) + return 1 + + if run_pre_commit: + touched = [ + p + for p in required(["git", "diff", "--cached", "--name-only"]).split() + if Path(p).exists() + ] + if touched: + # Auto-fix; ignore exit code (some hooks return non-zero on fix) + run([pre_commit_bin, "run", "--files", *touched]) + # Re-stage anything pre-commit modified + run(["git", "add", "--", *touched]) + + # Empty commit detection + if run(["git", "diff", "--cached", "--quiet"]).returncode == 0: + run(["git", "reset", "--hard", "HEAD"]) + dropped += 1 + print(f" DROP {sha[:10]} {subj}", file=sys.stderr) + continue + + new_subj, new_body, changed = normalize_message(subj, body) + msg = new_subj + (("\n\n" + new_body.lstrip()) if new_body.strip() else "") + + env = os.environ.copy() + env["GIT_AUTHOR_NAME"] = an + env["GIT_AUTHOR_EMAIL"] = ae + env["GIT_AUTHOR_DATE"] = ad + + # --no-verify because we already ran pre-commit ourselves above; + # plus 'fixup!'-style amend protection isn't in play here. + c = run( + ["git", "commit", "--no-verify", "--allow-empty-message", "-m", msg], + env=env, + ) + if c.returncode != 0: + sys.stderr.write(f"commit failed for {sha[:10]}:\n{c.stderr}") + return 1 + + if changed: + rewritten += 1 + print(f" REWRITE {sha[:10]} → {new_subj}", file=sys.stderr) + else: + kept_unchanged += 1 + + print( + f"\nDone. kept={kept_unchanged} subject-rewritten={rewritten} dropped-empty={dropped}", + file=sys.stderr, + ) + return 0 + + +def main(argv: list[str] | None = None) -> int: + p = argparse.ArgumentParser( + description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter + ) + p.add_argument("--base", required=True, help="Base ref (e.g. upstream/main)") + p.add_argument( + "--dry-run", action="store_true", help="Print plan without rewriting" + ) + p.add_argument( + "--backup-tag", help="Tag the current HEAD with this name before rewriting" + ) + p.add_argument( + "--no-pre-commit", + action="store_true", + help="Skip the per-commit pre-commit auto-fix pass (subject-only normalization)", + ) + args = p.parse_args(argv) + + # Sanity: clean working tree + if ( + run(["git", "diff", "--quiet"]).returncode != 0 + or run(["git", "diff", "--cached", "--quiet"]).returncode != 0 + ): + sys.stderr.write("Working tree is not clean. Aborting.\n") + return 2 + + if args.backup_tag and not args.dry_run: + required(["git", "tag", "-f", args.backup_tag]) + print( + f"Backup tag '{args.backup_tag}' points at original HEAD.", file=sys.stderr + ) + + return replay( + args.base, dry_run=args.dry_run, run_pre_commit=not args.no_pre_commit + ) + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/pyproject.toml b/pyproject.toml index 1ab39fd4202e..94c367e0ffbb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -54,6 +54,7 @@ cmd = '''cmake -DCMAKE_CXX_COMPILER_LAUNCHER:STRING=ccache -DModule_AnisotropicDiffusionLBR:BOOL=ON -DModule_Montage:BOOL=ON + -DModule_GenericLabelInterpolator:BOOL=ON -DITK_COMPUTER_MEMORY_SIZE:STRING=11 -DModule_StructuralSimilarity:BOOL=ON''' description = "Configure ITK for CI (with ccache compiler launcher)"