borreooo commited on
Commit
b42373a
·
1 Parent(s): 023de92

Add project files

Browse files
Files changed (21) hide show
  1. .gitattributes +35 -0
  2. Dockerfile +28 -0
  3. README.md +64 -0
  4. augment.py +156 -0
  5. barcode_scanner.py +114 -0
  6. compare_decoders.py +149 -0
  7. evaluate.py +116 -0
  8. learn.py +176 -0
  9. ocr.py +134 -0
  10. preprocess.py +117 -0
  11. requirements.txt +8 -0
  12. server.py +264 -0
  13. setup.bat +1 -0
  14. test_speed.py +12 -0
  15. web/app.css +609 -0
  16. web/app.js +346 -0
  17. web/icons/icon-192.png +0 -0
  18. web/icons/icon-512.png +0 -0
  19. web/index.html +182 -0
  20. web/manifest.json +24 -0
  21. 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
+ });