omni-lpr 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
omni_lpr-0.1.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Hassan Abedi
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,268 @@
1
+ Metadata-Version: 2.3
2
+ Name: omni-lpr
3
+ Version: 0.1.0
4
+ Summary: A multiprotocol (REST and MCP) server for automatic license plate recognition
5
+ License: MIT
6
+ Keywords: alpr,lpr,license plate recognition,automatic license plate recognition,api,rest,mcp,computer vision,server,microservice,onnx,openvino,cuda
7
+ Author: Hassan Abedi
8
+ Author-email: hassan.abedi.t+omnilpr@gmail.com
9
+ Maintainer: Hassan Abedi
10
+ Maintainer-email: hassan.abedi.t+omnilpr@gmail.com
11
+ Requires-Python: >=3.10,<4.0
12
+ Classifier: Development Status :: 4 - Beta
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: Intended Audience :: Science/Research
15
+ Classifier: License :: OSI Approved :: MIT License
16
+ Classifier: Operating System :: OS Independent
17
+ Classifier: Topic :: Internet :: WWW/HTTP :: WSGI :: Application
18
+ Classifier: Topic :: Scientific/Engineering :: Image Recognition
19
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
20
+ Classifier: Topic :: Utilities
21
+ Classifier: Programming Language :: Python :: 3
22
+ Classifier: Programming Language :: Python :: 3.10
23
+ Classifier: Programming Language :: Python :: 3.11
24
+ Classifier: Programming Language :: Python :: 3.12
25
+ Classifier: Programming Language :: Python :: 3.13
26
+ Provides-Extra: cuda
27
+ Provides-Extra: dev
28
+ Provides-Extra: openvino
29
+ Requires-Dist: click (>=8.2.1,<9.0.0)
30
+ Requires-Dist: fast-alpr[onnx-gpu] (>=0.3.0,<0.4.0) ; extra == "cuda"
31
+ Requires-Dist: fast-alpr[onnx-openvino] (>=0.3.0,<0.4.0) ; extra == "openvino"
32
+ Requires-Dist: fast-alpr[onnx] (>=0.3.0,<0.4.0)
33
+ Requires-Dist: gunicorn (>=23.0.0,<24.0.0)
34
+ Requires-Dist: httpx (>=0.28.1,<0.29.0)
35
+ Requires-Dist: mcp[cli] (>=1.12.3,<2.0.0)
36
+ Requires-Dist: mypy (>=1.11.1) ; extra == "dev"
37
+ Requires-Dist: pillow (>=11.3.0,<12.0.0)
38
+ Requires-Dist: pre-commit (>=4.2.0,<5.0.0) ; extra == "dev"
39
+ Requires-Dist: pydantic (>=2.11.7,<3.0.0)
40
+ Requires-Dist: pydantic-settings (>=2.10.1,<3.0.0)
41
+ Requires-Dist: pytest (>=8.0.1) ; extra == "dev"
42
+ Requires-Dist: pytest-asyncio (>=1.1.0,<2.0.0) ; extra == "dev"
43
+ Requires-Dist: pytest-cov (>=6.0.0) ; extra == "dev"
44
+ Requires-Dist: pytest-mock (>=3.14.0) ; extra == "dev"
45
+ Requires-Dist: python-dotenv (>=1.1.0,<2.0.0)
46
+ Requires-Dist: python-json-logger (>=3.3.0,<4.0.0)
47
+ Requires-Dist: ruff (>=0.9.3) ; extra == "dev"
48
+ Project-URL: Documentation, https://github.com/habedi/omni-lpr/blob/main/README.md
49
+ Project-URL: Repository, https://github.com/habedi/omni-lpr
50
+ Description-Content-Type: text/markdown
51
+
52
+ <div align="center">
53
+ <picture>
54
+ <img alt="Omni-LPR Logo" src="logo.svg" height="25%" width="25%">
55
+ </picture>
56
+ <br>
57
+
58
+ <h2>Omni-LPR</h2>
59
+
60
+ [![Tests](https://img.shields.io/github/actions/workflow/status/habedi/omni-lpr/tests.yml?label=tests&style=flat&labelColor=333333&logo=github&logoColor=white)](https://github.com/habedi/omni-lpr/actions/workflows/tests.yml)
61
+ [![Code Coverage](https://img.shields.io/codecov/c/github/habedi/omni-lpr?style=flat&label=coverage&labelColor=333333&logo=codecov&logoColor=white)](https://codecov.io/gh/habedi/omni-lpr)
62
+ [![Code Quality](https://img.shields.io/codefactor/grade/github/habedi/omni-lpr?style=flat&label=code%20quality&labelColor=333333&logo=codefactor&logoColor=white)](https://www.codefactor.io/repository/github/habedi/omni-lpr)
63
+ [![Python Version](https://img.shields.io/badge/python-%3E=3.10-3776ab?style=flat&labelColor=333333&logo=python&logoColor=white)](https://github.com/habedi/omni-lpr)
64
+ [![PyPI](https://img.shields.io/pypi/v/omni-lpr?style=flat&labelColor=333333&logo=pypi&logoColor=white)](https://pypi.org/project/omni-lpr/)
65
+ [![License](https://img.shields.io/badge/license-MIT-00acc1?style=flat&labelColor=333333&logo=open-source-initiative&logoColor=white)](https://github.com/habedi/omni-lpr/blob/main/LICENSE)
66
+
67
+ A multiprotocol (REST and MCP) server for automatic license plate recognition
68
+
69
+ </div>
70
+
71
+ ---
72
+
73
+ Omni-LPR is a self-hostable server that provides automatic license plate recognition (APLR) capabilities over REST and
74
+ the Model Context Protocol (MCP) APIs.
75
+ It can be both seen as a standalone ALPR microservice or as an ALPR toolbox for AI agents.
76
+
77
+ ### Why Omni-LPR?
78
+
79
+ Instead of integrating complex machine learning (ML) libraries directly into your application, Omni-LPR provides a
80
+ ready-to-deploy server that offers:
81
+
82
+ - **Decoupling:** Run your recognition model as a separate service. Your main application (in any language) doesn't need
83
+ Python or ML dependencies.
84
+ - **Multiple Interfaces:** Consume the service via a standard REST API, or the MCP for AI agent
85
+ integration.
86
+ - **Ready-to-Deploy:** Easy to deploy and start with Docker, Gunicorn, and multiple hardware backends support out of the
87
+ box.
88
+ - **Scalability:** Scale your recognition service independently of your main application.
89
+
90
+ ### Features
91
+
92
+ - **Multiple API Interfaces:** REST and MCP support.
93
+ - **High-Performance Recognition:** Fast and accurate license place recognition (detection and recognition) using
94
+ state-of-the-art computer vision models.
95
+ - **Hardware Acceleration:** Support for CPU (ONNX), Intel CPU/VPU (OpenVINO), and NVIDIA GPU (CUDA).
96
+ - **Easy Deployment:** Installable as a Python library or runnable via pre-built Docker images in a container.
97
+ - **Asynchronous Core:** Built on Starlette for high-performance, non-blocking I/O.
98
+
99
+ ---
100
+
101
+ ### Getting Started
102
+
103
+ You can run Omni-LPR either by installing it as a Python library or by using a pre-built Docker image.
104
+
105
+ #### Method 1
106
+
107
+ You can install Omni-LPR via `pip` or any other Python package manager.
108
+
109
+ ```sh
110
+ pip install omni-lpr
111
+ ````
112
+
113
+ By default, the server will use the CPU-enabled ONNX models for both detection and OCR.
114
+ To use hardware-accelerated models, you need to install the extra dependencies:
115
+
116
+ - **OpenVINO (Intel CPUs):** `pip install omni-lpr[openvino]`
117
+ - **CUDA (NVIDIA GPUs):** `pip install omni-lpr[cuda]`
118
+
119
+ ##### Starting the Server
120
+
121
+ To start the server with the REST and MCP APIs, set the `TRANSPORT` environment variable to `sse` and run the
122
+ `omni-lpr` command:
123
+
124
+ ```sh
125
+ TRANSPORT=sse omni-lpr --host 0.0.0.0 --port 8000
126
+ ```
127
+
128
+ #### Method 2
129
+
130
+ Pre-built Docker images are available from the GitHub Container Registry (`ghcr.io`).
131
+ You can build the images locally or pull them from the registry.
132
+
133
+ ##### Building the Docker Images
134
+
135
+ You can build the Docker images for different backends using the provided `Makefile`:
136
+
137
+ - **CPU (default):** `make docker-build-cpu`
138
+ - **CUDA:** `make docker-build-cuda`
139
+ - **OpenVINO:** `make docker-build-openvino`
140
+
141
+ ##### Running the Container
142
+
143
+ Once you have built or pulled the images, you can run them using the following commands:
144
+
145
+ - **CPU Image (ONNX):**
146
+ ```sh
147
+ make docker-run-cpu
148
+
149
+ # or manually:
150
+ docker run --rm -it -p 8000:8000 ghcr.io/habedi/omni-lpr:cpu
151
+ ```
152
+
153
+ - **CPU Image (OpenVINO):**
154
+ ```sh
155
+ make docker-run-openvino
156
+ # or manually:
157
+ docker run --rm -it -p 8000:8000 ghcr.io/habedi/omni-lpr:openvino
158
+ ```
159
+
160
+ - **GPU Image (CUDA):**
161
+ ```sh
162
+ make docker-run-cuda
163
+ # or manually:
164
+ docker run --rm -it --gpus all -p 8000:8000 ghcr.io/habedi/omni-lpr:cuda
165
+ ```
166
+
167
+ ---
168
+
169
+ ### API Documentation
170
+
171
+ Omni-LPR provides two distinct APIs to access its functionality.
172
+
173
+ > [!NOTE]
174
+ > This project does not provide interactive API documentation (e.g., Swagger UI or ReDoc).
175
+ > This `README` and the `GET /api/tools` endpoint are the primary sources of API documentation.
176
+
177
+ #### 1. REST API
178
+
179
+ The REST API provides simple endpoints for recognition tasks. All tool endpoints are available under the `/api/` prefix.
180
+
181
+ ##### Discovering Tools
182
+
183
+ To get a list of available tools and their input schemas, send a `GET` request to the `/api/tools` endpoint.
184
+
185
+ ```sh
186
+ curl http://localhost:8000/api/tools
187
+ ```
188
+
189
+ This will return a JSON array of tool objects, each with a `name`, `description`, and `input_schema`.
190
+
191
+ ##### Calling a Tool
192
+
193
+ To call a specific tool, send a `POST` request to the corresponding endpoint (e.g., `/api/detect_and_recognize_plate`).
194
+ The request body must be a JSON object matching the tool's `input_schema`.
195
+
196
+ ###### Example
197
+
198
+ To recognize a plate, `POST` a JSON payload with a Base64-encoded image to the `/api/detect_and_recognize_plate`
199
+ endpoint.
200
+
201
+ ```sh
202
+ # Encode your image to Base64
203
+ # On macOS: base64 -i /path/to/your/image.jpg | pbcopy
204
+ # On Linux: base64 /path/to/your/image.jpg | xsel -ib
205
+
206
+ curl -X POST \
207
+ -H "Content-Type: application/json" \
208
+ -d '{"image_base64": "PASTE_YOUR_BASE64_STRING_HERE"}' \
209
+ http://localhost:8000/api/detect_and_recognize_plate
210
+ ```
211
+
212
+ #### 2. MCP API (for AI Agents)
213
+
214
+ The server also exposes its capabilities as tools over the Model Context Protocol (MCP).
215
+ The MCP endpoint is available at [http://127.0.0.1:8000/mcp/sse](http://127.0.0.1:8000/mcp/sse).
216
+
217
+ ##### Implemented Tools
218
+
219
+ Currently, the following tools are implemented:
220
+
221
+ * **`recognize_plate`**: Recognizes text from a pre-cropped image of a license plate.
222
+ * **`recognize_plate_from_path`**: Recognizes text from a pre-cropped license plate image located at a given URL or
223
+ local file path.
224
+ * **`detect_and_recognize_plate`**: Detects and recognizes all license plates in a full image.
225
+ * **`detect_and_recognize_plate_from_path`**: Detects and recognizes license plates from an image at a given URL or
226
+ local file path.
227
+ * **`list_models`**: Lists the available detector and OCR models.
228
+
229
+ <div align="center">
230
+ <picture>
231
+ <img src="docs/assets/screenshots/mcp-inspector-1.png" alt="MCP Inspector Screenshot" width="auto" height="auto">
232
+ </picture>
233
+ </div>
234
+
235
+ -----
236
+
237
+ ### Configuration
238
+
239
+ The server is configured using environment variables that can be loaded from a `.env` file if present.
240
+
241
+ | Argument | Env Var | Description |
242
+ |-----------------------|---------------------|-------------------------------------------------------------------------------------------------------------------------------------|
243
+ | `--port` | `PORT` | Server port (default: `8000`) |
244
+ | `--host` | `HOST` | Server host (default: `127.0.0.1`) |
245
+ | `--transport` | `TRANSPORT` | Transport protocol (default: `stdio`). Valid values are `stdio` and `sse` |
246
+ | `--log-level` | `LOG_LEVEL` | Logging level (default: `INFO`). Valid values are `DEBUG`, `INFO`, `WARN`, and `ERROR` |
247
+ | `--default-ocr-model` | `DEFAULT_OCR_MODEL` | Default OCR model to use (default: `cct-xs-v1-global-model`). Valid values are `cct-xs-v1-global-model` and `cct-s-v1-global-model` |
248
+
249
+ > [!NOTE]
250
+ > The REST API is only available when `TRANSPORT` is set to `sse`.
251
+ > The MCP API is available for both `stdio` (in-process) and `sse` (http) transports.
252
+
253
+ -----
254
+
255
+ ### Contributing
256
+
257
+ See [CONTRIBUTING.md](CONTRIBUTING.md) for details on how to make a contribution.
258
+
259
+ ### License
260
+
261
+ Omni-LPR is licensed under the MIT License (see [LICENSE](LICENSE)).
262
+
263
+ ### Acknowledgements
264
+
265
+ - This project uses the awesome [fast-plate-ocr](https://github.com/ankandrew/fast-plate-ocr)
266
+ and [fast-alpr](https://github.com/ankandrew/fast-alpr) Python libraries.
267
+ - The project logo is from [SVG Repo](https://www.svgrepo.com/svg/237124/license-plate-number).
268
+
@@ -0,0 +1,216 @@
1
+ <div align="center">
2
+ <picture>
3
+ <img alt="Omni-LPR Logo" src="logo.svg" height="25%" width="25%">
4
+ </picture>
5
+ <br>
6
+
7
+ <h2>Omni-LPR</h2>
8
+
9
+ [![Tests](https://img.shields.io/github/actions/workflow/status/habedi/omni-lpr/tests.yml?label=tests&style=flat&labelColor=333333&logo=github&logoColor=white)](https://github.com/habedi/omni-lpr/actions/workflows/tests.yml)
10
+ [![Code Coverage](https://img.shields.io/codecov/c/github/habedi/omni-lpr?style=flat&label=coverage&labelColor=333333&logo=codecov&logoColor=white)](https://codecov.io/gh/habedi/omni-lpr)
11
+ [![Code Quality](https://img.shields.io/codefactor/grade/github/habedi/omni-lpr?style=flat&label=code%20quality&labelColor=333333&logo=codefactor&logoColor=white)](https://www.codefactor.io/repository/github/habedi/omni-lpr)
12
+ [![Python Version](https://img.shields.io/badge/python-%3E=3.10-3776ab?style=flat&labelColor=333333&logo=python&logoColor=white)](https://github.com/habedi/omni-lpr)
13
+ [![PyPI](https://img.shields.io/pypi/v/omni-lpr?style=flat&labelColor=333333&logo=pypi&logoColor=white)](https://pypi.org/project/omni-lpr/)
14
+ [![License](https://img.shields.io/badge/license-MIT-00acc1?style=flat&labelColor=333333&logo=open-source-initiative&logoColor=white)](https://github.com/habedi/omni-lpr/blob/main/LICENSE)
15
+
16
+ A multiprotocol (REST and MCP) server for automatic license plate recognition
17
+
18
+ </div>
19
+
20
+ ---
21
+
22
+ Omni-LPR is a self-hostable server that provides automatic license plate recognition (APLR) capabilities over REST and
23
+ the Model Context Protocol (MCP) APIs.
24
+ It can be both seen as a standalone ALPR microservice or as an ALPR toolbox for AI agents.
25
+
26
+ ### Why Omni-LPR?
27
+
28
+ Instead of integrating complex machine learning (ML) libraries directly into your application, Omni-LPR provides a
29
+ ready-to-deploy server that offers:
30
+
31
+ - **Decoupling:** Run your recognition model as a separate service. Your main application (in any language) doesn't need
32
+ Python or ML dependencies.
33
+ - **Multiple Interfaces:** Consume the service via a standard REST API, or the MCP for AI agent
34
+ integration.
35
+ - **Ready-to-Deploy:** Easy to deploy and start with Docker, Gunicorn, and multiple hardware backends support out of the
36
+ box.
37
+ - **Scalability:** Scale your recognition service independently of your main application.
38
+
39
+ ### Features
40
+
41
+ - **Multiple API Interfaces:** REST and MCP support.
42
+ - **High-Performance Recognition:** Fast and accurate license place recognition (detection and recognition) using
43
+ state-of-the-art computer vision models.
44
+ - **Hardware Acceleration:** Support for CPU (ONNX), Intel CPU/VPU (OpenVINO), and NVIDIA GPU (CUDA).
45
+ - **Easy Deployment:** Installable as a Python library or runnable via pre-built Docker images in a container.
46
+ - **Asynchronous Core:** Built on Starlette for high-performance, non-blocking I/O.
47
+
48
+ ---
49
+
50
+ ### Getting Started
51
+
52
+ You can run Omni-LPR either by installing it as a Python library or by using a pre-built Docker image.
53
+
54
+ #### Method 1
55
+
56
+ You can install Omni-LPR via `pip` or any other Python package manager.
57
+
58
+ ```sh
59
+ pip install omni-lpr
60
+ ````
61
+
62
+ By default, the server will use the CPU-enabled ONNX models for both detection and OCR.
63
+ To use hardware-accelerated models, you need to install the extra dependencies:
64
+
65
+ - **OpenVINO (Intel CPUs):** `pip install omni-lpr[openvino]`
66
+ - **CUDA (NVIDIA GPUs):** `pip install omni-lpr[cuda]`
67
+
68
+ ##### Starting the Server
69
+
70
+ To start the server with the REST and MCP APIs, set the `TRANSPORT` environment variable to `sse` and run the
71
+ `omni-lpr` command:
72
+
73
+ ```sh
74
+ TRANSPORT=sse omni-lpr --host 0.0.0.0 --port 8000
75
+ ```
76
+
77
+ #### Method 2
78
+
79
+ Pre-built Docker images are available from the GitHub Container Registry (`ghcr.io`).
80
+ You can build the images locally or pull them from the registry.
81
+
82
+ ##### Building the Docker Images
83
+
84
+ You can build the Docker images for different backends using the provided `Makefile`:
85
+
86
+ - **CPU (default):** `make docker-build-cpu`
87
+ - **CUDA:** `make docker-build-cuda`
88
+ - **OpenVINO:** `make docker-build-openvino`
89
+
90
+ ##### Running the Container
91
+
92
+ Once you have built or pulled the images, you can run them using the following commands:
93
+
94
+ - **CPU Image (ONNX):**
95
+ ```sh
96
+ make docker-run-cpu
97
+
98
+ # or manually:
99
+ docker run --rm -it -p 8000:8000 ghcr.io/habedi/omni-lpr:cpu
100
+ ```
101
+
102
+ - **CPU Image (OpenVINO):**
103
+ ```sh
104
+ make docker-run-openvino
105
+ # or manually:
106
+ docker run --rm -it -p 8000:8000 ghcr.io/habedi/omni-lpr:openvino
107
+ ```
108
+
109
+ - **GPU Image (CUDA):**
110
+ ```sh
111
+ make docker-run-cuda
112
+ # or manually:
113
+ docker run --rm -it --gpus all -p 8000:8000 ghcr.io/habedi/omni-lpr:cuda
114
+ ```
115
+
116
+ ---
117
+
118
+ ### API Documentation
119
+
120
+ Omni-LPR provides two distinct APIs to access its functionality.
121
+
122
+ > [!NOTE]
123
+ > This project does not provide interactive API documentation (e.g., Swagger UI or ReDoc).
124
+ > This `README` and the `GET /api/tools` endpoint are the primary sources of API documentation.
125
+
126
+ #### 1. REST API
127
+
128
+ The REST API provides simple endpoints for recognition tasks. All tool endpoints are available under the `/api/` prefix.
129
+
130
+ ##### Discovering Tools
131
+
132
+ To get a list of available tools and their input schemas, send a `GET` request to the `/api/tools` endpoint.
133
+
134
+ ```sh
135
+ curl http://localhost:8000/api/tools
136
+ ```
137
+
138
+ This will return a JSON array of tool objects, each with a `name`, `description`, and `input_schema`.
139
+
140
+ ##### Calling a Tool
141
+
142
+ To call a specific tool, send a `POST` request to the corresponding endpoint (e.g., `/api/detect_and_recognize_plate`).
143
+ The request body must be a JSON object matching the tool's `input_schema`.
144
+
145
+ ###### Example
146
+
147
+ To recognize a plate, `POST` a JSON payload with a Base64-encoded image to the `/api/detect_and_recognize_plate`
148
+ endpoint.
149
+
150
+ ```sh
151
+ # Encode your image to Base64
152
+ # On macOS: base64 -i /path/to/your/image.jpg | pbcopy
153
+ # On Linux: base64 /path/to/your/image.jpg | xsel -ib
154
+
155
+ curl -X POST \
156
+ -H "Content-Type: application/json" \
157
+ -d '{"image_base64": "PASTE_YOUR_BASE64_STRING_HERE"}' \
158
+ http://localhost:8000/api/detect_and_recognize_plate
159
+ ```
160
+
161
+ #### 2. MCP API (for AI Agents)
162
+
163
+ The server also exposes its capabilities as tools over the Model Context Protocol (MCP).
164
+ The MCP endpoint is available at [http://127.0.0.1:8000/mcp/sse](http://127.0.0.1:8000/mcp/sse).
165
+
166
+ ##### Implemented Tools
167
+
168
+ Currently, the following tools are implemented:
169
+
170
+ * **`recognize_plate`**: Recognizes text from a pre-cropped image of a license plate.
171
+ * **`recognize_plate_from_path`**: Recognizes text from a pre-cropped license plate image located at a given URL or
172
+ local file path.
173
+ * **`detect_and_recognize_plate`**: Detects and recognizes all license plates in a full image.
174
+ * **`detect_and_recognize_plate_from_path`**: Detects and recognizes license plates from an image at a given URL or
175
+ local file path.
176
+ * **`list_models`**: Lists the available detector and OCR models.
177
+
178
+ <div align="center">
179
+ <picture>
180
+ <img src="docs/assets/screenshots/mcp-inspector-1.png" alt="MCP Inspector Screenshot" width="auto" height="auto">
181
+ </picture>
182
+ </div>
183
+
184
+ -----
185
+
186
+ ### Configuration
187
+
188
+ The server is configured using environment variables that can be loaded from a `.env` file if present.
189
+
190
+ | Argument | Env Var | Description |
191
+ |-----------------------|---------------------|-------------------------------------------------------------------------------------------------------------------------------------|
192
+ | `--port` | `PORT` | Server port (default: `8000`) |
193
+ | `--host` | `HOST` | Server host (default: `127.0.0.1`) |
194
+ | `--transport` | `TRANSPORT` | Transport protocol (default: `stdio`). Valid values are `stdio` and `sse` |
195
+ | `--log-level` | `LOG_LEVEL` | Logging level (default: `INFO`). Valid values are `DEBUG`, `INFO`, `WARN`, and `ERROR` |
196
+ | `--default-ocr-model` | `DEFAULT_OCR_MODEL` | Default OCR model to use (default: `cct-xs-v1-global-model`). Valid values are `cct-xs-v1-global-model` and `cct-s-v1-global-model` |
197
+
198
+ > [!NOTE]
199
+ > The REST API is only available when `TRANSPORT` is set to `sse`.
200
+ > The MCP API is available for both `stdio` (in-process) and `sse` (http) transports.
201
+
202
+ -----
203
+
204
+ ### Contributing
205
+
206
+ See [CONTRIBUTING.md](CONTRIBUTING.md) for details on how to make a contribution.
207
+
208
+ ### License
209
+
210
+ Omni-LPR is licensed under the MIT License (see [LICENSE](LICENSE)).
211
+
212
+ ### Acknowledgements
213
+
214
+ - This project uses the awesome [fast-plate-ocr](https://github.com/ankandrew/fast-plate-ocr)
215
+ and [fast-alpr](https://github.com/ankandrew/fast-alpr) Python libraries.
216
+ - The project logo is from [SVG Repo](https://www.svgrepo.com/svg/237124/license-plate-number).
@@ -0,0 +1,163 @@
1
+ [project]
2
+ name = "omni-lpr"
3
+ version = "0.1.0"
4
+ description = " A multiprotocol (REST and MCP) server for automatic license plate recognition"
5
+ readme = "README.md"
6
+ license = { text = "MIT" }
7
+ authors = [
8
+ { name = "Hassan Abedi", email = "hassan.abedi.t+omnilpr@gmail.com" }
9
+ ]
10
+ maintainers = [
11
+ { name = "Hassan Abedi", email = "hassan.abedi.t+omnilpr@gmail.com" }
12
+ ]
13
+
14
+ keywords = [
15
+ "alpr",
16
+ "lpr",
17
+ "license plate recognition",
18
+ "automatic license plate recognition",
19
+ "api",
20
+ "rest",
21
+ "mcp",
22
+ "computer vision",
23
+ "server",
24
+ "microservice",
25
+ "onnx",
26
+ "openvino",
27
+ "cuda"
28
+ ]
29
+
30
+ classifiers = [
31
+ "Development Status :: 4 - Beta",
32
+ "Intended Audience :: Developers",
33
+ "Intended Audience :: Science/Research",
34
+ "License :: OSI Approved :: MIT License",
35
+ "Operating System :: OS Independent",
36
+ "Topic :: Internet :: WWW/HTTP :: WSGI :: Application",
37
+ "Topic :: Scientific/Engineering :: Image Recognition",
38
+ "Topic :: Software Development :: Libraries :: Python Modules",
39
+ "Topic :: Utilities",
40
+ "Programming Language :: Python :: 3",
41
+ "Programming Language :: Python :: 3.10",
42
+ "Programming Language :: Python :: 3.11",
43
+ "Programming Language :: Python :: 3.12",
44
+ "Programming Language :: Python :: 3.13",
45
+ ]
46
+
47
+ requires-python = ">=3.10,<4.0"
48
+ dependencies = [
49
+ "python-dotenv (>=1.1.0,<2.0.0)",
50
+ "mcp[cli] (>=1.12.3,<2.0.0)",
51
+ "pydantic-settings (>=2.10.1,<3.0.0)",
52
+ "click (>=8.2.1,<9.0.0)",
53
+ "pillow (>=11.3.0,<12.0.0)",
54
+ "gunicorn (>=23.0.0,<24.0.0)",
55
+ "python-json-logger (>=3.3.0,<4.0.0)",
56
+ "httpx (>=0.28.1,<0.29.0)",
57
+ "fast-alpr[onnx] (>=0.3.0,<0.4.0)",
58
+ "pydantic (>=2.11.7,<3.0.0)",
59
+ ]
60
+
61
+ [project.optional-dependencies]
62
+ openvino = [
63
+ "fast-alpr[onnx-openvino] (>=0.3.0,<0.4.0)",
64
+ ]
65
+ cuda = [
66
+ "fast-alpr[onnx-gpu] (>=0.3.0,<0.4.0)",
67
+ ]
68
+ dev = [
69
+ "pytest>=8.0.1",
70
+ "pytest-cov>=6.0.0",
71
+ "pytest-mock>=3.14.0",
72
+ "pytest-asyncio (>=1.1.0,<2.0.0)",
73
+ "mypy>=1.11.1",
74
+ "ruff>=0.9.3",
75
+ "pre-commit (>=4.2.0,<5.0.0)",
76
+ ]
77
+
78
+ [project.urls]
79
+ Repository = "https://github.com/habedi/omni-lpr"
80
+ Documentation = "https://github.com/habedi/omni-lpr/blob/main/README.md"
81
+
82
+ [project.scripts]
83
+ omni-lpr = "server.__main__:main"
84
+
85
+ [tool.poetry]
86
+ include = ["README.md"]
87
+ packages = [{ include = "server", from = "src" }]
88
+ package-mode = true
89
+
90
+ [build-system]
91
+ requires = ["poetry-core"]
92
+ build-backend = "poetry.core.masonry.api"
93
+
94
+ [tool.pytest.ini_options]
95
+ pythonpath = ["src"]
96
+ testpaths = ["tests"]
97
+ addopts = [
98
+ "--tb=short",
99
+ #"--disable-warnings",
100
+ "--cov=src",
101
+ "--cov-branch",
102
+ "--cov-report=term",
103
+ "--cov-report=xml",
104
+ "-rs"
105
+ ]
106
+ asyncio_mode = "auto"
107
+ asyncio_default_fixture_loop_scope = "function"
108
+ asyncio_default_test_loop_scope = "function"
109
+
110
+ [tool.coverage.run]
111
+ branch = true
112
+ parallel = true
113
+ source = ["."]
114
+ omit = ["tests/*"]
115
+
116
+ [tool.coverage.report]
117
+ show_missing = false
118
+ skip_empty = true
119
+ precision = 2
120
+
121
+ [tool.mypy]
122
+ python_version = "3.10"
123
+ ignore_missing_imports = true
124
+ disallow_untyped_defs = true
125
+ disallow_untyped_calls = true
126
+ disallow_incomplete_defs = true
127
+ check_untyped_defs = true
128
+ warn_return_any = true
129
+ strict_optional = true
130
+ warn_redundant_casts = true
131
+ exclude = "^(scripts/|tests/)"
132
+
133
+ [tool.ruff]
134
+ exclude = [
135
+ ".bzr", ".direnv", ".eggs", ".git", ".git-rewrite", ".hg", ".mypy_cache",
136
+ ".nox", ".pants.d", ".pytype", ".ruff_cache", ".svn", ".tox", ".venv",
137
+ "__pypackages__", "_build", "buck-out", "build", "dist", "node_modules",
138
+ "venv", "tests"
139
+ ]
140
+ line-length = 100
141
+ indent-width = 4
142
+ src = ["src"]
143
+ target-version = "py310"
144
+ unsafe-fixes = false
145
+
146
+ [tool.ruff.lint]
147
+ select = ["ANN", "E", "F", "I", "W", "B", "RUF", "SIM", "C90"]
148
+ ignore = ["D100", "D101", "D102", "D103", "D104", "D105", "D106", "D107"]
149
+ fixable = ["ALL"]
150
+ unfixable = []
151
+ dummy-variable-rgx = "^(_+|(_+[a-zA-Z0-9_]*[a-zA-Z0-9]+?))$"
152
+
153
+ [tool.ruff.format]
154
+ quote-style = "double"
155
+ indent-style = "space"
156
+ skip-magic-trailing-comma = false
157
+ line-ending = "auto"
158
+
159
+ [tool.ruff.lint.pydocstyle]
160
+ convention = "google"
161
+
162
+ [tool.ruff.lint.per-file-ignores]
163
+ "tests/**/*.py" = []
@@ -0,0 +1,20 @@
1
+ import logging
2
+
3
+ logging.basicConfig()
4
+
5
+ from importlib.metadata import PackageNotFoundError, version
6
+
7
+ from .__main__ import main, starlette_app
8
+
9
+ _logger = logging.getLogger(__name__)
10
+
11
+ try:
12
+ __version__ = version("omni-lpr")
13
+ except PackageNotFoundError:
14
+ __version__ = "0.0.0-unknown"
15
+ _logger.warning(
16
+ "Could not determine package version using importlib.metadata. "
17
+ "Is the library installed correctly?"
18
+ )
19
+
20
+ __all__ = ["main", "starlette_app"]
@@ -0,0 +1,83 @@
1
+ import logging
2
+
3
+ import click
4
+ from mcp.server.sse import SseServerTransport
5
+ from pythonjsonlogger import jsonlogger
6
+ from starlette.applications import Starlette
7
+ from starlette.responses import JSONResponse, Response
8
+ from starlette.routing import Mount, Route
9
+
10
+ from .mcp import app, run_stdio_transport
11
+ from .rest import setup_rest_routes
12
+ from .settings import update_settings
13
+
14
+ _logger = logging.getLogger(__name__)
15
+
16
+
17
+ def setup_logging(log_level: str):
18
+ level = logging.getLevelName(log_level.upper())
19
+ logHandler = logging.StreamHandler()
20
+ formatter = jsonlogger.JsonFormatter("%(asctime)s %(name)s %(levelname)s %(message)s")
21
+ logHandler.setFormatter(formatter)
22
+ logging.basicConfig(level=level, handlers=[logHandler])
23
+ _logger.info(f"Logging configured with level: {log_level.upper()}")
24
+
25
+
26
+ sse = SseServerTransport("/mcp/messages/")
27
+
28
+
29
+ async def handle_sse(request):
30
+ async with sse.connect_sse(request.scope, request.receive, request._send) as streams:
31
+ await app.run(streams[0], streams[1], app.create_initialization_options())
32
+ return Response()
33
+
34
+
35
+ async def health_check(_request):
36
+ _logger.debug("Health check requested.")
37
+ return JSONResponse({"status": "ok"})
38
+
39
+
40
+ starlette_app = Starlette(
41
+ debug=True,
42
+ routes=[
43
+ Route("/mcp/sse", endpoint=handle_sse, methods=["GET"]),
44
+ Mount("/mcp/messages/", app=sse.handle_post_message),
45
+ Route("/api/health", endpoint=health_check, methods=["GET"]),
46
+ Mount("/api", routes=setup_rest_routes()),
47
+ ],
48
+ )
49
+
50
+
51
+ @click.command()
52
+ @click.option("--host", default="127.0.0.1", help="The host to bind to.", envvar="HOST")
53
+ @click.option("--port", default=8000, help="The port to bind to.", envvar="PORT")
54
+ @click.option(
55
+ "--transport",
56
+ default="stdio",
57
+ help="The transport to use.",
58
+ type=click.Choice(["stdio", "sse"]),
59
+ envvar="TRANSPORT",
60
+ )
61
+ @click.option("--log-level", default="INFO", help="The log level to use.", envvar="LOG_LEVEL")
62
+ @click.option(
63
+ "--default-ocr-model",
64
+ default="cct-xs-v1-global-model",
65
+ help="The default OCR model to use.",
66
+ envvar="DEFAULT_OCR_MODEL",
67
+ )
68
+ def main(host: str, port: int, transport: str, log_level: str, default_ocr_model: str) -> int:
69
+ """Main entrypoint for the omni-lpr server."""
70
+ update_settings(default_ocr_model=default_ocr_model)
71
+ setup_logging(log_level)
72
+ if transport == "sse":
73
+ import uvicorn
74
+
75
+ _logger.info(f"Starting SSE server on {host}:{port}")
76
+ uvicorn.run(starlette_app, host=host, port=port)
77
+ else:
78
+ run_stdio_transport(app)
79
+ return 0
80
+
81
+
82
+ if __name__ == "__main__":
83
+ main()
@@ -0,0 +1,33 @@
1
+ """Defines standardized error structures for the Omni-LPR API."""
2
+
3
+ from enum import Enum
4
+ from typing import Any, Optional
5
+
6
+ from pydantic import BaseModel, Field
7
+
8
+
9
+ class ErrorCode(str, Enum):
10
+ """Enumeration for API error codes."""
11
+
12
+ VALIDATION_ERROR = "VALIDATION_ERROR"
13
+ DESERIALIZATION_ERROR = "DESERIALIZATION_ERROR"
14
+ TOOL_LOGIC_ERROR = "TOOL_LOGIC_ERROR"
15
+ UNKNOWN_ERROR = "UNKNOWN_ERROR"
16
+
17
+
18
+ class APIError(BaseModel):
19
+ """Represents a standardized API error."""
20
+
21
+ code: ErrorCode = Field(..., description="A unique code identifying the error type.")
22
+ message: str = Field(..., description="A human-readable message describing the error.")
23
+ details: Optional[Any] = Field(None, description="Optional structured details about the error.")
24
+
25
+
26
+ class ToolLogicError(Exception):
27
+ """Custom exception for tool-related errors that can be mapped to an APIError."""
28
+
29
+ def __init__(
30
+ self, message: str, code: ErrorCode = ErrorCode.TOOL_LOGIC_ERROR, details: Any = None
31
+ ):
32
+ super().__init__(message)
33
+ self.error = APIError(code=code, message=message, details=details)
@@ -0,0 +1,34 @@
1
+ import logging
2
+
3
+ import anyio
4
+ import mcp.types as types
5
+ from mcp.server.lowlevel import Server
6
+ from mcp.server.stdio import stdio_server
7
+
8
+ from .tools import tool_registry
9
+
10
+ _logger = logging.getLogger(__name__)
11
+
12
+ app = Server("omni-lpr")
13
+
14
+
15
+ @app.call_tool()
16
+ async def call_tool_handler(name: str, arguments: dict) -> list[types.ContentBlock]:
17
+ _logger.debug(f"Tool call received: {name} with arguments: {arguments}")
18
+ return await tool_registry.call(name, arguments)
19
+
20
+
21
+ @app.list_tools()
22
+ async def list_tools_handler() -> list[types.Tool]:
23
+ _logger.debug("Tool list requested.")
24
+ return tool_registry.list()
25
+
26
+
27
+ def run_stdio_transport(app: Server):
28
+ async def arun():
29
+ _logger.info("Starting stdio server.")
30
+
31
+ async with stdio_server() as streams:
32
+ await app.run(streams[0], streams[1], app.create_initialization_options())
33
+
34
+ anyio.run(arun)
File without changes
@@ -0,0 +1,109 @@
1
+ import json
2
+ import logging
3
+ from typing import Type
4
+
5
+ from mcp import types
6
+ from pydantic import BaseModel, ValidationError
7
+ from starlette.requests import Request
8
+ from starlette.responses import JSONResponse
9
+ from starlette.routing import Route
10
+
11
+ from .errors import APIError, ErrorCode
12
+ from .tools import tool_registry
13
+
14
+ _logger = logging.getLogger(__name__)
15
+
16
+
17
+ def create_rest_endpoint(tool_name: str, tool_func: callable, model: Type[BaseModel]) -> Route:
18
+ """Create a Starlette REST endpoint for a given tool."""
19
+
20
+ async def endpoint(request: Request) -> JSONResponse:
21
+ _logger.info(f"REST endpoint '{tool_name}' called.")
22
+ try:
23
+ if await request.body():
24
+ json_data = await request.json()
25
+ else:
26
+ json_data = {}
27
+
28
+ except json.JSONDecodeError:
29
+ _logger.warning(f"Invalid JSON received for tool '{tool_name}'.")
30
+ return JSONResponse({"error": "Invalid JSON"}, status_code=400)
31
+
32
+ try:
33
+ validated_args = model(**json_data)
34
+ except ValidationError as e:
35
+ _logger.warning(f"Input validation failed for tool '{tool_name}': {e}")
36
+ return JSONResponse(
37
+ {"error": "Input validation failed", "details": e.errors()},
38
+ status_code=400,
39
+ )
40
+
41
+ try:
42
+ result_content: list[types.ContentBlock] = await tool_func(validated_args)
43
+ # The result from the tool is a list of ContentBlock objects.
44
+ # We need to serialize them to a JSON-compatible format.
45
+ results = []
46
+ for item in result_content:
47
+ if isinstance(item, types.TextContent):
48
+ if not item.text:
49
+ continue # Skip empty text content
50
+ try:
51
+ results.append(json.loads(item.text))
52
+ except json.JSONDecodeError as e:
53
+ _logger.warning(
54
+ f"Failed to decode JSON from tool output for '{tool_name}': {e}"
55
+ )
56
+ error = APIError(
57
+ code=ErrorCode.DESERIALIZATION_ERROR,
58
+ message="A tool returned output that could not be decoded as JSON.",
59
+ details={"raw_output": item.text},
60
+ )
61
+ return JSONResponse({"errors": [error.model_dump()]}, status_code=500)
62
+ else:
63
+ results.append(item.model_dump())
64
+
65
+ if not results:
66
+ return JSONResponse({})
67
+
68
+ # If the original tool result was a single item that we parsed into a list,
69
+ # unwrap it for a cleaner API response.
70
+ final_result = (
71
+ results[0] if len(results) == 1 and isinstance(results[0], list) else results
72
+ )
73
+ final_result = final_result[0] if len(final_result) == 1 else final_result
74
+ return JSONResponse(final_result)
75
+
76
+ except ValueError as e:
77
+ _logger.error(f"Error executing tool '{tool_name}': {e}", exc_info=True)
78
+ return JSONResponse({"error": str(e)}, status_code=400)
79
+ except Exception as e:
80
+ _logger.error(f"An unexpected error occurred in tool '{tool_name}': {e}", exc_info=True)
81
+ return JSONResponse({"error": "An internal server error occurred"}, status_code=500)
82
+
83
+ return Route(f"/{tool_name}", endpoint=endpoint, methods=["POST"])
84
+
85
+
86
+ def setup_rest_routes() -> list[Route]:
87
+ """Create REST API routes for all registered tools."""
88
+ routes = []
89
+ for tool_name, tool_impl in tool_registry._tools.items():
90
+ model = tool_registry._tool_models[tool_name]
91
+ route = create_rest_endpoint(tool_name, tool_impl, model)
92
+ routes.append(route)
93
+
94
+ async def list_tools_endpoint(_request) -> JSONResponse:
95
+ """REST endpoint to list available tools."""
96
+ _logger.info("REST endpoint 'list_tools' called.")
97
+ tools_info = [
98
+ {
99
+ "name": tool.name,
100
+ "title": tool.title,
101
+ "description": tool.description,
102
+ "input_schema": tool.inputSchema,
103
+ }
104
+ for tool in tool_registry.list()
105
+ ]
106
+ return JSONResponse(tools_info)
107
+
108
+ routes.append(Route("/tools", endpoint=list_tools_endpoint, methods=["GET"]))
109
+ return routes
@@ -0,0 +1,14 @@
1
+ from dataclasses import dataclass
2
+
3
+
4
+ @dataclass
5
+ class ServerSettings:
6
+ default_ocr_model: str = "cct-xs-v1-global-model"
7
+
8
+
9
+ # Singleton instance
10
+ settings = ServerSettings()
11
+
12
+
13
+ def update_settings(default_ocr_model: str):
14
+ settings.default_ocr_model = default_ocr_model
@@ -0,0 +1,339 @@
1
+ import base64
2
+ import io
3
+ import json
4
+ import logging
5
+ from dataclasses import asdict
6
+ from functools import partial
7
+ from typing import TYPE_CHECKING, Literal, Type
8
+
9
+ import anyio
10
+ import httpx
11
+ import mcp.types as types
12
+ import numpy as np
13
+ from PIL import Image, UnidentifiedImageError
14
+ from pydantic import BaseModel, Field, ValidationError, field_validator
15
+
16
+ from .errors import ErrorCode, ToolLogicError
17
+ from .settings import settings
18
+
19
+ if TYPE_CHECKING:
20
+ from fast_alpr import ALPR
21
+ from fast_plate_ocr import LicensePlateRecognizer
22
+
23
+ _logger = logging.getLogger(__name__)
24
+ _ocr_model_cache: dict[str, "LicensePlateRecognizer"] = {}
25
+ _alpr_cache: dict[tuple[str, str], "ALPR"] = {}
26
+
27
+
28
+ # --- Pydantic Models for Input Validation ---
29
+ class RecognizePlateArgs(BaseModel):
30
+ image_base64: str
31
+ model_name: Literal["cct-s-v1-global-model", "cct-xs-v1-global-model"] = Field(
32
+ default_factory=lambda: settings.default_ocr_model
33
+ )
34
+
35
+ @field_validator("image_base64")
36
+ @classmethod
37
+ def validate_image_base64(cls, v: str) -> str:
38
+ if not v:
39
+ raise ValueError("image_base64 cannot be empty.")
40
+ if len(v) > 7000000:
41
+ raise ValueError("Input image is too large. The maximum size is 5MB.")
42
+ try:
43
+ base64.b64decode(v)
44
+ except (ValueError, TypeError) as e:
45
+ raise ValueError(f"Invalid base64 string provided. Error: {e}") from e
46
+ return v
47
+
48
+
49
+ class RecognizePlateFromPathArgs(BaseModel):
50
+ path: str
51
+ model_name: Literal["cct-s-v1-global-model", "cct-xs-v1-global-model"] = Field(
52
+ default_factory=lambda: settings.default_ocr_model
53
+ )
54
+
55
+ @field_validator("path")
56
+ @classmethod
57
+ def path_must_not_be_empty(cls, v: str) -> str:
58
+ if not v.strip():
59
+ raise ValueError("Path cannot be empty.")
60
+ return v
61
+
62
+
63
+ class DetectAndRecognizePlateArgs(BaseModel):
64
+ image_base64: str
65
+ detector_model: str = "yolo-v9-t-384-license-plate-end2end"
66
+ ocr_model: str = Field(default_factory=lambda: settings.default_ocr_model)
67
+
68
+ @field_validator("image_base64")
69
+ @classmethod
70
+ def validate_image_base64(cls, v: str) -> str:
71
+ if not v:
72
+ raise ValueError("image_base64 cannot be empty.")
73
+ if len(v) > 7000000:
74
+ raise ValueError("Input image is too large. The maximum size is 5MB.")
75
+ try:
76
+ base64.b64decode(v)
77
+ except (ValueError, TypeError) as e:
78
+ raise ValueError(f"Invalid base64 string provided. Error: {e}") from e
79
+ return v
80
+
81
+
82
+ class DetectAndRecognizePlateFromPathArgs(BaseModel):
83
+ path: str
84
+ detector_model: str = "yolo-v9-t-384-license-plate-end2end"
85
+ ocr_model: str = Field(default_factory=lambda: settings.default_ocr_model)
86
+
87
+ @field_validator("path")
88
+ @classmethod
89
+ def path_must_not_be_empty(cls, v: str) -> str:
90
+ if not v.strip():
91
+ raise ValueError("Path cannot be empty.")
92
+ return v
93
+
94
+
95
+ class ListModelsArgs(BaseModel):
96
+ """Input arguments for listing available models."""
97
+
98
+ pass
99
+
100
+
101
+ class ToolRegistry:
102
+ def __init__(self):
103
+ self._tools: dict[str, callable] = {}
104
+ self._tool_definitions: list[types.Tool] = []
105
+ self._tool_models: dict[str, Type[BaseModel]] = {}
106
+
107
+ def register(self, tool_definition: types.Tool, model: Type[BaseModel]):
108
+ def decorator(func: callable) -> callable:
109
+ name = tool_definition.name
110
+ if name in self._tools:
111
+ raise ValueError(f"Tool '{name}' is already registered.")
112
+ self._tools[name] = func
113
+ self._tool_definitions.append(tool_definition)
114
+ self._tool_models[name] = model
115
+ return func
116
+
117
+ return decorator
118
+
119
+ async def call(self, name: str, arguments: dict) -> list[types.ContentBlock]:
120
+ if name not in self._tools:
121
+ _logger.warning(f"Unknown tool requested: {name}")
122
+ raise ToolLogicError(message=f"Unknown tool: {name}", code=ErrorCode.VALIDATION_ERROR)
123
+
124
+ model = self._tool_models.get(name)
125
+ if not model:
126
+ raise ToolLogicError(
127
+ message=f"No validation model registered for tool '{name}'.",
128
+ code=ErrorCode.UNKNOWN_ERROR,
129
+ )
130
+
131
+ try:
132
+ validated_args = model(**arguments)
133
+ except ValidationError as e:
134
+ _logger.error(f"Input validation failed for tool '{name}': {e}")
135
+ raise ToolLogicError(
136
+ message=f"Input validation failed for tool '{name}'.",
137
+ code=ErrorCode.VALIDATION_ERROR,
138
+ details=e.errors(),
139
+ ) from e
140
+
141
+ func = self._tools[name]
142
+ try:
143
+ return await func(validated_args)
144
+ except ToolLogicError:
145
+ raise # Don't re-wrap our own errors
146
+ except Exception as e:
147
+ error_message = f"An unexpected error occurred in tool '{name}': {e}"
148
+ _logger.exception(error_message)
149
+ raise ToolLogicError(
150
+ message=error_message,
151
+ code=ErrorCode.TOOL_LOGIC_ERROR,
152
+ ) from e
153
+
154
+ def list(self) -> list[types.Tool]:
155
+ return self._tool_definitions
156
+
157
+
158
+ tool_registry = ToolRegistry()
159
+
160
+ # --- Tool Definitions and Implementations ---
161
+ recognize_plate_tool_definition = types.Tool(
162
+ name="recognize_plate",
163
+ title="License Plate Recognizer",
164
+ description="Recognizes text from a cropped image of a license plate.",
165
+ inputSchema=RecognizePlateArgs.model_json_schema(),
166
+ )
167
+
168
+
169
+ async def _get_ocr_recognizer(model_name: str) -> "LicensePlateRecognizer":
170
+ if model_name not in _ocr_model_cache:
171
+ _logger.info(f"Loading license plate OCR model: {model_name}")
172
+ from fast_plate_ocr import LicensePlateRecognizer
173
+
174
+ recognizer = await anyio.to_thread.run_sync(LicensePlateRecognizer, model_name)
175
+ _ocr_model_cache[model_name] = recognizer
176
+ return _ocr_model_cache[model_name]
177
+
178
+
179
+ @tool_registry.register(recognize_plate_tool_definition, RecognizePlateArgs)
180
+ async def recognize_plate(args: RecognizePlateArgs) -> list[types.ContentBlock]:
181
+ try:
182
+ image_bytes = base64.b64decode(args.image_base64)
183
+ image = Image.open(io.BytesIO(image_bytes))
184
+ image_rgb = image.convert("RGB")
185
+ except UnidentifiedImageError as e:
186
+ raise ValueError(f"Invalid image data provided. Could not decode image. Error: {e}") from e
187
+
188
+ recognizer = await _get_ocr_recognizer(args.model_name)
189
+ image_np = np.array(image_rgb)
190
+ result = await anyio.to_thread.run_sync(recognizer.run, image_np)
191
+
192
+ _logger.info(f"License plate recognized: {result}")
193
+ return [types.TextContent(type="text", text=json.dumps(result))]
194
+
195
+
196
+ recognize_plate_from_path_tool_definition = types.Tool(
197
+ name="recognize_plate_from_path",
198
+ title="License Plate Recognizer from Path",
199
+ description="Recognizes text from a cropped image located at a given URL or local file path.",
200
+ inputSchema=RecognizePlateFromPathArgs.model_json_schema(),
201
+ )
202
+
203
+
204
+ @tool_registry.register(recognize_plate_from_path_tool_definition, RecognizePlateFromPathArgs)
205
+ async def recognize_plate_from_path(args: RecognizePlateFromPathArgs) -> list[types.ContentBlock]:
206
+ path = args.path
207
+ try:
208
+ recognizer = await _get_ocr_recognizer(args.model_name)
209
+ if path.startswith("http://") or path.startswith("https://"):
210
+ async with httpx.AsyncClient() as client:
211
+ response = await client.get(path)
212
+ response.raise_for_status()
213
+ image_bytes = await response.aread()
214
+ image = Image.open(io.BytesIO(image_bytes))
215
+ image_rgb = image.convert("RGB")
216
+ image_np = np.array(image_rgb)
217
+ result = await anyio.to_thread.run_sync(recognizer.run, image_np)
218
+ else:
219
+ result = await anyio.to_thread.run_sync(recognizer.run, path)
220
+
221
+ except FileNotFoundError:
222
+ raise ValueError(f"File not found at path: {path}")
223
+ except httpx.HTTPStatusError as e:
224
+ raise ValueError(
225
+ f"Failed to fetch image from URL: {e.response.status_code} {e.response.reason_phrase}"
226
+ )
227
+ except UnidentifiedImageError as e:
228
+ raise ValueError(f"Data from path '{path}' is not a valid image file. Error: {e}") from e
229
+ except Exception as e:
230
+ raise ValueError(f"Could not process image from path '{path}': {e}")
231
+
232
+ _logger.info(f"License plate recognized from source '{path}': {result}")
233
+ return [types.TextContent(type="text", text=json.dumps(result))]
234
+
235
+
236
+ async def _get_alpr_instance(detector_model: str, ocr_model: str) -> "ALPR":
237
+ cache_key = (detector_model, ocr_model)
238
+ if cache_key not in _alpr_cache:
239
+ _logger.info(
240
+ f"Loading ALPR instance with detector '{detector_model}' and OCR '{ocr_model}'"
241
+ )
242
+ from fast_alpr import ALPR
243
+
244
+ alpr_constructor = partial(ALPR, detector_model=detector_model, ocr_model=ocr_model)
245
+ alpr_instance = await anyio.to_thread.run_sync(alpr_constructor)
246
+ _alpr_cache[cache_key] = alpr_instance
247
+ return _alpr_cache[cache_key]
248
+
249
+
250
+ detect_and_recognize_plate_tool_definition = types.Tool(
251
+ name="detect_and_recognize_plate",
252
+ title="Detect and Recognize License Plate",
253
+ description="Detects one or more license plates in an image and recognizes the text on each plate.",
254
+ inputSchema=DetectAndRecognizePlateArgs.model_json_schema(),
255
+ )
256
+
257
+
258
+ @tool_registry.register(detect_and_recognize_plate_tool_definition, DetectAndRecognizePlateArgs)
259
+ async def detect_and_recognize_plate(args: DetectAndRecognizePlateArgs) -> list[types.ContentBlock]:
260
+ try:
261
+ image_bytes = base64.b64decode(args.image_base64)
262
+ image = Image.open(io.BytesIO(image_bytes))
263
+ image_rgb = image.convert("RGB")
264
+ except UnidentifiedImageError as e:
265
+ raise ValueError(f"Invalid image data. Could not decode image. Error: {e}") from e
266
+
267
+ alpr = await _get_alpr_instance(args.detector_model, args.ocr_model)
268
+ image_np = np.array(image_rgb)
269
+ results = await anyio.to_thread.run_sync(alpr.predict, image_np)
270
+
271
+ results_dict = [asdict(res) for res in results]
272
+
273
+ _logger.info(f"ALPR processed. Found {len(results_dict)} plate(s).")
274
+ return [types.TextContent(type="text", text=json.dumps(results_dict))]
275
+
276
+
277
+ detect_and_recognize_plate_from_path_tool_definition = types.Tool(
278
+ name="detect_and_recognize_plate_from_path",
279
+ title="Detect and Recognize License Plate from Path",
280
+ description="Detects and recognizes license plates from an image at a given URL or local file path.",
281
+ inputSchema=DetectAndRecognizePlateFromPathArgs.model_json_schema(),
282
+ )
283
+
284
+
285
+ @tool_registry.register(
286
+ detect_and_recognize_plate_from_path_tool_definition, DetectAndRecognizePlateFromPathArgs
287
+ )
288
+ async def detect_and_recognize_plate_from_path(
289
+ args: DetectAndRecognizePlateFromPathArgs,
290
+ ) -> list[types.ContentBlock]:
291
+ path = args.path
292
+ try:
293
+ alpr = await _get_alpr_instance(args.detector_model, args.ocr_model)
294
+ if path.startswith("http://") or path.startswith("https://"):
295
+ async with httpx.AsyncClient() as client:
296
+ response = await client.get(path)
297
+ response.raise_for_status()
298
+ image_bytes = await response.aread()
299
+ image = Image.open(io.BytesIO(image_bytes))
300
+ image_rgb = image.convert("RGB")
301
+ image_np = np.array(image_rgb)
302
+ results = await anyio.to_thread.run_sync(alpr.predict, image_np)
303
+ else:
304
+ results = await anyio.to_thread.run_sync(alpr.predict, path)
305
+
306
+ except FileNotFoundError:
307
+ raise ValueError(f"File not found at path: {path}")
308
+ except httpx.HTTPStatusError as e:
309
+ raise ValueError(
310
+ f"Failed to fetch image from URL: {e.response.status_code} {e.response.reason_phrase}"
311
+ )
312
+ except UnidentifiedImageError as e:
313
+ raise ValueError(f"Data from path '{path}' is not a valid image file. Error: {e}") from e
314
+ except Exception as e:
315
+ raise ValueError(f"Could not process image from path '{path}': {e}")
316
+
317
+ results_dict = [asdict(res) for res in results]
318
+ _logger.info(f"ALPR processed source '{path}'. Found {len(results_dict)} plate(s).")
319
+ return [types.TextContent(type="text", text=json.dumps(results_dict))]
320
+
321
+
322
+ list_models_tool_definition = types.Tool(
323
+ name="list_models",
324
+ title="List Available Models",
325
+ description="Lists the available detector and OCR models for the full ALPR process.",
326
+ inputSchema=ListModelsArgs.model_json_schema(),
327
+ )
328
+
329
+
330
+ @tool_registry.register(list_models_tool_definition, ListModelsArgs)
331
+ async def list_models(_: ListModelsArgs) -> list[types.ContentBlock]:
332
+ """Lists available detector and OCR models."""
333
+ models = {
334
+ "detector_models": [
335
+ "yolo-v9-t-384-license-plate-end2end",
336
+ ],
337
+ "ocr_models": ["cct-s-v1-global-model", "cct-xs-v1-global-model"],
338
+ }
339
+ return [types.TextContent(type="text", text=json.dumps(models))]