Skip to content

Commit 0e4b1a0

Browse files
committed
COMP: Make ITKVtkGlue wrapping abi3-compatible
Exchange pointers with VTK's Python layer through the `__this__` and `Addr=0x...` encodings using only Limited API calls, instead of through vtkPythonUtil, whose header chain accesses PyTypeObject members that Py_LIMITED_API hides. Dropping VTK::WrappingPythonCore from the wrapping link interface is required for correctness, not tidiness: that library is built against one libpython, so an extension linking it cannot be version-agnostic. Neither encoding is documented VTK API, so PythonVtkGlueABI3EncodingTest asserts both still hold and PythonVtkGlueRoundTripTest exercises the typemaps end to end. Closes: #6711
1 parent 3ae346c commit 0e4b1a0

7 files changed

Lines changed: 317 additions & 36 deletions

File tree

Modules/Bridge/VtkGlue/CMakeLists.txt

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -99,7 +99,6 @@ set(_required_vtk_libraries
9999
)
100100
if(ITK_WRAP_PYTHON)
101101
list(APPEND _required_vtk_libraries
102-
VTK::WrappingPythonCore
103102
VTK::CommonCore
104103
VTK::CommonDataModel
105104
VTK::CommonExecutionModel)

Modules/Bridge/VtkGlue/itk-module-init.cmake

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,6 @@ if(ITK_WRAP_PYTHON)
3737
list(
3838
APPEND
3939
_required_vtk_libraries
40-
VTK::WrappingPythonCore
4140
VTK::CommonCore
4241
VTK::CommonDataModel
4342
VTK::CommonExecutionModel
Lines changed: 2 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,3 @@
11
itk_wrap_module(ITKVtkGlue)
2-
if(ITK_USE_PYTHON_LIMITED_API)
3-
message(
4-
FATAL_ERROR
5-
"The ITKVtkGlue module can only built without Python limited API due to VTK limitations."
6-
"Please set `ITK_USE_PYTHON_LIMITED_API` to `FALSE`."
7-
)
8-
else()
9-
list(
10-
APPEND
11-
WRAPPER_SWIG_LIBRARY_FILES
12-
"${CMAKE_CURRENT_SOURCE_DIR}/VtkGlue.i"
13-
)
14-
itk_auto_load_and_end_wrap_submodules()
15-
endif()
2+
list(APPEND WRAPPER_SWIG_LIBRARY_FILES "${CMAKE_CURRENT_SOURCE_DIR}/VtkGlue.i")
3+
itk_auto_load_and_end_wrap_submodules()

Modules/Bridge/VtkGlue/wrapping/VtkGlue.i

Lines changed: 91 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -51,44 +51,115 @@
5151
%module(package="itk",threads="1") VtkGluePython
5252

5353
%{
54-
#include "vtkPythonUtil.h"
55-
#include "vtkVersion.h"
56-
#if (VTK_MAJOR_VERSION > 5 ||((VTK_MAJOR_VERSION == 5)&&(VTK_MINOR_VERSION > 6)))
57-
#define vtkPythonGetObjectFromPointer vtkPythonUtil::GetObjectFromPointer
58-
#define vtkPythonGetPointerFromObject vtkPythonUtil::GetPointerFromObject
59-
#endif
54+
#include <cinttypes>
55+
#include <cstdio>
56+
#include <cstring>
57+
58+
// Pointer exchange with VTK's Python layer using only the Limited API, so this
59+
// module stays abi3 and needs no link against VTK::WrappingPythonCore.
60+
namespace itkVtkGlueABI3
61+
{
62+
63+
// Parses the `_<hex>_p_<ClassName>` encoding VTK publishes as `__this__`.
64+
inline void *
65+
GetPointerFromObject(PyObject * obj, const char * className)
66+
{
67+
PyObject * thisStr = PyObject_GetAttrString(obj, "__this__");
68+
if (!thisStr)
69+
{
70+
PyErr_Clear();
71+
PyErr_Format(PyExc_TypeError, "expected a VTK %s instance", className);
72+
return nullptr;
73+
}
74+
75+
void * ptr = nullptr;
76+
Py_ssize_t len = 0;
77+
const char * s = PyUnicode_AsUTF8AndSize(thisStr, &len);
78+
if (s && len > 4 && s[0] == '_' && std::strlen(s) == static_cast<size_t>(len))
79+
{
80+
const char * sep = std::strstr(s + 1, "_p_");
81+
if (sep && std::strcmp(sep + 3, className) == 0)
82+
{
83+
std::uintptr_t addr = 0;
84+
// '_' is not a hex digit, so the conversion stops at the separator.
85+
if (std::sscanf(s + 1, "%" SCNxPTR, &addr) == 1 && addr != 0)
86+
{
87+
ptr = reinterpret_cast<void *>(addr);
88+
}
89+
}
90+
}
91+
Py_DECREF(thisStr);
92+
93+
if (!ptr)
94+
{
95+
PyErr_Format(PyExc_TypeError, "expected a VTK %s instance", className);
96+
}
97+
return ptr;
98+
}
99+
100+
// Reconstructs through VTK's own `Addr=0x...` path so the IsA() check, the
101+
// object map, and reference counting all stay VTK's responsibility.
102+
// `__new__` is called explicitly rather than `cls(addr)`: vtkmodules.util.data_model
103+
// registers keyword-only `override` subclasses for the data-model classes, whose
104+
// __init__ would reject the positional address string.
105+
inline PyObject *
106+
GetObjectFromPointer(void * ptr, const char * moduleName, const char * className)
107+
{
108+
if (!ptr)
109+
{
110+
Py_RETURN_NONE;
111+
}
112+
113+
PyObject * mod = PyImport_ImportModule(moduleName);
114+
if (!mod)
115+
{
116+
return nullptr;
117+
}
118+
PyObject * cls = PyObject_GetAttrString(mod, className);
119+
Py_DECREF(mod);
120+
if (!cls)
121+
{
122+
return nullptr;
123+
}
124+
125+
char addr[64];
126+
std::snprintf(addr, sizeof(addr), "Addr=0x%" PRIxPTR, reinterpret_cast<std::uintptr_t>(ptr));
127+
PyObject * obj = PyObject_CallMethod(cls, "__new__", "Os", cls, addr);
128+
Py_DECREF(cls);
129+
return obj;
130+
}
131+
132+
} // namespace itkVtkGlueABI3
60133
%}
61134

62135
%typemap(out) vtkImageExport* {
63-
PyImport_ImportModule("vtk");
64-
$result = vtkPythonGetObjectFromPointer ( (vtkImageExport*)$1 );
136+
$result = itkVtkGlueABI3::GetObjectFromPointer($1, "vtkmodules.vtkIOImage", "vtkImageExport");
137+
if (!$result) { SWIG_fail; }
65138
}
66139

67140
%typemap(out) vtkImageImport* {
68-
PyImport_ImportModule("vtk");
69-
$result = vtkPythonGetObjectFromPointer ( (vtkImageImport*)$1 );
141+
$result = itkVtkGlueABI3::GetObjectFromPointer($1, "vtkmodules.vtkIOImage", "vtkImageImport");
142+
if (!$result) { SWIG_fail; }
70143
}
71144

72145
%typemap(out) vtkImageData* {
73-
PyImport_ImportModule("vtk");
74-
$result = vtkPythonGetObjectFromPointer ( (vtkImageData*)$1 );
146+
$result = itkVtkGlueABI3::GetObjectFromPointer($1, "vtkmodules.vtkCommonDataModel", "vtkImageData");
147+
if (!$result) { SWIG_fail; }
75148
}
76149

77150
%typemap(in) vtkImageData* {
78-
$1 = NULL;
79-
$1 = (vtkImageData*) vtkPythonGetPointerFromObject ( $input, "vtkImageData" );
80-
if ( $1 == NULL ) { SWIG_fail; }
151+
$1 = static_cast<vtkImageData *>(itkVtkGlueABI3::GetPointerFromObject($input, "vtkImageData"));
152+
if (!$1) { SWIG_fail; }
81153
}
82154

83155
%typemap(out) vtkPolyData* {
84-
PyImport_ImportModule("vtk");
85-
$result = vtkPythonGetObjectFromPointer ( (vtkPolyData*)$1 );
156+
$result = itkVtkGlueABI3::GetObjectFromPointer($1, "vtkmodules.vtkCommonDataModel", "vtkPolyData");
157+
if (!$result) { SWIG_fail; }
86158
}
87159

88160
%typemap(in) vtkPolyData* {
89-
$1 = NULL;
90-
$1 = (vtkPolyData*) vtkPythonGetPointerFromObject ( $input, "vtkPolyData" );
91-
if ( $1 == NULL ) { SWIG_fail; }
161+
$1 = static_cast<vtkPolyData *>(itkVtkGlueABI3::GetPointerFromObject($input, "vtkPolyData"));
162+
if (!$1) { SWIG_fail; }
92163
}
93164
#endif
94165

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
list(FIND ITK_WRAP_IMAGE_DIMS 2 wrap_2_index)
2+
if(
3+
ITK_WRAP_PYTHON
4+
AND
5+
VTK_WRAP_PYTHON
6+
AND
7+
ITK_WRAP_float
8+
AND
9+
wrap_2_index
10+
GREATER
11+
-1
12+
)
13+
itk_python_add_test(
14+
NAME PythonVtkGlueABI3EncodingTest
15+
COMMAND
16+
${CMAKE_CURRENT_SOURCE_DIR}/VtkGlueABI3EncodingTest.py
17+
)
18+
itk_python_add_test(
19+
NAME PythonVtkGlueRoundTripTest
20+
COMMAND
21+
${CMAKE_CURRENT_SOURCE_DIR}/VtkGlueRoundTripTest.py
22+
)
23+
# itkTestDriver prepends ITK's own entries to whatever PYTHONPATH it inherits.
24+
set_property(
25+
TEST
26+
PythonVtkGlueABI3EncodingTest
27+
PythonVtkGlueRoundTripTest
28+
PROPERTY
29+
ENVIRONMENT
30+
"PYTHONPATH=${VTK_PREFIX_PATH}/${VTK_PYTHONPATH}"
31+
)
32+
endif()
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
# ==========================================================================
2+
#
3+
# Copyright NumFOCUS
4+
#
5+
# Licensed under the Apache License, Version 2.0 (the "License");
6+
# you may not use this file except in compliance with the License.
7+
# You may obtain a copy of the License at
8+
#
9+
# https://www.apache.org/licenses/LICENSE-2.0.txt
10+
#
11+
# Unless required by applicable law or agreed to in writing, software
12+
# distributed under the License is distributed on an "AS IS" BASIS,
13+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14+
# See the License for the specific language governing permissions and
15+
# limitations under the License.
16+
#
17+
# ==========================================================================
18+
19+
"""Canary for the two VTK wrapper encodings the ITKVtkGlue abi3 typemaps rely on.
20+
21+
VtkGlue.i exchanges pointers with VTK through `__this__` and the `Addr=0x...`
22+
argument to `__new__` rather than through vtkPythonUtil, because vtkPythonUtil's
23+
header chain is not usable under Py_LIMITED_API. Neither encoding is documented
24+
VTK API, so this test fails loudly and specifically if VTK changes either one.
25+
26+
`__new__` is used rather than plain construction because vtkmodules.util.data_model
27+
registers keyword-only `override` subclasses for vtkImageData and vtkPolyData;
28+
`cls(addr)` reaches those and raises TypeError.
29+
"""
30+
31+
import re
32+
import sys
33+
34+
from vtkmodules.vtkCommonDataModel import vtkImageData, vtkPolyData
35+
from vtkmodules.vtkIOImage import vtkImageExport, vtkImageImport
36+
37+
# `_<2*sizeof(void*) hex digits>_p_<ClassName>`, per vtkPythonUtil::ManglePointer.
38+
THIS_RE = re.compile(r"^_([0-9a-fA-F]+)_p_(\w+)$")
39+
40+
failures = []
41+
42+
43+
def check(condition, message):
44+
if not condition:
45+
failures.append(message)
46+
47+
48+
for cls in (vtkImageData, vtkPolyData, vtkImageExport, vtkImageImport):
49+
name = cls.__name__
50+
obj = cls()
51+
52+
this = getattr(obj, "__this__", None)
53+
check(this is not None, f"{name}: instance has no __this__ attribute")
54+
if this is None:
55+
continue
56+
57+
match = THIS_RE.match(this)
58+
check(
59+
match is not None,
60+
f"{name}: __this__ {this!r} does not match _<hex>_p_<ClassName>",
61+
)
62+
if match is None:
63+
continue
64+
65+
address = int(match.group(1), 16)
66+
check(address != 0, f"{name}: __this__ encodes a null address")
67+
check(
68+
match.group(2) == name,
69+
f"{name}: __this__ encodes class {match.group(2)!r}, expected {name!r}",
70+
)
71+
72+
# The reconstruction path VtkGlue.i's `out` typemaps drive.
73+
try:
74+
rebuilt = cls.__new__(cls, f"Addr=0x{address:x}")
75+
except Exception as exception: # noqa: BLE001 - report any refusal verbatim
76+
failures.append(f"{name}: Addr=0x... reconstruction raised {exception!r}")
77+
continue
78+
79+
rebuilt_this = getattr(rebuilt, "__this__", None)
80+
check(
81+
rebuilt_this == this,
82+
f"{name}: reconstruction yielded __this__ {rebuilt_this!r}, expected {this!r}",
83+
)
84+
check(
85+
isinstance(rebuilt, cls),
86+
f"{name}: reconstruction yielded {type(rebuilt)!r}, not a {name} instance",
87+
)
88+
89+
if failures:
90+
print("VTK wrapper encodings assumed by VtkGlue.i have changed:", file=sys.stderr)
91+
for failure in failures:
92+
print(f" - {failure}", file=sys.stderr)
93+
sys.exit(1)
94+
95+
print("VTK __this__ and Addr=0x... encodings are intact.")
Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
# ==========================================================================
2+
#
3+
# Copyright NumFOCUS
4+
#
5+
# Licensed under the Apache License, Version 2.0 (the "License");
6+
# you may not use this file except in compliance with the License.
7+
# You may obtain a copy of the License at
8+
#
9+
# https://www.apache.org/licenses/LICENSE-2.0.txt
10+
#
11+
# Unless required by applicable law or agreed to in writing, software
12+
# distributed under the License is distributed on an "AS IS" BASIS,
13+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14+
# See the License for the specific language governing permissions and
15+
# limitations under the License.
16+
#
17+
# ==========================================================================
18+
19+
"""Round-trip itk.Image -> vtkImageData -> itk.Image through the VtkGlue filters.
20+
21+
This exercises the SWIG typemaps in VtkGlue.i directly: ImageToVTKImageFilter
22+
returns `vtkImageData *` (the `out` typemap) and VTKImageToImageFilter accepts
23+
`vtkImageData *` (the `in` typemap). The pure-numpy helpers in itk.support.extras
24+
bypass both, so they are deliberately not used here.
25+
"""
26+
27+
import sys
28+
29+
import numpy as np
30+
from vtkmodules.vtkCommonDataModel import vtkImageData
31+
32+
import itk
33+
34+
Dimension = 2
35+
PixelType = itk.F
36+
ImageType = itk.Image[PixelType, Dimension]
37+
38+
reference_array = np.arange(6 * 4, dtype=np.float32).reshape((4, 6))
39+
image = itk.image_from_array(reference_array)
40+
image.SetSpacing([0.5, 2.0])
41+
image.SetOrigin([-3.0, 7.0])
42+
43+
to_vtk = itk.ImageToVTKImageFilter[ImageType].New()
44+
to_vtk.SetInput(image)
45+
to_vtk.Update()
46+
vtk_image = to_vtk.GetOutput()
47+
48+
if not isinstance(vtk_image, vtkImageData):
49+
print(
50+
f"out typemap returned {type(vtk_image)!r}, expected vtkImageData",
51+
file=sys.stderr,
52+
)
53+
sys.exit(1)
54+
55+
if vtk_image.GetDimensions()[:2] != (6, 4):
56+
print(f"unexpected VTK dimensions {vtk_image.GetDimensions()}", file=sys.stderr)
57+
sys.exit(1)
58+
59+
from_vtk = itk.VTKImageToImageFilter[ImageType].New()
60+
from_vtk.SetInput(vtk_image)
61+
from_vtk.Update()
62+
result = from_vtk.GetOutput()
63+
64+
failures = []
65+
66+
result_array = itk.array_from_image(result)
67+
if not np.array_equal(result_array, reference_array):
68+
failures.append(
69+
f"pixel buffer differs:\n{result_array}\nexpected:\n{reference_array}"
70+
)
71+
72+
if not np.allclose(list(result.GetSpacing()), [0.5, 2.0]):
73+
failures.append(f"spacing {list(result.GetSpacing())}, expected [0.5, 2.0]")
74+
75+
if not np.allclose(list(result.GetOrigin()), [-3.0, 7.0]):
76+
failures.append(f"origin {list(result.GetOrigin())}, expected [-3.0, 7.0]")
77+
78+
direction = itk.array_from_matrix(result.GetDirection())
79+
if not np.allclose(direction, np.identity(Dimension)):
80+
failures.append(f"direction {direction}, expected identity")
81+
82+
# The `in` typemap must reject a non-VTK argument rather than dereference it.
83+
try:
84+
itk.VTKImageToImageFilter[ImageType].New().SetInput("not a vtkImageData")
85+
except TypeError:
86+
pass
87+
except Exception as exception: # noqa: BLE001 - anything but TypeError is a defect
88+
failures.append(f"in typemap raised {exception!r}, expected TypeError")
89+
else:
90+
failures.append("in typemap accepted a str; expected TypeError")
91+
92+
if failures:
93+
for failure in failures:
94+
print(f" - {failure}", file=sys.stderr)
95+
sys.exit(1)
96+
97+
print("itk.Image <-> vtkImageData round trip preserved geometry and pixels.")

0 commit comments

Comments
 (0)