Dynamsoft Document Normalizer is a document scanning SDK that can be used to do edge detection, perspective correction, and contrast enhancement. Its C/C++ API is available on Windows and Linux. In this article, we will show you how to build a Python wrapper for Dynamsoft C/C++ Document Normalizer SDK, as well as how to quickly implement a document scanner with Python on Windows and Linux.
Python Wrapper for Dynamsoft C/C++ Document Normalizer SDK
Here we are not going to talk about how to build a Python C/C++ extension from scratch. If you are interested in this topic, you can refer to this article. Instead, we will focus on the C/C++ implementation of the Python wrapper for Dynamsoft C/C++ Document Normalizer SDK.
Customized PyObjects in C/C++
We define three native PyObject
s, which will be interacted with Python code:
-
DynamsoftDocumentScanner: the native object that holds the pointer to the C/C++ Document Normalizer instance
typedef struct { PyObject_HEAD void *handler; PyObject *callback; WorkerThread *worker; } DynamsoftDocumentScanner;
-
DocumentResult: the native object that holds the contour points of the detected document
typedef struct { PyObject_HEAD PyObject *confidence; PyObject *x1; PyObject *y1; PyObject *x2; PyObject *y2; PyObject *x3; PyObject *y3; PyObject *x4; PyObject *y4; } DocumentResult;
-
NormalizedImage: the native object that holds the normalized image buffer
typedef struct { PyObject_HEAD PyObject *bytearray; PyObject *length; PyObject *width; PyObject *height; PyObject *stride; PyObject *format; NormalizedImageResult* normalizedResult; } NormalizedImage;
Methods Implementation
The DynamsoftDocumentScanner
module contains following methods:
static PyMethodDef instance_methods[] = { {"detectFile", detectFile, METH_VARARGS, NULL}, {"detectMat", detectMat, METH_VARARGS, NULL}, {"addAsyncListener", addAsyncListener, METH_VARARGS, NULL}, {"detectMatAsync", detectMatAsync, METH_VARARGS, NULL}, {"setParameters", setParameters, METH_VARARGS, NULL}, {"normalizeFile", normalizeFile, METH_VARARGS, NULL}, {"normalizeBuffer", normalizeBuffer, METH_VARARGS, NULL}, {NULL, NULL, 0, NULL} };
-
detectFile
: Detects the document in the image file and returns the contour points.
static PyObject *detectFile(PyObject *obj, PyObject *args) { DynamsoftDocumentScanner *self = (DynamsoftDocumentScanner *)obj; char *pFileName; // File name if (!PyArg_ParseTuple(args, "s", &pFileName)) { return NULL; } DetectedQuadResultArray *pResults = NULL; int ret = DDN_DetectQuadFromFile(self->handler, pFileName, "", &pResults); if (ret) { printf("Detection error: %s\n", DC_GetErrorString(ret)); } PyObject *list = createPyList(pResults); // Release memory if (pResults != NULL) DDN_FreeDetectedQuadResultArray(&pResults); return list; }
-
detectMat
: Detects the document in the image buffer and returns the contour points.
static PyObject *detectMat(PyObject *obj, PyObject *args) { DynamsoftDocumentScanner *self = (DynamsoftDocumentScanner *)obj; PyObject *o; if (!PyArg_ParseTuple(args, "O", &o)) return NULL; Py_buffer *view; int nd; PyObject *memoryview = PyMemoryView_FromObject(o); if (memoryview == NULL) { PyErr_Clear(); return NULL; } view = PyMemoryView_GET_BUFFER(memoryview); char *buffer = (char *)view->buf; nd = view->ndim; int len = view->len; int stride = view->strides[0]; int width = view->strides[0] / view->strides[1]; int height = len / stride; ImagePixelFormat format = IPF_RGB_888; if (width == stride) { format = IPF_GRAYSCALED; } else if (width * 3 == stride) { format = IPF_RGB_888; } else if (width * 4 == stride) { format = IPF_ARGB_8888; } ImageData data; data.bytes = (unsigned char *)buffer; data.width = width; data.height = height; data.stride = stride; data.format = format; data.bytesLength = len; DetectedQuadResultArray *pResults = NULL; int ret = DDN_DetectQuadFromBuffer(self->handler, &data, "", &pResults); if (ret) { printf("Detection error: %s\n", DC_GetErrorString(ret)); } PyObject *list = createPyList(pResults); // Release memory if (pResults != NULL) DDN_FreeDetectedQuadResultArray(&pResults); Py_DECREF(memoryview); return list; }
-
addAsyncListener
anddetectMatAsync
: Uses a native thread to run edge detection task asynchronously and returns the contour points.
static PyObject *addAsyncListener(PyObject *obj, PyObject *args) { DynamsoftDocumentScanner *self = (DynamsoftDocumentScanner *)obj; PyObject *callback = NULL; if (!PyArg_ParseTuple(args, "O", &callback)) { return NULL; } if (!PyCallable_Check(callback)) { PyErr_SetString(PyExc_TypeError, "parameter must be callable"); return NULL; } else { Py_XINCREF(callback); /* Add a reference to new callback */ Py_XDECREF(self->callback); /* Dispose of previous callback */ self->callback = callback; } if (self->worker == NULL) { self->worker = new WorkerThread(); self->worker->running = true; self->worker->t = std::thread(&run, self); } return Py_BuildValue("i", 0); } static PyObject *detectMatAsync(PyObject *obj, PyObject *args) { DynamsoftDocumentScanner *self = (DynamsoftDocumentScanner *)obj; PyObject *o; if (!PyArg_ParseTuple(args, "O", &o)) return NULL; Py_buffer *view; int nd; PyObject *memoryview = PyMemoryView_FromObject(o); if (memoryview == NULL) { PyErr_Clear(); return NULL; } view = PyMemoryView_GET_BUFFER(memoryview); char *buffer = (char *)view->buf; nd = view->ndim; int len = view->len; int stride = view->strides[0]; int width = view->strides[0] / view->strides[1]; int height = len / stride; ImagePixelFormat format = IPF_RGB_888; if (width == stride) { format = IPF_GRAYSCALED; } else if (width * 3 == stride) { format = IPF_RGB_888; } else if (width * 4 == stride) { format = IPF_ARGB_8888; } unsigned char *data = (unsigned char *)malloc(len); memcpy(data, buffer, len); std::unique_lock<std::mutex> lk(self->worker->m); if (self->worker->tasks.size() > 1) { std::queue<std::function<void()>> empty = {}; std::swap(self->worker->tasks, empty); } std::function<void()> task_function = std::bind(scan, self, data, width, height, stride, format, len); self->worker->tasks.push(task_function); self->worker->cv.notify_one(); lk.unlock(); Py_DECREF(memoryview); return Py_BuildValue("i", 0); }
-
setParameters
: Sets the parameters for the document scanner SDK.
static PyObject *setParameters(PyObject *obj, PyObject *args) { DynamsoftDocumentScanner *self = (DynamsoftDocumentScanner *)obj; const char*params; if (!PyArg_ParseTuple(args, "s", ¶ms)) { return NULL; } char errorMsgBuffer[512]; int ret = DDN_InitRuntimeSettingsFromString(self->handler, params, errorMsgBuffer, 512); printf("Init runtime settings: %s\n", errorMsgBuffer); return Py_BuildValue("i", ret); }
-
normalizeFile
: Normalizes the document based on the contour points.
static PyObject *normalizeFile(PyObject *obj, PyObject *args) { DynamsoftDocumentScanner *self = (DynamsoftDocumentScanner *)obj; char *pFileName; int x1, y1, x2, y2, x3, y3, x4, y4; if (!PyArg_ParseTuple(args, "siiiiiiii", &pFileName, &x1, &y1, &x2, &y2, &x3, &y3, &x4, &y4)) return NULL; Quadrilateral quad; quad.points[0].coordinate[0] = x1; quad.points[0].coordinate[1] = y1; quad.points[1].coordinate[0] = x2; quad.points[1].coordinate[1] = y2; quad.points[2].coordinate[0] = x3; quad.points[2].coordinate[1] = y3; quad.points[3].coordinate[0] = x4; quad.points[3].coordinate[1] = y4; NormalizedImageResult* normalizedResult = NULL; int errorCode = DDN_NormalizeFile(self->handler, pFileName, "", &quad, &normalizedResult); if (errorCode != DM_OK) printf("%s\r\n", DC_GetErrorString(errorCode)); PyObject *normalizedImage = createNormalizedImage(normalizedResult); return normalizedImage; }
-
normalizeBuffer
: Normalizes the document based on the contour points.
static PyObject *normalizeBuffer(PyObject *obj, PyObject *args) { DynamsoftDocumentScanner *self = (DynamsoftDocumentScanner *)obj; PyObject *o; int x1, y1, x2, y2, x3, y3, x4, y4; if (!PyArg_ParseTuple(args, "Oiiiiiiii", &o, &x1, &y1, &x2, &y2, &x3, &y3, &x4, &y4)) return NULL; Py_buffer *view; int nd; PyObject *memoryview = PyMemoryView_FromObject(o); if (memoryview == NULL) { PyErr_Clear(); return NULL; } view = PyMemoryView_GET_BUFFER(memoryview); char *buffer = (char *)view->buf; nd = view->ndim; int len = view->len; int stride = view->strides[0]; int width = view->strides[0] / view->strides[1]; int height = len / stride; ImagePixelFormat format = IPF_RGB_888; if (width == stride) { format = IPF_GRAYSCALED; } else if (width * 3 == stride) { format = IPF_RGB_888; } else if (width * 4 == stride) { format = IPF_ARGB_8888; } ImageData data; data.bytes = (unsigned char *)buffer; data.width = width; data.height = height; data.stride = stride; data.format = format; data.bytesLength = len; Quadrilateral quad; quad.points[0].coordinate[0] = x1; quad.points[0].coordinate[1] = y1; quad.points[1].coordinate[0] = x2; quad.points[1].coordinate[1] = y2; quad.points[2].coordinate[0] = x3; quad.points[2].coordinate[1] = y3; quad.points[3].coordinate[0] = x4; quad.points[3].coordinate[1] = y4; NormalizedImageResult* normalizedResult = NULL; int errorCode = DDN_NormalizeBuffer(self->handler, &data, "", &quad, &normalizedResult); if (errorCode != DM_OK) printf("%s\r\n", DC_GetErrorString(errorCode)); PyObject *normalizedImage = createNormalizedImage(normalizedResult); Py_DECREF(memoryview); return normalizedImage; }
The NormalizedImage
module contains two methods: save()
and recycle()
. Since the object holds the pointer of the native image data, the recycle()
method must be called when the NormalizedImage
object is no more used.
static PyMethodDef ni_instance_methods[] = { {"save", save, METH_VARARGS, NULL}, {"recycle", recycle, METH_VARARGS, NULL}, {NULL, NULL, 0, NULL} }; static PyObject *save(PyObject *obj, PyObject *args) { NormalizedImage *self = (NormalizedImage *)obj; char *pFileName; // File name if (!PyArg_ParseTuple(args, "s", &pFileName)) { return NULL; } if (self->normalizedResult) { DDN_SaveImageDataToFile(self->normalizedResult->image, pFileName); printf("Save image to file: %s\n", pFileName); return Py_BuildValue("i", 0); } return Py_BuildValue("i", -1); } static PyObject *recycle(PyObject *obj, PyObject *args) { NormalizedImage *self = (NormalizedImage *)obj; if (self->normalizedResult) { DDN_FreeNormalizedImageResult(&self->normalizedResult); self->normalizedResult = NULL; } return Py_BuildValue("i", 0); }
Download the Package from PyPI
The document-scanner-sdk
package is available on https://pypi.org/project/document-scanner-sdk/.
Get a 30-Day Free Trial License
To activate the SDK, you need to get a 30-day free trial license from the online portal.
Python Document Scanner
Install OpenCV and Document Scanner SDK:
pip install opencv-python document-scanner-sdk
The OpenCV package depends on NumPy
, which will be installed along with it.
Scan Documents from Image Files
Let's get started with a simple example. The following code snippet shows how to use the SDK to scan a document from an image file.
from time import sleep import docscanner import numpy as np import cv2 import time def showNormalizedImage(name, normalized_image): mat = docscanner.convertNormalizedImage2Mat(normalized_image) cv2.imshow(name, mat) return mat docscanner.initLicense("DLS2eyJoYW5kc2hha2VDb2RlIjoiMjAwMDAxLTE2NDk4Mjk3OTI2MzUiLCJvcmdhbml6YXRpb25JRCI6IjIwMDAwMSIsInNlc3Npb25QYXNzd29yZCI6IndTcGR6Vm05WDJrcEQ5YUoifQ==") scanner = docscanner.createInstance() ret = scanner.setParameters(docscanner.Templates.color) image = cv2.imread("images/1.png") results = scanner.detectMat(image) for result in results: x1 = result.x1 y1 = result.y1 x2 = result.x2 y2 = result.y2 x3 = result.x3 y3 = result.y3 x4 = result.x4 y4 = result.y4 normalized_image = scanner.normalizeBuffer(image, x1, y1, x2, y2, x3, y3, x4, y4) normalized_image.recycle() showNormalizedImage("Normalized Image", normalized_image) cv2.drawContours(image, [np.int0([(x1, y1), (x2, y2), (x3, y3), (x4, y4)])], 0, (0, 255, 0), 2) cv2.imshow('Document Image', image) cv2.waitKey(0)
Scan Documents from Camera
Now, we move to the camera example with detailed steps.
We utilize OpenCV's video I/O API to capture and show camera video stream:
import cv2 cap = cv2.VideoCapture(0) while True: ret, image = cap.read() ch = cv2.waitKey(1) if ch == 27: break cv2.imshow('Document Scanner', image)
Initialize the document scanner with a valid license key:
import docscanner docscanner.initLicense("DLS2eyJoYW5kc2hha2VDb2RlIjoiMjAwMDAxLTE2NDk4Mjk3OTI2MzUiLCJvcmdhbml6YXRpb25JRCI6IjIwMDAwMSIsInNlc3Npb25QYXNzd29yZCI6IndTcGR6Vm05WDJrcEQ5YUoifQ==") scanner = docscanner.createInstance()
You can select a built-in template to set the output type of a normalized document: binary
, grayscale
, or color
.
scanner.setParameters(docscanner.Templates.color)
The template can be customized by referring to the template documentation. A more complicated parameter template looks like this:
{ "GlobalParameter":{ "Name":"GP", "MaxTotalImageDimension":0 }, "ImageParameterArray":[ { "Name":"IP-1", "RegionPredetectionModes":[ { "Mode": "RPM_GENERAL" } ], "Timeout": 10000, "ColourChannelUsageType": "CCUT_AUTO", "MaxThreadCount": 4, "ScaleDownThreshold": 2300, "ColourConversionModes":[ { "Mode": "CICM_GENERAL", "BlueChannelWeight": -1, "GreenChannelWeight": -1, "RedChannelWeight": -1 } ], "GrayscaleTransformationModes":[ { "Mode": "GTM_ORIGINAL" } ], "GrayscaleEnhancementModes": [ { "Mode": "GEM_GENERAL" } ], "BinarizationModes":[ { "Mode": "BM_LOCAL_BLOCK", "BlockSizeX": 0, "BlockSizeY": 0, "EnableFillBinaryVacancy": 0, "MorphOperation": "Close", "MorphShape": "Rectangle", "MorphOperationKernelSizeX": -1, "MorphOperationKernelSizeY": -1, "ThresholdCompensation": 10 } ], "TextureDetectionModes":[ { "Mode": "TDM_GENERAL_WIDTH_CONCENTRATION", "Sensitivity": 5 } ], "TextFilterModes": [ { "Mode": "TFM_GENERAL_CONTOUR", "IfEraseTextZone": 0, "MinImageDimension": 65536, "Sensitivity": 0 } ], "LineExtractionModes": [ { "Mode": "LEM_GENERAL" } ], "NormalizerParameterName":"NP-1", "BaseImageParameterName":"" } ], "NormalizerParameterArray":[ { "Name":"NP-1", "ContentType": "CT_DOCUMENT", "InteriorAngleRangeArray": [ { "MinValue": 70, "MaxValue": 110 } ], "QuadrilateralDetectionModes": [ { "Mode": "QDM_GENERAL" } ], "DeskewMode": { "Mode": "DM_PERSPECTIVE_CORRECTION", "ContentDirection": 0 }, "PageSize": [-1, -1], "ColourMode": "ICM_BINARY", "Brightness": 0, "Contrast": 0 } ] }
In live camera scenarios, all CPU-intensive tasks, such as document edge detection, should be executed in a separate thread to avoid blocking the main thread. Therefore, we register a callback function to a native thread and call the asynchronous detection method to queue the detection tasks that will be consumed by the native thread:
g_results = None g_normalized_images = [] index = 0 def callback(results): global g_results g_results = results def process_video(scanner): global g_normalized_images, index scanner.addAsyncListener(callback) cap = cv2.VideoCapture(0) while True: ret, image = cap.read() ch = cv2.waitKey(1) if ch == 27: break if image is not None: scanner.detectMatAsync(image) if g_results != None: for result in g_results: x1 = result.x1 y1 = result.y1 x2 = result.x2 y2 = result.y2 x3 = result.x3 y3 = result.y3 x4 = result.x4 y4 = result.y4 cv2.drawContours(image, [np.int0([(x1, y1), (x2, y2), (x3, y3), (x4, y4)])], 0, (0, 255, 0), 2) cv2.imshow('Document Scanner', image)
So far, the real-time document edge detection function has been implemented. To crop the document based on the contour points and take a further step to normalize the document, we need to call the normalizeBuffer
method:
def showNormalizedImage(name, normalized_image): mat = docscanner.convertNormalizedImage2Mat(normalized_image) cv2.imshow(name, mat) return mat if ch == ord('n'): # normalize image if g_results != None: if len(g_results) > 0: for result in g_results: x1 = result.x1 y1 = result.y1 x2 = result.x2 y2 = result.y2 x3 = result.x3 y3 = result.y3 x4 = result.x4 y4 = result.y4 normalized_image = scanner.normalizeBuffer(image, x1, y1, x2, y2, x3, y3, x4, y4) g_normalized_images.append((str(index), normalized_image)) showNormalizedImage(str(index), normalized_image) index += 1 else: print('No document found')
If you are satisfied with the result, you can save the normalized image to a file:
if ch == ord('s'): # save image if len(g_normalized_images) > 0: for data in g_normalized_images: cv2.destroyWindow(data[0]) data[1].save(str(time.time()) + '.png') print('Image saved') data[1].recycle() g_normalized_images = [] index = 0
Finally, let's run the Python document scanner app in the terminal.
Top comments (0)