commit 71a20dff5e99f6c137e37c1cea218511cd3ec717 Author: Mathieu Broillet Date: Wed Jul 17 18:57:51 2024 +0200 Added working api diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e57cd5f --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +assets/ +libs/ +venv/ +build/ +dist/ +tmp/ +*.spec diff --git a/README.md b/README.md new file mode 100644 index 0000000..18a82a2 --- /dev/null +++ b/README.md @@ -0,0 +1,78 @@ +# Easy local ALPR (Automatic License Plate Recognition) + +This script is a REST API server that uses [ultimateALPR-SDK](https://github.com/DoubangoTelecom/ultimateALPR-SDK) to process images and return the license plate +information. The server is created using Flask and the ultimateALPR SDK is used to process the images. + +This script is intended to be used as a faster local alternative to the large and resource heavy [CodeProject AI](https://www.codeproject.com/AI/docs) software. +> [!IMPORTANT] +> The ultimateALPR SDK is a lightweight and much faster alternative (on CPU and GPU) to the CodeProject AI software but it has **a few limitations** with it's free version: +> - The last character of the license plate is masked with an asterisk +> - The SDK supposedly has a limit of requests per program execution *(never encountered yet)* **but I have implemented a workaround for this by restarting the SDK after 3000 requests just in case.** + + +## Usage +The server listens on port 5000 and has one endpoint: /v1/image/alpr. The endpoint accepts POST requests with an +image file in the 'upload' field. The image is processed using the ultimateALPR SDK and the license plate +information is returned in JSON format. The reponse follows the CodeProject AI ALPR API format. So it can be used +as a drop-in replacement for the [CodeProject AI ALPR API](https://www.codeproject.com/AI/docs/api/api_reference.html#license-plate-reader). + +> POST: http://localhost:32168/v1/vision/alpr + +**Parameters** +- upload: (File) The image file to process. (see [Pillow.Image.open()](https://pillow.readthedocs.io/en/stable/reference/Image.html#PIL.Image.open) for supported formats) + +**Response** +```json +{ + "success": (Boolean) // True if successful. + "message": (String) // A summary of the inference operation. + "error": (String) // (Optional) An description of the error if success was false. + "predictions": (Object[]) // An array of objects with the x_max, x_min, max, y_min bounds of the plate, label, the plate chars and confidence. + "processMs": (Integer) // The time (ms) to process the image (includes inference and image manipulation operations). +} +``` + + +## Included models in built executable +When using the built executable, only the **latin** charset models are bundled by default. If you want to use a different +charset, you need to set the charset in the JSON_CONFIG variable and rebuild the executable with the according +models found [here](https://github.com/DoubangoTelecom/ultimateALPR-SDK/tree/master/assets) +To build the executable, you can use the ``build_alpr_api.sh`` script, which will create an executable named ``alpr_api`` in +the ``dist`` folder. + +## Setup development environment + +### Install ultimateALPR SDK +#### Use already built wheel +I have already built the ultimateALPR SDK for x86_64 and ARM64 and included the python3.10 wheel in the wheel folder. +You can install the wheel using : ``pip install wheel/*.whl`` +#### Manually build the wheel +If you want to build the wheel yourself, you can use the ``build_and_setup_ultimatealvr.sh`` script. It will create a new +directory ``tmp`` and build the wheel in there. It also includes the assets and libs folders needed when developing. + +### Copy necessary files/folders +Then you need to copy the ``assets`` and ``libs`` folders to the same directory as the script. + +If you built the wheel in the previous step, you can copy the ``assets`` and ``libs`` folders from the ``tmp`` directory. +If you used the already built wheel, you can find the 'assets' and 'libs' folders on the [GitHub repository](https://github.com/DoubangoTelecom/ultimateALPR-SDK/tree/master/assets) + +The structure should look like this: +```bash +. +├── alpr_api.py +├── assets +│ ├── fonts +│ └── models +├── libs +│ ├── libxxxxxx.so +│ ├── ... +│ └── libxxxxxx.so +└── ... +``` + +### Important notes +When building or developing the script, make sure to set the ``LD_LIBRARY_PATH`` environment variable to the libs folder +*(limitation of the ultimateALPR SDK)*. +```bash +export LD_LIBRARY_PATH=libs:$LD_LIBRARY_PATH +``` diff --git a/alpr_api.py b/alpr_api.py new file mode 100644 index 0000000..2181bde --- /dev/null +++ b/alpr_api.py @@ -0,0 +1,199 @@ +import json +import os +import sys +import threading +from time import sleep + +import ultimateAlprSdk +from PIL import Image +from flask import Flask, request, jsonify + +counter = 0 + +""" +Hi there! + +This script is a REST API server that uses the ultimateALPR SDK to process images and return the license plate +information. The server is created using Flask and the ultimateALPR SDK is used to process the images. + +See the README.md file for more information on how to run this script. +""" + +# Defines the default JSON configuration. More information at https://www.doubango.org/SDKs/anpr/docs/Configuration_options.html +JSON_CONFIG = { + "debug_level": "info", + "debug_write_input_image_enabled": False, + "debug_internal_data_path": ".", + + "num_threads": -1, + "gpgpu_enabled": True, + "max_latency": -1, + + "klass_vcr_gamma": 1.5, + + "detect_roi": [0, 0, 0, 0], + "detect_minscore": 0.35, + + "car_noplate_detect_min_score": 0.8, + + "pyramidal_search_enabled": True, + "pyramidal_search_sensitivity": 0.38, # default 0.28 + "pyramidal_search_minscore": 0.8, + "pyramidal_search_min_image_size_inpixels": 800, + + "recogn_rectify_enabled": True, # heavy on cpu + "recogn_minscore": 0.4, + "recogn_score_type": "min" +} + +IMAGE_TYPES_MAPPING = { + 'RGB': ultimateAlprSdk.ULTALPR_SDK_IMAGE_TYPE_RGB24, + 'RGBA': ultimateAlprSdk.ULTALPR_SDK_IMAGE_TYPE_RGBA32, + 'L': ultimateAlprSdk.ULTALPR_SDK_IMAGE_TYPE_Y +} + + +def load_engine(): + bundle_dir = getattr(sys, '_MEIPASS', os.path.abspath(os.path.dirname(__file__))) + + JSON_CONFIG["assets_folder"] = os.path.join(bundle_dir, "assets") + JSON_CONFIG["charset"] = "latin" + JSON_CONFIG["car_noplate_detect_enabled"] = False # Whether to detect and return cars with no plate + JSON_CONFIG[ + "ienv_enabled"] = False # Whether to enable Image Enhancement for Night-Vision (IENV). More info about IENV at https://www.doubango.org/SDKs/anpr/docs/Features.html#image-enhancement-for-night-vision-ienv. Default: true for x86-64 and false for ARM. + JSON_CONFIG[ + "openvino_enabled"] = False # Whether to enable OpenVINO. Tensorflow will be used when OpenVINO is disabled + JSON_CONFIG[ + "openvino_device"] = "GPU" # Defines the OpenVINO device to use (CPU, GPU, FPGA...). More info at https://www.doubango.org/SDKs/anpr/docs/Configuration_options.html#openvino-device + JSON_CONFIG["npu_enabled"] = False # Whether to enable NPU (Neural Processing Unit) acceleration + JSON_CONFIG[ + "klass_lpci_enabled"] = False # Whether to enable License Plate Country Identification (LPCI). More info at https://www.doubango.org/SDKs/anpr/docs/Features.html#license-plate-country-identification-lpci + JSON_CONFIG[ + "klass_vcr_enabled"] = False # Whether to enable Vehicle Color Recognition (VCR). More info at https://www.doubango.org/SDKs/anpr/docs/Features.html#vehicle-color-recognition-vcr + JSON_CONFIG[ + "klass_vmmr_enabled"] = False # Whether to enable Vehicle Make Model Recognition (VMMR). More info at https://www.doubango.org/SDKs/anpr/docs/Features.html#vehicle-make-model-recognition-vmmr + JSON_CONFIG[ + "klass_vbsr_enabled"] = False # Whether to enable Vehicle Body Style Recognition (VBSR). More info at https://www.doubango.org/SDKs/anpr/docs/Features.html#vehicle-body-style-recognition-vbsr + JSON_CONFIG["license_token_file"] = "" # Path to license token file + JSON_CONFIG["license_token_data"] = "" # Base64 license token data + + result = ultimateAlprSdk.UltAlprSdkEngine_init(json.dumps(JSON_CONFIG)) + if not result.isOK(): + raise RuntimeError("Init failed: %s" % result.phrase()) + + while counter < 3000: + sleep(1) + + unload_engine() + load_engine() + + +def unload_engine(): + result = ultimateAlprSdk.UltAlprSdkEngine_deInit() + if not result.isOK(): + raise RuntimeError("DeInit failed: %s" % result.phrase()) + + +def process_image(image: Image) -> str: + global counter + counter += 1 + + width, height = image.size + + if image.mode in IMAGE_TYPES_MAPPING: + image_type = IMAGE_TYPES_MAPPING[image.mode] + else: + raise ValueError("Invalid mode: %s" % image.mode) + + result = ultimateAlprSdk.UltAlprSdkEngine_process( + image_type, + image.tobytes(), # type(x) == bytes + width, + height, + 0, # stride + 1 # exifOrientation (already rotated in load_image -> use default value: 1) + ) + if not result.isOK(): + raise RuntimeError("Process failed: %s" % result.phrase()) + else: + return result.json() + + +def create_rest_server_flask(): + app = Flask(__name__) + + @app.route('/v1//', methods=['POST']) + def alpr(domain, module): + # Only care about the ALPR endpoint + if domain == 'image' and module == 'alpr': + if 'upload' not in request.files: + return jsonify({'error': 'No image found'}) + + image = request.files['upload'] + if image.filename == '': + return jsonify({'error': 'No selected file'}) + + image = Image.open(image) + result = process_image(image) + result = convert_to_cpai_compatible(result) + + return jsonify(result) + else: + return jsonify({'error': 'Endpoint not implemented'}), 404 + + return app + + +def convert_to_cpai_compatible(result): + result = json.loads(result) + + response = { + 'success': "true", + 'processMs': result['duration'], + 'inferenceMs': result['duration'], + 'predictions': [], + 'message': '', + 'moduleId': 'ALPR', + 'moduleName': 'License Plate Reader', + 'code': 200, + 'command': 'alpr', + 'requestId': 'null', + 'inferenceDevice': 'none', + 'analysisRoundTripMs': 0, + 'processedBy': 'none', + 'timestamp': '' + } + + if 'plates' in result: + plates = result['plates'] + + for plate in plates: + warpedBox = plate['warpedBox'] + x_coords = warpedBox[0::2] + y_coords = warpedBox[1::2] + x_min = min(x_coords) + x_max = max(x_coords) + y_min = min(y_coords) + y_max = max(y_coords) + + response['predictions'].append({ + 'confidence': plate['confidence'] / 100, + 'label': "Plate: " + plate['text'], + 'plate': plate['text'], + 'x_min': x_min, + 'x_max': x_max, + 'y_min': y_min, + 'y_max': y_max + }) + + return response + + +if __name__ == '__main__': + engine = threading.Thread(target=load_engine, daemon=True) + engine.start() + + app = create_rest_server_flask() + app.run(host='0.0.0.0', port=5000) + + unload_engine() diff --git a/build_alpr_api.sh b/build_alpr_api.sh new file mode 100755 index 0000000..ee36e39 --- /dev/null +++ b/build_alpr_api.sh @@ -0,0 +1 @@ +pyinstaller --noconfirm --onefile --console --add-data libs:. --add-data assets:assets --name alpr_api "alpr_api.py" \ No newline at end of file diff --git a/build_and_setup_ultimatealvr.sh b/build_and_setup_ultimatealvr.sh new file mode 100755 index 0000000..21bf6ae --- /dev/null +++ b/build_and_setup_ultimatealvr.sh @@ -0,0 +1,58 @@ +# clone sdk +mkdir ./tmp +cd tmp +wget https://github.com/DoubangoTelecom/ultimateALPR-SDK/archive/8130c76140fe8edc60fe20f875796121a8d22fed.zip -O temp-sdk.zip +unzip temp-sdk.zip +rm temp-sdk.zip + +mkdir temp-sdk +mv ultimateALPR-SDK*/* ./temp-sdk +rm -R ultimateALPR-SDK* + +# create env to build ultimatealpr-sdk for python +python3.10 -m venv venv +source venv/bin/activate +pip install setuptools wheel Cython + +cd temp-sdk + +# move folders to simplify build +mkdir -p binaries/linux/x86_64/c++ +cp c++/* binaries/linux/x86_64/c++ +cp python/* binaries/linux/x86_64/ + +# edit setup.py to simplify build +cd binaries/linux/x86_64/ +sed -i "s|sources=\[os.path.abspath('../../../python/ultimateALPR-SDK-API-PUBLIC-SWIG_python.cxx')\]|sources=[os.path.abspath('ultimateALPR-SDK-API-PUBLIC-SWIG_python.cxx')]|g" setup.py +sed -i "s|include_dirs=\['../../../c++'\]|include_dirs=['c++']|g" setup.py +sed -i "s|library_dirs=\['.'\]|library_dirs=['libs']|g" setup.py + +# move all .so files into libs folder +mkdir libs +mv *.so libs/ +mv *.so.* libs/ + +# build the wheel +python setup.py bdist_wheel -v + +# move the built whl and the libs back to root dir +mv dist/* ../../../../ + +mv libs ../../../../ + +# move the assets to root dir +cd ../../../ +mv assets ../assets + +## install the whl +#cd .. +#pip install *.whl +#rm *.whl + +cd ../ + +# remove sdk +rm -R temp-sdk + +echo "UltimateALPR SDK built and setup successfully" +echo "You can now install the wheel using 'pip install ultimateAlprSdk-*.whl'" \ No newline at end of file diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..ca97d7b --- /dev/null +++ b/requirements.txt @@ -0,0 +1,3 @@ +flask +pillow +ultimateAlprSdk \ No newline at end of file diff --git a/test.py b/test.py new file mode 100644 index 0000000..411928e --- /dev/null +++ b/test.py @@ -0,0 +1,23 @@ +import threading +import time + +from PIL import Image + +import alpr_api +from alpr_api import process_image + +if __name__ == '__main__': + threading.Thread(target=alpr_api.load_engine, daemon=True).start() + + try: + counter = 0 + while True: + counter += 1 + # make test request with test_image.png + image = Image.open('test_image.jpg') + result = process_image(image) + print(str(counter) + " - " + result) + time.sleep(1) + except KeyboardInterrupt: + alpr_api.unload_engine() + exit(0) diff --git a/test_image.jpg b/test_image.jpg new file mode 100644 index 0000000..d738c18 Binary files /dev/null and b/test_image.jpg differ