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 +21 -0
- omni_lpr-0.1.0/PKG-INFO +268 -0
- omni_lpr-0.1.0/README.md +216 -0
- omni_lpr-0.1.0/pyproject.toml +163 -0
- omni_lpr-0.1.0/src/server/__init__.py +20 -0
- omni_lpr-0.1.0/src/server/__main__.py +83 -0
- omni_lpr-0.1.0/src/server/errors.py +33 -0
- omni_lpr-0.1.0/src/server/mcp.py +34 -0
- omni_lpr-0.1.0/src/server/py.typed +0 -0
- omni_lpr-0.1.0/src/server/rest.py +109 -0
- omni_lpr-0.1.0/src/server/settings.py +14 -0
- omni_lpr-0.1.0/src/server/tools.py +339 -0
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.
|
omni_lpr-0.1.0/PKG-INFO
ADDED
|
@@ -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
|
+
[](https://github.com/habedi/omni-lpr/actions/workflows/tests.yml)
|
|
61
|
+
[](https://codecov.io/gh/habedi/omni-lpr)
|
|
62
|
+
[](https://www.codefactor.io/repository/github/habedi/omni-lpr)
|
|
63
|
+
[](https://github.com/habedi/omni-lpr)
|
|
64
|
+
[](https://pypi.org/project/omni-lpr/)
|
|
65
|
+
[](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
|
+
|
omni_lpr-0.1.0/README.md
ADDED
|
@@ -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
|
+
[](https://github.com/habedi/omni-lpr/actions/workflows/tests.yml)
|
|
10
|
+
[](https://codecov.io/gh/habedi/omni-lpr)
|
|
11
|
+
[](https://www.codefactor.io/repository/github/habedi/omni-lpr)
|
|
12
|
+
[](https://github.com/habedi/omni-lpr)
|
|
13
|
+
[](https://pypi.org/project/omni-lpr/)
|
|
14
|
+
[](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))]
|