Python Ctypes: Call a C++ Shared Library Without Building Any C++ Code

You can call the Dynamsoft Capture Vision C++ shared libraries from pure Python ctypes with zero C++ build steps. The SDK exports its C++ class methods under ABI-specific mangled names, and the pure-virtual result objects can be driven through their vtables, so a small wrapper decodes every barcode in a test image — format, text, confidence and pixel coordinates — on Windows, Linux and macOS. ctypes also releases the GIL during each native call, which lets the decode run on a background thread without blocking your Python code.

What you’ll build

  • A dcv.py ctypes wrapper that loads the Dynamsoft license and Capture Vision router libraries, constructs a CCaptureVisionRouter via placement new, and walks the vtables of the returned result objects.
  • A success.py demo that decodes test.png and prints 18 barcodes.
  • A multithreading.py demo that runs a decode on a background thread while the main thread stays responsive — proving the GIL is released.

Key Takeaways

  • The shared libraries expose only C++ classes, not a flat C API, so ctypes must call the C++ exports by their mangled symbol names. The mangling differs by platform: MSVC on Windows, Itanium on Linux and macOS.
  • Pure-virtual result classes have no exported method names, but their vtable slots are stable — read the function pointer at a fixed slot and call it.
  • The vtable layout is not portable: MSVC reserves one destructor slot, the Itanium ABI reserves two, so every virtual method’s slot is shifted by one on Linux/macOS.
  • The SDK resolves Templates/ and Models/ relative to the library directory, not your working directory.
  • ctypes releases the GIL during a native call, so a decode running on a worker thread does not stall the rest of your Python program — the ctypes-native way to work around the GIL.

Prerequisites

  • Python 3.8+.
  • The Dynamsoft Capture Vision SDK (vendored in the sample repo under dcv/).
  • A license key. Get a 30-day free trial license for Dynamsoft Capture Vision.

Step 1: Find the exported C++ symbols

The shared libraries export the C++ methods we need under ABI-specific mangled names. You can list them with dumpbin /exports (Windows), nm -D (Linux), or a small Python ELF/Mach-O parser:

Purpose Windows (MSVC) Linux/macOS (Itanium)
CLicenseManager::InitLicense ?InitLicense@CLicenseManager@license@dynamsoft@@SAHPEBDQEADH@Z _ZN9dynamsoft7license15CLicenseManager11InitLicenseEPKcPci
CCaptureVisionRouter ctor ??0CCaptureVisionRouter@cvr@dynamsoft@@QEAA@XZ _ZN9dynamsoft3cvr20CCaptureVisionRouterC1Ev
CCaptureVisionRouter dtor ??1CCaptureVisionRouter@cvr@dynamsoft@@QEAA@XZ _ZN9dynamsoft3cvr20CCaptureVisionRouterD1Ev
InitSettingsFromFile ?InitSettingsFromFile@CCaptureVisionRouter@cvr@dynamsoft@@QEAAHPEBDQEADH@Z _ZN9dynamsoft3cvr20CCaptureVisionRouter20InitSettingsFromFileEPKcPci
Capture ?Capture@CCaptureVisionRouter@cvr@dynamsoft@@QEAAPEAVCCapturedResult@23@PEBD0@Z _ZN9dynamsoft3cvr20CCaptureVisionRouter7CaptureEPKcS3_
GetVersion ?GetVersion@CCaptureVisionRouterModule@cvr@dynamsoft@@SAPEBDXZ _ZN9dynamsoft3cvr26CCaptureVisionRouterModule10GetVersionEv

On the x64 calling conventions a non-static member function receives this in the first register (RCX on MSVC, RDI on Itanium), so it maps smoothly onto a CFUNCTYPE whose first parameter is c_void_p. On macOS the Mach-O symbol table stores the Itanium names with an extra leading underscore, which dcv.py accounts for. Be aware that some macOS .dylib builds hide the C++ class exports for dynamic lookup — if so, dcv.py raises a clear error and you should use the official Python bundle instead.

Step 2: Map the vtable slots of the result objects

Capture() returns a CCapturedResult*, and everything interesting (GetDecodedBarcodesResult, GetItemsCount, GetItem, GetText, GetFormatString, GetLocation …) is virtual — the method names are not exported. You call them by index into the vtable, whose address is the first 8 bytes of the object.

Counting the declarations in the DCV headers, the slot numbers depend on the ABI because a virtual destructor occupies one slot on MSVC but two (D1 + D0) on Itanium:

CCapturedResult (base CCapturedResultBase):

Method MSVC slot Itanium slot
GetErrorCode 4 5
GetErrorString 5 6
GetItemsCount 6 7
Release 12 13
GetDecodedBarcodesResult 13 14

CDecodedBarcodesResult:

Method MSVC slot Itanium slot
GetItemsCount 6 7
GetItem 7 8
Release 12 13

CBarcodeResultItem:

Method MSVC slot Itanium slot
GetFormatString 9 10
GetText 10 11
GetLocation 13 14
GetConfidence 14 15

The ctypes plumbing is tiny:

