Building a Production-Ready ALPR API with FastAPI and Docker
Automatic License Plate Recognition represents a challenging computer vision problem that requires both accurate object detection and robust optical character recognition. New Zealand plates introduce specific technical challenges including varied formats, reflective materials, and regional character sets that demand careful model selection and validation logic.
This article examines the technical implementation of a production ALPR system optimized for New Zealand license plates, focusing on the detection pipeline, OCR translation mechanisms, format validation strategies, and the practical considerations for deploying such a system.
New Zealand License Plate Specifications
Understanding the target format is essential for building effective validation logic. New Zealand plates follow specific patterns that have evolved over time.
Current Standard Format (Post-2001)
New Zealand plates use a three-letter, three-digit format starting from AAA104: ABC123
- Character set: Current plates exclude the letters I, O, V, and X to avoid confusion with similar-looking characters
- Dimensions: 372mm × 134mm (standard), 248mm × 134mm (motorcycle)
- Material: Retro-reflective acrylic with black characters on white background
- Font: Modified sans-serif with specific spacing requirements
- Security feature: Silver fern watermark visible at 15-30 degree angles
Legacy Formats
The system must also handle historical formats:
- Pre-2001: Two letters followed by one to four numbers (format LLnnnn), starting with AA1 and ending with ZZ9989
- Personalized plates: Up to 6 characters with custom combinations
- Special categories: Different formats for diplomatic vehicles (DC, CC, FC prefixes), Crown vehicles (CR prefix)
This format heterogeneity requires flexible pattern matching rather than rigid template-based validation.
Technical Architecture and Model Selection
Detection Pipeline: YOLOv9-Tiny
The detection stage uses YOLOv9-tiny with a 384×384 input resolution:
alpr = ALPR(
detector_model="yolo-v9-t-384-license-plate-end2end",
ocr_model="global-plates-mobile-vit-v2-model",
)
Why YOLOv9-Tiny?
YOLOv9 introduces Programmable Gradient Information (PGI) and Generalized Efficient Layer Aggregation Network (GELAN), which preserve critical information across network layers. The "tiny" variant trades some accuracy for inference speed—critical for real-time processing.
Key architectural features:
- Anchor-free bounding box prediction method inherited from YOLOv8
- Single forward pass through the network
- End-to-end trainable without separate NMS post-processing
- 384px input resolution balances accuracy with processing speed
The "end2end" designation indicates the model outputs directly usable bounding boxes without requiring additional post-processing steps.
Detection Challenges for NZ Plates
The detector must handle:
- Reflectivity: Retro-reflective surfaces create specular highlights that can overwhelm edges
- Angle variation: Plates photographed at extreme angles require the model to generalize across perspectives
- Occlusion: Partial obstruction from dirt, mounting hardware, or tow bars
- Size variance: Distance from camera creates scale challenges from near-field (large plates) to far-field (small plates)
- Environmental factors: Varying lighting conditions, weather effects, motion blur
OCR Pipeline: Mobile Vision Transformer v2
The OCR stage uses Mobile Vision Transformer v2, trained on international license plate character sets:
ocr_model="global-plates-mobile-vit-v2-model"
Vision Transformer Architecture for OCR
Vision Transformers split images into patches and process them as sequences, capturing global context more effectively than CNNs. For license plate OCR, this architecture offers distinct advantages.
Self-attention benefits for plate recognition:
- Handles variable character spacing better than fixed-kernel CNNs
- Captures contextual relationships between adjacent characters
- More robust to partial occlusion of individual characters
- Better handles non-standard fonts or degraded plates
The "mobile" variant uses optimized attention mechanisms to reduce computational requirements while maintaining accuracy—essential for deployment on edge devices or high-throughput server applications.
Character Recognition Challenges
New Zealand plates present specific OCR difficulties:
Ambiguous characters:
- 8 vs. B: Similar shapes, especially with degraded plates or poor resolution
- 5 vs. S: Particularly problematic with dirt or weathering
- 0 vs. O: O is excluded from letter positions, but format knowledge helps disambiguation
- 1 vs. I: I is excluded, simplifying this historically difficult case
Environmental factors:
- Glare and specular reflection: Overwhelms character edges on reflective material
- Motion blur: From moving vehicles or camera shake
- Perspective distortion: Non-frontal angles skew character aspect ratios
- Low contrast: Weather conditions, poor lighting, or faded plates reduce character definition
Image Processing Pipeline Implementation
Minimal Preprocessing Approach
The system uses a minimal preprocessing strategy, relying on model robustness:
def process_image(image_bytes: bytes):
"""Convert raw bytes to OpenCV image format"""
nparr = np.frombuffer(image_bytes, np.uint8)
frame = cv2.imdecode(nparr, cv2.IMREAD_COLOR)
if frame is None:
raise HTTPException(status_code=400, detail="Invalid image data.")
return frame
This loads images into OpenCV's BGR color space. The fast_alpr library internally handles:
- Resize and padding: Images resized to 384×384 with aspect ratio preservation
- Normalization: Pixel values scaled to [0, 1] range
- Color space conversion: BGR to RGB for model input
- Tensor creation: NumPy array to PyTorch/ONNX tensor
Optional: Advanced Preprocessing for Difficult Conditions
For challenging environments (night-time, rain, poor camera quality), additional preprocessing improves accuracy:
import cv2
def enhance_plate_image(frame):
"""Apply preprocessing for low-quality images"""
# Convert to grayscale
gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY)
# CLAHE: Improves local contrast without amplifying noise
clahe = cv2.createCLAHE(clipLimit=2.0, tileGridSize=(8, 8))
enhanced = clahe.apply(gray)
# Noise reduction while preserving edges
denoised = cv2.fastNlMeansDenoising(enhanced, h=10)
# Convert back to BGR for model input
return cv2.cvtColor(denoised, cv2.COLOR_GRAY2BGR)
CLAHE (Contrast Limited Adaptive Histogram Equalization) improves local contrast without amplifying noise, while edge-preserving denoising reduces artifacts that confuse character recognition.
Inference and Result Selection
The core ALPR pipeline returns predictions with confidence scores:
def run_alpr(frame):
"""Run ALPR and return highest confidence result"""
logger.info("Running ALPR on the image frame.")
predictions = alpr.predict(frame)
if not predictions:
logger.info("No predictions made by ALPR.")
return {"plate": None, "confidence": 0.0}
# Select highest confidence detection
best_prediction = max(predictions, key=lambda p: p.detection.confidence)
plate_text = best_prediction.ocr.text if best_prediction.ocr else None
plate_conf = (best_prediction.ocr.confidence if best_prediction.ocr
else best_prediction.detection.confidence)
result = {"plate": plate_text, "confidence": round(plate_conf, 2)}
logger.info(f"ALPR result: {result}")
return result
Understanding Confidence Scores
The system returns two types of confidence:
Detection confidence:
- Measures certainty that a plate exists in the bounding box
- Production threshold typically 0.5-0.7
- Higher thresholds reduce false positives but may miss legitimate plates
OCR confidence:
- Per-character probability aggregated into overall score
- New Zealand plates should achieve >0.85 under good conditions
- Lower confidence often indicates poor image quality or ambiguous characters
Handling Multiple Plates
For applications requiring all detected plates (parking lots, multi-lane monitoring):
def run_alpr_multi(frame, min_confidence=0.7):
"""Return all detected plates above confidence threshold"""
predictions = alpr.predict(frame)
results = []
for pred in predictions:
if pred.detection.confidence >= min_confidence:
plate_text = pred.ocr.text if pred.ocr else None
plate_conf = (pred.ocr.confidence if pred.ocr
else pred.detection.confidence)
results.append({
"plate": plate_text,
"confidence": round(plate_conf, 2),
"bbox": {
"x": pred.detection.x,
"y": pred.detection.y,
"width": pred.detection.width,
"height": pred.detection.height
}
})
# Sort by confidence, descending
return sorted(results, key=lambda x: x["confidence"], reverse=True)
New Zealand Plate Validation and Normalization
Raw OCR output requires validation against known NZ formats and character correction.
Format Validation Implementation
import re
def validate_nz_plate(plate_text: str) -> dict:
"""
Validate and normalize New Zealand license plate format.
Returns validation status, normalized text, and format type.
"""
if not plate_text:
return {"valid": False, "normalized": None, "format": None}
# Remove whitespace and convert to uppercase
normalized = plate_text.strip().upper().replace(" ", "")
# Standard format: ABC123 (3 letters, 3 digits)
standard_pattern = r'^[A-Z]{3}[0-9]{3}$'
# Legacy format: 2-3 letters, 1-4 digits
legacy_pattern = r'^[A-Z]{2,3}[0-9]{1,4}$'
# Personalized: Up to 6 alphanumeric characters
personalized_pattern = r'^[A-Z0-9]{1,6}$'
if re.match(standard_pattern, normalized):
# Check for prohibited letters in letter positions
prohibited = {'I', 'O', 'V', 'X'}
if any(char in prohibited for char in normalized[:3]):
return {
"valid": False,
"normalized": normalized,
"format": "standard",
"error": "Contains prohibited letters (I, O, V, X)"
}
return {
"valid": True,
"normalized": normalized,
"format": "standard"
}
elif re.match(legacy_pattern, normalized):
return {
"valid": True,
"normalized": normalized,
"format": "legacy"
}
elif re.match(personalized_pattern, normalized):
return {
"valid": True,
"normalized": normalized,
"format": "personalized"
}
else:
return {
"valid": False,
"normalized": normalized,
"format": None,
"error": "Does not match any known NZ plate format"
}
Character Correction Heuristics
OCR errors can be corrected using format knowledge:
def apply_correction_heuristics(plate_text: str, format_type: str) -> str:
"""
Apply character corrections based on NZ plate format rules.
Standard format: positions 0-2 must be letters, 3-5 must be digits.
"""
if format_type != "standard":
return plate_text
corrected = list(plate_text.upper())
# Correct letter positions (0-2)
for i in range(3):
if corrected[i].isdigit():
# Common digit-to-letter misrecognitions
corrections = {
'0': 'D', # Could be D or O, but O is prohibited
'1': 'L', # Could be I or L, but I is prohibited
'8': 'B',
'5': 'S',
'6': 'G'
}
if corrected[i] in corrections:
corrected[i] = corrections[corrected[i]]
# Correct digit positions (3-5)
for i in range(3, 6):
if i < len(corrected) and corrected[i].isalpha():
# Common letter-to-digit misrecognitions
corrections = {
'O': '0',
'I': '1',
'L': '1',
'Z': '2',
'B': '8',
'S': '5',
'G': '6'
}
if corrected[i] in corrections:
corrected[i] = corrections[corrected[i]]
return ''.join(corrected)
Integration into API Endpoints
Integrate validation and correction into the prediction endpoint:
from fastapi import FastAPI, UploadFile, File, HTTPException, Header, Depends
from fastapi.responses import JSONResponse
@app.post("/predict/file", dependencies=[Depends(verify_api_key)])
async def predict_from_file(file: UploadFile = File(...)):
"""Predict license plate from uploaded file with validation"""
logger.info("Received file for prediction.")
image_bytes = await file.read()
frame = process_image(image_bytes)
result = run_alpr(frame)
# Apply NZ-specific validation and correction
if result["plate"]:
validation = validate_nz_plate(result["plate"])
result["validation"] = validation
if validation["valid"] and validation["format"] == "standard":
# Apply correction heuristics
corrected = apply_correction_heuristics(
validation["normalized"],
validation["format"]
)
# Re-validate after correction
corrected_validation = validate_nz_plate(corrected)
if corrected_validation["valid"]:
result["corrected_plate"] = corrected
logger.info(f"Prediction completed: {result}")
return JSONResponse(content=result)
Performance Optimization Strategies
Model Quantization for Resource-Constrained Deployment
For deployment on edge devices or cost-sensitive cloud infrastructure, model quantization reduces memory and increases speed:
INT8 quantization benefits:
- 4× smaller model size
- 2-4× faster CPU inference
- Minimal accuracy loss (<2% typical)
If using ONNX models directly:
from onnxruntime.quantization import quantize_dynamic, QuantType
# Quantize detection model
quantize_dynamic(
model_input="yolo-v9-t.onnx",
model_output="yolo-v9-t-int8.onnx",
weight_type=QuantType.QInt8
)
# Quantize OCR model
quantize_dynamic(
model_input="mobile-vit-v2.onnx",
model_output="mobile-vit-v2-int8.onnx",
weight_type=QuantType.QInt8
)
Batch Processing for High-Throughput Applications
For offline processing of large image sets:
def run_alpr_batch(frames: list, batch_size=8):
"""Process multiple images efficiently"""
results = []
for i in range(0, len(frames), batch_size):
batch = frames[i:i + batch_size]
# Process batch
for frame in batch:
predictions = alpr.predict(frame)
if predictions:
best = max(predictions, key=lambda p: p.detection.confidence)
results.append({
"plate": best.ocr.text if best.ocr else None,
"confidence": (best.ocr.confidence if best.ocr
else best.detection.confidence)
})
else:
results.append({"plate": None, "confidence": 0.0})
return results
GPU Acceleration
For high-throughput applications, GPU acceleration provides 5-10× speedup:
alpr = ALPR(
detector_model="yolo-v9-t-384-license-plate-end2end",
ocr_model="global-plates-mobile-vit-v2-model",
device="cuda" # or "cuda:0" for specific GPU
)
Docker Configuration for GPU Support
FROM nvidia/cuda:11.8.0-cudnn8-runtime-ubuntu22.04
# Install Python and system dependencies
RUN apt-get update && apt-get install -y \
python3.10 \
python3-pip \
libgl1-mesa-glx \
libglib2.0-0 \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY requirements.txt ./
RUN pip3 install --no-cache-dir -r requirements.txt
# Install GPU-accelerated ONNX Runtime
RUN pip3 install onnxruntime-gpu
COPY . .
EXPOSE 8000
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]
Run with GPU access:
docker run --gpus all -p 8000:8000 alpr-api
Accuracy Benchmarking and Evaluation
Test Dataset Construction
Effective evaluation requires diverse test sets:
Lighting conditions:
- Bright sunlight (overexposure and glare)
- Dusk/dawn (low light, long shadows)
- Night with artificial lighting (harsh shadows, glare)
- Overcast (low contrast, uniform lighting)
Camera angles:
- Frontal (0-15 degrees) – baseline performance
- Moderate angle (15-30 degrees) – typical surveillance camera placement
- Extreme angle (30-45 degrees) – edge cases
- Partial occlusion scenarios
Plate conditions:
- Clean and new
- Moderately weathered
- Heavily degraded (faded, rust, damage)
- Partially obscured (dirt, mounting hardware)
Evaluation Metrics Implementation
def calculate_metrics(predictions, ground_truth):
"""
Calculate comprehensive ALPR performance metrics.
"""
correct = 0
partial = 0
character_errors = 0
total_chars = 0
total = len(ground_truth)
for pred, true in zip(predictions, ground_truth):
pred_plate = pred.get("plate", "")
if pred_plate == true:
correct += 1
elif pred_plate and len(pred_plate) == len(true):
# Calculate character-level accuracy
char_matches = sum(p == t for p, t in zip(pred_plate, true))
if char_matches > 0:
partial += 1
character_errors += (len(true) - char_matches)
total_chars += len(true)
elif pred_plate:
# Length mismatch but some content
partial += 1
accuracy = correct / total if total > 0 else 0
partial_rate = partial / total if total > 0 else 0
char_accuracy = (1 - character_errors / total_chars) if total_chars > 0 else 0
return {
"plate_accuracy": round(accuracy, 4),
"partial_match_rate": round(partial_rate, 4),
"character_accuracy": round(char_accuracy, 4),
"total_samples": total,
"correct": correct,
"partial": partial,
"failed": total - correct - partial
}
Target metrics for NZ plates:
- Plate accuracy: >95% for standard format, good conditions
- Character accuracy: >98% per-character recognition
- Partial match rate: <5% (indicates systematic issues)
- False positive rate: <1% (critical for enforcement)
Character-Level Error Analysis
Understanding systematic errors:
from collections import defaultdict
def character_error_analysis(predictions, ground_truth):
"""Analyze per-character misrecognitions"""
confusion_matrix = defaultdict(lambda: defaultdict(int))
position_errors = [0] * 6 # Track errors by position (6 chars)
for pred, true in zip(predictions, ground_truth):
pred_plate = pred.get("plate", "")
if len(pred_plate) == len(true):
for i, (p_char, t_char) in enumerate(zip(pred_plate, true)):
if p_char != t_char:
confusion_matrix[t_char][p_char] += 1
if i < 6:
position_errors[i] += 1
return {
"confusion_matrix": dict(confusion_matrix),
"position_errors": position_errors
}
Common error patterns for NZ plates:
- 8 ↔ B: Most frequent, especially positions 0-2
- 5 ↔ S: Common with weathered plates
- 0 ↔ O: Rare due to format constraints
- D ↔ O: Occurs with poor resolution
Production Deployment Considerations
False Positive Filtering
Reject detections unlikely to be genuine plates:
def filter_false_positives(predictions,
min_confidence=0.75,
min_aspect_ratio=2.0,
max_aspect_ratio=4.5):
"""
Filter detections using confidence and aspect ratio constraints.
NZ plates have typical aspect ratio ~2.8:1
"""
filtered = []
for pred in predictions:
bbox = pred.detection
aspect_ratio = bbox.width / bbox.height if bbox.height > 0 else 0
if (pred.detection.confidence >= min_confidence and
min_aspect_ratio <= aspect_ratio <= max_aspect_ratio):
filtered.append(pred)
return filtered
Temporal Consistency for Video Streams
Track plates across frames for improved reliability:
from collections import deque, Counter
class PlateTracker:
"""Track plate readings across video frames"""
def __init__(self, history_size=5, confidence_threshold=0.8):
self.history = deque(maxlen=history_size)
self.confidence_threshold = confidence_threshold
def update(self, prediction):
"""Add new prediction and return consensus if available"""
if prediction and prediction.get("confidence", 0) >= self.confidence_threshold:
plate = prediction.get("plate")
if plate:
self.history.append(plate)
# Return most common plate if sufficient agreement
if len(self.history) >= 3:
counts = Counter(self.history)
most_common_plate, frequency = counts.most_common(1)[0]
if frequency >= 3: # At least 3 frames agree
return {
"plate": most_common_plate,
"confidence": frequency / len(self.history),
"frame_count": len(self.history)
}
return None
def reset(self):
"""Clear history for new vehicle"""
self.history.clear()
# Usage example
tracker = PlateTracker(history_size=5, confidence_threshold=0.75)
for frame in video_frames:
prediction = run_alpr(frame)
consensus = tracker.update(prediction)
if consensus:
print(f"Consensus plate: {consensus['plate']} "
f"(confidence: {consensus['confidence']:.2f})")
tracker.reset() # Reset for next vehicle
This approach reduces false positives from single-frame artifacts while maintaining low latency for real-time applications.
System Monitoring and Alerting
Production systems require observability:
import logging
from prometheus_client import Counter, Histogram
# Define metrics
predictions_total = Counter(
'alpr_predictions_total',
'Total ALPR predictions',
['status'] # success, failure, low_confidence
)
inference_duration = Histogram(
'alpr_inference_duration_seconds',
'ALPR inference duration'
)
confidence_score = Histogram(
'alpr_confidence_score',
'ALPR confidence scores'
)
def run_alpr_monitored(frame):
"""ALPR with monitoring"""
import time
start = time.time()
try:
result = run_alpr(frame)
duration = time.time() - start
inference_duration.observe(duration)
if result["plate"]:
confidence = result["confidence"]
confidence_score.observe(confidence)
if confidence >= 0.75:
predictions_total.labels(status='success').inc()
else:
predictions_total.labels(status='low_confidence').inc()
else:
predictions_total.labels(status='failure').inc()
return result
except Exception as e:
predictions_total.labels(status='error').inc()
logger.error(f"ALPR error: {str(e)}")
raise
Complete FastAPI Implementation
Here's the complete API with all features integrated:
from fastapi import FastAPI, UploadFile, File, HTTPException, Header, Depends
from fastapi.responses import JSONResponse
from fastapi.middleware.cors import CORSMiddleware
from pydantic import BaseModel, HttpUrl
import uvicorn
import cv2
import numpy as np
import requests
import logging
import logging.config
from fast_alpr import ALPR
from typing import Optional
# Logging configuration
log_config = {
"version": 1,
"disable_existing_loggers": True,
"formatters": {
"default": {
"format": "%(asctime)s - %(name)s - %(levelname)s - %(message)s",
},
},
"handlers": {
"default": {
"class": "logging.StreamHandler",
"formatter": "default",
"stream": "ext://sys.stdout",
},
},
"root": {
"handlers": ["default"],
"level": "INFO",
},
}
logging.config.dictConfig(log_config)
logger = logging.getLogger(__name__)
app = FastAPI(title="NZ ALPR API", version="1.0.0")
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# API key authentication
API_KEY = "xFw8eJOKrq5o2NzsNLVgLmgr3sMMltqI" # Move to environment variable in production
async def verify_api_key(x_api_key: str = Header(...)):
if x_api_key != API_KEY:
logger.warning("Unauthorized access attempt")
raise HTTPException(status_code=401, detail="Unauthorized")
# Initialize ALPR
alpr = ALPR(
detector_model="yolo-v9-t-384-license-plate-end2end",
ocr_model="global-plates-mobile-vit-v2-model",
)
class ImageURL(BaseModel):
url: HttpUrl
def process_image(image_bytes: bytes):
"""Convert bytes to OpenCV image"""
nparr = np.frombuffer(image_bytes, np.uint8)
frame = cv2.imdecode(nparr, cv2.IMREAD_COLOR)
if frame is None:
raise HTTPException(status_code=400, detail="Invalid image data")
return frame
def run_alpr(frame):
"""Run ALPR inference"""
predictions = alpr.predict(frame)
if not predictions:
return {"plate": None, "confidence": 0.0}
best = max(predictions, key=lambda p: p.detection.confidence)
return {
"plate": best.ocr.text if best.ocr else None,
"confidence": round(
best.ocr.confidence if best.ocr else best.detection.confidence,
2
)
}
@app.post("/predict/file", dependencies=[Depends(verify_api_key)])
async def predict_from_file(file: UploadFile = File(...)):
"""Predict from uploaded file"""
logger.info("Received file upload")
image_bytes = await file.read()
frame = process_image(image_bytes)
result = run_alpr(frame)
# Add NZ validation
if result["plate"]:
validation = validate_nz_plate(result["plate"])
result["validation"] = validation
logger.info(f"Result: {result}")
return JSONResponse(content=result)
@app.post("/predict/url", dependencies=[Depends(verify_api_key)])
async def predict_from_url(image_url: ImageURL):
"""Predict from image URL"""
logger.info(f"Fetching URL: {image_url.url}")
try:
response = requests.get(str(image_url.url), timeout=10)
response.raise_for_status()
except requests.exceptions.RequestException as e:
logger.error(f"URL fetch error: {str(e)}")
raise HTTPException(status_code=400, detail=f"Error fetching image: {str(e)}")
frame = process_image(response.content)
result = run_alpr(frame)
if result["plate"]:
validation = validate_nz_plate(result["plate"])
result["validation"] = validation
logger.info(f"Result: {result}")
return JSONResponse(content=result)
@app.get("/health")
async def health_check():
"""Health check endpoint"""
return {"status": "healthy", "model": "loaded"}
if __name__ == "__main__":
logger.info("Starting ALPR API server")
uvicorn.run(
app,
host="0.0.0.0",
port=8000,
log_config=log_config
)
Docker Deployment
Dockerfile
FROM python:3.10-slim
# Install system dependencies
RUN apt-get update && apt-get install -y --no-install-recommends \
build-essential \
libgl1-mesa-glx \
libglib2.0-0 \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
# Copy requirements and install Python packages
COPY requirements.txt ./
RUN pip install --upgrade pip && \
pip install --no-cache-dir -r requirements.txt
# Copy application code
COPY . .
EXPOSE 8000
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]
requirements.txt
fastapi==0.104.1
uvicorn[standard]==0.24.0
fast-alpr==1.0.0
opencv-python-headless==4.8.1.78
requests==2.31.0
pydantic==2.5.0
python-multipart==0.0.6
numpy==1.24.3
Building and Running
# Build image
docker build -t nz-alpr-api .
# Run container
docker run -d \
-p 8000:8000 \
-e API_KEY="your-secure-key-here" \
--name alpr-api \
nz-alpr-api
# View logs
docker logs -f alpr-api
# Test the API
curl -X POST http://localhost:8000/predict/url \
-H "X-API-Key: your-secure-key-here" \
-H "Content-Type: application/json" \
-d '{"url": "https://example.com/plate.jpg"}'
Conclusion
Building an effective ALPR system for New Zealand license plates requires careful integration of modern computer vision architectures, format-specific validation logic, and production engineering practices. The combination of YOLOv9-tiny for fast detection and Mobile Vision Transformer v2 for robust character recognition provides a solid foundation, but real-world accuracy depends critically on preprocessing strategies, confidence thresholding, format-aware validation, and character correction heuristics.
The technical challenges—specular reflection from retro-reflective materials, perspective distortion, character ambiguity, and format heterogeneity—demand more than simply applying off-the-shelf models. Effective systems integrate domain knowledge about New Zealand plate specifications, implement intelligent error correction based on position and format rules, and provide confidence metrics that enable downstream applications to make informed decisions about result reliability.
For production deployments, the additional considerations of model quantization, GPU acceleration, batch processing, temporal consistency, and comprehensive monitoring separate functional prototypes from systems capable of handling real-world operational requirements at scale. The FastAPI implementation provided demonstrates a complete, production-ready system that can be extended with features like database logging, webhook notifications, or integration with access control systems.
This architecture and implementation approach scales from edge devices processing single camera streams to cloud deployments handling thousands of simultaneous requests, making it suitable for applications ranging from parking management to traffic monitoring and automated toll collection.