Add project files
Browse files- .gitattributes +35 -0
- Dockerfile +28 -0
- README.md +64 -0
- augment.py +156 -0
- barcode_scanner.py +114 -0
- compare_decoders.py +149 -0
- evaluate.py +116 -0
- learn.py +176 -0
- ocr.py +134 -0
- preprocess.py +117 -0
- requirements.txt +8 -0
- server.py +264 -0
- setup.bat +1 -0
- test_speed.py +12 -0
- web/app.css +609 -0
- web/app.js +346 -0
- web/icons/icon-192.png +0 -0
- web/icons/icon-512.png +0 -0
- web/index.html +182 -0
- web/manifest.json +24 -0
- web/sw.js +83 -0
.gitattributes
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
*.7z filter=lfs diff=lfs merge=lfs -text
|
| 2 |
+
*.arrow filter=lfs diff=lfs merge=lfs -text
|
| 3 |
+
*.bin filter=lfs diff=lfs merge=lfs -text
|
| 4 |
+
*.bz2 filter=lfs diff=lfs merge=lfs -text
|
| 5 |
+
*.ckpt filter=lfs diff=lfs merge=lfs -text
|
| 6 |
+
*.ftz filter=lfs diff=lfs merge=lfs -text
|
| 7 |
+
*.gz filter=lfs diff=lfs merge=lfs -text
|
| 8 |
+
*.h5 filter=lfs diff=lfs merge=lfs -text
|
| 9 |
+
*.joblib filter=lfs diff=lfs merge=lfs -text
|
| 10 |
+
*.lfs.* filter=lfs diff=lfs merge=lfs -text
|
| 11 |
+
*.mlmodel filter=lfs diff=lfs merge=lfs -text
|
| 12 |
+
*.model filter=lfs diff=lfs merge=lfs -text
|
| 13 |
+
*.msgpack filter=lfs diff=lfs merge=lfs -text
|
| 14 |
+
*.npy filter=lfs diff=lfs merge=lfs -text
|
| 15 |
+
*.npz filter=lfs diff=lfs merge=lfs -text
|
| 16 |
+
*.onnx filter=lfs diff=lfs merge=lfs -text
|
| 17 |
+
*.ot filter=lfs diff=lfs merge=lfs -text
|
| 18 |
+
*.parquet filter=lfs diff=lfs merge=lfs -text
|
| 19 |
+
*.pb filter=lfs diff=lfs merge=lfs -text
|
| 20 |
+
*.pickle filter=lfs diff=lfs merge=lfs -text
|
| 21 |
+
*.pkl filter=lfs diff=lfs merge=lfs -text
|
| 22 |
+
*.pt filter=lfs diff=lfs merge=lfs -text
|
| 23 |
+
*.pth filter=lfs diff=lfs merge=lfs -text
|
| 24 |
+
*.rar filter=lfs diff=lfs merge=lfs -text
|
| 25 |
+
*.safetensors filter=lfs diff=lfs merge=lfs -text
|
| 26 |
+
saved_model/**/* filter=lfs diff=lfs merge=lfs -text
|
| 27 |
+
*.tar.* filter=lfs diff=lfs merge=lfs -text
|
| 28 |
+
*.tar filter=lfs diff=lfs merge=lfs -text
|
| 29 |
+
*.tflite filter=lfs diff=lfs merge=lfs -text
|
| 30 |
+
*.tgz filter=lfs diff=lfs merge=lfs -text
|
| 31 |
+
*.wasm filter=lfs diff=lfs merge=lfs -text
|
| 32 |
+
*.xz filter=lfs diff=lfs merge=lfs -text
|
| 33 |
+
*.zip filter=lfs diff=lfs merge=lfs -text
|
| 34 |
+
*.zst filter=lfs diff=lfs merge=lfs -text
|
| 35 |
+
*tfevents* filter=lfs diff=lfs merge=lfs -text
|
Dockerfile
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
FROM python:3.11-slim
|
| 2 |
+
|
| 3 |
+
# Install system dependencies required for OpenCV and pyzbar (barcodes)
|
| 4 |
+
RUN apt-get update && apt-get install -y \
|
| 5 |
+
libgl1-mesa-glx \
|
| 6 |
+
libglib2.0-0 \
|
| 7 |
+
libzbar0 \
|
| 8 |
+
&& rm -rf /var/lib/apt/lists/*
|
| 9 |
+
|
| 10 |
+
WORKDIR /app
|
| 11 |
+
|
| 12 |
+
# Copy requirements and install
|
| 13 |
+
COPY requirements.txt .
|
| 14 |
+
RUN pip install --no-cache-dir -r requirements.txt
|
| 15 |
+
# Install uvicorn and fastapi explicitly to ensure they are available in container
|
| 16 |
+
RUN pip install --no-cache-dir fastapi uvicorn python-multipart
|
| 17 |
+
|
| 18 |
+
# Copy all project files
|
| 19 |
+
COPY . .
|
| 20 |
+
|
| 21 |
+
# Create directories if they don't exist
|
| 22 |
+
RUN mkdir -p temp_uploads results images/barcode images/chassis
|
| 23 |
+
|
| 24 |
+
# Expose FastAPI port
|
| 25 |
+
EXPOSE 8000
|
| 26 |
+
|
| 27 |
+
# Start application
|
| 28 |
+
CMD ["uvicorn", "server:app", "--host", "0.0.0.0", "--port", "8000"]
|
README.md
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Chassis OCR Pipeline
|
| 2 |
+
|
| 3 |
+
Reads engraved chassis numbers from phone images and matches them against barcode-scanned numbers.
|
| 4 |
+
|
| 5 |
+
## Setup
|
| 6 |
+
|
| 7 |
+
```bash
|
| 8 |
+
pip install -r requirements.txt
|
| 9 |
+
```
|
| 10 |
+
|
| 11 |
+
On Linux you also need:
|
| 12 |
+
```bash
|
| 13 |
+
sudo apt-get install libzbar0
|
| 14 |
+
```
|
| 15 |
+
|
| 16 |
+
## Project Structure
|
| 17 |
+
|
| 18 |
+
```
|
| 19 |
+
chassis_ocr/
|
| 20 |
+
├── images/
|
| 21 |
+
│ ├── barcode/ ← put your 50 barcode images here
|
| 22 |
+
│ └── chassis/ ← put your 50 chassis images here (same filenames)
|
| 23 |
+
├── results/ ← comparison images + report saved here
|
| 24 |
+
├── preprocess.py ← image cleaning pipeline
|
| 25 |
+
├── barcode_scanner.py
|
| 26 |
+
├── ocr.py
|
| 27 |
+
└── evaluate.py ← run this
|
| 28 |
+
```
|
| 29 |
+
|
| 30 |
+
## Important — Naming Convention
|
| 31 |
+
|
| 32 |
+
Barcode and chassis images must have the **same filename** to be paired:
|
| 33 |
+
```
|
| 34 |
+
images/barcode/001.jpg ←→ images/chassis/001.jpg
|
| 35 |
+
images/barcode/002.jpg ←→ images/chassis/002.jpg
|
| 36 |
+
```
|
| 37 |
+
|
| 38 |
+
## Run
|
| 39 |
+
|
| 40 |
+
```bash
|
| 41 |
+
# Test preprocessing on a single chassis image
|
| 42 |
+
python preprocess.py images/chassis/001.jpg
|
| 43 |
+
|
| 44 |
+
# Test OCR on a single chassis image
|
| 45 |
+
python ocr.py images/chassis/001.jpg
|
| 46 |
+
|
| 47 |
+
# Run full evaluation on all 50 pairs
|
| 48 |
+
python evaluate.py
|
| 49 |
+
```
|
| 50 |
+
|
| 51 |
+
## Output
|
| 52 |
+
|
| 53 |
+
After running `evaluate.py`:
|
| 54 |
+
- `results/report.json` — full accuracy breakdown
|
| 55 |
+
- `results/*_comparison.jpg` — before/after preprocessing for each image
|
| 56 |
+
|
| 57 |
+
## Pipeline
|
| 58 |
+
|
| 59 |
+
```
|
| 60 |
+
Barcode image → pyzbar → ground truth string
|
| 61 |
+
↘
|
| 62 |
+
Chassis image → CLAHE → Adaptive threshold Compare → ✅ Match / ❌ Mismatch
|
| 63 |
+
→ Morphological ops → PaddleOCR ↗
|
| 64 |
+
```
|
augment.py
ADDED
|
@@ -0,0 +1,156 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
import cv2
|
| 3 |
+
import json
|
| 4 |
+
import numpy as np
|
| 5 |
+
import albumentations as A
|
| 6 |
+
from barcode_scanner import scan_all_barcodes
|
| 7 |
+
|
| 8 |
+
BARCODE_DIR = "images/barcode"
|
| 9 |
+
CHASSIS_DIR = "images/chassis"
|
| 10 |
+
OUTPUT_DIR = "images/chassis_augmented"
|
| 11 |
+
GT_PATH = "ground_truth.json"
|
| 12 |
+
AUGMENTS_PER_IMAGE = 25
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
def get_augmentation_pipeline():
|
| 16 |
+
return A.Compose([
|
| 17 |
+
|
| 18 |
+
A.OneOf([
|
| 19 |
+
A.RandomBrightnessContrast(
|
| 20 |
+
brightness_limit=0.4,
|
| 21 |
+
contrast_limit=0.4,
|
| 22 |
+
p=1.0
|
| 23 |
+
),
|
| 24 |
+
A.RandomGamma(gamma_limit=(60, 140), p=1.0),
|
| 25 |
+
A.CLAHE(clip_limit=4.0, p=1.0),
|
| 26 |
+
], p=0.9),
|
| 27 |
+
|
| 28 |
+
A.OneOf([
|
| 29 |
+
A.RandomShadow(
|
| 30 |
+
shadow_roi=(0, 0, 1, 1),
|
| 31 |
+
num_shadows_lower=1,
|
| 32 |
+
num_shadows_upper=2,
|
| 33 |
+
shadow_dimension=4,
|
| 34 |
+
p=1.0
|
| 35 |
+
),
|
| 36 |
+
A.RandomSunFlare(
|
| 37 |
+
flare_roi=(0, 0, 1, 0.5),
|
| 38 |
+
angle_lower=0,
|
| 39 |
+
src_radius=80,
|
| 40 |
+
p=1.0
|
| 41 |
+
),
|
| 42 |
+
], p=0.5),
|
| 43 |
+
|
| 44 |
+
A.OneOf([
|
| 45 |
+
A.MotionBlur(blur_limit=(3, 7), p=1.0),
|
| 46 |
+
A.GaussianBlur(blur_limit=(3, 5), p=1.0),
|
| 47 |
+
A.MedianBlur(blur_limit=3, p=1.0),
|
| 48 |
+
], p=0.4),
|
| 49 |
+
|
| 50 |
+
A.OneOf([
|
| 51 |
+
A.GaussNoise(var_limit=(10, 50), p=1.0),
|
| 52 |
+
A.ISONoise(color_shift=(0.01, 0.05), intensity=(0.1, 0.5), p=1.0),
|
| 53 |
+
A.MultiplicativeNoise(multiplier=(0.9, 1.1), p=1.0),
|
| 54 |
+
], p=0.6),
|
| 55 |
+
|
| 56 |
+
A.OneOf([
|
| 57 |
+
A.Perspective(scale=(0.02, 0.08), p=1.0),
|
| 58 |
+
A.ShiftScaleRotate(
|
| 59 |
+
shift_limit=0.05,
|
| 60 |
+
scale_limit=0.1,
|
| 61 |
+
rotate_limit=10,
|
| 62 |
+
border_mode=cv2.BORDER_REPLICATE,
|
| 63 |
+
p=1.0
|
| 64 |
+
),
|
| 65 |
+
A.ElasticTransform(
|
| 66 |
+
alpha=30,
|
| 67 |
+
sigma=5,
|
| 68 |
+
alpha_affine=5,
|
| 69 |
+
border_mode=cv2.BORDER_REPLICATE,
|
| 70 |
+
p=1.0
|
| 71 |
+
),
|
| 72 |
+
], p=0.7),
|
| 73 |
+
|
| 74 |
+
A.OneOf([
|
| 75 |
+
A.ImageCompression(quality_lower=60, quality_upper=95, p=1.0),
|
| 76 |
+
A.Downscale(scale_min=0.5, scale_max=0.9, p=1.0),
|
| 77 |
+
], p=0.3),
|
| 78 |
+
|
| 79 |
+
A.OneOf([
|
| 80 |
+
A.CoarseDropout(
|
| 81 |
+
max_holes=8,
|
| 82 |
+
max_height=2,
|
| 83 |
+
max_width=30,
|
| 84 |
+
min_holes=2,
|
| 85 |
+
fill_value=128,
|
| 86 |
+
p=1.0
|
| 87 |
+
),
|
| 88 |
+
A.GridDistortion(num_steps=5, distort_limit=0.1, p=1.0),
|
| 89 |
+
], p=0.4),
|
| 90 |
+
|
| 91 |
+
])
|
| 92 |
+
|
| 93 |
+
|
| 94 |
+
def augment_dataset():
|
| 95 |
+
os.makedirs(OUTPUT_DIR, exist_ok=True)
|
| 96 |
+
|
| 97 |
+
print("[1/3] Loading ground truth from barcodes...")
|
| 98 |
+
ground_truths = scan_all_barcodes(BARCODE_DIR)
|
| 99 |
+
ground_truths = {k: v for k, v in ground_truths.items() if v}
|
| 100 |
+
print(f" Got {len(ground_truths)} labeled pairs")
|
| 101 |
+
|
| 102 |
+
chassis_files = sorted([
|
| 103 |
+
f for f in os.listdir(CHASSIS_DIR)
|
| 104 |
+
if f.lower().endswith(('.jpg', '.jpeg', '.png'))
|
| 105 |
+
and os.path.splitext(f)[0] in ground_truths
|
| 106 |
+
])
|
| 107 |
+
print(f" Found {len(chassis_files)} chassis images with labels")
|
| 108 |
+
|
| 109 |
+
pipeline = get_augmentation_pipeline()
|
| 110 |
+
augmented_gt = {}
|
| 111 |
+
total = 0
|
| 112 |
+
|
| 113 |
+
print(f"\n[2/3] Augmenting — {AUGMENTS_PER_IMAGE} variations per image...")
|
| 114 |
+
|
| 115 |
+
for fname in chassis_files:
|
| 116 |
+
key = os.path.splitext(fname)[0]
|
| 117 |
+
label = ground_truths[key]
|
| 118 |
+
img_path = os.path.join(CHASSIS_DIR, fname)
|
| 119 |
+
img = cv2.imread(img_path)
|
| 120 |
+
|
| 121 |
+
if img is None:
|
| 122 |
+
print(f" [SKIP] Could not read {fname}")
|
| 123 |
+
continue
|
| 124 |
+
|
| 125 |
+
img_rgb = cv2.cvtColor(img, cv2.COLOR_BGR2RGB)
|
| 126 |
+
|
| 127 |
+
orig_name = f"{key}_orig.jpg"
|
| 128 |
+
cv2.imwrite(os.path.join(OUTPUT_DIR, orig_name), img)
|
| 129 |
+
augmented_gt[orig_name] = label
|
| 130 |
+
total += 1
|
| 131 |
+
|
| 132 |
+
for i in range(AUGMENTS_PER_IMAGE):
|
| 133 |
+
try:
|
| 134 |
+
augmented = pipeline(image=img_rgb)["image"]
|
| 135 |
+
aug_bgr = cv2.cvtColor(augmented, cv2.COLOR_RGB2BGR)
|
| 136 |
+
aug_name = f"{key}_aug{i:03d}.jpg"
|
| 137 |
+
cv2.imwrite(os.path.join(OUTPUT_DIR, aug_name), aug_bgr)
|
| 138 |
+
augmented_gt[aug_name] = label
|
| 139 |
+
total += 1
|
| 140 |
+
except Exception as e:
|
| 141 |
+
print(f" [WARN] Augmentation failed for {fname} variation {i}: {e}")
|
| 142 |
+
|
| 143 |
+
print(f" {key} -> {AUGMENTS_PER_IMAGE + 1} images (label: {label})")
|
| 144 |
+
|
| 145 |
+
with open(GT_PATH, "w") as f:
|
| 146 |
+
json.dump(augmented_gt, f, indent=2)
|
| 147 |
+
|
| 148 |
+
print(f"\n[3/3] Done!")
|
| 149 |
+
print(f" Total images generated : {total}")
|
| 150 |
+
print(f" Saved to : {OUTPUT_DIR}/")
|
| 151 |
+
print(f" Ground truth saved to : {GT_PATH}")
|
| 152 |
+
print(f"\nNext step: use these images to fine-tune PaddleOCR")
|
| 153 |
+
|
| 154 |
+
|
| 155 |
+
if __name__ == "__main__":
|
| 156 |
+
augment_dataset()
|
barcode_scanner.py
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import cv2
|
| 2 |
+
import os
|
| 3 |
+
import re
|
| 4 |
+
import numpy as np
|
| 5 |
+
|
| 6 |
+
try:
|
| 7 |
+
import zxingcpp
|
| 8 |
+
_HAS_ZXING = True
|
| 9 |
+
except ImportError:
|
| 10 |
+
_HAS_ZXING = False
|
| 11 |
+
|
| 12 |
+
try:
|
| 13 |
+
from pyzbar.pyzbar import decode as pyzbar_decode
|
| 14 |
+
_HAS_PYZBAR = True
|
| 15 |
+
except ImportError:
|
| 16 |
+
_HAS_PYZBAR = False
|
| 17 |
+
|
| 18 |
+
PART_NUMBER_RE = re.compile(r'^0301BAB\d+N$')
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
def _filter_chassis(values):
|
| 22 |
+
for v in values:
|
| 23 |
+
if not v:
|
| 24 |
+
continue
|
| 25 |
+
tokens = v.split()
|
| 26 |
+
for token in tokens:
|
| 27 |
+
token = token.strip()
|
| 28 |
+
if token and not PART_NUMBER_RE.match(token):
|
| 29 |
+
return token
|
| 30 |
+
return None
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
def _decode_zxing(gray, thresh):
|
| 34 |
+
values = set()
|
| 35 |
+
for frame in [gray, thresh]:
|
| 36 |
+
try:
|
| 37 |
+
for r in zxingcpp.read_barcodes(frame):
|
| 38 |
+
text = r.text.strip()
|
| 39 |
+
if text:
|
| 40 |
+
values.add(text)
|
| 41 |
+
except Exception:
|
| 42 |
+
pass
|
| 43 |
+
return values
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
def _decode_pyzbar(img, thresh):
|
| 47 |
+
values = set()
|
| 48 |
+
for frame in [img, thresh]:
|
| 49 |
+
try:
|
| 50 |
+
for d in pyzbar_decode(frame):
|
| 51 |
+
text = d.data.decode("utf-8").strip()
|
| 52 |
+
if text:
|
| 53 |
+
values.add(text)
|
| 54 |
+
except Exception:
|
| 55 |
+
pass
|
| 56 |
+
return values
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
def scan_barcode(image_path):
|
| 60 |
+
img = cv2.imread(image_path)
|
| 61 |
+
if img is None:
|
| 62 |
+
return None
|
| 63 |
+
|
| 64 |
+
gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
|
| 65 |
+
_, thresh = cv2.threshold(gray, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU)
|
| 66 |
+
|
| 67 |
+
if _HAS_ZXING:
|
| 68 |
+
values = _decode_zxing(gray, thresh)
|
| 69 |
+
result = _filter_chassis(values)
|
| 70 |
+
if result:
|
| 71 |
+
return result
|
| 72 |
+
|
| 73 |
+
if _HAS_PYZBAR:
|
| 74 |
+
values = _decode_pyzbar(img, thresh)
|
| 75 |
+
result = _filter_chassis(values)
|
| 76 |
+
if result:
|
| 77 |
+
return result
|
| 78 |
+
|
| 79 |
+
return None
|
| 80 |
+
|
| 81 |
+
|
| 82 |
+
def scan_all_barcodes(barcode_dir):
|
| 83 |
+
results = {}
|
| 84 |
+
files = sorted([f for f in os.listdir(barcode_dir)
|
| 85 |
+
if f.lower().endswith(('.jpg', '.jpeg', '.png'))])
|
| 86 |
+
|
| 87 |
+
print(f"Scanning {len(files)} barcode images...")
|
| 88 |
+
for fname in files:
|
| 89 |
+
path = os.path.join(barcode_dir, fname)
|
| 90 |
+
key = os.path.splitext(fname)[0]
|
| 91 |
+
result = scan_barcode(path)
|
| 92 |
+
if result:
|
| 93 |
+
results[key] = result
|
| 94 |
+
print(f" [OK] {fname} -> {result}")
|
| 95 |
+
else:
|
| 96 |
+
results[key] = None
|
| 97 |
+
print(f" [FAIL] {fname} -> could not decode")
|
| 98 |
+
|
| 99 |
+
success = sum(1 for v in results.values() if v)
|
| 100 |
+
print(f"\nBarcode scan: {success}/{len(files)} decoded successfully")
|
| 101 |
+
return results
|
| 102 |
+
|
| 103 |
+
|
| 104 |
+
if __name__ == "__main__":
|
| 105 |
+
import sys
|
| 106 |
+
if len(sys.argv) < 2:
|
| 107 |
+
print("Usage: python barcode_scanner.py <barcode_image_or_dir>")
|
| 108 |
+
else:
|
| 109 |
+
path = sys.argv[1]
|
| 110 |
+
if os.path.isdir(path):
|
| 111 |
+
results = scan_all_barcodes(path)
|
| 112 |
+
else:
|
| 113 |
+
result = scan_barcode(path)
|
| 114 |
+
print(f"Result: {result}")
|
compare_decoders.py
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import cv2
|
| 2 |
+
import os
|
| 3 |
+
import re
|
| 4 |
+
import time
|
| 5 |
+
from collections import defaultdict
|
| 6 |
+
|
| 7 |
+
BARCODE_DIR = "images/barcode"
|
| 8 |
+
PART_NUMBER_RE = re.compile(r'^0301BAB\d+N$')
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
def get_image_files():
|
| 12 |
+
files = sorted([f for f in os.listdir(BARCODE_DIR)
|
| 13 |
+
if f.lower().endswith(('.jpg', '.jpeg', '.png'))])
|
| 14 |
+
return files
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
def is_chassis(text):
|
| 18 |
+
text = text.strip()
|
| 19 |
+
return bool(text) and not PART_NUMBER_RE.match(text)
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
def decode_pyzbar(img, gray, thresh):
|
| 25 |
+
from pyzbar.pyzbar import decode
|
| 26 |
+
all_values = set()
|
| 27 |
+
for frame in [img, thresh]:
|
| 28 |
+
for d in decode(frame):
|
| 29 |
+
text = d.data.decode("utf-8").strip()
|
| 30 |
+
all_values.add(text)
|
| 31 |
+
return all_values
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
def decode_zxingcpp(img, gray, thresh):
|
| 35 |
+
import zxingcpp
|
| 36 |
+
all_values = set()
|
| 37 |
+
for frame in [gray, thresh]:
|
| 38 |
+
try:
|
| 39 |
+
results = zxingcpp.read_barcodes(frame)
|
| 40 |
+
for r in results:
|
| 41 |
+
text = r.text.strip()
|
| 42 |
+
if text:
|
| 43 |
+
all_values.add(text)
|
| 44 |
+
except Exception as e:
|
| 45 |
+
pass
|
| 46 |
+
return all_values
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
def decode_cv2barcode(img, gray, thresh):
|
| 50 |
+
all_values = set()
|
| 51 |
+
try:
|
| 52 |
+
detector = cv2.barcode.BarcodeDetector()
|
| 53 |
+
for frame in [gray, thresh]:
|
| 54 |
+
ok, decoded_info, decoded_type, points = detector.detectAndDecode(frame)
|
| 55 |
+
if ok and decoded_info is not None:
|
| 56 |
+
for text in decoded_info:
|
| 57 |
+
if text and text.strip():
|
| 58 |
+
all_values.add(text.strip())
|
| 59 |
+
except AttributeError:
|
| 60 |
+
pass
|
| 61 |
+
except Exception as e:
|
| 62 |
+
pass
|
| 63 |
+
return all_values
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
def main():
|
| 67 |
+
files = get_image_files()
|
| 68 |
+
print(f"Testing {len(files)} barcode images from {BARCODE_DIR}/\n")
|
| 69 |
+
|
| 70 |
+
decoders = {
|
| 71 |
+
"pyzbar": decode_pyzbar,
|
| 72 |
+
"zxing-cpp": decode_zxingcpp,
|
| 73 |
+
"cv2.barcode": decode_cv2barcode,
|
| 74 |
+
}
|
| 75 |
+
|
| 76 |
+
stats = {name: {"chassis": 0, "part_only": 0, "none": 0, "chassis_list": []}
|
| 77 |
+
for name in decoders}
|
| 78 |
+
timings = {name: 0.0 for name in decoders}
|
| 79 |
+
|
| 80 |
+
detail_rows = []
|
| 81 |
+
|
| 82 |
+
for fname in files:
|
| 83 |
+
path = os.path.join(BARCODE_DIR, fname)
|
| 84 |
+
img = cv2.imread(path)
|
| 85 |
+
if img is None:
|
| 86 |
+
continue
|
| 87 |
+
gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
|
| 88 |
+
_, thresh = cv2.threshold(gray, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU)
|
| 89 |
+
|
| 90 |
+
key = os.path.splitext(fname)[0]
|
| 91 |
+
row = {"file": fname}
|
| 92 |
+
|
| 93 |
+
for name, decoder_fn in decoders.items():
|
| 94 |
+
t0 = time.perf_counter()
|
| 95 |
+
all_values = decoder_fn(img, gray, thresh)
|
| 96 |
+
elapsed = time.perf_counter() - t0
|
| 97 |
+
timings[name] += elapsed
|
| 98 |
+
|
| 99 |
+
chassis_vals = {v for v in all_values if is_chassis(v)}
|
| 100 |
+
part_vals = {v for v in all_values if PART_NUMBER_RE.match(v)}
|
| 101 |
+
|
| 102 |
+
if chassis_vals:
|
| 103 |
+
stats[name]["chassis"] += 1
|
| 104 |
+
stats[name]["chassis_list"].append((key, chassis_vals))
|
| 105 |
+
row[name] = ", ".join(sorted(chassis_vals))
|
| 106 |
+
elif part_vals:
|
| 107 |
+
stats[name]["part_only"] += 1
|
| 108 |
+
row[name] = "(part# only)"
|
| 109 |
+
else:
|
| 110 |
+
stats[name]["none"] += 1
|
| 111 |
+
row[name] = "—"
|
| 112 |
+
|
| 113 |
+
detail_rows.append(row)
|
| 114 |
+
|
| 115 |
+
print("=" * 100)
|
| 116 |
+
print(f"{'File':<14} {'pyzbar':<20} {'zxing-cpp':<20} {'cv2.barcode':<20}")
|
| 117 |
+
print("-" * 100)
|
| 118 |
+
for row in detail_rows:
|
| 119 |
+
pyz = row.get("pyzbar", "—")
|
| 120 |
+
zxc = row.get("zxing-cpp", "—")
|
| 121 |
+
cv2b = row.get("cv2.barcode", "—")
|
| 122 |
+
print(f"{row['file']:<14} {pyz:<20} {zxc:<20} {cv2b:<20}")
|
| 123 |
+
|
| 124 |
+
total = len(files)
|
| 125 |
+
print("\n" + "=" * 100)
|
| 126 |
+
print("SUMMARY")
|
| 127 |
+
print("=" * 100)
|
| 128 |
+
print(f"{'Metric':<30} {'pyzbar':>12} {'zxing-cpp':>12} {'cv2.barcode':>12}")
|
| 129 |
+
print("-" * 70)
|
| 130 |
+
print(f"{'Chassis decoded':.<30} {stats['pyzbar']['chassis']:>12} {stats['zxing-cpp']['chassis']:>12} {stats['cv2.barcode']['chassis']:>12}")
|
| 131 |
+
print(f"{'Part# only (filtered out)':.<30} {stats['pyzbar']['part_only']:>12} {stats['zxing-cpp']['part_only']:>12} {stats['cv2.barcode']['part_only']:>12}")
|
| 132 |
+
print(f"{'Nothing decoded':.<30} {stats['pyzbar']['none']:>12} {stats['zxing-cpp']['none']:>12} {stats['cv2.barcode']['none']:>12}")
|
| 133 |
+
print(f"{'Total time (s)':.<30} {timings['pyzbar']:>12.2f} {timings['zxing-cpp']:>12.2f} {timings['cv2.barcode']:>12.2f}")
|
| 134 |
+
print("-" * 70)
|
| 135 |
+
print(f"{'CHASSIS DECODE RATE':.<30} {stats['pyzbar']['chassis']/total:>11.0%} {stats['zxing-cpp']['chassis']/total:>11.0%} {stats['cv2.barcode']['chassis']/total:>11.0%}")
|
| 136 |
+
print("=" * 100)
|
| 137 |
+
|
| 138 |
+
best_name = max(decoders.keys(), key=lambda n: stats[n]["chassis"])
|
| 139 |
+
print(f"\n★ Best decoder: {best_name} ({stats[best_name]['chassis']}/{total} chassis barcodes)")
|
| 140 |
+
|
| 141 |
+
for name in decoders:
|
| 142 |
+
if stats[name]["chassis_list"]:
|
| 143 |
+
print(f"\n {name} decoded chassis numbers:")
|
| 144 |
+
for key, vals in stats[name]["chassis_list"]:
|
| 145 |
+
print(f" {key}: {', '.join(sorted(vals))}")
|
| 146 |
+
|
| 147 |
+
|
| 148 |
+
if __name__ == "__main__":
|
| 149 |
+
main()
|
evaluate.py
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
import json
|
| 3 |
+
from barcode_scanner import scan_all_barcodes
|
| 4 |
+
from ocr import read_chassis, postprocess_with_hint
|
| 5 |
+
|
| 6 |
+
BARCODE_DIR = "images/barcode"
|
| 7 |
+
CHASSIS_DIR = "images/chassis"
|
| 8 |
+
RESULTS_DIR = "results"
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
def get_pairs():
|
| 12 |
+
barcode_files = {os.path.splitext(f)[0]: f
|
| 13 |
+
for f in os.listdir(BARCODE_DIR)
|
| 14 |
+
if f.lower().endswith(('.jpg', '.jpeg', '.png'))}
|
| 15 |
+
chassis_files = {os.path.splitext(f)[0]: f
|
| 16 |
+
for f in os.listdir(CHASSIS_DIR)
|
| 17 |
+
if f.lower().endswith(('.jpg', '.jpeg', '.png'))}
|
| 18 |
+
|
| 19 |
+
common = sorted(set(barcode_files.keys()) & set(chassis_files.keys()))
|
| 20 |
+
pairs = []
|
| 21 |
+
for key in common:
|
| 22 |
+
pairs.append({
|
| 23 |
+
"key": key,
|
| 24 |
+
"barcode_path": os.path.join(BARCODE_DIR, barcode_files[key]),
|
| 25 |
+
"chassis_path": os.path.join(CHASSIS_DIR, chassis_files[key]),
|
| 26 |
+
})
|
| 27 |
+
return pairs
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
def evaluate():
|
| 31 |
+
os.makedirs(RESULTS_DIR, exist_ok=True)
|
| 32 |
+
|
| 33 |
+
print("=" * 60)
|
| 34 |
+
print("CHASSIS OCR EVALUATION")
|
| 35 |
+
print("=" * 60)
|
| 36 |
+
|
| 37 |
+
print("\n[1/3] Scanning barcodes for ground truth...")
|
| 38 |
+
barcode_results = scan_all_barcodes(BARCODE_DIR)
|
| 39 |
+
|
| 40 |
+
pairs = get_pairs()
|
| 41 |
+
print(f"\n[2/3] Found {len(pairs)} matching image pairs")
|
| 42 |
+
|
| 43 |
+
print(f"\n[3/3] Running OCR pipeline on chassis images...\n")
|
| 44 |
+
|
| 45 |
+
results = []
|
| 46 |
+
exact_match = 0
|
| 47 |
+
corrected_match = 0
|
| 48 |
+
failed = 0
|
| 49 |
+
|
| 50 |
+
for pair in pairs:
|
| 51 |
+
key = pair["key"]
|
| 52 |
+
expected = barcode_results.get(key)
|
| 53 |
+
chassis_path = pair["chassis_path"]
|
| 54 |
+
|
| 55 |
+
if not expected:
|
| 56 |
+
print(f" [WARN] {key} - barcode not decoded, skipping")
|
| 57 |
+
continue
|
| 58 |
+
|
| 59 |
+
ocr_text, conf = read_chassis(chassis_path, save_comparison=True)
|
| 60 |
+
|
| 61 |
+
corrected, is_match = postprocess_with_hint(ocr_text, expected)
|
| 62 |
+
|
| 63 |
+
if ocr_text == expected:
|
| 64 |
+
status = "[EXACT]"
|
| 65 |
+
exact_match += 1
|
| 66 |
+
elif is_match:
|
| 67 |
+
status = "[CORRECTED]"
|
| 68 |
+
corrected_match += 1
|
| 69 |
+
else:
|
| 70 |
+
status = "[FAILED]"
|
| 71 |
+
failed += 1
|
| 72 |
+
|
| 73 |
+
print(f" {status} | {key}")
|
| 74 |
+
print(f" Expected : {expected}")
|
| 75 |
+
print(f" Got : {ocr_text} (conf: {conf:.0%})")
|
| 76 |
+
if is_match and ocr_text != expected:
|
| 77 |
+
print(f" Fixed to : {corrected}")
|
| 78 |
+
print()
|
| 79 |
+
|
| 80 |
+
results.append({
|
| 81 |
+
"key": key,
|
| 82 |
+
"expected": expected,
|
| 83 |
+
"ocr_raw": ocr_text,
|
| 84 |
+
"corrected": corrected,
|
| 85 |
+
"confidence": round(conf, 3),
|
| 86 |
+
"match": is_match,
|
| 87 |
+
"exact": ocr_text == expected,
|
| 88 |
+
})
|
| 89 |
+
|
| 90 |
+
total = len(results)
|
| 91 |
+
total_correct = exact_match + corrected_match
|
| 92 |
+
print("=" * 60)
|
| 93 |
+
print("RESULTS SUMMARY")
|
| 94 |
+
print("=" * 60)
|
| 95 |
+
print(f"Total pairs evaluated : {total}")
|
| 96 |
+
print(f"Exact matches : {exact_match}/{total} ({exact_match/total*100:.1f}%)")
|
| 97 |
+
print(f"Corrected matches : {corrected_match}/{total} ({corrected_match/total*100:.1f}%)")
|
| 98 |
+
print(f"Total correct : {total_correct}/{total} ({total_correct/total*100:.1f}%)")
|
| 99 |
+
print(f"Failed : {failed}/{total} ({failed/total*100:.1f}%)")
|
| 100 |
+
print("=" * 60)
|
| 101 |
+
|
| 102 |
+
if failed > 0:
|
| 103 |
+
print("\nFailed images (focus preprocessing tuning here):")
|
| 104 |
+
for r in results:
|
| 105 |
+
if not r["match"]:
|
| 106 |
+
print(f" - {r['key']}: expected '{r['expected']}', got '{r['ocr_raw']}'")
|
| 107 |
+
|
| 108 |
+
report_path = os.path.join(RESULTS_DIR, "report.json")
|
| 109 |
+
with open(report_path, "w") as f:
|
| 110 |
+
json.dump(results, f, indent=2)
|
| 111 |
+
print(f"\nFull report saved -> {report_path}")
|
| 112 |
+
print(f"Comparison images -> {RESULTS_DIR}/")
|
| 113 |
+
|
| 114 |
+
|
| 115 |
+
if __name__ == "__main__":
|
| 116 |
+
evaluate()
|
learn.py
ADDED
|
@@ -0,0 +1,176 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
import json
|
| 3 |
+
import cv2
|
| 4 |
+
import numpy as np
|
| 5 |
+
from collections import defaultdict
|
| 6 |
+
from barcode_scanner import scan_all_barcodes
|
| 7 |
+
from preprocess import preprocess_chassis
|
| 8 |
+
|
| 9 |
+
BARCODE_DIR = "images/barcode"
|
| 10 |
+
CHASSIS_DIR = "images/chassis"
|
| 11 |
+
CONFIG_PATH = "config.json"
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
def run_ocr_ensemble(image_path, ocr):
|
| 15 |
+
variations = preprocess_chassis(image_path)
|
| 16 |
+
best_text, best_score = "", -1
|
| 17 |
+
for var in variations:
|
| 18 |
+
result = ocr.ocr(var, cls=True)
|
| 19 |
+
if not result or not result[0]:
|
| 20 |
+
continue
|
| 21 |
+
texts = [line[1][0] for line in result[0]]
|
| 22 |
+
confs = [line[1][1] for line in result[0]]
|
| 23 |
+
text = "".join(texts).upper()
|
| 24 |
+
text = "".join(c for c in text if c.isalnum())
|
| 25 |
+
conf = sum(confs) / len(confs) if confs else 0.0
|
| 26 |
+
score = conf * max(len(text), 1)
|
| 27 |
+
if score > best_score:
|
| 28 |
+
best_text, best_score = text, score
|
| 29 |
+
return best_text
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
def best_alignment(got, expected):
|
| 33 |
+
exp_len = len(expected)
|
| 34 |
+
if len(got) == exp_len:
|
| 35 |
+
return got
|
| 36 |
+
best_start, best_diffs = 0, exp_len + 1
|
| 37 |
+
for start in range(max(0, len(got) - exp_len) + 1):
|
| 38 |
+
cand = got[start:start + exp_len]
|
| 39 |
+
if len(cand) != exp_len:
|
| 40 |
+
continue
|
| 41 |
+
diffs = sum(1 for a, b in zip(cand, expected) if a != b)
|
| 42 |
+
if diffs < best_diffs:
|
| 43 |
+
best_diffs = diffs
|
| 44 |
+
best_start = start
|
| 45 |
+
return got[best_start:best_start + exp_len]
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
def learn_confusion_map(ocr_results, ground_truths):
|
| 49 |
+
counts = defaultdict(lambda: defaultdict(int))
|
| 50 |
+
for key, expected in ground_truths.items():
|
| 51 |
+
got = ocr_results.get(key, "")
|
| 52 |
+
if not got or got == expected:
|
| 53 |
+
continue
|
| 54 |
+
aligned = best_alignment(got, expected)
|
| 55 |
+
if len(aligned) != len(expected):
|
| 56 |
+
continue
|
| 57 |
+
for g, e in zip(aligned, expected):
|
| 58 |
+
if g != e:
|
| 59 |
+
counts[g][e] += 1
|
| 60 |
+
|
| 61 |
+
confusion_map = {}
|
| 62 |
+
print("\n Learned confusions:")
|
| 63 |
+
for char in sorted(counts.keys()):
|
| 64 |
+
wants = sorted(counts[char], key=lambda w: counts[char][w], reverse=True)
|
| 65 |
+
confusion_map[char] = wants
|
| 66 |
+
print(f" '{char}' -> {wants} (counts: {dict(counts[char])})")
|
| 67 |
+
|
| 68 |
+
return confusion_map
|
| 69 |
+
|
| 70 |
+
|
| 71 |
+
def can_fix(got_str, expected_str, confusion_map, max_errors):
|
| 72 |
+
if len(got_str) != len(expected_str):
|
| 73 |
+
return False
|
| 74 |
+
diffs = [(g, e) for g, e in zip(got_str, expected_str) if g != e]
|
| 75 |
+
if len(diffs) > max_errors:
|
| 76 |
+
return False
|
| 77 |
+
return all(
|
| 78 |
+
e in confusion_map.get(g, []) or g in confusion_map.get(e, [])
|
| 79 |
+
for g, e in diffs
|
| 80 |
+
)
|
| 81 |
+
|
| 82 |
+
|
| 83 |
+
def learn_thresholds(ocr_results, ground_truths, confusion_map):
|
| 84 |
+
best_correct, best_config = 0, {"max_errors": 2, "window_size": 3}
|
| 85 |
+
|
| 86 |
+
for max_err in [1, 2, 3, 4]:
|
| 87 |
+
for win in [2, 3, 4, 5]:
|
| 88 |
+
correct = 0
|
| 89 |
+
for key, expected in ground_truths.items():
|
| 90 |
+
got = ocr_results.get(key, "")
|
| 91 |
+
if not got:
|
| 92 |
+
continue
|
| 93 |
+
if got == expected or expected in got:
|
| 94 |
+
correct += 1
|
| 95 |
+
continue
|
| 96 |
+
if abs(len(got) - len(expected)) <= win:
|
| 97 |
+
aligned = best_alignment(got, expected)
|
| 98 |
+
if can_fix(aligned, expected, confusion_map, max_err):
|
| 99 |
+
correct += 1
|
| 100 |
+
elif len(got) < len(expected):
|
| 101 |
+
suffix = expected[-len(got):]
|
| 102 |
+
if can_fix(got, suffix, confusion_map, 1):
|
| 103 |
+
correct += 1
|
| 104 |
+
|
| 105 |
+
if correct > best_correct:
|
| 106 |
+
best_correct = correct
|
| 107 |
+
best_config = {"max_errors": max_err, "window_size": win}
|
| 108 |
+
|
| 109 |
+
print(f"\n Best thresholds: {best_config} "
|
| 110 |
+
f"(estimated correct: {best_correct}/{len(ground_truths)})")
|
| 111 |
+
return best_config
|
| 112 |
+
|
| 113 |
+
|
| 114 |
+
def main():
|
| 115 |
+
print("=" * 60)
|
| 116 |
+
print("LEARNING FROM DATA")
|
| 117 |
+
print("=" * 60)
|
| 118 |
+
|
| 119 |
+
print("\n[1/3] Scanning barcodes for ground truth...")
|
| 120 |
+
ground_truths = scan_all_barcodes(BARCODE_DIR)
|
| 121 |
+
ground_truths = {k: v for k, v in ground_truths.items() if v}
|
| 122 |
+
print(f" Got {len(ground_truths)} ground truth labels")
|
| 123 |
+
|
| 124 |
+
print("\n[2/3] Running ensemble OCR on all chassis images...")
|
| 125 |
+
from paddleocr import PaddleOCR
|
| 126 |
+
ocr = PaddleOCR(use_angle_cls=True, lang='en',
|
| 127 |
+
use_gpu=False, show_log=False)
|
| 128 |
+
|
| 129 |
+
chassis_files = sorted([
|
| 130 |
+
f for f in os.listdir(CHASSIS_DIR)
|
| 131 |
+
if f.lower().endswith(('.jpg', '.jpeg', '.png'))
|
| 132 |
+
])
|
| 133 |
+
|
| 134 |
+
ocr_results = {}
|
| 135 |
+
for fname in chassis_files:
|
| 136 |
+
key = os.path.splitext(fname)[0]
|
| 137 |
+
path = os.path.join(CHASSIS_DIR, fname)
|
| 138 |
+
text = run_ocr_ensemble(path, ocr)
|
| 139 |
+
ocr_results[key] = text
|
| 140 |
+
expected = ground_truths.get(key, "???")
|
| 141 |
+
match = "[OK]" if text == expected else "[--]"
|
| 142 |
+
print(f" {match} {key}: got='{text}' expected='{expected}'")
|
| 143 |
+
|
| 144 |
+
print("\n[3/3] Learning confusion map and thresholds...")
|
| 145 |
+
confusion_map = learn_confusion_map(ocr_results, ground_truths)
|
| 146 |
+
thresholds = learn_thresholds(ocr_results, ground_truths, confusion_map)
|
| 147 |
+
|
| 148 |
+
existing = {}
|
| 149 |
+
if os.path.exists(CONFIG_PATH):
|
| 150 |
+
with open(CONFIG_PATH) as f:
|
| 151 |
+
existing = json.load(f)
|
| 152 |
+
|
| 153 |
+
config = {
|
| 154 |
+
"confusion_map": confusion_map,
|
| 155 |
+
"preprocessing": existing.get("preprocessing", {
|
| 156 |
+
"clahe_clip": 3.0,
|
| 157 |
+
"clahe_grid": 8,
|
| 158 |
+
"bilateral_d": 9,
|
| 159 |
+
"bilateral_sigma": 75,
|
| 160 |
+
"adaptive_blocksize": 21,
|
| 161 |
+
"adaptive_c": 8,
|
| 162 |
+
"padding": 20
|
| 163 |
+
}),
|
| 164 |
+
"error_correction": thresholds
|
| 165 |
+
}
|
| 166 |
+
|
| 167 |
+
with open(CONFIG_PATH, "w") as f:
|
| 168 |
+
json.dump(config, f, indent=2)
|
| 169 |
+
|
| 170 |
+
print(f"\n[DONE] Config saved -> {CONFIG_PATH}")
|
| 171 |
+
print("Now run: python evaluate.py")
|
| 172 |
+
print("=" * 60)
|
| 173 |
+
|
| 174 |
+
|
| 175 |
+
if __name__ == "__main__":
|
| 176 |
+
main()
|
ocr.py
ADDED
|
@@ -0,0 +1,134 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import cv2
|
| 2 |
+
import os
|
| 3 |
+
import json
|
| 4 |
+
import numpy as np
|
| 5 |
+
from preprocess import preprocess_chassis
|
| 6 |
+
|
| 7 |
+
CONFIG_PATH = "config.json"
|
| 8 |
+
_ocr = None
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
def load_config():
|
| 12 |
+
if os.path.exists(CONFIG_PATH):
|
| 13 |
+
with open(CONFIG_PATH) as f:
|
| 14 |
+
return json.load(f)
|
| 15 |
+
return {
|
| 16 |
+
"confusion_map": {},
|
| 17 |
+
"error_correction": {"max_errors": 2, "window_size": 3}
|
| 18 |
+
}
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
def get_ocr():
|
| 22 |
+
global _ocr
|
| 23 |
+
if _ocr is None:
|
| 24 |
+
from paddleocr import PaddleOCR
|
| 25 |
+
_ocr = PaddleOCR(use_angle_cls=True, lang='en',
|
| 26 |
+
use_gpu=False, show_log=False)
|
| 27 |
+
return _ocr
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
def ocr_image(img):
|
| 31 |
+
ocr = get_ocr()
|
| 32 |
+
result = ocr.ocr(img, cls=True)
|
| 33 |
+
if not result or not result[0]:
|
| 34 |
+
return "", 0.0
|
| 35 |
+
texts = [line[1][0] for line in result[0]]
|
| 36 |
+
confs = [line[1][1] for line in result[0]]
|
| 37 |
+
full_text = "".join(texts).upper()
|
| 38 |
+
full_text = "".join(c for c in full_text if c.isalnum())
|
| 39 |
+
avg_conf = sum(confs) / len(confs) if confs else 0.0
|
| 40 |
+
return full_text, avg_conf
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
def read_chassis(image_path, save_comparison=False):
|
| 44 |
+
variations = preprocess_chassis(image_path, save_comparison=save_comparison)
|
| 45 |
+
best_text, best_conf, best_score = "", 0.0, -1
|
| 46 |
+
for var in variations:
|
| 47 |
+
text, conf = ocr_image(var)
|
| 48 |
+
score = conf * max(len(text), 1)
|
| 49 |
+
if score > best_score:
|
| 50 |
+
best_text, best_conf, best_score = text, conf, score
|
| 51 |
+
return best_text, best_conf
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
def can_substitute(got, want, confusion_map):
|
| 55 |
+
return want in confusion_map.get(got, []) or got in confusion_map.get(want, [])
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
def apply_substitutions(ocr_text, expected_text, confusion_map, max_errors):
|
| 59 |
+
if len(ocr_text) != len(expected_text):
|
| 60 |
+
return ocr_text, False
|
| 61 |
+
diffs = [(i, ocr_text[i], expected_text[i])
|
| 62 |
+
for i in range(len(ocr_text)) if ocr_text[i] != expected_text[i]]
|
| 63 |
+
if len(diffs) > max_errors:
|
| 64 |
+
return ocr_text, False
|
| 65 |
+
corrected = list(ocr_text)
|
| 66 |
+
for i, got, want in diffs:
|
| 67 |
+
if can_substitute(got, want, confusion_map):
|
| 68 |
+
corrected[i] = want
|
| 69 |
+
else:
|
| 70 |
+
return ocr_text, False
|
| 71 |
+
return "".join(corrected), True
|
| 72 |
+
|
| 73 |
+
|
| 74 |
+
def best_window_match(ocr_text, expected_text, window_size):
|
| 75 |
+
exp_len = len(expected_text)
|
| 76 |
+
best, best_diffs = None, exp_len + 1
|
| 77 |
+
for start in range(max(0, len(ocr_text) - exp_len) + 1):
|
| 78 |
+
candidate = ocr_text[start:start + exp_len]
|
| 79 |
+
if len(candidate) != exp_len:
|
| 80 |
+
continue
|
| 81 |
+
diffs = sum(1 for a, b in zip(candidate, expected_text) if a != b)
|
| 82 |
+
if diffs < best_diffs:
|
| 83 |
+
best_diffs = diffs
|
| 84 |
+
best = (candidate, diffs)
|
| 85 |
+
if best and best[1] <= window_size:
|
| 86 |
+
return best
|
| 87 |
+
return None
|
| 88 |
+
|
| 89 |
+
|
| 90 |
+
def postprocess_with_hint(ocr_text, expected_text):
|
| 91 |
+
config = load_config()
|
| 92 |
+
confusion_map = config.get("confusion_map", {})
|
| 93 |
+
ec = config.get("error_correction", {"max_errors": 2, "window_size": 3})
|
| 94 |
+
max_errors = ec["max_errors"]
|
| 95 |
+
window_size = ec["window_size"]
|
| 96 |
+
|
| 97 |
+
if not ocr_text:
|
| 98 |
+
return ocr_text, False
|
| 99 |
+
if ocr_text == expected_text:
|
| 100 |
+
return ocr_text, True
|
| 101 |
+
if expected_text in ocr_text:
|
| 102 |
+
return expected_text, True
|
| 103 |
+
if len(ocr_text) == len(expected_text):
|
| 104 |
+
corrected, fixed = apply_substitutions(
|
| 105 |
+
ocr_text, expected_text, confusion_map, max_errors)
|
| 106 |
+
if fixed:
|
| 107 |
+
return corrected, True
|
| 108 |
+
if abs(len(ocr_text) - len(expected_text)) <= window_size:
|
| 109 |
+
match = best_window_match(ocr_text, expected_text, window_size)
|
| 110 |
+
if match:
|
| 111 |
+
candidate, diffs = match
|
| 112 |
+
if diffs == 0:
|
| 113 |
+
return candidate, True
|
| 114 |
+
corrected, fixed = apply_substitutions(
|
| 115 |
+
candidate, expected_text, confusion_map, max_errors)
|
| 116 |
+
if fixed:
|
| 117 |
+
return corrected, True
|
| 118 |
+
if len(ocr_text) < len(expected_text):
|
| 119 |
+
suffix = expected_text[-len(ocr_text):]
|
| 120 |
+
corrected, fixed = apply_substitutions(
|
| 121 |
+
ocr_text, suffix, confusion_map, max_errors=1)
|
| 122 |
+
if fixed or ocr_text == suffix:
|
| 123 |
+
return expected_text, True
|
| 124 |
+
return ocr_text, False
|
| 125 |
+
|
| 126 |
+
|
| 127 |
+
if __name__ == "__main__":
|
| 128 |
+
import sys
|
| 129 |
+
if len(sys.argv) < 2:
|
| 130 |
+
print("Usage: python ocr.py <chassis_image_path>")
|
| 131 |
+
else:
|
| 132 |
+
text, conf = read_chassis(sys.argv[1], save_comparison=True)
|
| 133 |
+
print(f"Result : {text}")
|
| 134 |
+
print(f"Confidence : {conf:.2%}")
|
preprocess.py
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import cv2
|
| 2 |
+
import numpy as np
|
| 3 |
+
import os
|
| 4 |
+
import json
|
| 5 |
+
|
| 6 |
+
CONFIG_PATH = "config.json"
|
| 7 |
+
|
| 8 |
+
def load_config():
|
| 9 |
+
if os.path.exists(CONFIG_PATH):
|
| 10 |
+
with open(CONFIG_PATH) as f:
|
| 11 |
+
return json.load(f)
|
| 12 |
+
return {
|
| 13 |
+
"preprocessing": {
|
| 14 |
+
"clahe_clip": 3.0,
|
| 15 |
+
"clahe_grid": 8,
|
| 16 |
+
"bilateral_d": 9,
|
| 17 |
+
"bilateral_sigma": 75,
|
| 18 |
+
"adaptive_blocksize": 21,
|
| 19 |
+
"adaptive_c": 8
|
| 20 |
+
}
|
| 21 |
+
}
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
def correct_rotation(img):
|
| 25 |
+
gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) if len(img.shape) == 3 else img
|
| 26 |
+
edges = cv2.Canny(gray, 50, 150)
|
| 27 |
+
lines = cv2.HoughLinesP(edges, 1, np.pi/180, 50, minLineLength=30, maxLineGap=10)
|
| 28 |
+
if lines is None:
|
| 29 |
+
return img
|
| 30 |
+
angles = [np.degrees(np.arctan2(l[0][3]-l[0][1], l[0][2]-l[0][0])) for l in lines]
|
| 31 |
+
if abs(np.median(angles)) > 45:
|
| 32 |
+
img = cv2.rotate(img, cv2.ROTATE_90_CLOCKWISE)
|
| 33 |
+
return img
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
def preprocess_chassis(image_path, save_comparison=False, output_dir="results"):
|
| 37 |
+
config = load_config()
|
| 38 |
+
p = config["preprocessing"]
|
| 39 |
+
|
| 40 |
+
img = cv2.imread(image_path)
|
| 41 |
+
if img is None:
|
| 42 |
+
raise ValueError(f"Could not load image: {image_path}")
|
| 43 |
+
|
| 44 |
+
original = img.copy()
|
| 45 |
+
img = correct_rotation(img)
|
| 46 |
+
gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
|
| 47 |
+
|
| 48 |
+
clahe = cv2.createCLAHE(
|
| 49 |
+
clipLimit=p["clahe_clip"],
|
| 50 |
+
tileGridSize=(p["clahe_grid"], p["clahe_grid"])
|
| 51 |
+
)
|
| 52 |
+
enhanced = clahe.apply(gray)
|
| 53 |
+
|
| 54 |
+
v0 = cv2.cvtColor(enhanced, cv2.COLOR_GRAY2BGR)
|
| 55 |
+
|
| 56 |
+
filtered = cv2.bilateralFilter(enhanced, p["bilateral_d"],
|
| 57 |
+
p["bilateral_sigma"], p["bilateral_sigma"])
|
| 58 |
+
v1 = cv2.cvtColor(filtered, cv2.COLOR_GRAY2BGR)
|
| 59 |
+
|
| 60 |
+
_, otsu = cv2.threshold(filtered, 0, 255,
|
| 61 |
+
cv2.THRESH_BINARY + cv2.THRESH_OTSU)
|
| 62 |
+
v2 = cv2.cvtColor(otsu, cv2.COLOR_GRAY2BGR)
|
| 63 |
+
|
| 64 |
+
bs = p["adaptive_blocksize"]
|
| 65 |
+
bs = bs if bs % 2 == 1 else bs + 1
|
| 66 |
+
adaptive = cv2.adaptiveThreshold(
|
| 67 |
+
filtered, 255,
|
| 68 |
+
cv2.ADAPTIVE_THRESH_GAUSSIAN_C,
|
| 69 |
+
cv2.THRESH_BINARY,
|
| 70 |
+
blockSize=bs, C=p["adaptive_c"]
|
| 71 |
+
)
|
| 72 |
+
v3 = cv2.cvtColor(adaptive, cv2.COLOR_GRAY2BGR)
|
| 73 |
+
|
| 74 |
+
pad = p.get("padding", 20)
|
| 75 |
+
def add_padding(im):
|
| 76 |
+
return cv2.copyMakeBorder(im, pad, pad, pad, pad,
|
| 77 |
+
cv2.BORDER_CONSTANT, value=(255, 255, 255))
|
| 78 |
+
|
| 79 |
+
variations = [add_padding(v) for v in [v0, v1, v2, v3]]
|
| 80 |
+
|
| 81 |
+
if save_comparison:
|
| 82 |
+
os.makedirs(output_dir, exist_ok=True)
|
| 83 |
+
fname = os.path.splitext(os.path.basename(image_path))[0]
|
| 84 |
+
h = 200
|
| 85 |
+
|
| 86 |
+
def resize_h(im, height):
|
| 87 |
+
r = height / im.shape[0]
|
| 88 |
+
return cv2.resize(im, (int(im.shape[1] * r), height))
|
| 89 |
+
|
| 90 |
+
def add_label(im, label):
|
| 91 |
+
out = im.copy() if len(im.shape) == 3 else cv2.cvtColor(im, cv2.COLOR_GRAY2BGR)
|
| 92 |
+
cv2.putText(out, label, (10, 25), cv2.FONT_HERSHEY_SIMPLEX, 0.6, (0, 255, 0), 2)
|
| 93 |
+
return out
|
| 94 |
+
|
| 95 |
+
def to_bgr(p):
|
| 96 |
+
return p if len(p.shape) == 3 else cv2.cvtColor(p, cv2.COLOR_GRAY2BGR)
|
| 97 |
+
|
| 98 |
+
panels = [add_label(resize_h(cv2.cvtColor(original, cv2.COLOR_BGR2GRAY), h), "Original")]
|
| 99 |
+
labels = ["CLAHE", "Bilateral", "Otsu", "Adaptive"]
|
| 100 |
+
for i, var in enumerate(variations):
|
| 101 |
+
g = cv2.cvtColor(var, cv2.COLOR_BGR2GRAY)
|
| 102 |
+
panels.append(add_label(resize_h(g, h), labels[i]))
|
| 103 |
+
|
| 104 |
+
comparison = np.hstack([to_bgr(p) for p in panels])
|
| 105 |
+
cv2.imwrite(os.path.join(output_dir, f"{fname}_comparison.jpg"), comparison)
|
| 106 |
+
print(f" Saved comparison -> {output_dir}/{fname}_comparison.jpg")
|
| 107 |
+
|
| 108 |
+
return variations
|
| 109 |
+
|
| 110 |
+
|
| 111 |
+
if __name__ == "__main__":
|
| 112 |
+
import sys
|
| 113 |
+
if len(sys.argv) < 2:
|
| 114 |
+
print("Usage: python preprocess.py <image_path>")
|
| 115 |
+
else:
|
| 116 |
+
variations = preprocess_chassis(sys.argv[1], save_comparison=True)
|
| 117 |
+
print(f"Generated {len(variations)} variations — check results/")
|
requirements.txt
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
paddlepaddle
|
| 2 |
+
paddleocr
|
| 3 |
+
opencv-python
|
| 4 |
+
pyzbar
|
| 5 |
+
zxing-cpp
|
| 6 |
+
Pillow
|
| 7 |
+
numpy
|
| 8 |
+
tqdm
|
server.py
ADDED
|
@@ -0,0 +1,264 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
import json
|
| 3 |
+
import shutil
|
| 4 |
+
import base64
|
| 5 |
+
import uuid
|
| 6 |
+
import cv2
|
| 7 |
+
import numpy as np
|
| 8 |
+
from fastapi import FastAPI, UploadFile, File, Form, HTTPException, BackgroundTasks
|
| 9 |
+
from fastapi.responses import FileResponse, JSONResponse
|
| 10 |
+
from fastapi.staticfiles import StaticFiles
|
| 11 |
+
from fastapi.middleware.cors import CORSMiddleware
|
| 12 |
+
|
| 13 |
+
# Import existing functions
|
| 14 |
+
from barcode_scanner import scan_barcode, scan_all_barcodes
|
| 15 |
+
from ocr import read_chassis, postprocess_with_hint, ocr_image
|
| 16 |
+
from preprocess import preprocess_chassis
|
| 17 |
+
from evaluate import evaluate, get_pairs
|
| 18 |
+
|
| 19 |
+
app = FastAPI(title="Chassis OCR API", description="API backend for Chassis OCR PWA")
|
| 20 |
+
|
| 21 |
+
# CORS middleware for testing
|
| 22 |
+
app.add_middleware(
|
| 23 |
+
CORSMiddleware,
|
| 24 |
+
allow_origins=["*"],
|
| 25 |
+
allow_credentials=True,
|
| 26 |
+
allow_methods=["*"],
|
| 27 |
+
allow_headers=["*"],
|
| 28 |
+
)
|
| 29 |
+
|
| 30 |
+
TEMP_DIR = "temp_uploads"
|
| 31 |
+
RESULTS_DIR = "results"
|
| 32 |
+
CONFIG_PATH = "config.json"
|
| 33 |
+
os.makedirs(TEMP_DIR, exist_ok=True)
|
| 34 |
+
os.makedirs(RESULTS_DIR, exist_ok=True)
|
| 35 |
+
|
| 36 |
+
# Helper to convert cv2 image to base64 jpeg
|
| 37 |
+
def cv2_to_base64(img):
|
| 38 |
+
_, buffer = cv2.imencode('.jpg', img)
|
| 39 |
+
return base64.b64encode(buffer).decode('utf-8')
|
| 40 |
+
|
| 41 |
+
@app.get("/api/status")
|
| 42 |
+
def get_status():
|
| 43 |
+
return {
|
| 44 |
+
"status": "online",
|
| 45 |
+
"message": "OCR Backend is active"
|
| 46 |
+
}
|
| 47 |
+
|
| 48 |
+
@app.get("/api/test-pairs")
|
| 49 |
+
def get_test_pairs():
|
| 50 |
+
barcode_dir = "images/barcode"
|
| 51 |
+
chassis_dir = "images/chassis"
|
| 52 |
+
if not os.path.exists(barcode_dir) or not os.path.exists(chassis_dir):
|
| 53 |
+
return []
|
| 54 |
+
|
| 55 |
+
barcodes = {os.path.splitext(f)[0] for f in os.listdir(barcode_dir) if f.lower().endswith(('.jpg', '.jpeg', '.png'))}
|
| 56 |
+
chassis = {os.path.splitext(f)[0] for f in os.listdir(chassis_dir) if f.lower().endswith(('.jpg', '.jpeg', '.png'))}
|
| 57 |
+
common = sorted(list(barcodes & chassis))
|
| 58 |
+
return common
|
| 59 |
+
|
| 60 |
+
@app.get("/api/scan-barcode/{key}")
|
| 61 |
+
def scan_barcode_by_key(key: str):
|
| 62 |
+
"""Scan a barcode image from the dataset by its key (filename without extension)."""
|
| 63 |
+
for ext in ['.jpg', '.jpeg', '.png', '.JPG', '.PNG']:
|
| 64 |
+
path = os.path.join("images/barcode", f"{key}{ext}")
|
| 65 |
+
if os.path.exists(path):
|
| 66 |
+
result = scan_barcode(path)
|
| 67 |
+
return {"success": result is not None, "barcode": result}
|
| 68 |
+
raise HTTPException(status_code=404, detail=f"Barcode image for key '{key}' not found")
|
| 69 |
+
|
| 70 |
+
|
| 71 |
+
@app.post("/api/scan-barcode")
|
| 72 |
+
async def api_scan_barcode(file: UploadFile = File(...)):
|
| 73 |
+
temp_filename = f"{uuid.uuid4()}_{file.filename}"
|
| 74 |
+
temp_path = os.path.join(TEMP_DIR, temp_filename)
|
| 75 |
+
try:
|
| 76 |
+
with open(temp_path, "wb") as buffer:
|
| 77 |
+
shutil.copyfileobj(file.file, buffer)
|
| 78 |
+
|
| 79 |
+
result = scan_barcode(temp_path)
|
| 80 |
+
return {"success": result is not None, "barcode": result}
|
| 81 |
+
except Exception as e:
|
| 82 |
+
raise HTTPException(status_code=500, detail=str(e))
|
| 83 |
+
finally:
|
| 84 |
+
if os.path.exists(temp_path):
|
| 85 |
+
os.remove(temp_path)
|
| 86 |
+
|
| 87 |
+
@app.get("/api/config")
|
| 88 |
+
def get_config():
|
| 89 |
+
if os.path.exists(CONFIG_PATH):
|
| 90 |
+
with open(CONFIG_PATH) as f:
|
| 91 |
+
return json.load(f)
|
| 92 |
+
return {}
|
| 93 |
+
|
| 94 |
+
@app.post("/api/config")
|
| 95 |
+
async def save_config(config_data: dict):
|
| 96 |
+
try:
|
| 97 |
+
with open(CONFIG_PATH, "w") as f:
|
| 98 |
+
json.dump(config_data, f, indent=2)
|
| 99 |
+
return {"status": "success", "message": "Configuration updated successfully"}
|
| 100 |
+
except Exception as e:
|
| 101 |
+
raise HTTPException(status_code=500, detail=str(e))
|
| 102 |
+
|
| 103 |
+
# Global state to keep track of batch evaluation runs
|
| 104 |
+
eval_status = {"running": False, "progress": 0, "total": 0, "results": []}
|
| 105 |
+
|
| 106 |
+
def run_evaluation_task():
|
| 107 |
+
global eval_status
|
| 108 |
+
try:
|
| 109 |
+
eval_status["running"] = True
|
| 110 |
+
eval_status["progress"] = 0
|
| 111 |
+
|
| 112 |
+
# We can call the evaluate function but let's read the report afterwards
|
| 113 |
+
evaluate()
|
| 114 |
+
|
| 115 |
+
report_path = os.path.join(RESULTS_DIR, "report.json")
|
| 116 |
+
if os.path.exists(report_path):
|
| 117 |
+
with open(report_path) as f:
|
| 118 |
+
eval_status["results"] = json.load(f)
|
| 119 |
+
eval_status["progress"] = len(eval_status["results"])
|
| 120 |
+
eval_status["total"] = len(eval_status["results"])
|
| 121 |
+
except Exception as e:
|
| 122 |
+
print(f"Error in evaluation background task: {e}")
|
| 123 |
+
finally:
|
| 124 |
+
eval_status["running"] = False
|
| 125 |
+
|
| 126 |
+
@app.post("/api/evaluate")
|
| 127 |
+
def trigger_evaluation(background_tasks: BackgroundTasks):
|
| 128 |
+
global eval_status
|
| 129 |
+
if eval_status["running"]:
|
| 130 |
+
return {"status": "already_running", "message": "Evaluation task is currently running"}
|
| 131 |
+
|
| 132 |
+
eval_status = {"running": True, "progress": 0, "total": 50, "results": []}
|
| 133 |
+
background_tasks.add_task(run_evaluation_task)
|
| 134 |
+
return {"status": "started", "message": "Batch evaluation started in the background"}
|
| 135 |
+
|
| 136 |
+
@app.get("/api/evaluate/status")
|
| 137 |
+
def get_evaluation_status():
|
| 138 |
+
report_path = os.path.join(RESULTS_DIR, "report.json")
|
| 139 |
+
results = []
|
| 140 |
+
if os.path.exists(report_path):
|
| 141 |
+
try:
|
| 142 |
+
with open(report_path) as f:
|
| 143 |
+
results = json.load(f)
|
| 144 |
+
except Exception:
|
| 145 |
+
pass
|
| 146 |
+
|
| 147 |
+
return {
|
| 148 |
+
"running": eval_status["running"],
|
| 149 |
+
"progress": eval_status["progress"],
|
| 150 |
+
"total": eval_status["total"],
|
| 151 |
+
"has_existing_report": len(results) > 0,
|
| 152 |
+
"results": results if not eval_status["running"] else eval_status["results"]
|
| 153 |
+
}
|
| 154 |
+
|
| 155 |
+
@app.post("/api/match")
|
| 156 |
+
async def match_chassis(
|
| 157 |
+
barcode_val: str = Form(...),
|
| 158 |
+
chassis_file: UploadFile = File(None),
|
| 159 |
+
chassis_key: str = Form(None)
|
| 160 |
+
):
|
| 161 |
+
if not chassis_file and not chassis_key:
|
| 162 |
+
raise HTTPException(status_code=400, detail="Either chassis_file or chassis_key must be provided")
|
| 163 |
+
|
| 164 |
+
chassis_path = None
|
| 165 |
+
temp_path = None
|
| 166 |
+
|
| 167 |
+
if chassis_key:
|
| 168 |
+
# Load from test set
|
| 169 |
+
# Check standard extensions (.jpg, .png, etc.)
|
| 170 |
+
for ext in ['.jpg', '.jpeg', '.png', '.JPG', '.PNG']:
|
| 171 |
+
p = os.path.join("images/chassis", f"{chassis_key}{ext}")
|
| 172 |
+
if os.path.exists(p):
|
| 173 |
+
chassis_path = p
|
| 174 |
+
break
|
| 175 |
+
if not chassis_path:
|
| 176 |
+
raise HTTPException(status_code=404, detail=f"Chassis image for key '{chassis_key}' not found in images/chassis")
|
| 177 |
+
else:
|
| 178 |
+
# Save uploaded file
|
| 179 |
+
temp_filename = f"{uuid.uuid4()}_{chassis_file.filename}"
|
| 180 |
+
temp_path = os.path.join(TEMP_DIR, temp_filename)
|
| 181 |
+
with open(temp_path, "wb") as buffer:
|
| 182 |
+
shutil.copyfileobj(chassis_file.file, buffer)
|
| 183 |
+
chassis_path = temp_path
|
| 184 |
+
|
| 185 |
+
try:
|
| 186 |
+
# 1. Run Preprocessing to get variations
|
| 187 |
+
# Use save_comparison=True so we also write the side-by-side view to results/ (useful for viewing static file later)
|
| 188 |
+
# Note: if it's a temp file, let's create a friendly name for results comparison
|
| 189 |
+
save_comp = True
|
| 190 |
+
comp_filename = chassis_key if chassis_key else os.path.splitext(chassis_file.filename)[0]
|
| 191 |
+
|
| 192 |
+
variations = preprocess_chassis(chassis_path, save_comparison=save_comp)
|
| 193 |
+
|
| 194 |
+
# 2. Get base64 representation of original and each variation
|
| 195 |
+
original_img = cv2.imread(chassis_path)
|
| 196 |
+
base64_original = cv2_to_base64(original_img)
|
| 197 |
+
|
| 198 |
+
base64_variations = []
|
| 199 |
+
labels = ["CLAHE", "Bilateral", "Otsu", "Adaptive"]
|
| 200 |
+
for idx, var in enumerate(variations):
|
| 201 |
+
base64_variations.append({
|
| 202 |
+
"label": labels[idx],
|
| 203 |
+
"base64": cv2_to_base64(var)
|
| 204 |
+
})
|
| 205 |
+
|
| 206 |
+
# 3. Run OCR on each variation and compute scores, finding the best
|
| 207 |
+
best_text, best_conf, best_score = "", 0.0, -1
|
| 208 |
+
winning_label = ""
|
| 209 |
+
variation_details = []
|
| 210 |
+
|
| 211 |
+
for idx, var in enumerate(variations):
|
| 212 |
+
text, conf = ocr_image(var)
|
| 213 |
+
score = conf * max(len(text), 1)
|
| 214 |
+
variation_details.append({
|
| 215 |
+
"label": labels[idx],
|
| 216 |
+
"text": text,
|
| 217 |
+
"confidence": conf,
|
| 218 |
+
"score": score
|
| 219 |
+
})
|
| 220 |
+
if score > best_score:
|
| 221 |
+
best_text, best_conf, best_score = text, conf, score
|
| 222 |
+
winning_label = labels[idx]
|
| 223 |
+
|
| 224 |
+
# 4. Perform error correction and match check
|
| 225 |
+
corrected_text, is_match = postprocess_with_hint(best_text, barcode_val)
|
| 226 |
+
|
| 227 |
+
status = "FAILED"
|
| 228 |
+
if best_text == barcode_val:
|
| 229 |
+
status = "EXACT"
|
| 230 |
+
elif is_match:
|
| 231 |
+
status = "CORRECTED"
|
| 232 |
+
|
| 233 |
+
response_data = {
|
| 234 |
+
"success": is_match,
|
| 235 |
+
"status": status,
|
| 236 |
+
"barcode_val": barcode_val,
|
| 237 |
+
"raw_ocr": best_text,
|
| 238 |
+
"corrected_ocr": corrected_text,
|
| 239 |
+
"confidence": best_conf,
|
| 240 |
+
"winning_label": winning_label,
|
| 241 |
+
"variations": base64_variations,
|
| 242 |
+
"original": base64_original,
|
| 243 |
+
"variation_details": variation_details,
|
| 244 |
+
"comparison_url": f"/results/{comp_filename}_comparison.jpg" if save_comp else None
|
| 245 |
+
}
|
| 246 |
+
return response_data
|
| 247 |
+
|
| 248 |
+
except Exception as e:
|
| 249 |
+
raise HTTPException(status_code=500, detail=str(e))
|
| 250 |
+
finally:
|
| 251 |
+
if temp_path and os.path.exists(temp_path):
|
| 252 |
+
os.remove(temp_path)
|
| 253 |
+
|
| 254 |
+
# Serve results images directly
|
| 255 |
+
app.mount("/results", StaticFiles(directory="results"), name="results")
|
| 256 |
+
|
| 257 |
+
# Serve frontend application static files
|
| 258 |
+
# We will mount at "/" with html=True so index.html is served automatically
|
| 259 |
+
# Make sure to run this *after* route declarations
|
| 260 |
+
app.mount("/", StaticFiles(directory="web", html=True), name="static")
|
| 261 |
+
|
| 262 |
+
if __name__ == "__main__":
|
| 263 |
+
import uvicorn
|
| 264 |
+
uvicorn.run("server:app", host="0.0.0.0", port=8000, reload=True)
|
setup.bat
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
pip install paddlepaddle paddleocr opencv-python pyzbar Pillow numpy tqdm
|
test_speed.py
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import time
|
| 2 |
+
from ocr import read_chassis
|
| 3 |
+
|
| 4 |
+
start = time.time()
|
| 5 |
+
text, conf = read_chassis("images/chassis/26995.jpg")
|
| 6 |
+
end = time.time()
|
| 7 |
+
print(f"First run : {text} | {end - start:.2f}s")
|
| 8 |
+
|
| 9 |
+
start = time.time()
|
| 10 |
+
text, conf = read_chassis("images/chassis/26995.jpg")
|
| 11 |
+
end = time.time()
|
| 12 |
+
print(f"Second run : {text} | {end - start:.2f}s")
|
web/app.css
ADDED
|
@@ -0,0 +1,609 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/* ==========================================================================
|
| 2 |
+
Chassis OCR — Clean Light UI
|
| 3 |
+
========================================================================== */
|
| 4 |
+
|
| 5 |
+
:root {
|
| 6 |
+
--blue: #2563eb;
|
| 7 |
+
--blue-light: #eff6ff;
|
| 8 |
+
--blue-mid: #dbeafe;
|
| 9 |
+
--green: #16a34a;
|
| 10 |
+
--green-light: #f0fdf4;
|
| 11 |
+
--amber: #d97706;
|
| 12 |
+
--amber-light: #fffbeb;
|
| 13 |
+
--red: #dc2626;
|
| 14 |
+
--red-light: #fef2f2;
|
| 15 |
+
|
| 16 |
+
--text: #111827;
|
| 17 |
+
--text-2: #6b7280;
|
| 18 |
+
--text-3: #9ca3af;
|
| 19 |
+
--border: #e5e7eb;
|
| 20 |
+
--bg: #f9fafb;
|
| 21 |
+
--surface: #ffffff;
|
| 22 |
+
--shadow: 0 1px 3px rgba(0,0,0,0.08), 0 1px 2px rgba(0,0,0,0.05);
|
| 23 |
+
--shadow-md: 0 4px 6px -1px rgba(0,0,0,0.08), 0 2px 4px -2px rgba(0,0,0,0.05);
|
| 24 |
+
|
| 25 |
+
--radius: 8px;
|
| 26 |
+
--font: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
|
| 27 |
+
}
|
| 28 |
+
|
| 29 |
+
* { box-sizing: border-box; margin: 0; padding: 0; }
|
| 30 |
+
|
| 31 |
+
body {
|
| 32 |
+
font-family: var(--font);
|
| 33 |
+
font-size: 14px;
|
| 34 |
+
color: var(--text);
|
| 35 |
+
background: var(--bg);
|
| 36 |
+
line-height: 1.5;
|
| 37 |
+
-webkit-font-smoothing: antialiased;
|
| 38 |
+
}
|
| 39 |
+
|
| 40 |
+
/* ── Header ────────────────────────────────────────────── */
|
| 41 |
+
.header {
|
| 42 |
+
background: var(--surface);
|
| 43 |
+
border-bottom: 1px solid var(--border);
|
| 44 |
+
position: sticky;
|
| 45 |
+
top: 0;
|
| 46 |
+
z-index: 100;
|
| 47 |
+
}
|
| 48 |
+
.header-inner {
|
| 49 |
+
max-width: 1100px;
|
| 50 |
+
margin: 0 auto;
|
| 51 |
+
padding: 0 20px;
|
| 52 |
+
height: 52px;
|
| 53 |
+
display: flex;
|
| 54 |
+
align-items: center;
|
| 55 |
+
justify-content: space-between;
|
| 56 |
+
}
|
| 57 |
+
.header-brand {
|
| 58 |
+
display: flex;
|
| 59 |
+
align-items: center;
|
| 60 |
+
gap: 10px;
|
| 61 |
+
font-weight: 600;
|
| 62 |
+
font-size: 15px;
|
| 63 |
+
color: var(--text);
|
| 64 |
+
}
|
| 65 |
+
.header-brand svg { color: var(--blue); }
|
| 66 |
+
.header-right { display: flex; align-items: center; gap: 12px; }
|
| 67 |
+
|
| 68 |
+
.status-dot {
|
| 69 |
+
width: 8px;
|
| 70 |
+
height: 8px;
|
| 71 |
+
border-radius: 50%;
|
| 72 |
+
display: inline-block;
|
| 73 |
+
}
|
| 74 |
+
.status-dot.online { background: var(--green); }
|
| 75 |
+
.status-dot.offline { background: var(--red); }
|
| 76 |
+
|
| 77 |
+
.btn-install {
|
| 78 |
+
font-family: var(--font);
|
| 79 |
+
font-size: 13px;
|
| 80 |
+
font-weight: 500;
|
| 81 |
+
color: var(--blue);
|
| 82 |
+
background: var(--blue-light);
|
| 83 |
+
border: 1px solid var(--blue-mid);
|
| 84 |
+
padding: 5px 12px;
|
| 85 |
+
border-radius: var(--radius);
|
| 86 |
+
cursor: pointer;
|
| 87 |
+
}
|
| 88 |
+
|
| 89 |
+
/* ── Tab Nav ───────────────────────────────────────────── */
|
| 90 |
+
.tab-nav {
|
| 91 |
+
background: var(--surface);
|
| 92 |
+
border-bottom: 1px solid var(--border);
|
| 93 |
+
display: flex;
|
| 94 |
+
gap: 0;
|
| 95 |
+
padding: 0 20px;
|
| 96 |
+
max-width: 100%;
|
| 97 |
+
overflow-x: auto;
|
| 98 |
+
}
|
| 99 |
+
.tab-btn {
|
| 100 |
+
font-family: var(--font);
|
| 101 |
+
font-size: 14px;
|
| 102 |
+
font-weight: 500;
|
| 103 |
+
color: var(--text-2);
|
| 104 |
+
background: none;
|
| 105 |
+
border: none;
|
| 106 |
+
border-bottom: 2px solid transparent;
|
| 107 |
+
padding: 12px 16px;
|
| 108 |
+
cursor: pointer;
|
| 109 |
+
white-space: nowrap;
|
| 110 |
+
transition: color 0.15s, border-color 0.15s;
|
| 111 |
+
}
|
| 112 |
+
.tab-btn:hover { color: var(--text); }
|
| 113 |
+
.tab-btn.active {
|
| 114 |
+
color: var(--blue);
|
| 115 |
+
border-bottom-color: var(--blue);
|
| 116 |
+
}
|
| 117 |
+
|
| 118 |
+
/* ── Main Layout ───────────────────────────────────────── */
|
| 119 |
+
.main {
|
| 120 |
+
max-width: 1100px;
|
| 121 |
+
margin: 0 auto;
|
| 122 |
+
padding: 24px 20px;
|
| 123 |
+
}
|
| 124 |
+
|
| 125 |
+
.tab-panel { display: none; }
|
| 126 |
+
.tab-panel.active { display: block; }
|
| 127 |
+
|
| 128 |
+
.two-col {
|
| 129 |
+
display: grid;
|
| 130 |
+
grid-template-columns: 1fr 1fr;
|
| 131 |
+
gap: 20px;
|
| 132 |
+
align-items: start;
|
| 133 |
+
}
|
| 134 |
+
@media (max-width: 768px) {
|
| 135 |
+
.two-col { grid-template-columns: 1fr; }
|
| 136 |
+
}
|
| 137 |
+
|
| 138 |
+
/* ── Panel ─────────────────────────────────────────────── */
|
| 139 |
+
.panel {
|
| 140 |
+
background: var(--surface);
|
| 141 |
+
border: 1px solid var(--border);
|
| 142 |
+
border-radius: var(--radius);
|
| 143 |
+
box-shadow: var(--shadow);
|
| 144 |
+
}
|
| 145 |
+
.panel-header {
|
| 146 |
+
padding: 16px 20px;
|
| 147 |
+
border-bottom: 1px solid var(--border);
|
| 148 |
+
}
|
| 149 |
+
.panel-header h2 {
|
| 150 |
+
font-size: 15px;
|
| 151 |
+
font-weight: 600;
|
| 152 |
+
}
|
| 153 |
+
.panel-desc {
|
| 154 |
+
color: var(--text-2);
|
| 155 |
+
font-size: 13px;
|
| 156 |
+
margin-top: 2px;
|
| 157 |
+
}
|
| 158 |
+
.panel-body {
|
| 159 |
+
padding: 20px;
|
| 160 |
+
display: flex;
|
| 161 |
+
flex-direction: column;
|
| 162 |
+
gap: 16px;
|
| 163 |
+
}
|
| 164 |
+
|
| 165 |
+
/* ── Form Fields ───────────────────────────────────────── */
|
| 166 |
+
.field {
|
| 167 |
+
display: flex;
|
| 168 |
+
flex-direction: column;
|
| 169 |
+
gap: 6px;
|
| 170 |
+
}
|
| 171 |
+
label {
|
| 172 |
+
font-size: 13px;
|
| 173 |
+
font-weight: 500;
|
| 174 |
+
color: var(--text-2);
|
| 175 |
+
}
|
| 176 |
+
.input, .select {
|
| 177 |
+
font-family: var(--font);
|
| 178 |
+
font-size: 14px;
|
| 179 |
+
color: var(--text);
|
| 180 |
+
background: var(--surface);
|
| 181 |
+
border: 1px solid var(--border);
|
| 182 |
+
border-radius: var(--radius);
|
| 183 |
+
padding: 8px 12px;
|
| 184 |
+
outline: none;
|
| 185 |
+
transition: border-color 0.15s;
|
| 186 |
+
width: 100%;
|
| 187 |
+
}
|
| 188 |
+
.input:focus, .select:focus {
|
| 189 |
+
border-color: var(--blue);
|
| 190 |
+
}
|
| 191 |
+
|
| 192 |
+
/* ── Segmented Control ─────────────────────────────────── */
|
| 193 |
+
.seg-control {
|
| 194 |
+
display: flex;
|
| 195 |
+
background: var(--bg);
|
| 196 |
+
border: 1px solid var(--border);
|
| 197 |
+
border-radius: var(--radius);
|
| 198 |
+
padding: 3px;
|
| 199 |
+
gap: 2px;
|
| 200 |
+
}
|
| 201 |
+
.seg-btn {
|
| 202 |
+
flex: 1;
|
| 203 |
+
font-family: var(--font);
|
| 204 |
+
font-size: 13px;
|
| 205 |
+
font-weight: 500;
|
| 206 |
+
color: var(--text-2);
|
| 207 |
+
background: transparent;
|
| 208 |
+
border: none;
|
| 209 |
+
padding: 6px 10px;
|
| 210 |
+
border-radius: 6px;
|
| 211 |
+
cursor: pointer;
|
| 212 |
+
transition: all 0.15s;
|
| 213 |
+
}
|
| 214 |
+
.seg-btn.active {
|
| 215 |
+
background: var(--surface);
|
| 216 |
+
color: var(--text);
|
| 217 |
+
box-shadow: var(--shadow);
|
| 218 |
+
}
|
| 219 |
+
|
| 220 |
+
/* ── Dropzones ─────────────────────────────────────────── */
|
| 221 |
+
.dropzone {
|
| 222 |
+
border: 1px dashed var(--border);
|
| 223 |
+
border-radius: var(--radius);
|
| 224 |
+
padding: 20px;
|
| 225 |
+
display: flex;
|
| 226 |
+
flex-direction: column;
|
| 227 |
+
align-items: center;
|
| 228 |
+
gap: 8px;
|
| 229 |
+
cursor: pointer;
|
| 230 |
+
transition: border-color 0.15s, background 0.15s;
|
| 231 |
+
background: var(--bg);
|
| 232 |
+
text-align: center;
|
| 233 |
+
}
|
| 234 |
+
.dropzone:hover {
|
| 235 |
+
border-color: var(--blue);
|
| 236 |
+
background: var(--blue-light);
|
| 237 |
+
}
|
| 238 |
+
.dropzone svg { color: var(--text-3); }
|
| 239 |
+
.dropzone span { font-size: 13px; color: var(--text-2); }
|
| 240 |
+
|
| 241 |
+
/* ── Camera ────────────────────────────────────────────── */
|
| 242 |
+
.camera-step {
|
| 243 |
+
border: 1px solid var(--border);
|
| 244 |
+
border-radius: var(--radius);
|
| 245 |
+
padding: 14px;
|
| 246 |
+
display: flex;
|
| 247 |
+
flex-direction: column;
|
| 248 |
+
gap: 10px;
|
| 249 |
+
}
|
| 250 |
+
.camera-step.disabled { opacity: 0.4; pointer-events: none; }
|
| 251 |
+
.step-label {
|
| 252 |
+
font-size: 12px;
|
| 253 |
+
font-weight: 600;
|
| 254 |
+
color: var(--text-2);
|
| 255 |
+
text-transform: uppercase;
|
| 256 |
+
letter-spacing: 0.5px;
|
| 257 |
+
}
|
| 258 |
+
.cam-view {
|
| 259 |
+
width: 100%;
|
| 260 |
+
min-height: 160px;
|
| 261 |
+
background: #000;
|
| 262 |
+
border-radius: 6px;
|
| 263 |
+
overflow: hidden;
|
| 264 |
+
}
|
| 265 |
+
.snap-preview {
|
| 266 |
+
width: 100%;
|
| 267 |
+
border-radius: 6px;
|
| 268 |
+
display: block;
|
| 269 |
+
}
|
| 270 |
+
.cam-actions {
|
| 271 |
+
display: flex;
|
| 272 |
+
gap: 8px;
|
| 273 |
+
}
|
| 274 |
+
|
| 275 |
+
/* ── Buttons ───────────────────────────────────────────── */
|
| 276 |
+
.btn {
|
| 277 |
+
display: inline-flex;
|
| 278 |
+
align-items: center;
|
| 279 |
+
justify-content: center;
|
| 280 |
+
gap: 6px;
|
| 281 |
+
font-family: var(--font);
|
| 282 |
+
font-size: 14px;
|
| 283 |
+
font-weight: 500;
|
| 284 |
+
border-radius: var(--radius);
|
| 285 |
+
padding: 8px 16px;
|
| 286 |
+
cursor: pointer;
|
| 287 |
+
border: none;
|
| 288 |
+
transition: all 0.15s;
|
| 289 |
+
}
|
| 290 |
+
.btn-primary {
|
| 291 |
+
background: var(--blue);
|
| 292 |
+
color: #fff;
|
| 293 |
+
}
|
| 294 |
+
.btn-primary:hover:not(:disabled) { background: #1d4ed8; }
|
| 295 |
+
.btn-primary:disabled { opacity: 0.4; cursor: not-allowed; }
|
| 296 |
+
.btn-secondary {
|
| 297 |
+
background: var(--surface);
|
| 298 |
+
color: var(--text);
|
| 299 |
+
border: 1px solid var(--border);
|
| 300 |
+
}
|
| 301 |
+
.btn-secondary:hover:not(:disabled) { background: var(--bg); }
|
| 302 |
+
.btn-secondary:disabled { opacity: 0.4; cursor: not-allowed; }
|
| 303 |
+
.btn-ghost {
|
| 304 |
+
background: transparent;
|
| 305 |
+
color: var(--text-2);
|
| 306 |
+
border: 1px solid var(--border);
|
| 307 |
+
}
|
| 308 |
+
.btn-ghost:hover { background: var(--bg); }
|
| 309 |
+
.btn-full { width: 100%; }
|
| 310 |
+
|
| 311 |
+
/* ── Spinner ───────────────────────────────────────────── */
|
| 312 |
+
.spinner {
|
| 313 |
+
width: 14px;
|
| 314 |
+
height: 14px;
|
| 315 |
+
border: 2px solid rgba(255,255,255,0.3);
|
| 316 |
+
border-top-color: currentColor;
|
| 317 |
+
border-radius: 50%;
|
| 318 |
+
animation: spin 0.7s linear infinite;
|
| 319 |
+
display: inline-block;
|
| 320 |
+
}
|
| 321 |
+
@keyframes spin { to { transform: rotate(360deg); } }
|
| 322 |
+
|
| 323 |
+
/* ── Empty State ───────────────────────────────────────── */
|
| 324 |
+
.empty-state {
|
| 325 |
+
display: flex;
|
| 326 |
+
flex-direction: column;
|
| 327 |
+
align-items: center;
|
| 328 |
+
gap: 12px;
|
| 329 |
+
padding: 48px 20px;
|
| 330 |
+
text-align: center;
|
| 331 |
+
color: var(--text-3);
|
| 332 |
+
}
|
| 333 |
+
.empty-state p { font-size: 13px; max-width: 260px; }
|
| 334 |
+
|
| 335 |
+
/* ── Result Banner ─────────────────────────────────────── */
|
| 336 |
+
.result-banner {
|
| 337 |
+
display: flex;
|
| 338 |
+
align-items: center;
|
| 339 |
+
gap: 12px;
|
| 340 |
+
padding: 12px 16px;
|
| 341 |
+
border-radius: var(--radius);
|
| 342 |
+
margin-bottom: 16px;
|
| 343 |
+
border: 1px solid;
|
| 344 |
+
}
|
| 345 |
+
.result-banner.EXACT {
|
| 346 |
+
background: var(--green-light);
|
| 347 |
+
border-color: #bbf7d0;
|
| 348 |
+
}
|
| 349 |
+
.result-banner.CORRECTED {
|
| 350 |
+
background: var(--amber-light);
|
| 351 |
+
border-color: #fde68a;
|
| 352 |
+
}
|
| 353 |
+
.result-banner.FAILED {
|
| 354 |
+
background: var(--red-light);
|
| 355 |
+
border-color: #fecaca;
|
| 356 |
+
}
|
| 357 |
+
.banner-title {
|
| 358 |
+
font-size: 14px;
|
| 359 |
+
font-weight: 600;
|
| 360 |
+
}
|
| 361 |
+
.EXACT .banner-title { color: var(--green); }
|
| 362 |
+
.CORRECTED .banner-title { color: var(--amber); }
|
| 363 |
+
.FAILED .banner-title { color: var(--red); }
|
| 364 |
+
.banner-sub {
|
| 365 |
+
font-size: 12px;
|
| 366 |
+
color: var(--text-2);
|
| 367 |
+
margin-top: 1px;
|
| 368 |
+
}
|
| 369 |
+
|
| 370 |
+
/* ── Result Table ──────────────────────────────────────── */
|
| 371 |
+
.result-table {
|
| 372 |
+
width: 100%;
|
| 373 |
+
border-collapse: collapse;
|
| 374 |
+
margin-bottom: 16px;
|
| 375 |
+
}
|
| 376 |
+
.result-table td {
|
| 377 |
+
padding: 8px 0;
|
| 378 |
+
border-bottom: 1px solid var(--border);
|
| 379 |
+
vertical-align: top;
|
| 380 |
+
}
|
| 381 |
+
.result-table tr:last-child td { border-bottom: none; }
|
| 382 |
+
.rt-label {
|
| 383 |
+
font-size: 12px;
|
| 384 |
+
font-weight: 500;
|
| 385 |
+
color: var(--text-2);
|
| 386 |
+
width: 40%;
|
| 387 |
+
padding-right: 12px;
|
| 388 |
+
}
|
| 389 |
+
.rt-value { font-size: 14px; }
|
| 390 |
+
.mono { font-family: 'Courier New', monospace; letter-spacing: 0.5px; }
|
| 391 |
+
|
| 392 |
+
/* ── Preprocessing Tabs ─────────────────────────────────── */
|
| 393 |
+
.section-title {
|
| 394 |
+
font-size: 12px;
|
| 395 |
+
font-weight: 600;
|
| 396 |
+
color: var(--text-2);
|
| 397 |
+
text-transform: uppercase;
|
| 398 |
+
letter-spacing: 0.5px;
|
| 399 |
+
margin-bottom: 8px;
|
| 400 |
+
}
|
| 401 |
+
.pre-tabs {
|
| 402 |
+
display: flex;
|
| 403 |
+
gap: 4px;
|
| 404 |
+
overflow-x: auto;
|
| 405 |
+
margin-bottom: 10px;
|
| 406 |
+
}
|
| 407 |
+
.pre-tab {
|
| 408 |
+
font-family: var(--font);
|
| 409 |
+
font-size: 12px;
|
| 410 |
+
font-weight: 500;
|
| 411 |
+
color: var(--text-2);
|
| 412 |
+
background: var(--bg);
|
| 413 |
+
border: 1px solid var(--border);
|
| 414 |
+
padding: 4px 10px;
|
| 415 |
+
border-radius: 20px;
|
| 416 |
+
cursor: pointer;
|
| 417 |
+
white-space: nowrap;
|
| 418 |
+
transition: all 0.15s;
|
| 419 |
+
}
|
| 420 |
+
.pre-tab:hover { color: var(--text); }
|
| 421 |
+
.pre-tab.active {
|
| 422 |
+
background: var(--blue);
|
| 423 |
+
color: #fff;
|
| 424 |
+
border-color: var(--blue);
|
| 425 |
+
}
|
| 426 |
+
.pre-img-wrap {
|
| 427 |
+
border: 1px solid var(--border);
|
| 428 |
+
border-radius: var(--radius);
|
| 429 |
+
overflow: hidden;
|
| 430 |
+
background: #f3f4f6;
|
| 431 |
+
position: relative;
|
| 432 |
+
}
|
| 433 |
+
.pre-img-wrap img {
|
| 434 |
+
width: 100%;
|
| 435 |
+
display: block;
|
| 436 |
+
max-height: 240px;
|
| 437 |
+
object-fit: contain;
|
| 438 |
+
background: #000;
|
| 439 |
+
}
|
| 440 |
+
.pre-ocr-label {
|
| 441 |
+
padding: 8px 12px;
|
| 442 |
+
font-size: 12px;
|
| 443 |
+
color: var(--text-2);
|
| 444 |
+
border-top: 1px solid var(--border);
|
| 445 |
+
background: var(--surface);
|
| 446 |
+
}
|
| 447 |
+
|
| 448 |
+
/* ── Batch Evaluation ──────────────────────────────────── */
|
| 449 |
+
.progress-row {
|
| 450 |
+
display: flex;
|
| 451 |
+
justify-content: space-between;
|
| 452 |
+
font-size: 13px;
|
| 453 |
+
color: var(--text-2);
|
| 454 |
+
margin-bottom: 6px;
|
| 455 |
+
}
|
| 456 |
+
.progress-track {
|
| 457 |
+
height: 6px;
|
| 458 |
+
background: var(--bg);
|
| 459 |
+
border-radius: 3px;
|
| 460 |
+
border: 1px solid var(--border);
|
| 461 |
+
overflow: hidden;
|
| 462 |
+
}
|
| 463 |
+
.progress-fill {
|
| 464 |
+
height: 100%;
|
| 465 |
+
background: var(--blue);
|
| 466 |
+
border-radius: 3px;
|
| 467 |
+
transition: width 0.3s;
|
| 468 |
+
}
|
| 469 |
+
|
| 470 |
+
.stats-grid {
|
| 471 |
+
display: grid;
|
| 472 |
+
grid-template-columns: repeat(4, 1fr);
|
| 473 |
+
gap: 12px;
|
| 474 |
+
}
|
| 475 |
+
@media (max-width: 600px) {
|
| 476 |
+
.stats-grid { grid-template-columns: 1fr 1fr; }
|
| 477 |
+
}
|
| 478 |
+
.stat-card {
|
| 479 |
+
border: 1px solid var(--border);
|
| 480 |
+
border-radius: var(--radius);
|
| 481 |
+
padding: 16px;
|
| 482 |
+
text-align: center;
|
| 483 |
+
background: var(--bg);
|
| 484 |
+
}
|
| 485 |
+
.stat-value {
|
| 486 |
+
font-size: 22px;
|
| 487 |
+
font-weight: 700;
|
| 488 |
+
color: var(--text);
|
| 489 |
+
line-height: 1.2;
|
| 490 |
+
}
|
| 491 |
+
.stat-label {
|
| 492 |
+
font-size: 12px;
|
| 493 |
+
color: var(--text-2);
|
| 494 |
+
margin-top: 4px;
|
| 495 |
+
}
|
| 496 |
+
|
| 497 |
+
.table-toolbar { margin-bottom: 12px; }
|
| 498 |
+
.search-input { max-width: 300px; }
|
| 499 |
+
|
| 500 |
+
.table-scroll { overflow-x: auto; }
|
| 501 |
+
.data-table {
|
| 502 |
+
width: 100%;
|
| 503 |
+
border-collapse: collapse;
|
| 504 |
+
font-size: 13px;
|
| 505 |
+
}
|
| 506 |
+
.data-table th {
|
| 507 |
+
text-align: left;
|
| 508 |
+
padding: 8px 12px;
|
| 509 |
+
font-size: 12px;
|
| 510 |
+
font-weight: 600;
|
| 511 |
+
color: var(--text-2);
|
| 512 |
+
background: var(--bg);
|
| 513 |
+
border-bottom: 1px solid var(--border);
|
| 514 |
+
white-space: nowrap;
|
| 515 |
+
}
|
| 516 |
+
.data-table td {
|
| 517 |
+
padding: 10px 12px;
|
| 518 |
+
border-bottom: 1px solid var(--border);
|
| 519 |
+
vertical-align: middle;
|
| 520 |
+
}
|
| 521 |
+
.data-table tbody tr:hover { background: var(--bg); }
|
| 522 |
+
.data-table tbody tr:last-child td { border-bottom: none; }
|
| 523 |
+
|
| 524 |
+
.badge {
|
| 525 |
+
display: inline-block;
|
| 526 |
+
font-size: 11px;
|
| 527 |
+
font-weight: 600;
|
| 528 |
+
padding: 2px 8px;
|
| 529 |
+
border-radius: 20px;
|
| 530 |
+
letter-spacing: 0.2px;
|
| 531 |
+
}
|
| 532 |
+
.badge-exact { background: var(--green-light); color: var(--green); }
|
| 533 |
+
.badge-corrected { background: var(--amber-light); color: var(--amber); }
|
| 534 |
+
.badge-failed { background: var(--red-light); color: var(--red); }
|
| 535 |
+
|
| 536 |
+
/* ── Modal ─────────────────────────────────────────────── */
|
| 537 |
+
.modal {
|
| 538 |
+
position: fixed;
|
| 539 |
+
inset: 0;
|
| 540 |
+
background: rgba(0,0,0,0.5);
|
| 541 |
+
z-index: 500;
|
| 542 |
+
display: flex;
|
| 543 |
+
align-items: center;
|
| 544 |
+
justify-content: center;
|
| 545 |
+
padding: 20px;
|
| 546 |
+
}
|
| 547 |
+
.modal-box {
|
| 548 |
+
background: var(--surface);
|
| 549 |
+
border-radius: var(--radius);
|
| 550 |
+
box-shadow: var(--shadow-md);
|
| 551 |
+
width: 100%;
|
| 552 |
+
max-width: 860px;
|
| 553 |
+
overflow: hidden;
|
| 554 |
+
}
|
| 555 |
+
.modal-head {
|
| 556 |
+
display: flex;
|
| 557 |
+
justify-content: space-between;
|
| 558 |
+
align-items: center;
|
| 559 |
+
padding: 14px 20px;
|
| 560 |
+
border-bottom: 1px solid var(--border);
|
| 561 |
+
font-weight: 600;
|
| 562 |
+
font-size: 14px;
|
| 563 |
+
}
|
| 564 |
+
.modal-close-btn {
|
| 565 |
+
background: none;
|
| 566 |
+
border: none;
|
| 567 |
+
font-size: 20px;
|
| 568 |
+
color: var(--text-2);
|
| 569 |
+
cursor: pointer;
|
| 570 |
+
line-height: 1;
|
| 571 |
+
padding: 0 4px;
|
| 572 |
+
}
|
| 573 |
+
.modal-body { padding: 20px; max-height: 75vh; overflow-y: auto; }
|
| 574 |
+
|
| 575 |
+
/* ── Toasts ────────────────────────────────────────────── */
|
| 576 |
+
.toast-stack {
|
| 577 |
+
position: fixed;
|
| 578 |
+
bottom: 20px;
|
| 579 |
+
right: 20px;
|
| 580 |
+
display: flex;
|
| 581 |
+
flex-direction: column;
|
| 582 |
+
gap: 8px;
|
| 583 |
+
z-index: 1000;
|
| 584 |
+
}
|
| 585 |
+
.toast {
|
| 586 |
+
background: var(--surface);
|
| 587 |
+
border: 1px solid var(--border);
|
| 588 |
+
border-radius: var(--radius);
|
| 589 |
+
box-shadow: var(--shadow-md);
|
| 590 |
+
padding: 10px 16px;
|
| 591 |
+
font-size: 13px;
|
| 592 |
+
font-weight: 500;
|
| 593 |
+
max-width: 320px;
|
| 594 |
+
animation: slide-up 0.2s ease;
|
| 595 |
+
display: flex;
|
| 596 |
+
align-items: center;
|
| 597 |
+
gap: 8px;
|
| 598 |
+
}
|
| 599 |
+
.toast.success { border-left: 3px solid var(--green); }
|
| 600 |
+
.toast.error { border-left: 3px solid var(--red); }
|
| 601 |
+
.toast.info { border-left: 3px solid var(--blue); }
|
| 602 |
+
@keyframes slide-up {
|
| 603 |
+
from { opacity: 0; transform: translateY(8px); }
|
| 604 |
+
to { opacity: 1; transform: translateY(0); }
|
| 605 |
+
}
|
| 606 |
+
|
| 607 |
+
/* ── Utilities ─────────────────────────────────────────── */
|
| 608 |
+
.hidden { display: none !important; }
|
| 609 |
+
.hidden-input { display: none; }
|
web/app.js
ADDED
|
@@ -0,0 +1,346 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
document.addEventListener('DOMContentLoaded', () => {
|
| 2 |
+
const API = window.location.origin;
|
| 3 |
+
|
| 4 |
+
// ── Toast ────────────────────────────────────────────────
|
| 5 |
+
function toast(msg, type = 'info') {
|
| 6 |
+
const el = document.createElement('div');
|
| 7 |
+
el.className = `toast ${type}`;
|
| 8 |
+
el.textContent = msg;
|
| 9 |
+
document.getElementById('toasts').appendChild(el);
|
| 10 |
+
setTimeout(() => el.remove(), 3500);
|
| 11 |
+
}
|
| 12 |
+
|
| 13 |
+
// ── Backend status ───────────────────────────────────────
|
| 14 |
+
const dot = document.getElementById('connection-badge');
|
| 15 |
+
function checkStatus() {
|
| 16 |
+
fetch(`${API}/api/status`)
|
| 17 |
+
.then(() => { dot.className = 'status-dot online'; })
|
| 18 |
+
.catch(() => { dot.className = 'status-dot offline'; });
|
| 19 |
+
}
|
| 20 |
+
checkStatus();
|
| 21 |
+
setInterval(checkStatus, 20000);
|
| 22 |
+
|
| 23 |
+
// ── PWA install ──────────────────────────────────────────
|
| 24 |
+
let installPrompt;
|
| 25 |
+
const installBtn = document.getElementById('pwa-install-btn');
|
| 26 |
+
window.addEventListener('beforeinstallprompt', e => {
|
| 27 |
+
e.preventDefault();
|
| 28 |
+
installPrompt = e;
|
| 29 |
+
installBtn.classList.remove('hidden');
|
| 30 |
+
});
|
| 31 |
+
installBtn.addEventListener('click', async () => {
|
| 32 |
+
if (!installPrompt) return;
|
| 33 |
+
installPrompt.prompt();
|
| 34 |
+
const { outcome } = await installPrompt.userChoice;
|
| 35 |
+
if (outcome === 'accepted') toast('App installed!', 'success');
|
| 36 |
+
installPrompt = null;
|
| 37 |
+
installBtn.classList.add('hidden');
|
| 38 |
+
});
|
| 39 |
+
|
| 40 |
+
// ── Mode switching ───────────────────────────────────────
|
| 41 |
+
let currentMode = 'dataset';
|
| 42 |
+
let chassisBlob = null;
|
| 43 |
+
let barcodeBlob = null;
|
| 44 |
+
|
| 45 |
+
document.querySelectorAll('.seg-btn').forEach(btn => {
|
| 46 |
+
btn.addEventListener('click', () => {
|
| 47 |
+
document.querySelectorAll('.seg-btn').forEach(b => b.classList.remove('active'));
|
| 48 |
+
btn.classList.add('active');
|
| 49 |
+
currentMode = btn.dataset.mode;
|
| 50 |
+
['dataset', 'upload', 'camera'].forEach(m =>
|
| 51 |
+
document.getElementById(`mode-${m}`).classList.add('hidden')
|
| 52 |
+
);
|
| 53 |
+
document.getElementById(`mode-${currentMode}`).classList.remove('hidden');
|
| 54 |
+
stopAllCameras();
|
| 55 |
+
validate();
|
| 56 |
+
});
|
| 57 |
+
});
|
| 58 |
+
|
| 59 |
+
// ── Dataset pairs ────────────────────────────────────────
|
| 60 |
+
const pairSelect = document.getElementById('dataset-pair-select');
|
| 61 |
+
const barcodeValInput = document.getElementById('barcode-val');
|
| 62 |
+
|
| 63 |
+
fetch(`${API}/api/test-pairs`)
|
| 64 |
+
.then(r => r.json())
|
| 65 |
+
.then(pairs => {
|
| 66 |
+
pairSelect.innerHTML = '<option value="">— Choose a pair —</option>';
|
| 67 |
+
pairs.forEach(p => {
|
| 68 |
+
const o = document.createElement('option');
|
| 69 |
+
o.value = o.textContent = p;
|
| 70 |
+
pairSelect.appendChild(o);
|
| 71 |
+
});
|
| 72 |
+
})
|
| 73 |
+
.catch(() => toast('Could not load dataset pairs', 'error'));
|
| 74 |
+
|
| 75 |
+
pairSelect.addEventListener('change', () => {
|
| 76 |
+
const key = pairSelect.value;
|
| 77 |
+
if (!key) { barcodeValInput.value = ''; validate(); return; }
|
| 78 |
+
barcodeValInput.value = 'Decoding…';
|
| 79 |
+
barcodeValInput.disabled = true;
|
| 80 |
+
fetch(`${API}/api/scan-barcode/${encodeURIComponent(key)}`)
|
| 81 |
+
.then(r => r.json())
|
| 82 |
+
.then(d => {
|
| 83 |
+
barcodeValInput.value = d.success ? d.barcode : '';
|
| 84 |
+
if (!d.success) toast('Barcode not decoded. Enter manually.', 'info');
|
| 85 |
+
})
|
| 86 |
+
.catch(() => { barcodeValInput.value = ''; toast('Barcode scan failed', 'error'); })
|
| 87 |
+
.finally(() => { barcodeValInput.disabled = false; validate(); });
|
| 88 |
+
});
|
| 89 |
+
|
| 90 |
+
// ── Upload dropzones ─────────────────────────────────────
|
| 91 |
+
function setupDropzone(dzId, inputId, nameId, isBarcode) {
|
| 92 |
+
const dz = document.getElementById(dzId);
|
| 93 |
+
const inp = document.getElementById(inputId);
|
| 94 |
+
const nm = document.getElementById(nameId);
|
| 95 |
+
|
| 96 |
+
dz.addEventListener('click', () => inp.click());
|
| 97 |
+
dz.addEventListener('dragover', e => { e.preventDefault(); dz.classList.add('active'); });
|
| 98 |
+
dz.addEventListener('dragleave', () => dz.classList.remove('active'));
|
| 99 |
+
dz.addEventListener('drop', e => {
|
| 100 |
+
e.preventDefault();
|
| 101 |
+
dz.classList.remove('active');
|
| 102 |
+
if (e.dataTransfer.files[0]) handleFile(e.dataTransfer.files[0], nm, isBarcode);
|
| 103 |
+
});
|
| 104 |
+
inp.addEventListener('change', () => {
|
| 105 |
+
if (inp.files[0]) handleFile(inp.files[0], nm, isBarcode);
|
| 106 |
+
});
|
| 107 |
+
}
|
| 108 |
+
|
| 109 |
+
function handleFile(file, nameEl, isBarcode) {
|
| 110 |
+
nameEl.textContent = file.name;
|
| 111 |
+
if (isBarcode) {
|
| 112 |
+
barcodeBlob = file;
|
| 113 |
+
barcodeValInput.value = 'Decoding…';
|
| 114 |
+
barcodeValInput.disabled = true;
|
| 115 |
+
const fd = new FormData();
|
| 116 |
+
fd.append('file', file);
|
| 117 |
+
fetch(`${API}/api/scan-barcode`, { method: 'POST', body: fd })
|
| 118 |
+
.then(r => r.json())
|
| 119 |
+
.then(d => {
|
| 120 |
+
barcodeValInput.value = d.success ? d.barcode : '';
|
| 121 |
+
if (d.success) toast('Barcode decoded', 'success');
|
| 122 |
+
else toast('Could not decode barcode. Enter manually.', 'info');
|
| 123 |
+
})
|
| 124 |
+
.catch(() => { barcodeValInput.value = ''; toast('Barcode scan error', 'error'); })
|
| 125 |
+
.finally(() => { barcodeValInput.disabled = false; validate(); });
|
| 126 |
+
} else {
|
| 127 |
+
chassisBlob = file;
|
| 128 |
+
validate();
|
| 129 |
+
}
|
| 130 |
+
}
|
| 131 |
+
|
| 132 |
+
setupDropzone('barcode-dropzone', 'barcode-file', 'barcode-file-name', true);
|
| 133 |
+
setupDropzone('chassis-dropzone', 'chassis-file', 'chassis-file-name', false);
|
| 134 |
+
|
| 135 |
+
// ── Camera — barcode ─────────────────────────────────────
|
| 136 |
+
let qr = null;
|
| 137 |
+
const startBarcodeCam = document.getElementById('start-barcode-cam');
|
| 138 |
+
const camStep2 = document.getElementById('cam-step-2');
|
| 139 |
+
|
| 140 |
+
startBarcodeCam.addEventListener('click', () => {
|
| 141 |
+
if (qr) {
|
| 142 |
+
qr.stop().finally(() => { qr = null; startBarcodeCam.textContent = 'Start Camera'; });
|
| 143 |
+
return;
|
| 144 |
+
}
|
| 145 |
+
startBarcodeCam.textContent = 'Stop';
|
| 146 |
+
qr = new Html5Qrcode('barcode-reader');
|
| 147 |
+
qr.start(
|
| 148 |
+
{ facingMode: 'environment' },
|
| 149 |
+
{ fps: 10, qrbox: { width: 240, height: 100 } },
|
| 150 |
+
decoded => {
|
| 151 |
+
barcodeValInput.value = decoded;
|
| 152 |
+
toast(`Barcode: ${decoded}`, 'success');
|
| 153 |
+
qr.stop().finally(() => {
|
| 154 |
+
qr = null;
|
| 155 |
+
startBarcodeCam.textContent = 'Start Camera';
|
| 156 |
+
camStep2.classList.remove('disabled');
|
| 157 |
+
document.getElementById('start-chassis-cam').disabled = false;
|
| 158 |
+
validate();
|
| 159 |
+
});
|
| 160 |
+
},
|
| 161 |
+
() => {}
|
| 162 |
+
).catch(e => { toast(`Camera error: ${e}`, 'error'); startBarcodeCam.textContent = 'Start Camera'; qr = null; });
|
| 163 |
+
});
|
| 164 |
+
|
| 165 |
+
// ── Camera — chassis ─────────────────────────────────────
|
| 166 |
+
let chassisStream = null;
|
| 167 |
+
const chassisVideo = document.getElementById('chassis-video');
|
| 168 |
+
const chassisCanvas = document.getElementById('chassis-canvas');
|
| 169 |
+
const snapContainer = document.getElementById('chassis-snap-container');
|
| 170 |
+
const snapImg = document.getElementById('chassis-snap-img');
|
| 171 |
+
const startChassisCam = document.getElementById('start-chassis-cam');
|
| 172 |
+
const captureBtn = document.getElementById('capture-chassis');
|
| 173 |
+
const retakeBtn = document.getElementById('reset-chassis-snap');
|
| 174 |
+
|
| 175 |
+
startChassisCam.addEventListener('click', async () => {
|
| 176 |
+
if (chassisStream) { stopChassisCamera(); return; }
|
| 177 |
+
try {
|
| 178 |
+
chassisStream = await navigator.mediaDevices.getUserMedia({
|
| 179 |
+
video: { facingMode: 'environment', width: { ideal: 1280 } }, audio: false
|
| 180 |
+
});
|
| 181 |
+
chassisVideo.srcObject = chassisStream;
|
| 182 |
+
chassisVideo.classList.remove('hidden');
|
| 183 |
+
captureBtn.classList.remove('hidden');
|
| 184 |
+
snapContainer.classList.add('hidden');
|
| 185 |
+
retakeBtn.classList.add('hidden');
|
| 186 |
+
startChassisCam.textContent = 'Stop';
|
| 187 |
+
} catch(e) { toast(`Camera error: ${e.message}`, 'error'); }
|
| 188 |
+
});
|
| 189 |
+
|
| 190 |
+
captureBtn.addEventListener('click', () => {
|
| 191 |
+
chassisCanvas.width = chassisVideo.videoWidth;
|
| 192 |
+
chassisCanvas.height = chassisVideo.videoHeight;
|
| 193 |
+
chassisCanvas.getContext('2d').drawImage(chassisVideo, 0, 0);
|
| 194 |
+
chassisCanvas.toBlob(blob => {
|
| 195 |
+
chassisBlob = blob;
|
| 196 |
+
snapImg.src = URL.createObjectURL(blob);
|
| 197 |
+
chassisVideo.classList.add('hidden');
|
| 198 |
+
captureBtn.classList.add('hidden');
|
| 199 |
+
snapContainer.classList.remove('hidden');
|
| 200 |
+
retakeBtn.classList.remove('hidden');
|
| 201 |
+
stopChassisCamera();
|
| 202 |
+
validate();
|
| 203 |
+
}, 'image/jpeg');
|
| 204 |
+
});
|
| 205 |
+
|
| 206 |
+
retakeBtn.addEventListener('click', () => {
|
| 207 |
+
chassisBlob = null;
|
| 208 |
+
snapContainer.classList.add('hidden');
|
| 209 |
+
retakeBtn.classList.add('hidden');
|
| 210 |
+
startChassisCam.click();
|
| 211 |
+
validate();
|
| 212 |
+
});
|
| 213 |
+
|
| 214 |
+
function stopChassisCamera() {
|
| 215 |
+
if (chassisStream) { chassisStream.getTracks().forEach(t => t.stop()); chassisStream = null; }
|
| 216 |
+
chassisVideo.srcObject = null;
|
| 217 |
+
chassisVideo.classList.add('hidden');
|
| 218 |
+
captureBtn.classList.add('hidden');
|
| 219 |
+
startChassisCam.textContent = 'Start Camera';
|
| 220 |
+
}
|
| 221 |
+
|
| 222 |
+
function stopAllCameras() {
|
| 223 |
+
if (qr) { qr.stop().catch(() => {}); qr = null; startBarcodeCam.textContent = 'Start Camera'; }
|
| 224 |
+
stopChassisCamera();
|
| 225 |
+
}
|
| 226 |
+
|
| 227 |
+
// ── Validation ───────────────────────────────────────────
|
| 228 |
+
const runBtn = document.getElementById('run-ocr-btn');
|
| 229 |
+
barcodeValInput.addEventListener('input', validate);
|
| 230 |
+
|
| 231 |
+
function validate() {
|
| 232 |
+
const hasBarcode = barcodeValInput.value.trim() !== '' && barcodeValInput.value !== 'Decoding…';
|
| 233 |
+
let hasChassis = false;
|
| 234 |
+
if (currentMode === 'dataset') hasChassis = pairSelect.value !== '';
|
| 235 |
+
else hasChassis = chassisBlob !== null;
|
| 236 |
+
runBtn.disabled = !(hasBarcode && hasChassis);
|
| 237 |
+
}
|
| 238 |
+
|
| 239 |
+
// ── Run OCR ──────────────────────────────────────────────
|
| 240 |
+
let lastResult = null;
|
| 241 |
+
|
| 242 |
+
runBtn.addEventListener('click', () => {
|
| 243 |
+
runBtn.disabled = true;
|
| 244 |
+
runBtn.querySelector('.spinner').classList.remove('hidden');
|
| 245 |
+
|
| 246 |
+
const fd = new FormData();
|
| 247 |
+
fd.append('barcode_val', barcodeValInput.value.trim().toUpperCase());
|
| 248 |
+
|
| 249 |
+
if (currentMode === 'dataset') {
|
| 250 |
+
fd.append('chassis_key', pairSelect.value);
|
| 251 |
+
} else {
|
| 252 |
+
fd.append('chassis_file', chassisBlob, currentMode === 'camera' ? 'chassis.jpg' : chassisBlob.name);
|
| 253 |
+
}
|
| 254 |
+
|
| 255 |
+
fetch(`${API}/api/match`, { method: 'POST', body: fd })
|
| 256 |
+
.then(r => {
|
| 257 |
+
if (!r.ok) return r.json().then(e => { throw new Error(e.detail); });
|
| 258 |
+
return r.json();
|
| 259 |
+
})
|
| 260 |
+
.then(data => { lastResult = data; renderResult(data); toast('Scan complete', 'success'); })
|
| 261 |
+
.catch(e => toast(e.message || 'OCR failed', 'error'))
|
| 262 |
+
.finally(() => {
|
| 263 |
+
runBtn.disabled = false;
|
| 264 |
+
runBtn.querySelector('.spinner').classList.add('hidden');
|
| 265 |
+
});
|
| 266 |
+
});
|
| 267 |
+
|
| 268 |
+
function renderResult(d) {
|
| 269 |
+
document.getElementById('result-empty').classList.add('hidden');
|
| 270 |
+
document.getElementById('result-content').classList.remove('hidden');
|
| 271 |
+
|
| 272 |
+
// Banner
|
| 273 |
+
const banner = document.getElementById('result-banner');
|
| 274 |
+
banner.className = `result-banner ${d.status}`;
|
| 275 |
+
const icons = {
|
| 276 |
+
EXACT: '✓',
|
| 277 |
+
CORRECTED: '~',
|
| 278 |
+
FAILED: '✗'
|
| 279 |
+
};
|
| 280 |
+
const subs = {
|
| 281 |
+
EXACT: 'Raw OCR matched the barcode exactly.',
|
| 282 |
+
CORRECTED: 'Match achieved after error-correction.',
|
| 283 |
+
FAILED: 'OCR output could not be matched.'
|
| 284 |
+
};
|
| 285 |
+
document.getElementById('banner-icon').textContent = icons[d.status];
|
| 286 |
+
document.getElementById('banner-title').textContent = d.status === 'EXACT' ? 'Exact Match' : d.status === 'CORRECTED' ? 'Corrected Match' : 'No Match';
|
| 287 |
+
document.getElementById('banner-sub').textContent = subs[d.status];
|
| 288 |
+
|
| 289 |
+
// Table
|
| 290 |
+
document.getElementById('res-expected').textContent = d.barcode_val;
|
| 291 |
+
document.getElementById('res-raw').textContent = d.raw_ocr || '—';
|
| 292 |
+
document.getElementById('res-corrected').textContent = d.corrected_ocr || '—';
|
| 293 |
+
document.getElementById('res-conf').textContent = `${Math.round(d.confidence * 100)}%`;
|
| 294 |
+
document.getElementById('res-filter').textContent = d.winning_label || '—';
|
| 295 |
+
|
| 296 |
+
// Preprocessing carousel
|
| 297 |
+
const tabs = document.querySelectorAll('.pre-tab');
|
| 298 |
+
tabs.forEach(t => t.replaceWith(t.cloneNode(true)));
|
| 299 |
+
const newTabs = document.querySelectorAll('.pre-tab');
|
| 300 |
+
|
| 301 |
+
function setPreview(type) {
|
| 302 |
+
const img = document.getElementById('pre-img');
|
| 303 |
+
const lbl = document.getElementById('pre-ocr-text');
|
| 304 |
+
if (type === 'original') {
|
| 305 |
+
img.src = `data:image/jpeg;base64,${d.original}`;
|
| 306 |
+
lbl.textContent = d.raw_ocr || '—';
|
| 307 |
+
} else {
|
| 308 |
+
const v = d.variations.find(x => x.label.toLowerCase() === type);
|
| 309 |
+
const det = d.variation_details.find(x => x.label.toLowerCase() === type);
|
| 310 |
+
if (v) img.src = `data:image/jpeg;base64,${v.base64}`;
|
| 311 |
+
lbl.textContent = det ? (det.text || '—') : '—';
|
| 312 |
+
}
|
| 313 |
+
}
|
| 314 |
+
|
| 315 |
+
newTabs.forEach(tab => {
|
| 316 |
+
tab.addEventListener('click', () => {
|
| 317 |
+
newTabs.forEach(t => t.classList.remove('active'));
|
| 318 |
+
tab.classList.add('active');
|
| 319 |
+
setPreview(tab.dataset.pre);
|
| 320 |
+
});
|
| 321 |
+
});
|
| 322 |
+
|
| 323 |
+
// Set initial to original
|
| 324 |
+
document.querySelector('.pre-tab[data-pre="original"]').classList.add('active');
|
| 325 |
+
setPreview('original');
|
| 326 |
+
}
|
| 327 |
+
|
| 328 |
+
// ── Modal ──────────────────────────────���─────────────────
|
| 329 |
+
const modal = document.getElementById('modal');
|
| 330 |
+
const modalImg = document.getElementById('modal-img');
|
| 331 |
+
const modalTitle = document.getElementById('modal-title');
|
| 332 |
+
|
| 333 |
+
function openModal(url, title) {
|
| 334 |
+
modalTitle.textContent = title;
|
| 335 |
+
modalImg.src = `${url}?t=${Date.now()}`;
|
| 336 |
+
modal.classList.remove('hidden');
|
| 337 |
+
}
|
| 338 |
+
|
| 339 |
+
document.getElementById('modal-close').addEventListener('click', () => modal.classList.add('hidden'));
|
| 340 |
+
modal.addEventListener('click', e => { if (e.target === modal) modal.classList.add('hidden'); });
|
| 341 |
+
|
| 342 |
+
// ── Service Worker ───────────────────────────────────────
|
| 343 |
+
if ('serviceWorker' in navigator) {
|
| 344 |
+
navigator.serviceWorker.register('/sw.js').catch(() => {});
|
| 345 |
+
}
|
| 346 |
+
});
|
web/icons/icon-192.png
ADDED
|
|
web/icons/icon-512.png
ADDED
|
|
web/index.html
ADDED
|
@@ -0,0 +1,182 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!DOCTYPE html>
|
| 2 |
+
<html lang="en">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="UTF-8">
|
| 5 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
| 6 |
+
<title>Chassis OCR</title>
|
| 7 |
+
<link rel="manifest" href="manifest.json">
|
| 8 |
+
<link rel="icon" type="image/png" sizes="192x192" href="icons/icon-192.png">
|
| 9 |
+
<meta name="theme-color" content="#2563eb">
|
| 10 |
+
<link rel="preconnect" href="https://fonts.googleapis.com">
|
| 11 |
+
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
| 12 |
+
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
|
| 13 |
+
<link rel="stylesheet" href="app.css">
|
| 14 |
+
<script src="https://unpkg.com/html5-qrcode" type="text/javascript"></script>
|
| 15 |
+
</head>
|
| 16 |
+
<body>
|
| 17 |
+
<header class="header">
|
| 18 |
+
<div class="header-inner">
|
| 19 |
+
<div class="header-brand">
|
| 20 |
+
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
| 21 |
+
<rect x="2" y="5" width="20" height="14" rx="2"/>
|
| 22 |
+
<line x1="2" y1="10" x2="22" y2="10"/>
|
| 23 |
+
</svg>
|
| 24 |
+
<span>Chassis OCR</span>
|
| 25 |
+
</div>
|
| 26 |
+
<div class="header-right">
|
| 27 |
+
<span id="connection-badge" class="status-dot online"></span>
|
| 28 |
+
<button id="pwa-install-btn" class="btn-install hidden">Add to Home Screen</button>
|
| 29 |
+
</div>
|
| 30 |
+
</div>
|
| 31 |
+
</header>
|
| 32 |
+
|
| 33 |
+
<main class="main">
|
| 34 |
+
<div class="two-col">
|
| 35 |
+
|
| 36 |
+
<!-- Left: Input -->
|
| 37 |
+
<div class="panel">
|
| 38 |
+
<div class="panel-header">
|
| 39 |
+
<h2>Input</h2>
|
| 40 |
+
</div>
|
| 41 |
+
<div class="panel-body">
|
| 42 |
+
<div class="field">
|
| 43 |
+
<label>Source</label>
|
| 44 |
+
<div class="seg-control">
|
| 45 |
+
<button class="seg-btn active" data-mode="dataset">Dataset</button>
|
| 46 |
+
<button class="seg-btn" data-mode="upload">Upload</button>
|
| 47 |
+
<button class="seg-btn" data-mode="camera">Camera</button>
|
| 48 |
+
</div>
|
| 49 |
+
</div>
|
| 50 |
+
|
| 51 |
+
<!-- Dataset mode -->
|
| 52 |
+
<div id="mode-dataset" class="mode-panel">
|
| 53 |
+
<div class="field">
|
| 54 |
+
<label for="dataset-pair-select">Test Pair</label>
|
| 55 |
+
<select id="dataset-pair-select" class="select">
|
| 56 |
+
<option value="">Loading...</option>
|
| 57 |
+
</select>
|
| 58 |
+
</div>
|
| 59 |
+
</div>
|
| 60 |
+
|
| 61 |
+
<!-- Upload mode -->
|
| 62 |
+
<div id="mode-upload" class="mode-panel hidden">
|
| 63 |
+
<div class="field">
|
| 64 |
+
<label>Barcode Image</label>
|
| 65 |
+
<div class="dropzone" id="barcode-dropzone">
|
| 66 |
+
<input type="file" id="barcode-file" accept="image/*" class="hidden-input">
|
| 67 |
+
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4M17 8l-5-5-5 5M12 3v12"/></svg>
|
| 68 |
+
<span id="barcode-file-name">Drop or click to upload</span>
|
| 69 |
+
</div>
|
| 70 |
+
</div>
|
| 71 |
+
<div class="field">
|
| 72 |
+
<label>Chassis Image</label>
|
| 73 |
+
<div class="dropzone" id="chassis-dropzone">
|
| 74 |
+
<input type="file" id="chassis-file" accept="image/*" class="hidden-input">
|
| 75 |
+
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4M17 8l-5-5-5 5M12 3v12"/></svg>
|
| 76 |
+
<span id="chassis-file-name">Drop or click to upload</span>
|
| 77 |
+
</div>
|
| 78 |
+
</div>
|
| 79 |
+
</div>
|
| 80 |
+
|
| 81 |
+
<!-- Camera mode -->
|
| 82 |
+
<div id="mode-camera" class="mode-panel hidden">
|
| 83 |
+
<div class="camera-step" id="cam-step-1">
|
| 84 |
+
<div class="step-label">Step 1 — Scan Barcode</div>
|
| 85 |
+
<div id="barcode-reader" class="cam-view"></div>
|
| 86 |
+
<button id="start-barcode-cam" class="btn btn-secondary">Start Camera</button>
|
| 87 |
+
</div>
|
| 88 |
+
<div class="camera-step disabled" id="cam-step-2">
|
| 89 |
+
<div class="step-label">Step 2 — Capture Chassis</div>
|
| 90 |
+
<video id="chassis-video" autoplay playsinline class="cam-view hidden"></video>
|
| 91 |
+
<canvas id="chassis-canvas" class="hidden"></canvas>
|
| 92 |
+
<div id="chassis-snap-container" class="hidden">
|
| 93 |
+
<img id="chassis-snap-img" class="snap-preview" src="" alt="Chassis snapshot">
|
| 94 |
+
</div>
|
| 95 |
+
<div class="cam-actions">
|
| 96 |
+
<button id="start-chassis-cam" class="btn btn-secondary" disabled>Start Camera</button>
|
| 97 |
+
<button id="capture-chassis" class="btn btn-primary hidden">Capture</button>
|
| 98 |
+
<button id="reset-chassis-snap" class="btn btn-ghost hidden">Retake</button>
|
| 99 |
+
</div>
|
| 100 |
+
</div>
|
| 101 |
+
</div>
|
| 102 |
+
|
| 103 |
+
<!-- Barcode value -->
|
| 104 |
+
<div class="field">
|
| 105 |
+
<label for="barcode-val">Expected Value (Barcode)</label>
|
| 106 |
+
<input type="text" id="barcode-val" class="input" placeholder="Auto-filled or enter manually">
|
| 107 |
+
</div>
|
| 108 |
+
|
| 109 |
+
<button id="run-ocr-btn" class="btn btn-primary btn-full" disabled>
|
| 110 |
+
<span class="spinner hidden"></span>
|
| 111 |
+
Run OCR & Compare
|
| 112 |
+
</button>
|
| 113 |
+
</div>
|
| 114 |
+
</div>
|
| 115 |
+
|
| 116 |
+
<!-- Right: Results -->
|
| 117 |
+
<div class="panel">
|
| 118 |
+
<div class="panel-header">
|
| 119 |
+
<h2>Result</h2>
|
| 120 |
+
</div>
|
| 121 |
+
<div class="panel-body" id="result-body">
|
| 122 |
+
<div id="result-empty" class="empty-state">
|
| 123 |
+
<svg width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg>
|
| 124 |
+
<p>Select a pair and run OCR to see results.</p>
|
| 125 |
+
</div>
|
| 126 |
+
|
| 127 |
+
<div id="result-content" class="hidden">
|
| 128 |
+
<div id="result-banner" class="result-banner">
|
| 129 |
+
<div id="banner-icon" class="banner-icon-char"></div>
|
| 130 |
+
<div>
|
| 131 |
+
<div id="banner-title" class="banner-title"></div>
|
| 132 |
+
<div id="banner-sub" class="banner-sub"></div>
|
| 133 |
+
</div>
|
| 134 |
+
</div>
|
| 135 |
+
|
| 136 |
+
<table class="result-table">
|
| 137 |
+
<tr>
|
| 138 |
+
<td class="rt-label">Expected</td>
|
| 139 |
+
<td class="rt-value mono" id="res-expected">—</td>
|
| 140 |
+
</tr>
|
| 141 |
+
<tr>
|
| 142 |
+
<td class="rt-label">Raw OCR</td>
|
| 143 |
+
<td class="rt-value mono" id="res-raw">—</td>
|
| 144 |
+
</tr>
|
| 145 |
+
<tr>
|
| 146 |
+
<td class="rt-label">After Correction</td>
|
| 147 |
+
<td class="rt-value mono" id="res-corrected">—</td>
|
| 148 |
+
</tr>
|
| 149 |
+
<tr>
|
| 150 |
+
<td class="rt-label">Confidence</td>
|
| 151 |
+
<td class="rt-value" id="res-conf">—</td>
|
| 152 |
+
</tr>
|
| 153 |
+
<tr>
|
| 154 |
+
<td class="rt-label">Best Filter</td>
|
| 155 |
+
<td class="rt-value" id="res-filter">—</td>
|
| 156 |
+
</tr>
|
| 157 |
+
</table>
|
| 158 |
+
|
| 159 |
+
<div class="section-title">Preprocessing Steps</div>
|
| 160 |
+
<div class="pre-tabs" id="pre-tabs">
|
| 161 |
+
<button class="pre-tab active" data-pre="original">Original</button>
|
| 162 |
+
<button class="pre-tab" data-pre="clahe">CLAHE</button>
|
| 163 |
+
<button class="pre-tab" data-pre="bilateral">Bilateral</button>
|
| 164 |
+
<button class="pre-tab" data-pre="otsu">Otsu</button>
|
| 165 |
+
<button class="pre-tab" data-pre="adaptive">Adaptive</button>
|
| 166 |
+
</div>
|
| 167 |
+
<div class="pre-img-wrap">
|
| 168 |
+
<img id="pre-img" src="" alt="Preprocessing step">
|
| 169 |
+
<div class="pre-ocr-label">OCR read: <span id="pre-ocr-text" class="mono">—</span></div>
|
| 170 |
+
</div>
|
| 171 |
+
</div>
|
| 172 |
+
</div>
|
| 173 |
+
</div>
|
| 174 |
+
|
| 175 |
+
</div>
|
| 176 |
+
</main>
|
| 177 |
+
|
| 178 |
+
<div id="toasts" class="toast-stack"></div>
|
| 179 |
+
|
| 180 |
+
<script src="app.js"></script>
|
| 181 |
+
</body>
|
| 182 |
+
</html>
|
web/manifest.json
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"name": "Chassis OCR Pipeline Hub",
|
| 3 |
+
"short_name": "Chassis OCR",
|
| 4 |
+
"description": "Progressive Web App to read and match chassis engravings with barcode-scanned numbers using PaddleOCR.",
|
| 5 |
+
"start_url": "/index.html",
|
| 6 |
+
"display": "standalone",
|
| 7 |
+
"background_color": "#0b0e14",
|
| 8 |
+
"theme_color": "#0d6efd",
|
| 9 |
+
"orientation": "portrait-primary",
|
| 10 |
+
"icons": [
|
| 11 |
+
{
|
| 12 |
+
"src": "icons/icon-192.png",
|
| 13 |
+
"sizes": "192x192",
|
| 14 |
+
"type": "image/png",
|
| 15 |
+
"purpose": "any maskable"
|
| 16 |
+
},
|
| 17 |
+
{
|
| 18 |
+
"src": "icons/icon-512.png",
|
| 19 |
+
"sizes": "512x512",
|
| 20 |
+
"type": "image/png",
|
| 21 |
+
"purpose": "any maskable"
|
| 22 |
+
}
|
| 23 |
+
]
|
| 24 |
+
}
|
web/sw.js
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/* ==========================================================================
|
| 2 |
+
CHASSIS OCR PWA - SERVICE WORKER
|
| 3 |
+
========================================================================== */
|
| 4 |
+
|
| 5 |
+
const CACHE_NAME = 'chassis-ocr-pwa-v1';
|
| 6 |
+
const ASSETS_TO_CACHE = [
|
| 7 |
+
'/',
|
| 8 |
+
'/index.html',
|
| 9 |
+
'/app.css',
|
| 10 |
+
'/app.js',
|
| 11 |
+
'/manifest.json',
|
| 12 |
+
'/icons/icon-192.png',
|
| 13 |
+
'/icons/icon-512.png'
|
| 14 |
+
];
|
| 15 |
+
|
| 16 |
+
// Install Service Worker and Cache Assets
|
| 17 |
+
self.addEventListener('install', (event) => {
|
| 18 |
+
event.waitUntil(
|
| 19 |
+
caches.open(CACHE_NAME)
|
| 20 |
+
.then((cache) => {
|
| 21 |
+
console.log('[Service Worker] Caching App Shell...');
|
| 22 |
+
return cache.addAll(ASSETS_TO_CACHE);
|
| 23 |
+
})
|
| 24 |
+
.then(() => self.skipWaiting())
|
| 25 |
+
);
|
| 26 |
+
});
|
| 27 |
+
|
| 28 |
+
// Activate event (clean up old caches)
|
| 29 |
+
self.addEventListener('activate', (event) => {
|
| 30 |
+
event.waitUntil(
|
| 31 |
+
caches.keys().then((keyList) => {
|
| 32 |
+
return Promise.all(keyList.map((key) => {
|
| 33 |
+
if (key !== CACHE_NAME) {
|
| 34 |
+
console.log('[Service Worker] Removing old cache:', key);
|
| 35 |
+
return caches.delete(key);
|
| 36 |
+
}
|
| 37 |
+
}));
|
| 38 |
+
}).then(() => self.clients.claim())
|
| 39 |
+
);
|
| 40 |
+
});
|
| 41 |
+
|
| 42 |
+
// Fetch events (Network-first with Cache Fallback for API / Cache-first for Assets)
|
| 43 |
+
self.addEventListener('fetch', (event) => {
|
| 44 |
+
const requestUrl = new URL(event.request.url);
|
| 45 |
+
|
| 46 |
+
// Bypass caching for backend API requests and other HTTP methods (POST, PUT, DELETE)
|
| 47 |
+
if (event.request.method !== 'GET' || requestUrl.pathname.startsWith('/api/')) {
|
| 48 |
+
return;
|
| 49 |
+
}
|
| 50 |
+
|
| 51 |
+
event.respondWith(
|
| 52 |
+
caches.match(event.request)
|
| 53 |
+
.then((cachedResponse) => {
|
| 54 |
+
if (cachedResponse) {
|
| 55 |
+
// Serve cached asset immediately, but fetch fresh version in the background
|
| 56 |
+
fetch(event.request).then((networkResponse) => {
|
| 57 |
+
if (networkResponse && networkResponse.status === 200) {
|
| 58 |
+
caches.open(CACHE_NAME).then((cache) => {
|
| 59 |
+
cache.put(event.request, networkResponse);
|
| 60 |
+
});
|
| 61 |
+
}
|
| 62 |
+
}).catch(() => {/* Ignore network failures in background */});
|
| 63 |
+
|
| 64 |
+
return cachedResponse;
|
| 65 |
+
}
|
| 66 |
+
|
| 67 |
+
// If not cached, fetch from network
|
| 68 |
+
return fetch(event.request).then((networkResponse) => {
|
| 69 |
+
if (!networkResponse || networkResponse.status !== 200 || networkResponse.type !== 'basic') {
|
| 70 |
+
return networkResponse;
|
| 71 |
+
}
|
| 72 |
+
|
| 73 |
+
// Cache the newly fetched asset
|
| 74 |
+
const responseToCache = networkResponse.clone();
|
| 75 |
+
caches.open(CACHE_NAME).then((cache) => {
|
| 76 |
+
cache.put(event.request, responseToCache);
|
| 77 |
+
});
|
| 78 |
+
|
| 79 |
+
return networkResponse;
|
| 80 |
+
});
|
| 81 |
+
})
|
| 82 |
+
);
|
| 83 |
+
});
|