def _vtable_slot(obj, slot):
    vtable = ctypes.cast(ctypes.c_void_p(obj), ctypes.POINTER(ctypes.c_void_p)).contents
    return ctypes.cast(vtable, ctypes.POINTER(ctypes.c_void_p))[slot]

def _call(obj, slot, restype, argtypes=()):
    fn = ctypes.cast(ctypes.c_void_p(_vtable_slot(obj, slot)),
                     ctypes.CFUNCTYPE(restype, ctypes.c_void_p, *argtypes))
    return fn(obj)

Step 3: Handle the by-value struct return ABI

GetLocation() returns a CQuadrilateral by value. Two details matter:

  1. The struct is 36 bytes — CPoint points[4] (8 ints) plus a trailing int id member that is easy to miss in the header. Anything larger than 8 bytes is returned through a hidden buffer pointer.
  2. The argument order differs by ABI. On MSVC the call is (this, &buffer); under the Itanium ABI the hidden return buffer is passed first, so the call is (&buffer, this).
class Quadrilateral(ctypes.Structure):
    _fields_ = [("points", (ctypes.c_int * 2) * 4), ("id", ctypes.c_int)]

quad = Quadrilateral()
fn = ctypes.cast(ctypes.c_void_p(_vtable_slot(self._ptr, slot_location)),
                 ctypes.CFUNCTYPE(None, ctypes.c_void_p, ctypes.c_void_p))
if is_windows:
    fn(self._ptr, ctypes.addressof(quad))   # (this, &buffer)
else:
    fn(ctypes.addressof(quad), self._ptr)   # (&buffer, this)

Getting that order wrong silently yields zeroed coordinates.

Step 4: Put Templates and Models next to the libraries

The SDK discovers its preset templates and neural models relative to the directory that hosts the router library, not your current working directory. dcv.py therefore copies Templates/ and Models/ from dcv/resource/ into the library directory on first use:

def _ensure_runtime_resources(lib_dir, sdk_root):
    resource_dir = os.path.join(sdk_root, "resource")
    for sub in ("Templates", "Models"):
        dst = os.path.join(lib_dir, sub)
        src = os.path.join(resource_dir, sub)
        if not os.path.isdir(dst) and os.path.isdir(src):
            shutil.copytree(src, dst)

The library directory itself is chosen per platform:

def _lib_layout(sdk_root):
    if is_windows:
        return (os.path.join(sdk_root, "lib", "win"), "DynamsoftCaptureVisionRouterx64.dll", "DynamsoftLicensex64.dll")
    if is_mac:
        return (os.path.join(sdk_root, "lib", "mac"), "libDynamsoftCaptureVisionRouter.dylib", "libDynamsoftLicense.dylib")
    arch = "arm64" if platform.machine().lower() in ("aarch64", "arm64") else "x64"
    return (os.path.join(sdk_root, "lib", "linux", arch), "libDynamsoftCaptureVisionRouter.so", "libDynamsoftLicense.so")

On Linux and macOS the dynamic linker must find the dependency .so/.dylib files, so set LD_LIBRARY_PATH (Linux) or DYLD_LIBRARY_PATH (macOS) to the library directory before launching Python.

Step 5: Initialize the license and construct the router

The router is created with placement new into a Python-allocated buffer — no C++ new needed:

license_dll = _load(os.path.join(lib_dir, lic_name))
cvr_dll = _load(os.path.join(lib_dir, cvr_name))

init_license = license_dll[s_license_init]       # static method: no `this`
init_license.restype = ctypes.c_int
init_license.argtypes = [ctypes.c_char_p, ctypes.c_char_p, ctypes.c_int]
err = ctypes.create_string_buffer(256)
ret = init_license(license_key.encode(), err, len(err))

ctor = cvr_dll[s_cvr_ctor]
ctor.restype = None
ctor.argtypes = [ctypes.c_void_p]
router_mem = ctypes.create_string_buffer(4096)   # the object is one pointer
router = ctypes.addressof(router_mem)
ctor(router)                                     # placement new

Step 6: Capture and walk the results

capture = cvr_dll[s_cvr_capture]
capture.restype = ctypes.c_void_p
capture.argtypes = [ctypes.c_void_p, ctypes.c_char_p, ctypes.c_char_p]
captured = capture(router, b"test.png", b"ReadBarcodes_Default")

decoded = _call(captured, slot_get_decoded, ctypes.c_void_p)         # GetDecodedBarcodesResult
count = _call(decoded, slot_get_items_count, ctypes.c_int)           # GetItemsCount
for i in range(count):
    item = _call(decoded, slot_get_item, ctypes.c_void_p, (ctypes.c_int,))(i)
    fmt  = _call(item, slot_format,  ctypes.c_char_p).decode()       # GetFormatString
    text = _call(item, slot_text,    ctypes.c_char_p).decode()       # GetText
    conf = _call(item, slot_conf,    ctypes.c_int)                   # GetConfidence

When finished, call Release() on the decoded result and the captured result, then run the router’s destructor.

Step 7: Bypass the GIL with native threading

Python’s Global Interpreter Lock normally prevents two threads from running Python bytecode at once. ctypes releases the GIL for the duration of every native call, so while the C++ decoder is running inside the shared library, the rest of your Python code keeps executing on another thread. This is the ctypes-native way to work around the GIL — no C++ bridge or callback is needed.

import threading

router = CaptureVisionRouter(SDK_ROOT, license_key)
result = {}

def decode_worker():
    captured = router.capture("test.png", "ReadBarcodes_Default")
    result["count"] = captured.get_decoded_barcodes_result().items_count
    captured.release()

thread = threading.Thread(target=decode_worker)
thread.start()

# While the decoder runs in native code, the main thread keeps working.
counter = 0
while thread.is_alive():
    counter += 1
thread.join()
print(f"main thread did {counter:,} ops while the decode ran")

Run multithreading.py to see it live:

Python ctypes releasing the GIL during a native Capture call

The decode returns 18 barcodes while the main thread executes hundreds of thousands of Python operations, confirming the GIL is released during the native call. You can apply the same pattern to run decodes on a worker thread so a long decode never blocks your UI or loop.

Running the demo

python success.py

Output:

Python ctypes decoding 18 barcodes with Dynamsoft Capture Vision

All 18 barcode symbologies in the test image are decoded in one pass — 1D (CODE_128, CODE_93, CODE_39_EXTENDED, CODABAR, EAN_8, EAN_13, UPC_A, UPC_E, ITF, INDUSTRIAL_25, MSI_CODE, GS1 DataBar) and 2D (QR_CODE, DATAMATRIX, PDF417, AZTEC, MAXICODE) — each with format string, text, confidence and the four corner points in pixel coordinates.

Common Developer Questions

Can Python ctypes call C++ class methods directly?

Yes, as long as the methods are exported. On x64, a non-static member function receives this in the first register, so a CFUNCTYPE whose first parameter is c_void_p matches exactly. You only need the mangled symbol name and the argument types.

Why do the symbol names look different on Linux and macOS?

They use the Itanium C++ ABI instead of MSVC. The mangled name for Capture is _ZN9dynamsoft3cvr20CCaptureVisionRouter7CaptureEPKcS3_ on both Linux and macOS, but the Mach-O symbol table adds a leading underscore on macOS. dcv.py selects the right set automatically.

Why does the same method have a different vtable slot number per platform?

The vtable index counts virtual methods, and a virtual destructor takes one slot on MSVC but two (D1 complete-object + D0 deleting) on the Itanium ABI. That shifts every later virtual method by one on Linux/macOS.

How do I receive a C++ struct returned by value in ctypes?

Declare a matching ctypes.Structure, allocate it, and pass a pointer to it as the hidden return-buffer argument. Check the exact struct size in the header — for example CQuadrilateral is 36 bytes (four CPoints plus an int id) — and use the ABI-correct argument order (this, &buffer on MSVC vs &buffer, this on Itanium).

How does using Python threads help with a CPU-bound native library?

ctypes releases the GIL for the duration of a foreign-function call. So while a Dynamsoft Capture runs in the shared library, other Python threads execute freely. That means you can run decodes on a background thread without freezing your main thread — the ctypes-native way to work around the GIL.

What do I need to set on Linux or macOS to run the demo?

Set the dynamic-loader search path before launching Python: export LD_LIBRARY_PATH=$PWD/../../dcv/lib/linux/x64:$LD_LIBRARY_PATH on Linux, or export DYLD_LIBRARY_PATH=$PWD/../../dcv/lib/mac:$DYLD_LIBRARY_PATH on macOS. The SDK also requires Templates/ and Models/ under the library directory, which dcv.py copies automatically.

Is this approach production-ready?

It is excellent for prototyping, scripting and environments where a C++ toolchain is unavailable, but it relies on mangled names and vtable layouts that are compiler- and version-specific. For production applications, use the official dynamsoft-barcode-reader-bundle Python package, which handles memory management, error handling, and cross-platform ABI differences for you.

Drawbacks of Using Ctypes

  • ABI fragility: mangled names and vtable slot numbers are tied to the compiler and this SDK version. An SDK upgrade can change them.
  • Manual memory management: you must call Release() on result objects and run the destructor yourself; forgetting either leaks native memory.
  • Platform-specific vtable layout: the slot offsets differ between the MSVC and Itanium ABIs, so the wrapper must carry per-platform tables.
  • Harder debugging: mistakes surface as access violations or silent zeros rather than Python exceptions.

Conclusion

Python ctypes can drive the Dynamsoft Capture Vision C++ shared libraries end-to-end — license initialization, router construction, template loading, barcode capture and result traversal — without compiling a single line of C++, on Windows, Linux and macOS. The key ingredients are the exported mangled symbols, ABI-aware vtable slot dispatch, the by-value return ABI of CQuadrilateral, placing Templates//Models/ next to the libraries, and the fact that ctypes releases the GIL so the decode can run on a background thread. For production use, prefer the official Python bundle; for quick tooling and scriptable pipelines, this zero-build approach is hard to beat.

Source Code

Get the complete sample project source code on GitHub.