lylron commited on
Commit
ab254a7
·
verified ·
1 Parent(s): 4c37e51

Mirror current local backend deployment

Browse files
Files changed (45) hide show
  1. ai_providers.py +0 -262
  2. benchmark/__init__.py +0 -0
  3. benchmark/ground_truth.example.json +0 -32
  4. benchmark/ground_truth.json +0 -25
  5. benchmark/metrics.py +0 -122
  6. benchmark/results/after_fix.json +0 -160
  7. benchmark/results/benchmark_20260629_114932.json +0 -61
  8. benchmark/results/benchmark_20260629_115100.json +0 -92
  9. benchmark/results/benchmark_20260707_110502.json +0 -139
  10. benchmark/results/benchmark_20260707_111530.json +0 -121
  11. benchmark/results/benchmark_20260707_111703.json +0 -120
  12. benchmark/results/benchmark_20260707_111914.json +0 -160
  13. benchmark/results/benchmark_20260707_112107.json +0 -163
  14. benchmark/results/benchmark_20260707_150812.json +0 -163
  15. benchmark/results/benchmark_20260707_163623.json +0 -163
  16. benchmark/results/benchmark_20260707_163851.json +0 -158
  17. benchmark/results/benchmark_20260707_164015.json +0 -158
  18. benchmark/results/benchmark_20260707_164018.json +0 -158
  19. benchmark/results/benchmark_20260707_164213.json +0 -158
  20. benchmark/results/benchmark_20260707_181600.json +0 -158
  21. benchmark/results/benchmark_20260708_034005.json +0 -135
  22. benchmark/results/benchmark_20260708_034317.json +0 -159
  23. benchmark/results/benchmark_20260708_034854.json +0 -159
  24. benchmark/results/benchmark_20260708_035238.json +0 -159
  25. benchmark/results/benchmark_20260708_035329.json +0 -135
  26. benchmark/run_benchmark.py +0 -432
  27. processors/enhancement/__init__.py +0 -12
  28. processors/enhancement/base.py +0 -92
  29. processors/enhancement/classical.py +0 -35
  30. processors/enhancement/groq_guided.py +0 -288
  31. processors/enhancement/huggingface.py +0 -130
  32. processors/enhancement/huggingface_api.py +0 -140
  33. processors/enhancement/pipeline.py +0 -124
  34. processors/enhancement/real_esrgan.py +0 -153
  35. processors/enhancement/registry.py +0 -133
  36. processors/ocr_classification.py +0 -82
  37. processors/paddleocr_processor.py +0 -157
  38. requirements.cloud.txt +0 -19
  39. tests/__init__.py +0 -0
  40. tests/conftest.py +0 -53
  41. tests/test_api.py +0 -122
  42. tests/test_image_enhancer.py +0 -52
  43. tests/test_line_extractor.py +0 -81
  44. tests/test_ocr_processor.py +0 -106
  45. tests/test_svg_generator.py +0 -75
ai_providers.py DELETED
@@ -1,262 +0,0 @@
1
- """
2
- AI Vision Provider Router
3
- ==========================
4
- Reads API keys from .env and routes /api/analyze requests to whichever
5
- provider the caller requests (or the first available one by default).
6
-
7
- Supported providers
8
- -------------------
9
- anthropic — Claude claude-sonnet-4-6 (pip install anthropic)
10
- openai — GPT-4o (pip install openai)
11
- gemini — Gemini 2.0 Flash (pip install google-genai)
12
- groq — Llama 4 Scout (vision) (pip install groq)
13
-
14
- Adding a new provider
15
- ---------------------
16
- 1. Add its API key variable to .env / .env.example
17
- 2. Add an entry to PROVIDERS below
18
- 3. Implement a _call_<name>() function
19
- """
20
-
21
- from __future__ import annotations
22
- import base64
23
- import json
24
- import logging
25
- import os
26
- from typing import Optional
27
-
28
- logger = logging.getLogger(__name__)
29
-
30
- # ── Prompt sent to every provider ────────────────────────────────────────────
31
- _ANALYSIS_PROMPT = (
32
- "You are an expert in architectural drawings and CAD digitisation.\n"
33
- "Analyse this architectural drawing and respond ONLY with valid JSON "
34
- "(no markdown fences) using exactly these fields:\n\n"
35
- "{\n"
36
- ' "drawing_type": "floor plan | elevation | section | detail | site plan | unknown",\n'
37
- ' "scale": "e.g. 1:100, or null if not visible",\n'
38
- ' "elements": ["list of detected architectural elements"],\n'
39
- ' "rooms": ["list of room names if readable"],\n'
40
- ' "quality_score": 1-10,\n'
41
- ' "quality_notes": "brief assessment of drawing condition",\n'
42
- ' "ocr_difficulty": "easy | moderate | hard | very hard",\n'
43
- ' "digitisation_recommendations": ["actionable recommendations"]\n'
44
- "}"
45
- )
46
-
47
- # ── Provider registry ─────────────────────────────────────────────────────────
48
- # Each entry: { env_key, display_name, model_id, sdk_package }
49
- PROVIDERS: dict[str, dict] = {
50
- "anthropic": {
51
- "env_key": "ANTHROPIC_API_KEY",
52
- "display_name": "Anthropic Claude",
53
- "model_id": "claude-sonnet-4-6",
54
- "sdk_package": "anthropic",
55
- },
56
- "openai": {
57
- "env_key": "OPENAI_API_KEY",
58
- "display_name": "OpenAI GPT-4o",
59
- "model_id": "gpt-4o",
60
- "sdk_package": "openai",
61
- },
62
- "gemini": {
63
- "env_key": "GOOGLE_API_KEY",
64
- "display_name": "Google Gemini 2.0 Flash",
65
- "model_id": "gemini-2.0-flash",
66
- "sdk_package": "google.genai",
67
- },
68
- "groq": {
69
- "env_key": "GROQ_API_KEY",
70
- "display_name": "Groq Llama 4 Scout",
71
- "model_id": "meta-llama/llama-4-scout-17b-16e-instruct",
72
- "sdk_package": "groq",
73
- },
74
- }
75
-
76
- # Priority order when no provider is specified
77
- _DEFAULT_PRIORITY = ["anthropic", "openai", "groq", "gemini"]
78
-
79
-
80
- # ── Public helpers ────────────────────────────────────────────────────────────
81
-
82
- def list_providers() -> list[dict]:
83
- """Return status of every provider — used by /api/info."""
84
- result = []
85
- for name, cfg in PROVIDERS.items():
86
- key = os.getenv(cfg["env_key"], "").strip()
87
- result.append({
88
- "name": name,
89
- "display_name": cfg["display_name"],
90
- "model": cfg["model_id"],
91
- "configured": bool(key),
92
- })
93
- return result
94
-
95
-
96
- def get_default_provider() -> Optional[str]:
97
- """Return the name of the first provider that has a key set."""
98
- for name in _DEFAULT_PRIORITY:
99
- key = os.getenv(PROVIDERS[name]["env_key"], "").strip()
100
- if key:
101
- return name
102
- return None
103
-
104
-
105
- def analyze(
106
- image_bytes: bytes,
107
- filename: str,
108
- provider_name: Optional[str] = None,
109
- ) -> dict:
110
- """
111
- Run vision analysis on the image using the specified provider.
112
- Falls back to the first configured provider if none is specified.
113
-
114
- Returns the parsed JSON analysis dict.
115
- Raises ValueError if no provider is available.
116
- Raises RuntimeError on API call failure.
117
- """
118
- name = provider_name or get_default_provider()
119
- if name is None:
120
- raise ValueError(
121
- "No AI provider configured. Add at least one API key to backend/.env"
122
- )
123
- if name not in PROVIDERS:
124
- available = ", ".join(PROVIDERS.keys())
125
- raise ValueError(f"Unknown provider '{name}'. Choose from: {available}")
126
-
127
- cfg = PROVIDERS[name]
128
- api_key = os.getenv(cfg["env_key"], "").strip()
129
- if not api_key:
130
- raise ValueError(
131
- f"Provider '{name}' has no API key set. "
132
- f"Add {cfg['env_key']} to backend/.env"
133
- )
134
-
135
- media_type = _media_type(filename)
136
- logger.info("AI analysis via %s (%s)", cfg["display_name"], cfg["model_id"])
137
-
138
- if name == "anthropic":
139
- raw = _call_anthropic(api_key, cfg["model_id"], image_bytes, media_type)
140
- elif name == "openai":
141
- raw = _call_openai(api_key, cfg["model_id"], image_bytes, media_type)
142
- elif name == "gemini":
143
- raw = _call_gemini(api_key, cfg["model_id"], image_bytes, media_type)
144
- elif name == "groq":
145
- raw = _call_groq(api_key, cfg["model_id"], image_bytes, media_type)
146
- else:
147
- raise ValueError(f"No call handler for provider '{name}'")
148
-
149
- return _parse_response(raw, name)
150
-
151
-
152
- # ── Provider call implementations ─────────────────────────────────────────────
153
-
154
- def _call_anthropic(api_key: str, model: str, image_bytes: bytes, media_type: str) -> str:
155
- import anthropic
156
- client = anthropic.Anthropic(api_key=api_key)
157
- b64 = base64.b64encode(image_bytes).decode()
158
- msg = client.messages.create(
159
- model=model,
160
- max_tokens=1024,
161
- messages=[{
162
- "role": "user",
163
- "content": [
164
- {"type": "image", "source": {"type": "base64", "media_type": media_type, "data": b64}},
165
- {"type": "text", "text": _ANALYSIS_PROMPT},
166
- ],
167
- }],
168
- )
169
- return msg.content[0].text
170
-
171
-
172
- def _call_openai(api_key: str, model: str, image_bytes: bytes, media_type: str) -> str:
173
- from openai import OpenAI
174
- client = OpenAI(api_key=api_key)
175
- b64 = base64.b64encode(image_bytes).decode()
176
- data_url = f"data:{media_type};base64,{b64}"
177
- resp = client.chat.completions.create(
178
- model=model,
179
- max_tokens=1024,
180
- messages=[{
181
- "role": "user",
182
- "content": [
183
- {"type": "image_url", "image_url": {"url": data_url}},
184
- {"type": "text", "text": _ANALYSIS_PROMPT},
185
- ],
186
- }],
187
- )
188
- return resp.choices[0].message.content
189
-
190
-
191
- def _call_gemini(api_key: str, model: str, image_bytes: bytes, media_type: str) -> str:
192
- from google import genai
193
- from google.genai import types
194
-
195
- client = genai.Client(api_key=api_key)
196
- img_part = types.Part.from_bytes(data=image_bytes, mime_type=media_type)
197
- resp = client.models.generate_content(
198
- model=model,
199
- contents=[img_part, _ANALYSIS_PROMPT],
200
- config=types.GenerateContentConfig(
201
- safety_settings=[
202
- types.SafetySetting(category="HARM_CATEGORY_HARASSMENT", threshold="BLOCK_NONE"),
203
- types.SafetySetting(category="HARM_CATEGORY_HATE_SPEECH", threshold="BLOCK_NONE"),
204
- types.SafetySetting(category="HARM_CATEGORY_SEXUALLY_EXPLICIT", threshold="BLOCK_NONE"),
205
- types.SafetySetting(category="HARM_CATEGORY_DANGEROUS_CONTENT", threshold="BLOCK_NONE"),
206
- ]
207
- ),
208
- )
209
- return resp.text
210
-
211
-
212
- def _call_groq(api_key: str, model: str, image_bytes: bytes, media_type: str) -> str:
213
- import base64, warnings
214
- import httpx
215
- from groq import Groq
216
-
217
- # Corporate proxy uses a self-signed cert — bypass SSL verification
218
- with warnings.catch_warnings():
219
- warnings.simplefilter("ignore")
220
- http_client = httpx.Client(verify=False) # noqa: S501
221
- client = Groq(api_key=api_key, http_client=http_client)
222
-
223
- b64 = base64.b64encode(image_bytes).decode()
224
- data_url = f"data:{media_type};base64,{b64}"
225
- resp = client.chat.completions.create(
226
- model=model,
227
- max_tokens=1024,
228
- messages=[{
229
- "role": "user",
230
- "content": [
231
- {"type": "image_url", "image_url": {"url": data_url}},
232
- {"type": "text", "text": _ANALYSIS_PROMPT},
233
- ],
234
- }],
235
- )
236
- return resp.choices[0].message.content
237
-
238
-
239
- # ── Helpers ───────────────────────────────────────────────────────────────────
240
-
241
- def _media_type(filename: str) -> str:
242
- fname = (filename or "").lower()
243
- if fname.endswith(".png"):
244
- return "image/png"
245
- if fname.endswith(".webp"):
246
- return "image/webp"
247
- return "image/jpeg"
248
-
249
-
250
- def _parse_response(raw: str, provider: str) -> dict:
251
- text = raw.strip()
252
- if text.startswith("```"):
253
- parts = text.split("```")
254
- text = parts[1]
255
- if text.startswith("json"):
256
- text = text[4:]
257
- text = text.strip()
258
- try:
259
- return json.loads(text)
260
- except json.JSONDecodeError:
261
- logger.warning("Provider %s returned non-JSON: %s", provider, text[:200])
262
- return {"raw_response": text}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
benchmark/__init__.py DELETED
File without changes
benchmark/ground_truth.example.json DELETED
@@ -1,32 +0,0 @@
1
- {
2
- "_comment": "Copy this file to ground_truth.json and fill in values for your test images.",
3
- "_format": {
4
- "image_filename": {
5
- "drawing_type": "floor plan | elevation | section | detail | site plan",
6
- "expected_text": ["list", "of", "key", "labels", "that", "must", "be", "detected"],
7
- "expected_dimensions": ["3640mm", "2730mm"],
8
- "expected_room_names": ["Living Room", "Bedroom"],
9
- "expected_line_count_min": 10,
10
- "expected_line_count_max": 100,
11
- "notes": "Any context about this drawing"
12
- }
13
- },
14
- "sample_floor_plan.jpg": {
15
- "drawing_type": "floor plan",
16
- "expected_text": ["Living Room", "Bedroom", "Kitchen", "3640", "1:100"],
17
- "expected_dimensions": [],
18
- "expected_room_names": ["Living Room", "Bedroom", "Kitchen"],
19
- "expected_line_count_min": 20,
20
- "expected_line_count_max": 150,
21
- "notes": "Standard A3 floor plan, moderate yellowing"
22
- },
23
- "sample_elevation.jpg": {
24
- "drawing_type": "elevation",
25
- "expected_text": ["GL", "FL", "2F", "1F"],
26
- "expected_dimensions": [],
27
- "expected_room_names": [],
28
- "expected_line_count_min": 15,
29
- "expected_line_count_max": 80,
30
- "notes": "South elevation, faded ink"
31
- }
32
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
benchmark/ground_truth.json DELETED
@@ -1,25 +0,0 @@
1
- {
2
- "_comment": "Ground truth for benchmark/sample_images/*. See ground_truth.example.json for field meanings.",
3
-
4
- "floor_plan_level1.jpg": {
5
- "drawing_type": "floor plan",
6
- "expected_text": ["Bedroom 1", "Bedroom 2", "Kitchen", "Bathroom", "Living Room",
7
- "3,640 x 3,640", "2,730 x 4,000", "1,820 x 3,000", "8,190 x 4,000",
8
- "SCALE", "1:100", "FLOOR PLAN", "LEVEL 1"],
9
- "expected_dimensions": ["3,640 x 3,640", "2,730 x 4,000", "1,820 x 3,000", "8,190 x 4,000"],
10
- "expected_room_names": ["Bedroom 1", "Bedroom 2", "Kitchen", "Bathroom", "Living Room"],
11
- "expected_line_count_min": 15,
12
- "expected_line_count_max": 40,
13
- "notes": "4-room floor plan with door swings, dimension callouts, and a title block (SCALE / FLOOR PLAN — LEVEL 1). Line count reflects actual wall/curve segments (walls + 2 door-swing arcs, each arc approximated as several short polyline segments), not raw Hough fragments — see line_extractor.py's merge/curve-detection rewrite."
14
- },
15
-
16
- "synthetic_floor_plan.png": {
17
- "drawing_type": "floor plan",
18
- "expected_text": ["Bedroom", "Living Room", "3640mm", "1:100"],
19
- "expected_dimensions": ["3640mm"],
20
- "expected_room_names": ["Bedroom", "Living Room"],
21
- "expected_line_count_min": 4,
22
- "expected_line_count_max": 10,
23
- "notes": "Simple 2-room synthetic floor plan with one dimension callout and a scale label."
24
- }
25
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
benchmark/metrics.py DELETED
@@ -1,122 +0,0 @@
1
- """
2
- Accuracy metrics for the architectural drawing benchmark.
3
-
4
- All functions return float values in [0, 1] unless noted otherwise.
5
- """
6
-
7
- from __future__ import annotations
8
- import re
9
- from typing import List
10
-
11
-
12
- # ---------------------------------------------------------------------------
13
- # OCR metrics
14
- # ---------------------------------------------------------------------------
15
-
16
- def _normalize(s: str) -> str:
17
- """
18
- Normalize OCR text for comparison so a correctly-read character isn't
19
- scored as an error just because it differs in glyph from how the
20
- ground truth happens to be transcribed — e.g. PaddleOCR reads the
21
- drawing's actual '×' (U+00D7) multiplication sign for dimension
22
- callouts like "2,730 × 4,000", while ground truth was transcribed with
23
- a plain ASCII 'x'. Both represent the same text; only the codepoint
24
- differs.
25
- """
26
- return s.strip().lower().replace("×", "x")
27
-
28
-
29
- def character_error_rate(predicted: str, ground_truth: str) -> float:
30
- """
31
- Character Error Rate (CER) — standard metric for OCR quality.
32
-
33
- CER = edit_distance(predicted, ground_truth) / len(ground_truth)
34
-
35
- Lower is better. 0.0 = perfect, 1.0 = completely wrong.
36
- Returns 0.0 if ground_truth is empty.
37
- """
38
- if not ground_truth:
39
- return 0.0
40
- predicted, ground_truth = _normalize(predicted), _normalize(ground_truth)
41
- dist = _levenshtein(predicted, ground_truth)
42
- return min(dist / len(ground_truth), 1.0)
43
-
44
-
45
- def word_error_rate(predicted: str, ground_truth: str) -> float:
46
- """
47
- Word Error Rate (WER) — edit distance at word level.
48
-
49
- WER = edit_distance(pred_words, gt_words) / len(gt_words)
50
-
51
- Lower is better.
52
- """
53
- pred_words = _normalize(predicted).split()
54
- gt_words = _normalize(ground_truth).split()
55
- if not gt_words:
56
- return 0.0
57
- dist = _levenshtein(pred_words, gt_words)
58
- return min(dist / len(gt_words), 1.0)
59
-
60
-
61
- def token_recall(predicted_tokens: List[str], expected_tokens: List[str]) -> float:
62
- """
63
- Fraction of expected tokens found anywhere in the predicted token list.
64
-
65
- Useful for checking that key labels (room names, dimensions) were detected
66
- without penalising extra detections.
67
- """
68
- if not expected_tokens:
69
- return 1.0
70
- pred_set = {_normalize(t) for t in predicted_tokens}
71
- hits = sum(1 for t in expected_tokens if _normalize(t) in pred_set)
72
- return hits / len(expected_tokens)
73
-
74
-
75
- def token_precision(predicted_tokens: List[str], expected_tokens: List[str]) -> float:
76
- """
77
- Fraction of predicted tokens that appear in the expected list.
78
- """
79
- if not predicted_tokens:
80
- return 1.0 if not expected_tokens else 0.0
81
- exp_set = {_normalize(t) for t in expected_tokens}
82
- hits = sum(1 for t in predicted_tokens if _normalize(t) in exp_set)
83
- return hits / len(predicted_tokens)
84
-
85
-
86
- def f1(precision: float, recall: float) -> float:
87
- if precision + recall == 0:
88
- return 0.0
89
- return 2 * precision * recall / (precision + recall)
90
-
91
-
92
- # ---------------------------------------------------------------------------
93
- # Line detection metrics
94
- # ---------------------------------------------------------------------------
95
-
96
- def line_count_accuracy(detected: int, expected_min: int, expected_max: int) -> float:
97
- """
98
- Returns 1.0 if detected count is within the expected range,
99
- otherwise a linear penalty based on how far outside the range it falls.
100
- """
101
- if expected_min <= detected <= expected_max:
102
- return 1.0
103
- midpoint = (expected_min + expected_max) / 2
104
- deviation = abs(detected - midpoint) / max(midpoint, 1)
105
- return max(0.0, 1.0 - deviation)
106
-
107
-
108
- # ---------------------------------------------------------------------------
109
- # Internal helpers
110
- # ---------------------------------------------------------------------------
111
-
112
- def _levenshtein(a, b) -> int:
113
- """Generic Levenshtein distance — works on strings or lists."""
114
- m, n = len(a), len(b)
115
- dp = list(range(n + 1))
116
- for i in range(1, m + 1):
117
- prev = dp[:]
118
- dp[0] = i
119
- for j in range(1, n + 1):
120
- cost = 0 if a[i - 1] == b[j - 1] else 1
121
- dp[j] = min(dp[j] + 1, dp[j - 1] + 1, prev[j - 1] + cost)
122
- return dp[n]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
benchmark/results/after_fix.json DELETED
@@ -1,160 +0,0 @@
1
- {
2
- "metadata": {
3
- "timestamp": "2026-07-07T11:17:46.470453",
4
- "image_count": 2,
5
- "ground_truth": "benchmark/ground_truth.json",
6
- "output_path": "benchmark/results/after_fix.json",
7
- "pipeline_version": "1.0.0"
8
- },
9
- "images": [
10
- {
11
- "filename": "floor_plan_level1.jpg",
12
- "size_bytes": 233218,
13
- "dimensions": {
14
- "width": 1400,
15
- "height": 1000
16
- },
17
- "stages": {
18
- "enhancement": {
19
- "success": true,
20
- "time_s": 0.647
21
- },
22
- "line_extraction": {
23
- "success": true,
24
- "time_s": 0.011,
25
- "lines_detected": 243,
26
- "horizontal": 223,
27
- "vertical": 20,
28
- "diagonal": 0
29
- },
30
- "ocr": {
31
- "success": true,
32
- "time_s": 1.013,
33
- "element_count": 17,
34
- "full_text": "tum ttや5R5R5t5や5や5つOOつつつつつつべつべつべべ\nBedroom 1 Kitchen Bathroom\n3,640 x 3,640 2,730 x 4,000 1,820 x 3,000\nBedroom 2 Living Room\n3,640 x 5,640 8,190 x 4,000\n— oo oo Hr rT HH\nSCALE 1:10 g00mm FLOOR PLAN — LEVEL 1 6,400mm",
35
- "classified": {
36
- "dimensions": [
37
- "3,640 x 3,640",
38
- "2,730 x 4,000",
39
- "1,820 x 3,000",
40
- "3,640 x 5,640",
41
- "8,190 x 4,000",
42
- "6,400mm"
43
- ],
44
- "room_names": [
45
- "Bedroom 1",
46
- "Kitchen",
47
- "Bathroom",
48
- "Bedroom 2",
49
- "Living Room"
50
- ],
51
- "numbers": [],
52
- "labels": [
53
- "oo",
54
- "rT",
55
- "SCALE",
56
- "g00mm",
57
- "FLOOR PLAN",
58
- "LEVEL 1"
59
- ]
60
- }
61
- },
62
- "svg": {
63
- "success": true,
64
- "time_s": 0.006,
65
- "svg_size_bytes": 21209
66
- }
67
- },
68
- "errors": [],
69
- "total_time_s": 1.677,
70
- "accuracy": {
71
- "ocr_cer": 1.0,
72
- "ocr_wer": 0.8462,
73
- "ocr_token_recall": 0.9231,
74
- "ocr_token_precision": 0.7059,
75
- "ocr_token_f1": 0.8,
76
- "room_name_recall": 1.0,
77
- "line_count_accuracy": 1.0
78
- }
79
- },
80
- {
81
- "filename": "synthetic_floor_plan.png",
82
- "size_bytes": 12406,
83
- "dimensions": {
84
- "width": 1200,
85
- "height": 900
86
- },
87
- "stages": {
88
- "enhancement": {
89
- "success": true,
90
- "time_s": 0.567
91
- },
92
- "line_extraction": {
93
- "success": true,
94
- "time_s": 0.005,
95
- "lines_detected": 26,
96
- "horizontal": 20,
97
- "vertical": 6,
98
- "diagonal": 0
99
- },
100
- "ocr": {
101
- "success": true,
102
- "time_s": 0.381,
103
- "element_count": 3,
104
- "full_text": "ーー ーー\n3640mm\nLiving Room\n1:100",
105
- "classified": {
106
- "dimensions": [
107
- "3640mm"
108
- ],
109
- "room_names": [
110
- "Living Room"
111
- ],
112
- "numbers": [],
113
- "labels": [
114
- "1:100"
115
- ]
116
- }
117
- },
118
- "svg": {
119
- "success": true,
120
- "time_s": 0.001,
121
- "svg_size_bytes": 2698
122
- }
123
- },
124
- "errors": [],
125
- "total_time_s": 0.954,
126
- "accuracy": {
127
- "ocr_cer": 0.7812,
128
- "ocr_wer": 0.8,
129
- "ocr_token_recall": 0.75,
130
- "ocr_token_precision": 1.0,
131
- "ocr_token_f1": 0.8571,
132
- "room_name_recall": 0.5,
133
- "line_count_accuracy": 1.0
134
- }
135
- }
136
- ],
137
- "aggregate": {
138
- "avg_ocr_cer": 0.8906,
139
- "min_ocr_cer": 0.7812,
140
- "max_ocr_cer": 1.0,
141
- "avg_ocr_wer": 0.8231,
142
- "min_ocr_wer": 0.8,
143
- "max_ocr_wer": 0.8462,
144
- "avg_ocr_token_recall": 0.8366,
145
- "min_ocr_token_recall": 0.75,
146
- "max_ocr_token_recall": 0.9231,
147
- "avg_ocr_token_precision": 0.8529,
148
- "min_ocr_token_precision": 0.7059,
149
- "max_ocr_token_precision": 1.0,
150
- "avg_ocr_token_f1": 0.8286,
151
- "min_ocr_token_f1": 0.8,
152
- "max_ocr_token_f1": 0.8571,
153
- "avg_room_name_recall": 0.75,
154
- "min_room_name_recall": 0.5,
155
- "max_room_name_recall": 1.0,
156
- "avg_line_count_accuracy": 1.0,
157
- "min_line_count_accuracy": 1.0,
158
- "max_line_count_accuracy": 1.0
159
- }
160
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
benchmark/results/benchmark_20260629_114932.json DELETED
@@ -1,61 +0,0 @@
1
- {
2
- "metadata": {
3
- "timestamp": "2026-06-29T11:49:32.822180",
4
- "image_count": 1,
5
- "ground_truth": null,
6
- "output_path": "/Users/aaronlee/Downloads/architectural-drawing-poc/backend/benchmark/results/benchmark_20260629_114932.json",
7
- "pipeline_version": "1.0.0"
8
- },
9
- "images": [
10
- {
11
- "filename": "synthetic_floor_plan.png",
12
- "size_bytes": 12406,
13
- "dimensions": {
14
- "width": 1200,
15
- "height": 900
16
- },
17
- "stages": {
18
- "enhancement": {
19
- "success": true,
20
- "time_s": 0.149
21
- },
22
- "line_extraction": {
23
- "success": true,
24
- "time_s": 0.004,
25
- "lines_detected": 20,
26
- "horizontal": 14,
27
- "vertical": 6,
28
- "diagonal": 0
29
- },
30
- "ocr": {
31
- "success": true,
32
- "time_s": 0.247,
33
- "element_count": 5,
34
- "full_text": "_ _\n3640mm\nLiving Room\n1:100",
35
- "classified": {
36
- "dimensions": [
37
- "3640mm"
38
- ],
39
- "room_names": [
40
- "Living",
41
- "Room"
42
- ],
43
- "numbers": [],
44
- "labels": [
45
- "_",
46
- "1:100"
47
- ]
48
- }
49
- },
50
- "svg": {
51
- "success": true,
52
- "time_s": 0.001,
53
- "svg_size_bytes": 2307
54
- }
55
- },
56
- "errors": [],
57
- "total_time_s": 0.401
58
- }
59
- ],
60
- "aggregate": {}
61
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
benchmark/results/benchmark_20260629_115100.json DELETED
@@ -1,92 +0,0 @@
1
- {
2
- "metadata": {
3
- "timestamp": "2026-06-29T11:51:01.173922",
4
- "image_count": 1,
5
- "ground_truth": "benchmark/ground_truth_test.json",
6
- "output_path": "/Users/aaronlee/Downloads/architectural-drawing-poc/backend/benchmark/results/benchmark_20260629_115100.json",
7
- "pipeline_version": "1.0.0"
8
- },
9
- "images": [
10
- {
11
- "filename": "synthetic_floor_plan.png",
12
- "size_bytes": 12406,
13
- "dimensions": {
14
- "width": 1200,
15
- "height": 900
16
- },
17
- "stages": {
18
- "enhancement": {
19
- "success": true,
20
- "time_s": 0.115
21
- },
22
- "line_extraction": {
23
- "success": true,
24
- "time_s": 0.004,
25
- "lines_detected": 20,
26
- "horizontal": 14,
27
- "vertical": 6,
28
- "diagonal": 0
29
- },
30
- "ocr": {
31
- "success": true,
32
- "time_s": 0.255,
33
- "element_count": 5,
34
- "full_text": "_ _\n3640mm\nLiving Room\n1:100",
35
- "classified": {
36
- "dimensions": [
37
- "3640mm"
38
- ],
39
- "room_names": [
40
- "Living",
41
- "Room"
42
- ],
43
- "numbers": [],
44
- "labels": [
45
- "_",
46
- "1:100"
47
- ]
48
- }
49
- },
50
- "svg": {
51
- "success": true,
52
- "time_s": 0.001,
53
- "svg_size_bytes": 2307
54
- }
55
- },
56
- "errors": [],
57
- "total_time_s": 0.375,
58
- "accuracy": {
59
- "ocr_cer": 0.75,
60
- "ocr_wer": 1.0,
61
- "ocr_token_recall": 0.5,
62
- "ocr_token_precision": 0.4,
63
- "ocr_token_f1": 0.4444,
64
- "room_name_recall": 0.0,
65
- "line_count_accuracy": 1.0
66
- }
67
- }
68
- ],
69
- "aggregate": {
70
- "avg_ocr_cer": 0.75,
71
- "min_ocr_cer": 0.75,
72
- "max_ocr_cer": 0.75,
73
- "avg_ocr_wer": 1.0,
74
- "min_ocr_wer": 1.0,
75
- "max_ocr_wer": 1.0,
76
- "avg_ocr_token_recall": 0.5,
77
- "min_ocr_token_recall": 0.5,
78
- "max_ocr_token_recall": 0.5,
79
- "avg_ocr_token_precision": 0.4,
80
- "min_ocr_token_precision": 0.4,
81
- "max_ocr_token_precision": 0.4,
82
- "avg_ocr_token_f1": 0.4444,
83
- "min_ocr_token_f1": 0.4444,
84
- "max_ocr_token_f1": 0.4444,
85
- "avg_room_name_recall": 0.0,
86
- "min_room_name_recall": 0.0,
87
- "max_room_name_recall": 0.0,
88
- "avg_line_count_accuracy": 1.0,
89
- "min_line_count_accuracy": 1.0,
90
- "max_line_count_accuracy": 1.0
91
- }
92
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
benchmark/results/benchmark_20260707_110502.json DELETED
@@ -1,139 +0,0 @@
1
- {
2
- "metadata": {
3
- "timestamp": "2026-07-07T11:05:05.449218",
4
- "image_count": 2,
5
- "ground_truth": null,
6
- "output_path": "/Users/aaronlee/Downloads/architectural-drawing-poc/backend/benchmark/results/benchmark_20260707_110502.json",
7
- "pipeline_version": "1.0.0"
8
- },
9
- "images": [
10
- {
11
- "filename": "floor_plan_level1.jpg",
12
- "size_bytes": 233218,
13
- "dimensions": {
14
- "width": 1400,
15
- "height": 1000
16
- },
17
- "stages": {
18
- "enhancement": {
19
- "success": true,
20
- "time_s": 0.627
21
- },
22
- "line_extraction": {
23
- "success": true,
24
- "time_s": 0.011,
25
- "lines_detected": 242,
26
- "horizontal": 221,
27
- "vertical": 21,
28
- "diagonal": 0
29
- },
30
- "ocr": {
31
- "success": true,
32
- "time_s": 1.064,
33
- "element_count": 32,
34
- "full_text": "a1 a\n\nBedroom 1 Kitchen Bathroom\n\n3,640 x 3,640 2,730 x 4,000 1,820 x 3,000\n\nBedroom 2 Living Room\n\n3,640 x 5,640 8,190 x 4,000\nmm\nSCALE 11100600mm FLOOR PLAN — LEVEL 1 6.400mm",
35
- "classified": {
36
- "dimensions": [
37
- "6.400mm"
38
- ],
39
- "room_names": [
40
- "Bedroom",
41
- "Kitchen",
42
- "Bathroom",
43
- "Bedroom",
44
- "Living",
45
- "Room",
46
- "SCALE",
47
- "FLOOR",
48
- "PLAN",
49
- "LEVEL"
50
- ],
51
- "numbers": [
52
- "1",
53
- "2",
54
- "1"
55
- ],
56
- "labels": [
57
- "a1",
58
- "a",
59
- "3,640",
60
- "x",
61
- "3,640",
62
- "2,730",
63
- "x",
64
- "4,000",
65
- "1,820",
66
- "x",
67
- "3,000",
68
- "3,640",
69
- "x",
70
- "5,640",
71
- "8,190",
72
- "x",
73
- "4,000",
74
- "—"
75
- ]
76
- }
77
- },
78
- "svg": {
79
- "success": true,
80
- "time_s": 0.006,
81
- "svg_size_bytes": 22576
82
- }
83
- },
84
- "errors": [],
85
- "total_time_s": 1.708
86
- },
87
- {
88
- "filename": "synthetic_floor_plan.png",
89
- "size_bytes": 12406,
90
- "dimensions": {
91
- "width": 1200,
92
- "height": 900
93
- },
94
- "stages": {
95
- "enhancement": {
96
- "success": true,
97
- "time_s": 0.542
98
- },
99
- "line_extraction": {
100
- "success": true,
101
- "time_s": 0.006,
102
- "lines_detected": 26,
103
- "horizontal": 20,
104
- "vertical": 6,
105
- "diagonal": 0
106
- },
107
- "ocr": {
108
- "success": true,
109
- "time_s": 0.38,
110
- "element_count": 6,
111
- "full_text": "ーー ーー\n3640mm\nLiving Room\n1:100",
112
- "classified": {
113
- "dimensions": [
114
- "3640mm"
115
- ],
116
- "room_names": [
117
- "Living",
118
- "Room"
119
- ],
120
- "numbers": [],
121
- "labels": [
122
- "ーー",
123
- "ーー",
124
- "1:100"
125
- ]
126
- }
127
- },
128
- "svg": {
129
- "success": true,
130
- "time_s": 0.001,
131
- "svg_size_bytes": 2982
132
- }
133
- },
134
- "errors": [],
135
- "total_time_s": 0.929
136
- }
137
- ],
138
- "aggregate": {}
139
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
benchmark/results/benchmark_20260707_111530.json DELETED
@@ -1,121 +0,0 @@
1
- {
2
- "metadata": {
3
- "timestamp": "2026-07-07T11:15:32.885738",
4
- "image_count": 2,
5
- "ground_truth": null,
6
- "output_path": "/Users/aaronlee/Downloads/architectural-drawing-poc/backend/benchmark/results/benchmark_20260707_111530.json",
7
- "pipeline_version": "1.0.0"
8
- },
9
- "images": [
10
- {
11
- "filename": "floor_plan_level1.jpg",
12
- "size_bytes": 233218,
13
- "dimensions": {
14
- "width": 1400,
15
- "height": 1000
16
- },
17
- "stages": {
18
- "enhancement": {
19
- "success": true,
20
- "time_s": 0.635
21
- },
22
- "line_extraction": {
23
- "success": true,
24
- "time_s": 0.01,
25
- "lines_detected": 243,
26
- "horizontal": 223,
27
- "vertical": 20,
28
- "diagonal": 0
29
- },
30
- "ocr": {
31
- "success": true,
32
- "time_s": 0.982,
33
- "element_count": 18,
34
- "full_text": "tum ttや5R5R5t5や5や5つOOつつつつつつべつべつべべ\nBedroom 1 Kitchen Bathroom\n3,640 x 3,640 2,730 x 4,000 1,820 x 3,000\nBedroom 2 Living Room\n3,640 x 5,640 8,190 x 4,000\n— oo oo Hr rT HH\nSCALE 1:10 g00mm FLOOR PLAN — LEVEL 1 6,400mm",
35
- "classified": {
36
- "dimensions": [
37
- "3,640 x 3,640",
38
- "2,730 x 4,000",
39
- "1,820 x 3,000",
40
- "3,640 x 5,640",
41
- "8,190 x 4,000",
42
- "6,400mm"
43
- ],
44
- "room_names": [
45
- "Bedroom 1",
46
- "Kitchen",
47
- "Bathroom",
48
- "Bedroom 2",
49
- "Living Room"
50
- ],
51
- "numbers": [],
52
- "labels": [
53
- "5 つつ つつ べつ べつ",
54
- "oo",
55
- "rT",
56
- "SCALE",
57
- "g00mm",
58
- "FLOOR PLAN",
59
- "LEVEL 1"
60
- ]
61
- }
62
- },
63
- "svg": {
64
- "success": true,
65
- "time_s": 0.005,
66
- "svg_size_bytes": 21209
67
- }
68
- },
69
- "errors": [],
70
- "total_time_s": 1.632
71
- },
72
- {
73
- "filename": "synthetic_floor_plan.png",
74
- "size_bytes": 12406,
75
- "dimensions": {
76
- "width": 1200,
77
- "height": 900
78
- },
79
- "stages": {
80
- "enhancement": {
81
- "success": true,
82
- "time_s": 0.553
83
- },
84
- "line_extraction": {
85
- "success": true,
86
- "time_s": 0.005,
87
- "lines_detected": 26,
88
- "horizontal": 20,
89
- "vertical": 6,
90
- "diagonal": 0
91
- },
92
- "ocr": {
93
- "success": true,
94
- "time_s": 0.379,
95
- "element_count": 3,
96
- "full_text": "ーー ーー\n3640mm\nLiving Room\n1:100",
97
- "classified": {
98
- "dimensions": [
99
- "3640mm"
100
- ],
101
- "room_names": [
102
- "Living Room"
103
- ],
104
- "numbers": [],
105
- "labels": [
106
- "1:100"
107
- ]
108
- }
109
- },
110
- "svg": {
111
- "success": true,
112
- "time_s": 0.001,
113
- "svg_size_bytes": 2698
114
- }
115
- },
116
- "errors": [],
117
- "total_time_s": 0.938
118
- }
119
- ],
120
- "aggregate": {}
121
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
benchmark/results/benchmark_20260707_111703.json DELETED
@@ -1,120 +0,0 @@
1
- {
2
- "metadata": {
3
- "timestamp": "2026-07-07T11:17:05.935392",
4
- "image_count": 2,
5
- "ground_truth": null,
6
- "output_path": "/Users/aaronlee/Downloads/architectural-drawing-poc/backend/benchmark/results/benchmark_20260707_111703.json",
7
- "pipeline_version": "1.0.0"
8
- },
9
- "images": [
10
- {
11
- "filename": "floor_plan_level1.jpg",
12
- "size_bytes": 233218,
13
- "dimensions": {
14
- "width": 1400,
15
- "height": 1000
16
- },
17
- "stages": {
18
- "enhancement": {
19
- "success": true,
20
- "time_s": 0.635
21
- },
22
- "line_extraction": {
23
- "success": true,
24
- "time_s": 0.01,
25
- "lines_detected": 243,
26
- "horizontal": 223,
27
- "vertical": 20,
28
- "diagonal": 0
29
- },
30
- "ocr": {
31
- "success": true,
32
- "time_s": 0.973,
33
- "element_count": 17,
34
- "full_text": "tum ttや5R5R5t5や5や5つOOつつつつつつべつべつべべ\nBedroom 1 Kitchen Bathroom\n3,640 x 3,640 2,730 x 4,000 1,820 x 3,000\nBedroom 2 Living Room\n3,640 x 5,640 8,190 x 4,000\n— oo oo Hr rT HH\nSCALE 1:10 g00mm FLOOR PLAN — LEVEL 1 6,400mm",
35
- "classified": {
36
- "dimensions": [
37
- "3,640 x 3,640",
38
- "2,730 x 4,000",
39
- "1,820 x 3,000",
40
- "3,640 x 5,640",
41
- "8,190 x 4,000",
42
- "6,400mm"
43
- ],
44
- "room_names": [
45
- "Bedroom 1",
46
- "Kitchen",
47
- "Bathroom",
48
- "Bedroom 2",
49
- "Living Room"
50
- ],
51
- "numbers": [],
52
- "labels": [
53
- "oo",
54
- "rT",
55
- "SCALE",
56
- "g00mm",
57
- "FLOOR PLAN",
58
- "LEVEL 1"
59
- ]
60
- }
61
- },
62
- "svg": {
63
- "success": true,
64
- "time_s": 0.005,
65
- "svg_size_bytes": 21209
66
- }
67
- },
68
- "errors": [],
69
- "total_time_s": 1.623
70
- },
71
- {
72
- "filename": "synthetic_floor_plan.png",
73
- "size_bytes": 12406,
74
- "dimensions": {
75
- "width": 1200,
76
- "height": 900
77
- },
78
- "stages": {
79
- "enhancement": {
80
- "success": true,
81
- "time_s": 0.549
82
- },
83
- "line_extraction": {
84
- "success": true,
85
- "time_s": 0.005,
86
- "lines_detected": 26,
87
- "horizontal": 20,
88
- "vertical": 6,
89
- "diagonal": 0
90
- },
91
- "ocr": {
92
- "success": true,
93
- "time_s": 0.375,
94
- "element_count": 3,
95
- "full_text": "ーー ーー\n3640mm\nLiving Room\n1:100",
96
- "classified": {
97
- "dimensions": [
98
- "3640mm"
99
- ],
100
- "room_names": [
101
- "Living Room"
102
- ],
103
- "numbers": [],
104
- "labels": [
105
- "1:100"
106
- ]
107
- }
108
- },
109
- "svg": {
110
- "success": true,
111
- "time_s": 0.001,
112
- "svg_size_bytes": 2698
113
- }
114
- },
115
- "errors": [],
116
- "total_time_s": 0.93
117
- }
118
- ],
119
- "aggregate": {}
120
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
benchmark/results/benchmark_20260707_111914.json DELETED
@@ -1,160 +0,0 @@
1
- {
2
- "metadata": {
3
- "timestamp": "2026-07-07T11:19:17.139313",
4
- "image_count": 2,
5
- "ground_truth": "benchmark/ground_truth.json",
6
- "output_path": "/Users/aaronlee/Downloads/architectural-drawing-poc/backend/benchmark/results/benchmark_20260707_111914.json",
7
- "pipeline_version": "1.0.0"
8
- },
9
- "images": [
10
- {
11
- "filename": "floor_plan_level1.jpg",
12
- "size_bytes": 233218,
13
- "dimensions": {
14
- "width": 1400,
15
- "height": 1000
16
- },
17
- "stages": {
18
- "enhancement": {
19
- "success": true,
20
- "time_s": 0.636
21
- },
22
- "line_extraction": {
23
- "success": true,
24
- "time_s": 0.01,
25
- "lines_detected": 243,
26
- "horizontal": 223,
27
- "vertical": 20,
28
- "diagonal": 0
29
- },
30
- "ocr": {
31
- "success": true,
32
- "time_s": 0.565,
33
- "element_count": 17,
34
- "full_text": "7 1Z,000mm |\n\nBedroom 1 Kitchen Bathroom\n3,640 x 3,640 2,730 x 4,000 1,820 x 3,000\n\nBedroom 2 Living Room\n3,640 x 3,640 8,190 x 4,000\n\n1 {\n\"SCALE 1:100690mm FLOOR PLAN — LEVEL 1 6.400mm",
35
- "classified": {
36
- "dimensions": [
37
- "3,640 x 3,640",
38
- "2,730 x 4,000",
39
- "1,820 x 3,000",
40
- "3,640 x 3,640",
41
- "8,190 x 4,000",
42
- "6.400mm"
43
- ],
44
- "room_names": [
45
- "Bedroom 1",
46
- "Kitchen",
47
- "Bathroom",
48
- "Bedroom 2",
49
- "Living Room"
50
- ],
51
- "numbers": [
52
- "7",
53
- "1"
54
- ],
55
- "labels": [
56
- "{",
57
- "\"SCALE",
58
- "FLOOR PLAN",
59
- "LEVEL 1"
60
- ]
61
- }
62
- },
63
- "svg": {
64
- "success": true,
65
- "time_s": 0.006,
66
- "svg_size_bytes": 21483
67
- }
68
- },
69
- "errors": [],
70
- "total_time_s": 1.217,
71
- "accuracy": {
72
- "ocr_cer": 1.0,
73
- "ocr_wer": 0.7308,
74
- "ocr_token_recall": 0.8462,
75
- "ocr_token_precision": 0.7059,
76
- "ocr_token_f1": 0.7697,
77
- "room_name_recall": 1.0,
78
- "line_count_accuracy": 1.0
79
- }
80
- },
81
- {
82
- "filename": "synthetic_floor_plan.png",
83
- "size_bytes": 12406,
84
- "dimensions": {
85
- "width": 1200,
86
- "height": 900
87
- },
88
- "stages": {
89
- "enhancement": {
90
- "success": true,
91
- "time_s": 0.552
92
- },
93
- "line_extraction": {
94
- "success": true,
95
- "time_s": 0.005,
96
- "lines_detected": 26,
97
- "horizontal": 20,
98
- "vertical": 6,
99
- "diagonal": 0
100
- },
101
- "ocr": {
102
- "success": true,
103
- "time_s": 0.353,
104
- "element_count": 3,
105
- "full_text": "Bedroom\n\nLiving Room\n\n1:100",
106
- "classified": {
107
- "dimensions": [],
108
- "room_names": [
109
- "Bedroom",
110
- "Living Room"
111
- ],
112
- "numbers": [],
113
- "labels": [
114
- "1:100"
115
- ]
116
- }
117
- },
118
- "svg": {
119
- "success": true,
120
- "time_s": 0.001,
121
- "svg_size_bytes": 2699
122
- }
123
- },
124
- "errors": [],
125
- "total_time_s": 0.911,
126
- "accuracy": {
127
- "ocr_cer": 0.3125,
128
- "ocr_wer": 0.2,
129
- "ocr_token_recall": 0.75,
130
- "ocr_token_precision": 1.0,
131
- "ocr_token_f1": 0.8571,
132
- "room_name_recall": 1.0,
133
- "line_count_accuracy": 1.0
134
- }
135
- }
136
- ],
137
- "aggregate": {
138
- "avg_ocr_cer": 0.6562,
139
- "min_ocr_cer": 0.3125,
140
- "max_ocr_cer": 1.0,
141
- "avg_ocr_wer": 0.4654,
142
- "min_ocr_wer": 0.2,
143
- "max_ocr_wer": 0.7308,
144
- "avg_ocr_token_recall": 0.7981,
145
- "min_ocr_token_recall": 0.75,
146
- "max_ocr_token_recall": 0.8462,
147
- "avg_ocr_token_precision": 0.8529,
148
- "min_ocr_token_precision": 0.7059,
149
- "max_ocr_token_precision": 1.0,
150
- "avg_ocr_token_f1": 0.8134,
151
- "min_ocr_token_f1": 0.7697,
152
- "max_ocr_token_f1": 0.8571,
153
- "avg_room_name_recall": 1.0,
154
- "min_room_name_recall": 1.0,
155
- "max_room_name_recall": 1.0,
156
- "avg_line_count_accuracy": 1.0,
157
- "min_line_count_accuracy": 1.0,
158
- "max_line_count_accuracy": 1.0
159
- }
160
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
benchmark/results/benchmark_20260707_112107.json DELETED
@@ -1,163 +0,0 @@
1
- {
2
- "metadata": {
3
- "timestamp": "2026-07-07T11:21:10.382211",
4
- "image_count": 2,
5
- "ground_truth": "benchmark/ground_truth.json",
6
- "output_path": "/Users/aaronlee/Downloads/architectural-drawing-poc/backend/benchmark/results/benchmark_20260707_112107.json",
7
- "pipeline_version": "1.0.0"
8
- },
9
- "images": [
10
- {
11
- "filename": "floor_plan_level1.jpg",
12
- "size_bytes": 233218,
13
- "dimensions": {
14
- "width": 1400,
15
- "height": 1000
16
- },
17
- "stages": {
18
- "enhancement": {
19
- "success": true,
20
- "time_s": 0.627
21
- },
22
- "line_extraction": {
23
- "success": true,
24
- "time_s": 0.01,
25
- "lines_detected": 243,
26
- "horizontal": 223,
27
- "vertical": 20,
28
- "diagonal": 0
29
- },
30
- "ocr": {
31
- "success": true,
32
- "time_s": 0.843,
33
- "element_count": 18,
34
- "full_text": "7 1Z,000mm |\n\nBedroom 1 Kitchen Bathroom\n3,640 x 3,640 2,730 x 4,000 1,820 x 3,000\n\nBedroom 2 Living Room\n3,640 x 3,640 8,190 x 4,000\n\n1 {\n\"SCALE 1:100690mm FLOOR PLAN — LEVEL 1 6.400mm",
35
- "classified": {
36
- "dimensions": [
37
- "3,640 x 3,640",
38
- "2,730 x 4,000",
39
- "1,820 x 3,000",
40
- "3,640 x 3,640",
41
- "8,190 x 4,000",
42
- "6.400mm"
43
- ],
44
- "room_names": [
45
- "Bedroom 1",
46
- "Kitchen",
47
- "Bathroom",
48
- "Bedroom 2",
49
- "Living Room"
50
- ],
51
- "numbers": [
52
- "7",
53
- "1"
54
- ],
55
- "labels": [
56
- "{",
57
- "\"SCALE",
58
- "FLOOR PLAN",
59
- "LEVEL 1",
60
- "a"
61
- ]
62
- }
63
- },
64
- "svg": {
65
- "success": true,
66
- "time_s": 0.005,
67
- "svg_size_bytes": 21575
68
- }
69
- },
70
- "errors": [],
71
- "total_time_s": 1.485,
72
- "accuracy": {
73
- "ocr_cer": 1.0,
74
- "ocr_wer": 0.7308,
75
- "ocr_token_recall": 0.8462,
76
- "ocr_token_precision": 0.6667,
77
- "ocr_token_f1": 0.7458,
78
- "room_name_recall": 1.0,
79
- "line_count_accuracy": 1.0
80
- }
81
- },
82
- {
83
- "filename": "synthetic_floor_plan.png",
84
- "size_bytes": 12406,
85
- "dimensions": {
86
- "width": 1200,
87
- "height": 900
88
- },
89
- "stages": {
90
- "enhancement": {
91
- "success": true,
92
- "time_s": 0.549
93
- },
94
- "line_extraction": {
95
- "success": true,
96
- "time_s": 0.005,
97
- "lines_detected": 26,
98
- "horizontal": 20,
99
- "vertical": 6,
100
- "diagonal": 0
101
- },
102
- "ocr": {
103
- "success": true,
104
- "time_s": 0.532,
105
- "element_count": 4,
106
- "full_text": "Bedroom\n\nLiving Room\n\n1:100",
107
- "classified": {
108
- "dimensions": [
109
- "3640mm"
110
- ],
111
- "room_names": [
112
- "Bedroom",
113
- "Living Room"
114
- ],
115
- "numbers": [],
116
- "labels": [
117
- "1:100"
118
- ]
119
- }
120
- },
121
- "svg": {
122
- "success": true,
123
- "time_s": 0.001,
124
- "svg_size_bytes": 2796
125
- }
126
- },
127
- "errors": [],
128
- "total_time_s": 1.087,
129
- "accuracy": {
130
- "ocr_cer": 0.3125,
131
- "ocr_wer": 0.2,
132
- "ocr_token_recall": 1.0,
133
- "ocr_token_precision": 1.0,
134
- "ocr_token_f1": 1.0,
135
- "room_name_recall": 1.0,
136
- "line_count_accuracy": 1.0
137
- }
138
- }
139
- ],
140
- "aggregate": {
141
- "avg_ocr_cer": 0.6562,
142
- "min_ocr_cer": 0.3125,
143
- "max_ocr_cer": 1.0,
144
- "avg_ocr_wer": 0.4654,
145
- "min_ocr_wer": 0.2,
146
- "max_ocr_wer": 0.7308,
147
- "avg_ocr_token_recall": 0.9231,
148
- "min_ocr_token_recall": 0.8462,
149
- "max_ocr_token_recall": 1.0,
150
- "avg_ocr_token_precision": 0.8334,
151
- "min_ocr_token_precision": 0.6667,
152
- "max_ocr_token_precision": 1.0,
153
- "avg_ocr_token_f1": 0.8729,
154
- "min_ocr_token_f1": 0.7458,
155
- "max_ocr_token_f1": 1.0,
156
- "avg_room_name_recall": 1.0,
157
- "min_room_name_recall": 1.0,
158
- "max_room_name_recall": 1.0,
159
- "avg_line_count_accuracy": 1.0,
160
- "min_line_count_accuracy": 1.0,
161
- "max_line_count_accuracy": 1.0
162
- }
163
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
benchmark/results/benchmark_20260707_150812.json DELETED
@@ -1,163 +0,0 @@
1
- {
2
- "metadata": {
3
- "timestamp": "2026-07-07T15:08:17.169789",
4
- "image_count": 2,
5
- "ground_truth": "benchmark/ground_truth.json",
6
- "output_path": "/Users/aaronlee/Downloads/architectural-drawing-poc/backend/benchmark/results/benchmark_20260707_150812.json",
7
- "pipeline_version": "1.0.0"
8
- },
9
- "images": [
10
- {
11
- "filename": "floor_plan_level1.jpg",
12
- "size_bytes": 233218,
13
- "dimensions": {
14
- "width": 1400,
15
- "height": 1000
16
- },
17
- "stages": {
18
- "enhancement": {
19
- "success": true,
20
- "time_s": 0.979
21
- },
22
- "line_extraction": {
23
- "success": true,
24
- "time_s": 0.013,
25
- "lines_detected": 243,
26
- "horizontal": 223,
27
- "vertical": 20,
28
- "diagonal": 0
29
- },
30
- "ocr": {
31
- "success": true,
32
- "time_s": 2.075,
33
- "element_count": 18,
34
- "full_text": "7 1Z,000mm |\n\nBedroom 1 Kitchen Bathroom\n3,640 x 3,640 2,730 x 4,000 1,820 x 3,000\n\nBedroom 2 Living Room\n3,640 x 3,640 8,190 x 4,000\n\n1 {\n\"SCALE 1:100690mm FLOOR PLAN — LEVEL 1 6.400mm",
35
- "classified": {
36
- "dimensions": [
37
- "3,640 x 3,640",
38
- "2,730 x 4,000",
39
- "1,820 x 3,000",
40
- "3,640 x 3,640",
41
- "8,190 x 4,000",
42
- "6.400mm"
43
- ],
44
- "room_names": [
45
- "Bedroom 1",
46
- "Kitchen",
47
- "Bathroom",
48
- "Bedroom 2",
49
- "Living Room"
50
- ],
51
- "numbers": [
52
- "7",
53
- "1"
54
- ],
55
- "labels": [
56
- "{",
57
- "\"SCALE",
58
- "FLOOR PLAN",
59
- "LEVEL 1",
60
- "a"
61
- ]
62
- }
63
- },
64
- "svg": {
65
- "success": true,
66
- "time_s": 0.007,
67
- "svg_size_bytes": 21575
68
- }
69
- },
70
- "errors": [],
71
- "total_time_s": 3.074,
72
- "accuracy": {
73
- "ocr_cer": 1.0,
74
- "ocr_wer": 0.7308,
75
- "ocr_token_recall": 0.8462,
76
- "ocr_token_precision": 0.6667,
77
- "ocr_token_f1": 0.7458,
78
- "room_name_recall": 1.0,
79
- "line_count_accuracy": 1.0
80
- }
81
- },
82
- {
83
- "filename": "synthetic_floor_plan.png",
84
- "size_bytes": 12406,
85
- "dimensions": {
86
- "width": 1200,
87
- "height": 900
88
- },
89
- "stages": {
90
- "enhancement": {
91
- "success": true,
92
- "time_s": 0.626
93
- },
94
- "line_extraction": {
95
- "success": true,
96
- "time_s": 0.006,
97
- "lines_detected": 26,
98
- "horizontal": 20,
99
- "vertical": 6,
100
- "diagonal": 0
101
- },
102
- "ocr": {
103
- "success": true,
104
- "time_s": 0.596,
105
- "element_count": 4,
106
- "full_text": "Bedroom\n\nLiving Room\n\n1:100",
107
- "classified": {
108
- "dimensions": [
109
- "3640mm"
110
- ],
111
- "room_names": [
112
- "Bedroom",
113
- "Living Room"
114
- ],
115
- "numbers": [],
116
- "labels": [
117
- "1:100"
118
- ]
119
- }
120
- },
121
- "svg": {
122
- "success": true,
123
- "time_s": 0.003,
124
- "svg_size_bytes": 2796
125
- }
126
- },
127
- "errors": [],
128
- "total_time_s": 1.231,
129
- "accuracy": {
130
- "ocr_cer": 0.3125,
131
- "ocr_wer": 0.2,
132
- "ocr_token_recall": 1.0,
133
- "ocr_token_precision": 1.0,
134
- "ocr_token_f1": 1.0,
135
- "room_name_recall": 1.0,
136
- "line_count_accuracy": 1.0
137
- }
138
- }
139
- ],
140
- "aggregate": {
141
- "avg_ocr_cer": 0.6562,
142
- "min_ocr_cer": 0.3125,
143
- "max_ocr_cer": 1.0,
144
- "avg_ocr_wer": 0.4654,
145
- "min_ocr_wer": 0.2,
146
- "max_ocr_wer": 0.7308,
147
- "avg_ocr_token_recall": 0.9231,
148
- "min_ocr_token_recall": 0.8462,
149
- "max_ocr_token_recall": 1.0,
150
- "avg_ocr_token_precision": 0.8334,
151
- "min_ocr_token_precision": 0.6667,
152
- "max_ocr_token_precision": 1.0,
153
- "avg_ocr_token_f1": 0.8729,
154
- "min_ocr_token_f1": 0.7458,
155
- "max_ocr_token_f1": 1.0,
156
- "avg_room_name_recall": 1.0,
157
- "min_room_name_recall": 1.0,
158
- "max_room_name_recall": 1.0,
159
- "avg_line_count_accuracy": 1.0,
160
- "min_line_count_accuracy": 1.0,
161
- "max_line_count_accuracy": 1.0
162
- }
163
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
benchmark/results/benchmark_20260707_163623.json DELETED
@@ -1,163 +0,0 @@
1
- {
2
- "metadata": {
3
- "timestamp": "2026-07-07T16:36:26.607603",
4
- "image_count": 2,
5
- "ground_truth": "benchmark/ground_truth.json",
6
- "output_path": "/Users/aaronlee/Downloads/architectural-drawing-poc/backend/benchmark/results/benchmark_20260707_163623.json",
7
- "pipeline_version": "1.0.0"
8
- },
9
- "images": [
10
- {
11
- "filename": "floor_plan_level1.jpg",
12
- "size_bytes": 233218,
13
- "dimensions": {
14
- "width": 1400,
15
- "height": 1000
16
- },
17
- "stages": {
18
- "enhancement": {
19
- "success": true,
20
- "time_s": 0.684
21
- },
22
- "line_extraction": {
23
- "success": true,
24
- "time_s": 0.027,
25
- "lines_detected": 27,
26
- "horizontal": 8,
27
- "vertical": 9,
28
- "diagonal": 10
29
- },
30
- "ocr": {
31
- "success": true,
32
- "time_s": 0.985,
33
- "element_count": 18,
34
- "full_text": "7 1Z,000mm |\n\nBedroom 1 Kitchen Bathroom\n3,640 x 3,640 2,730 x 4,000 1,820 x 3,000\n\nBedroom 2 Living Room\n3,640 x 3,640 8,190 x 4,000\n\n1 {\n\"SCALE 1:100690mm FLOOR PLAN — LEVEL 1 6.400mm",
35
- "classified": {
36
- "dimensions": [
37
- "3,640 x 3,640",
38
- "2,730 x 4,000",
39
- "1,820 x 3,000",
40
- "3,640 x 3,640",
41
- "8,190 x 4,000",
42
- "6.400mm"
43
- ],
44
- "room_names": [
45
- "Bedroom 1",
46
- "Kitchen",
47
- "Bathroom",
48
- "Bedroom 2",
49
- "Living Room"
50
- ],
51
- "numbers": [
52
- "7",
53
- "1"
54
- ],
55
- "labels": [
56
- "{",
57
- "\"SCALE",
58
- "FLOOR PLAN",
59
- "LEVEL 1",
60
- "a"
61
- ]
62
- }
63
- },
64
- "svg": {
65
- "success": true,
66
- "time_s": 0.002,
67
- "svg_size_bytes": 4265
68
- }
69
- },
70
- "errors": [],
71
- "total_time_s": 1.698,
72
- "accuracy": {
73
- "ocr_cer": 1.0,
74
- "ocr_wer": 0.7308,
75
- "ocr_token_recall": 0.8462,
76
- "ocr_token_precision": 0.6667,
77
- "ocr_token_f1": 0.7458,
78
- "room_name_recall": 1.0,
79
- "line_count_accuracy": 1.0
80
- }
81
- },
82
- {
83
- "filename": "synthetic_floor_plan.png",
84
- "size_bytes": 12406,
85
- "dimensions": {
86
- "width": 1200,
87
- "height": 900
88
- },
89
- "stages": {
90
- "enhancement": {
91
- "success": true,
92
- "time_s": 0.57
93
- },
94
- "line_extraction": {
95
- "success": true,
96
- "time_s": 0.024,
97
- "lines_detected": 6,
98
- "horizontal": 3,
99
- "vertical": 3,
100
- "diagonal": 0
101
- },
102
- "ocr": {
103
- "success": true,
104
- "time_s": 0.582,
105
- "element_count": 4,
106
- "full_text": "Bedroom\n\nLiving Room\n\n1:100",
107
- "classified": {
108
- "dimensions": [
109
- "3640mm"
110
- ],
111
- "room_names": [
112
- "Bedroom",
113
- "Living Room"
114
- ],
115
- "numbers": [],
116
- "labels": [
117
- "1:100"
118
- ]
119
- }
120
- },
121
- "svg": {
122
- "success": true,
123
- "time_s": 0.001,
124
- "svg_size_bytes": 1202
125
- }
126
- },
127
- "errors": [],
128
- "total_time_s": 1.177,
129
- "accuracy": {
130
- "ocr_cer": 0.3125,
131
- "ocr_wer": 0.2,
132
- "ocr_token_recall": 1.0,
133
- "ocr_token_precision": 1.0,
134
- "ocr_token_f1": 1.0,
135
- "room_name_recall": 1.0,
136
- "line_count_accuracy": 1.0
137
- }
138
- }
139
- ],
140
- "aggregate": {
141
- "avg_ocr_cer": 0.6562,
142
- "min_ocr_cer": 0.3125,
143
- "max_ocr_cer": 1.0,
144
- "avg_ocr_wer": 0.4654,
145
- "min_ocr_wer": 0.2,
146
- "max_ocr_wer": 0.7308,
147
- "avg_ocr_token_recall": 0.9231,
148
- "min_ocr_token_recall": 0.8462,
149
- "max_ocr_token_recall": 1.0,
150
- "avg_ocr_token_precision": 0.8334,
151
- "min_ocr_token_precision": 0.6667,
152
- "max_ocr_token_precision": 1.0,
153
- "avg_ocr_token_f1": 0.8729,
154
- "min_ocr_token_f1": 0.7458,
155
- "max_ocr_token_f1": 1.0,
156
- "avg_room_name_recall": 1.0,
157
- "min_room_name_recall": 1.0,
158
- "max_room_name_recall": 1.0,
159
- "avg_line_count_accuracy": 1.0,
160
- "min_line_count_accuracy": 1.0,
161
- "max_line_count_accuracy": 1.0
162
- }
163
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
benchmark/results/benchmark_20260707_163851.json DELETED
@@ -1,158 +0,0 @@
1
- {
2
- "metadata": {
3
- "timestamp": "2026-07-07T16:38:54.291474",
4
- "image_count": 2,
5
- "ground_truth": "benchmark/ground_truth.json",
6
- "output_path": "/Users/aaronlee/Downloads/architectural-drawing-poc/backend/benchmark/results/benchmark_20260707_163851.json",
7
- "pipeline_version": "1.0.0"
8
- },
9
- "images": [
10
- {
11
- "filename": "floor_plan_level1.jpg",
12
- "size_bytes": 233218,
13
- "dimensions": {
14
- "width": 1400,
15
- "height": 1000
16
- },
17
- "stages": {
18
- "enhancement": {
19
- "success": true,
20
- "time_s": 0.637
21
- },
22
- "line_extraction": {
23
- "success": true,
24
- "time_s": 0.026,
25
- "lines_detected": 27,
26
- "horizontal": 8,
27
- "vertical": 9,
28
- "diagonal": 10
29
- },
30
- "ocr": {
31
- "success": true,
32
- "time_s": 0.849,
33
- "element_count": 14,
34
- "full_text": "7 1Z,000mm |\n\nBedroom 1 Kitchen Bathroom\n3,640 x 3,640 2,730 x 4,000 1,820 x 3,000\n\nBedroom 2 Living Room\n3,640 x 3,640 8,190 x 4,000\n\n1 {\n\"SCALE 1:100690mm FLOOR PLAN — LEVEL 1 6.400mm",
35
- "classified": {
36
- "dimensions": [
37
- "3,640 x 3,640",
38
- "2,730 x 4,000",
39
- "1,820 x 3,000",
40
- "3,640 x 3,640",
41
- "8,190 x 4,000",
42
- "6.400mm"
43
- ],
44
- "room_names": [
45
- "Bedroom 1",
46
- "Kitchen",
47
- "Bathroom",
48
- "Bedroom 2",
49
- "Living Room"
50
- ],
51
- "numbers": [],
52
- "labels": [
53
- "\"SCALE",
54
- "FLOOR PLAN",
55
- "LEVEL 1"
56
- ]
57
- }
58
- },
59
- "svg": {
60
- "success": true,
61
- "time_s": 0.001,
62
- "svg_size_bytes": 3900
63
- }
64
- },
65
- "errors": [],
66
- "total_time_s": 1.513,
67
- "accuracy": {
68
- "ocr_cer": 1.0,
69
- "ocr_wer": 0.7308,
70
- "ocr_token_recall": 0.8462,
71
- "ocr_token_precision": 0.8571,
72
- "ocr_token_f1": 0.8516,
73
- "room_name_recall": 1.0,
74
- "line_count_accuracy": 1.0
75
- }
76
- },
77
- {
78
- "filename": "synthetic_floor_plan.png",
79
- "size_bytes": 12406,
80
- "dimensions": {
81
- "width": 1200,
82
- "height": 900
83
- },
84
- "stages": {
85
- "enhancement": {
86
- "success": true,
87
- "time_s": 0.564
88
- },
89
- "line_extraction": {
90
- "success": true,
91
- "time_s": 0.023,
92
- "lines_detected": 6,
93
- "horizontal": 3,
94
- "vertical": 3,
95
- "diagonal": 0
96
- },
97
- "ocr": {
98
- "success": true,
99
- "time_s": 0.585,
100
- "element_count": 4,
101
- "full_text": "Bedroom\n\nLiving Room\n\n1:100",
102
- "classified": {
103
- "dimensions": [
104
- "3640mm"
105
- ],
106
- "room_names": [
107
- "Bedroom",
108
- "Living Room"
109
- ],
110
- "numbers": [],
111
- "labels": [
112
- "1:100"
113
- ]
114
- }
115
- },
116
- "svg": {
117
- "success": true,
118
- "time_s": 0.001,
119
- "svg_size_bytes": 1202
120
- }
121
- },
122
- "errors": [],
123
- "total_time_s": 1.173,
124
- "accuracy": {
125
- "ocr_cer": 0.3125,
126
- "ocr_wer": 0.2,
127
- "ocr_token_recall": 1.0,
128
- "ocr_token_precision": 1.0,
129
- "ocr_token_f1": 1.0,
130
- "room_name_recall": 1.0,
131
- "line_count_accuracy": 1.0
132
- }
133
- }
134
- ],
135
- "aggregate": {
136
- "avg_ocr_cer": 0.6562,
137
- "min_ocr_cer": 0.3125,
138
- "max_ocr_cer": 1.0,
139
- "avg_ocr_wer": 0.4654,
140
- "min_ocr_wer": 0.2,
141
- "max_ocr_wer": 0.7308,
142
- "avg_ocr_token_recall": 0.9231,
143
- "min_ocr_token_recall": 0.8462,
144
- "max_ocr_token_recall": 1.0,
145
- "avg_ocr_token_precision": 0.9285,
146
- "min_ocr_token_precision": 0.8571,
147
- "max_ocr_token_precision": 1.0,
148
- "avg_ocr_token_f1": 0.9258,
149
- "min_ocr_token_f1": 0.8516,
150
- "max_ocr_token_f1": 1.0,
151
- "avg_room_name_recall": 1.0,
152
- "min_room_name_recall": 1.0,
153
- "max_room_name_recall": 1.0,
154
- "avg_line_count_accuracy": 1.0,
155
- "min_line_count_accuracy": 1.0,
156
- "max_line_count_accuracy": 1.0
157
- }
158
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
benchmark/results/benchmark_20260707_164015.json DELETED
@@ -1,158 +0,0 @@
1
- {
2
- "metadata": {
3
- "timestamp": "2026-07-07T16:40:18.370959",
4
- "image_count": 2,
5
- "ground_truth": "benchmark/ground_truth.json",
6
- "output_path": "/Users/aaronlee/Downloads/architectural-drawing-poc/backend/benchmark/results/benchmark_20260707_164015.json",
7
- "pipeline_version": "1.0.0"
8
- },
9
- "images": [
10
- {
11
- "filename": "floor_plan_level1.jpg",
12
- "size_bytes": 233218,
13
- "dimensions": {
14
- "width": 1400,
15
- "height": 1000
16
- },
17
- "stages": {
18
- "enhancement": {
19
- "success": true,
20
- "time_s": 0.642
21
- },
22
- "line_extraction": {
23
- "success": true,
24
- "time_s": 0.026,
25
- "lines_detected": 27,
26
- "horizontal": 8,
27
- "vertical": 9,
28
- "diagonal": 10
29
- },
30
- "ocr": {
31
- "success": true,
32
- "time_s": 0.852,
33
- "element_count": 14,
34
- "full_text": "7 1Z,000mm |\n\nBedroom 1 Kitchen Bathroom\n3,640 x 3,640 2,730 x 4,000 1,820 x 3,000\n\nBedroom 2 Living Room\n3,640 x 3,640 8,190 x 4,000\n\n1 {\n\"SCALE 1:100690mm FLOOR PLAN — LEVEL 1 6.400mm",
35
- "classified": {
36
- "dimensions": [
37
- "3,640 x 3,640",
38
- "2,730 x 4,000",
39
- "1,820 x 3,000",
40
- "3,640 x 3,640",
41
- "8,190 x 4,000",
42
- "6,400mm"
43
- ],
44
- "room_names": [
45
- "Bedroom 1",
46
- "Kitchen",
47
- "Bathroom",
48
- "Bedroom 2",
49
- "Living Room"
50
- ],
51
- "numbers": [],
52
- "labels": [
53
- "\"SCALE",
54
- "FLOOR PLAN",
55
- "LEVEL 1"
56
- ]
57
- }
58
- },
59
- "svg": {
60
- "success": true,
61
- "time_s": 0.001,
62
- "svg_size_bytes": 3900
63
- }
64
- },
65
- "errors": [],
66
- "total_time_s": 1.521,
67
- "accuracy": {
68
- "ocr_cer": 1.0,
69
- "ocr_wer": 0.7308,
70
- "ocr_token_recall": 0.8462,
71
- "ocr_token_precision": 0.8571,
72
- "ocr_token_f1": 0.8516,
73
- "room_name_recall": 1.0,
74
- "line_count_accuracy": 1.0
75
- }
76
- },
77
- {
78
- "filename": "synthetic_floor_plan.png",
79
- "size_bytes": 12406,
80
- "dimensions": {
81
- "width": 1200,
82
- "height": 900
83
- },
84
- "stages": {
85
- "enhancement": {
86
- "success": true,
87
- "time_s": 0.56
88
- },
89
- "line_extraction": {
90
- "success": true,
91
- "time_s": 0.024,
92
- "lines_detected": 6,
93
- "horizontal": 3,
94
- "vertical": 3,
95
- "diagonal": 0
96
- },
97
- "ocr": {
98
- "success": true,
99
- "time_s": 0.558,
100
- "element_count": 4,
101
- "full_text": "Bedroom\n\nLiving Room\n\n1:100",
102
- "classified": {
103
- "dimensions": [
104
- "3640mm"
105
- ],
106
- "room_names": [
107
- "Bedroom",
108
- "Living Room"
109
- ],
110
- "numbers": [],
111
- "labels": [
112
- "1:100"
113
- ]
114
- }
115
- },
116
- "svg": {
117
- "success": true,
118
- "time_s": 0.001,
119
- "svg_size_bytes": 1202
120
- }
121
- },
122
- "errors": [],
123
- "total_time_s": 1.143,
124
- "accuracy": {
125
- "ocr_cer": 0.3125,
126
- "ocr_wer": 0.2,
127
- "ocr_token_recall": 1.0,
128
- "ocr_token_precision": 1.0,
129
- "ocr_token_f1": 1.0,
130
- "room_name_recall": 1.0,
131
- "line_count_accuracy": 1.0
132
- }
133
- }
134
- ],
135
- "aggregate": {
136
- "avg_ocr_cer": 0.6562,
137
- "min_ocr_cer": 0.3125,
138
- "max_ocr_cer": 1.0,
139
- "avg_ocr_wer": 0.4654,
140
- "min_ocr_wer": 0.2,
141
- "max_ocr_wer": 0.7308,
142
- "avg_ocr_token_recall": 0.9231,
143
- "min_ocr_token_recall": 0.8462,
144
- "max_ocr_token_recall": 1.0,
145
- "avg_ocr_token_precision": 0.9285,
146
- "min_ocr_token_precision": 0.8571,
147
- "max_ocr_token_precision": 1.0,
148
- "avg_ocr_token_f1": 0.9258,
149
- "min_ocr_token_f1": 0.8516,
150
- "max_ocr_token_f1": 1.0,
151
- "avg_room_name_recall": 1.0,
152
- "min_room_name_recall": 1.0,
153
- "max_room_name_recall": 1.0,
154
- "avg_line_count_accuracy": 1.0,
155
- "min_line_count_accuracy": 1.0,
156
- "max_line_count_accuracy": 1.0
157
- }
158
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
benchmark/results/benchmark_20260707_164018.json DELETED
@@ -1,158 +0,0 @@
1
- {
2
- "metadata": {
3
- "timestamp": "2026-07-07T16:40:21.307646",
4
- "image_count": 2,
5
- "ground_truth": "benchmark/ground_truth.json",
6
- "output_path": "/Users/aaronlee/Downloads/architectural-drawing-poc/backend/benchmark/results/benchmark_20260707_164018.json",
7
- "pipeline_version": "1.0.0"
8
- },
9
- "images": [
10
- {
11
- "filename": "floor_plan_level1.jpg",
12
- "size_bytes": 233218,
13
- "dimensions": {
14
- "width": 1400,
15
- "height": 1000
16
- },
17
- "stages": {
18
- "enhancement": {
19
- "success": true,
20
- "time_s": 0.631
21
- },
22
- "line_extraction": {
23
- "success": true,
24
- "time_s": 0.026,
25
- "lines_detected": 27,
26
- "horizontal": 8,
27
- "vertical": 9,
28
- "diagonal": 10
29
- },
30
- "ocr": {
31
- "success": true,
32
- "time_s": 0.879,
33
- "element_count": 14,
34
- "full_text": "7 1Z,000mm |\n\nBedroom 1 Kitchen Bathroom\n3,640 x 3,640 2,730 x 4,000 1,820 x 3,000\n\nBedroom 2 Living Room\n3,640 x 3,640 8,190 x 4,000\n\n1 {\n\"SCALE 1:100690mm FLOOR PLAN — LEVEL 1 6.400mm",
35
- "classified": {
36
- "dimensions": [
37
- "3,640 x 3,640",
38
- "2,730 x 4,000",
39
- "1,820 x 3,000",
40
- "3,640 x 3,640",
41
- "8,190 x 4,000",
42
- "6,400mm"
43
- ],
44
- "room_names": [
45
- "Bedroom 1",
46
- "Kitchen",
47
- "Bathroom",
48
- "Bedroom 2",
49
- "Living Room"
50
- ],
51
- "numbers": [],
52
- "labels": [
53
- "\"SCALE",
54
- "FLOOR PLAN",
55
- "LEVEL 1"
56
- ]
57
- }
58
- },
59
- "svg": {
60
- "success": true,
61
- "time_s": 0.001,
62
- "svg_size_bytes": 3900
63
- }
64
- },
65
- "errors": [],
66
- "total_time_s": 1.537,
67
- "accuracy": {
68
- "ocr_cer": 1.0,
69
- "ocr_wer": 0.7308,
70
- "ocr_token_recall": 0.8462,
71
- "ocr_token_precision": 0.8571,
72
- "ocr_token_f1": 0.8516,
73
- "room_name_recall": 1.0,
74
- "line_count_accuracy": 1.0
75
- }
76
- },
77
- {
78
- "filename": "synthetic_floor_plan.png",
79
- "size_bytes": 12406,
80
- "dimensions": {
81
- "width": 1200,
82
- "height": 900
83
- },
84
- "stages": {
85
- "enhancement": {
86
- "success": true,
87
- "time_s": 0.581
88
- },
89
- "line_extraction": {
90
- "success": true,
91
- "time_s": 0.024,
92
- "lines_detected": 6,
93
- "horizontal": 3,
94
- "vertical": 3,
95
- "diagonal": 0
96
- },
97
- "ocr": {
98
- "success": true,
99
- "time_s": 0.553,
100
- "element_count": 4,
101
- "full_text": "Bedroom\n\nLiving Room\n\n1:100",
102
- "classified": {
103
- "dimensions": [
104
- "3640mm"
105
- ],
106
- "room_names": [
107
- "Bedroom",
108
- "Living Room"
109
- ],
110
- "numbers": [],
111
- "labels": [
112
- "1:100"
113
- ]
114
- }
115
- },
116
- "svg": {
117
- "success": true,
118
- "time_s": 0.0,
119
- "svg_size_bytes": 1202
120
- }
121
- },
122
- "errors": [],
123
- "total_time_s": 1.158,
124
- "accuracy": {
125
- "ocr_cer": 0.3125,
126
- "ocr_wer": 0.2,
127
- "ocr_token_recall": 1.0,
128
- "ocr_token_precision": 1.0,
129
- "ocr_token_f1": 1.0,
130
- "room_name_recall": 1.0,
131
- "line_count_accuracy": 1.0
132
- }
133
- }
134
- ],
135
- "aggregate": {
136
- "avg_ocr_cer": 0.6562,
137
- "min_ocr_cer": 0.3125,
138
- "max_ocr_cer": 1.0,
139
- "avg_ocr_wer": 0.4654,
140
- "min_ocr_wer": 0.2,
141
- "max_ocr_wer": 0.7308,
142
- "avg_ocr_token_recall": 0.9231,
143
- "min_ocr_token_recall": 0.8462,
144
- "max_ocr_token_recall": 1.0,
145
- "avg_ocr_token_precision": 0.9285,
146
- "min_ocr_token_precision": 0.8571,
147
- "max_ocr_token_precision": 1.0,
148
- "avg_ocr_token_f1": 0.9258,
149
- "min_ocr_token_f1": 0.8516,
150
- "max_ocr_token_f1": 1.0,
151
- "avg_room_name_recall": 1.0,
152
- "min_room_name_recall": 1.0,
153
- "max_room_name_recall": 1.0,
154
- "avg_line_count_accuracy": 1.0,
155
- "min_line_count_accuracy": 1.0,
156
- "max_line_count_accuracy": 1.0
157
- }
158
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
benchmark/results/benchmark_20260707_164213.json DELETED
@@ -1,158 +0,0 @@
1
- {
2
- "metadata": {
3
- "timestamp": "2026-07-07T16:42:16.087131",
4
- "image_count": 2,
5
- "ground_truth": "benchmark/ground_truth.json",
6
- "output_path": "/Users/aaronlee/Downloads/architectural-drawing-poc/backend/benchmark/results/benchmark_20260707_164213.json",
7
- "pipeline_version": "1.0.0"
8
- },
9
- "images": [
10
- {
11
- "filename": "floor_plan_level1.jpg",
12
- "size_bytes": 233218,
13
- "dimensions": {
14
- "width": 1400,
15
- "height": 1000
16
- },
17
- "stages": {
18
- "enhancement": {
19
- "success": true,
20
- "time_s": 0.644
21
- },
22
- "line_extraction": {
23
- "success": true,
24
- "time_s": 0.027,
25
- "lines_detected": 27,
26
- "horizontal": 8,
27
- "vertical": 9,
28
- "diagonal": 10
29
- },
30
- "ocr": {
31
- "success": true,
32
- "time_s": 0.925,
33
- "element_count": 14,
34
- "full_text": "7 1Z,000mm |\n\nBedroom 1 Kitchen Bathroom\n3,640 x 3,640 2,730 x 4,000 1,820 x 3,000\n\nBedroom 2 Living Room\n3,640 x 3,640 8,190 x 4,000\n\n1 {\n\"SCALE 1:100690mm FLOOR PLAN — LEVEL 1 6.400mm",
35
- "classified": {
36
- "dimensions": [
37
- "3,640 x 3,640",
38
- "2,730 x 4,000",
39
- "1,820 x 3,000",
40
- "3,640 x 3,640",
41
- "8,190 x 4,000",
42
- "6,400mm"
43
- ],
44
- "room_names": [
45
- "Bedroom 1",
46
- "Kitchen",
47
- "Bathroom",
48
- "Bedroom 2",
49
- "Living Room"
50
- ],
51
- "numbers": [],
52
- "labels": [
53
- "SCALE",
54
- "FLOOR PLAN",
55
- "LEVEL 1"
56
- ]
57
- }
58
- },
59
- "svg": {
60
- "success": true,
61
- "time_s": 0.001,
62
- "svg_size_bytes": 3899
63
- }
64
- },
65
- "errors": [],
66
- "total_time_s": 1.597,
67
- "accuracy": {
68
- "ocr_cer": 1.0,
69
- "ocr_wer": 0.7308,
70
- "ocr_token_recall": 0.9231,
71
- "ocr_token_precision": 0.9286,
72
- "ocr_token_f1": 0.9258,
73
- "room_name_recall": 1.0,
74
- "line_count_accuracy": 1.0
75
- }
76
- },
77
- {
78
- "filename": "synthetic_floor_plan.png",
79
- "size_bytes": 12406,
80
- "dimensions": {
81
- "width": 1200,
82
- "height": 900
83
- },
84
- "stages": {
85
- "enhancement": {
86
- "success": true,
87
- "time_s": 0.58
88
- },
89
- "line_extraction": {
90
- "success": true,
91
- "time_s": 0.025,
92
- "lines_detected": 6,
93
- "horizontal": 3,
94
- "vertical": 3,
95
- "diagonal": 0
96
- },
97
- "ocr": {
98
- "success": true,
99
- "time_s": 0.569,
100
- "element_count": 4,
101
- "full_text": "Bedroom\n\nLiving Room\n\n1:100",
102
- "classified": {
103
- "dimensions": [
104
- "3640mm"
105
- ],
106
- "room_names": [
107
- "Bedroom",
108
- "Living Room"
109
- ],
110
- "numbers": [],
111
- "labels": [
112
- "1:100"
113
- ]
114
- }
115
- },
116
- "svg": {
117
- "success": true,
118
- "time_s": 0.001,
119
- "svg_size_bytes": 1202
120
- }
121
- },
122
- "errors": [],
123
- "total_time_s": 1.175,
124
- "accuracy": {
125
- "ocr_cer": 0.3125,
126
- "ocr_wer": 0.2,
127
- "ocr_token_recall": 1.0,
128
- "ocr_token_precision": 1.0,
129
- "ocr_token_f1": 1.0,
130
- "room_name_recall": 1.0,
131
- "line_count_accuracy": 1.0
132
- }
133
- }
134
- ],
135
- "aggregate": {
136
- "avg_ocr_cer": 0.6562,
137
- "min_ocr_cer": 0.3125,
138
- "max_ocr_cer": 1.0,
139
- "avg_ocr_wer": 0.4654,
140
- "min_ocr_wer": 0.2,
141
- "max_ocr_wer": 0.7308,
142
- "avg_ocr_token_recall": 0.9616,
143
- "min_ocr_token_recall": 0.9231,
144
- "max_ocr_token_recall": 1.0,
145
- "avg_ocr_token_precision": 0.9643,
146
- "min_ocr_token_precision": 0.9286,
147
- "max_ocr_token_precision": 1.0,
148
- "avg_ocr_token_f1": 0.9629,
149
- "min_ocr_token_f1": 0.9258,
150
- "max_ocr_token_f1": 1.0,
151
- "avg_room_name_recall": 1.0,
152
- "min_room_name_recall": 1.0,
153
- "max_room_name_recall": 1.0,
154
- "avg_line_count_accuracy": 1.0,
155
- "min_line_count_accuracy": 1.0,
156
- "max_line_count_accuracy": 1.0
157
- }
158
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
benchmark/results/benchmark_20260707_181600.json DELETED
@@ -1,158 +0,0 @@
1
- {
2
- "metadata": {
3
- "timestamp": "2026-07-07T18:16:02.710676",
4
- "image_count": 2,
5
- "ground_truth": "benchmark/ground_truth.json",
6
- "output_path": "/Users/aaronlee/Downloads/architectural-drawing-poc/backend/benchmark/results/benchmark_20260707_181600.json",
7
- "pipeline_version": "1.0.0"
8
- },
9
- "images": [
10
- {
11
- "filename": "floor_plan_level1.jpg",
12
- "size_bytes": 233218,
13
- "dimensions": {
14
- "width": 1400,
15
- "height": 1000
16
- },
17
- "stages": {
18
- "enhancement": {
19
- "success": true,
20
- "time_s": 0.628
21
- },
22
- "line_extraction": {
23
- "success": true,
24
- "time_s": 0.025,
25
- "lines_detected": 27,
26
- "horizontal": 8,
27
- "vertical": 9,
28
- "diagonal": 10
29
- },
30
- "ocr": {
31
- "success": true,
32
- "time_s": 0.861,
33
- "element_count": 14,
34
- "full_text": "7 1Z,000mm |\n\nBedroom 1 Kitchen Bathroom\n3,640 x 3,640 2,730 x 4,000 1,820 x 3,000\n\nBedroom 2 Living Room\n3,640 x 3,640 8,190 x 4,000\n\n1 {\n\"SCALE 1:100690mm FLOOR PLAN — LEVEL 1 6.400mm",
35
- "classified": {
36
- "dimensions": [
37
- "3,640 x 3,640",
38
- "2,730 x 4,000",
39
- "1,820 x 3,000",
40
- "3,640 x 3,640",
41
- "8,190 x 4,000",
42
- "6,400mm"
43
- ],
44
- "room_names": [
45
- "Bedroom 1",
46
- "Kitchen",
47
- "Bathroom",
48
- "Bedroom 2",
49
- "Living Room"
50
- ],
51
- "numbers": [],
52
- "labels": [
53
- "SCALE",
54
- "FLOOR PLAN",
55
- "LEVEL 1"
56
- ]
57
- }
58
- },
59
- "svg": {
60
- "success": true,
61
- "time_s": 0.001,
62
- "svg_size_bytes": 3899
63
- }
64
- },
65
- "errors": [],
66
- "total_time_s": 1.515,
67
- "accuracy": {
68
- "ocr_cer": 1.0,
69
- "ocr_wer": 0.7308,
70
- "ocr_token_recall": 0.9231,
71
- "ocr_token_precision": 0.9286,
72
- "ocr_token_f1": 0.9258,
73
- "room_name_recall": 1.0,
74
- "line_count_accuracy": 1.0
75
- }
76
- },
77
- {
78
- "filename": "synthetic_floor_plan.png",
79
- "size_bytes": 12406,
80
- "dimensions": {
81
- "width": 1200,
82
- "height": 900
83
- },
84
- "stages": {
85
- "enhancement": {
86
- "success": true,
87
- "time_s": 0.556
88
- },
89
- "line_extraction": {
90
- "success": true,
91
- "time_s": 0.024,
92
- "lines_detected": 6,
93
- "horizontal": 3,
94
- "vertical": 3,
95
- "diagonal": 0
96
- },
97
- "ocr": {
98
- "success": true,
99
- "time_s": 0.55,
100
- "element_count": 4,
101
- "full_text": "Bedroom\n\nLiving Room\n\n1:100",
102
- "classified": {
103
- "dimensions": [
104
- "3640mm"
105
- ],
106
- "room_names": [
107
- "Bedroom",
108
- "Living Room"
109
- ],
110
- "numbers": [],
111
- "labels": [
112
- "1:100"
113
- ]
114
- }
115
- },
116
- "svg": {
117
- "success": true,
118
- "time_s": 0.0,
119
- "svg_size_bytes": 1202
120
- }
121
- },
122
- "errors": [],
123
- "total_time_s": 1.13,
124
- "accuracy": {
125
- "ocr_cer": 0.3125,
126
- "ocr_wer": 0.2,
127
- "ocr_token_recall": 1.0,
128
- "ocr_token_precision": 1.0,
129
- "ocr_token_f1": 1.0,
130
- "room_name_recall": 1.0,
131
- "line_count_accuracy": 1.0
132
- }
133
- }
134
- ],
135
- "aggregate": {
136
- "avg_ocr_cer": 0.6562,
137
- "min_ocr_cer": 0.3125,
138
- "max_ocr_cer": 1.0,
139
- "avg_ocr_wer": 0.4654,
140
- "min_ocr_wer": 0.2,
141
- "max_ocr_wer": 0.7308,
142
- "avg_ocr_token_recall": 0.9616,
143
- "min_ocr_token_recall": 0.9231,
144
- "max_ocr_token_recall": 1.0,
145
- "avg_ocr_token_precision": 0.9643,
146
- "min_ocr_token_precision": 0.9286,
147
- "max_ocr_token_precision": 1.0,
148
- "avg_ocr_token_f1": 0.9629,
149
- "min_ocr_token_f1": 0.9258,
150
- "max_ocr_token_f1": 1.0,
151
- "avg_room_name_recall": 1.0,
152
- "min_room_name_recall": 1.0,
153
- "max_room_name_recall": 1.0,
154
- "avg_line_count_accuracy": 1.0,
155
- "min_line_count_accuracy": 1.0,
156
- "max_line_count_accuracy": 1.0
157
- }
158
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
benchmark/results/benchmark_20260708_034005.json DELETED
@@ -1,135 +0,0 @@
1
- {
2
- "metadata": {
3
- "timestamp": "2026-07-08T03:40:13.475146",
4
- "image_count": 2,
5
- "ground_truth": "benchmark/ground_truth.json",
6
- "output_path": "C:\\Users\\drago\\Downloads\\architecture-poc\\backend\\benchmark\\results\\benchmark_20260708_034005.json",
7
- "pipeline_version": "1.0.0",
8
- "ocr_model": "paddleocr"
9
- },
10
- "images": [
11
- {
12
- "filename": "floor_plan_level1.jpg",
13
- "size_bytes": 233218,
14
- "dimensions": {
15
- "width": 1400,
16
- "height": 1000
17
- },
18
- "stages": {
19
- "enhancement": {
20
- "success": true,
21
- "time_s": 1.21
22
- },
23
- "line_extraction": {
24
- "success": true,
25
- "time_s": 0.069,
26
- "lines_detected": 27,
27
- "horizontal": 8,
28
- "vertical": 9,
29
- "diagonal": 10
30
- },
31
- "ocr": {
32
- "success": true,
33
- "time_s": 5.352,
34
- "element_count": 0,
35
- "full_text": "",
36
- "classified": {
37
- "dimensions": [],
38
- "room_names": [],
39
- "numbers": [],
40
- "labels": []
41
- }
42
- },
43
- "svg": {
44
- "success": true,
45
- "time_s": 0.003,
46
- "svg_size_bytes": 2463
47
- }
48
- },
49
- "errors": [],
50
- "total_time_s": 6.634,
51
- "accuracy": {
52
- "ocr_cer": 1.0,
53
- "ocr_wer": 1.0,
54
- "ocr_token_recall": 0.0,
55
- "ocr_token_precision": 0.0,
56
- "ocr_token_f1": 0.0,
57
- "room_name_recall": 0.0,
58
- "line_count_accuracy": 1.0
59
- }
60
- },
61
- {
62
- "filename": "synthetic_floor_plan.png",
63
- "size_bytes": 12406,
64
- "dimensions": {
65
- "width": 1200,
66
- "height": 900
67
- },
68
- "stages": {
69
- "enhancement": {
70
- "success": true,
71
- "time_s": 0.913
72
- },
73
- "line_extraction": {
74
- "success": true,
75
- "time_s": 0.053,
76
- "lines_detected": 6,
77
- "horizontal": 3,
78
- "vertical": 3,
79
- "diagonal": 0
80
- },
81
- "ocr": {
82
- "success": true,
83
- "time_s": 0.03,
84
- "element_count": 0,
85
- "full_text": "",
86
- "classified": {
87
- "dimensions": [],
88
- "room_names": [],
89
- "numbers": [],
90
- "labels": []
91
- }
92
- },
93
- "svg": {
94
- "success": true,
95
- "time_s": 0.001,
96
- "svg_size_bytes": 784
97
- }
98
- },
99
- "errors": [],
100
- "total_time_s": 0.997,
101
- "accuracy": {
102
- "ocr_cer": 1.0,
103
- "ocr_wer": 1.0,
104
- "ocr_token_recall": 0.0,
105
- "ocr_token_precision": 0.0,
106
- "ocr_token_f1": 0.0,
107
- "room_name_recall": 0.0,
108
- "line_count_accuracy": 1.0
109
- }
110
- }
111
- ],
112
- "aggregate": {
113
- "avg_ocr_cer": 1.0,
114
- "min_ocr_cer": 1.0,
115
- "max_ocr_cer": 1.0,
116
- "avg_ocr_wer": 1.0,
117
- "min_ocr_wer": 1.0,
118
- "max_ocr_wer": 1.0,
119
- "avg_ocr_token_recall": 0.0,
120
- "min_ocr_token_recall": 0.0,
121
- "max_ocr_token_recall": 0.0,
122
- "avg_ocr_token_precision": 0.0,
123
- "min_ocr_token_precision": 0.0,
124
- "max_ocr_token_precision": 0.0,
125
- "avg_ocr_token_f1": 0.0,
126
- "min_ocr_token_f1": 0.0,
127
- "max_ocr_token_f1": 0.0,
128
- "avg_room_name_recall": 0.0,
129
- "min_room_name_recall": 0.0,
130
- "max_room_name_recall": 0.0,
131
- "avg_line_count_accuracy": 1.0,
132
- "min_line_count_accuracy": 1.0,
133
- "max_line_count_accuracy": 1.0
134
- }
135
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
benchmark/results/benchmark_20260708_034317.json DELETED
@@ -1,159 +0,0 @@
1
- {
2
- "metadata": {
3
- "timestamp": "2026-07-08T03:43:54.440447",
4
- "image_count": 2,
5
- "ground_truth": "benchmark/ground_truth.json",
6
- "output_path": "C:\\Users\\drago\\Downloads\\architecture-poc\\backend\\benchmark\\results\\benchmark_20260708_034317.json",
7
- "pipeline_version": "1.0.0",
8
- "ocr_model": "paddleocr"
9
- },
10
- "images": [
11
- {
12
- "filename": "floor_plan_level1.jpg",
13
- "size_bytes": 233218,
14
- "dimensions": {
15
- "width": 1400,
16
- "height": 1000
17
- },
18
- "stages": {
19
- "enhancement": {
20
- "success": true,
21
- "time_s": 1.095
22
- },
23
- "line_extraction": {
24
- "success": true,
25
- "time_s": 0.058,
26
- "lines_detected": 27,
27
- "horizontal": 8,
28
- "vertical": 9,
29
- "diagonal": 10
30
- },
31
- "ocr": {
32
- "success": true,
33
- "time_s": 21.326,
34
- "element_count": 14,
35
- "full_text": "12,000mm\nBedroom 1\nKitchen\nBathroom\n2,730 � 4,000\n3,640 � 3,640\n1,820 � 3,000\nBedroom 2\nLiving Room\n3,640 � 3,640\n8,190 � 4,000\nSCALE 1:100,600mm\nFLOOR PLAN - LEVEL 1\n6,400mm",
36
- "classified": {
37
- "dimensions": [
38
- "12,000mm",
39
- "2,730 � 4,000",
40
- "3,640 � 3,640",
41
- "1,820 � 3,000",
42
- "3,640 � 3,640",
43
- "8,190 � 4,000",
44
- "6,400mm"
45
- ],
46
- "room_names": [
47
- "Bedroom 1",
48
- "Kitchen",
49
- "Bathroom",
50
- "Bedroom 2",
51
- "Living Room"
52
- ],
53
- "numbers": [],
54
- "labels": [
55
- "SCALE 1:100,600mm",
56
- "FLOOR PLAN - LEVEL 1"
57
- ]
58
- }
59
- },
60
- "svg": {
61
- "success": true,
62
- "time_s": 0.003,
63
- "svg_size_bytes": 3925
64
- }
65
- },
66
- "errors": [],
67
- "total_time_s": 22.482,
68
- "accuracy": {
69
- "ocr_cer": 0.6,
70
- "ocr_wer": 0.8077,
71
- "ocr_token_recall": 0.3846,
72
- "ocr_token_precision": 0.3571,
73
- "ocr_token_f1": 0.3704,
74
- "room_name_recall": 1.0,
75
- "line_count_accuracy": 1.0
76
- }
77
- },
78
- {
79
- "filename": "synthetic_floor_plan.png",
80
- "size_bytes": 12406,
81
- "dimensions": {
82
- "width": 1200,
83
- "height": 900
84
- },
85
- "stages": {
86
- "enhancement": {
87
- "success": true,
88
- "time_s": 1.049
89
- },
90
- "line_extraction": {
91
- "success": true,
92
- "time_s": 0.082,
93
- "lines_detected": 6,
94
- "horizontal": 3,
95
- "vertical": 3,
96
- "diagonal": 0
97
- },
98
- "ocr": {
99
- "success": true,
100
- "time_s": 12.898,
101
- "element_count": 4,
102
- "full_text": "Bedroom\n3640mm\nLiving Room\n1:100",
103
- "classified": {
104
- "dimensions": [
105
- "3640mm"
106
- ],
107
- "room_names": [
108
- "Bedroom",
109
- "Living Room"
110
- ],
111
- "numbers": [],
112
- "labels": [
113
- "1:100"
114
- ]
115
- }
116
- },
117
- "svg": {
118
- "success": true,
119
- "time_s": 0.001,
120
- "svg_size_bytes": 1202
121
- }
122
- },
123
- "errors": [],
124
- "total_time_s": 14.03,
125
- "accuracy": {
126
- "ocr_cer": 0.5,
127
- "ocr_wer": 0.4,
128
- "ocr_token_recall": 1.0,
129
- "ocr_token_precision": 1.0,
130
- "ocr_token_f1": 1.0,
131
- "room_name_recall": 1.0,
132
- "line_count_accuracy": 1.0
133
- }
134
- }
135
- ],
136
- "aggregate": {
137
- "avg_ocr_cer": 0.55,
138
- "min_ocr_cer": 0.5,
139
- "max_ocr_cer": 0.6,
140
- "avg_ocr_wer": 0.6038,
141
- "min_ocr_wer": 0.4,
142
- "max_ocr_wer": 0.8077,
143
- "avg_ocr_token_recall": 0.6923,
144
- "min_ocr_token_recall": 0.3846,
145
- "max_ocr_token_recall": 1.0,
146
- "avg_ocr_token_precision": 0.6785,
147
- "min_ocr_token_precision": 0.3571,
148
- "max_ocr_token_precision": 1.0,
149
- "avg_ocr_token_f1": 0.6852,
150
- "min_ocr_token_f1": 0.3704,
151
- "max_ocr_token_f1": 1.0,
152
- "avg_room_name_recall": 1.0,
153
- "min_room_name_recall": 1.0,
154
- "max_room_name_recall": 1.0,
155
- "avg_line_count_accuracy": 1.0,
156
- "min_line_count_accuracy": 1.0,
157
- "max_line_count_accuracy": 1.0
158
- }
159
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
benchmark/results/benchmark_20260708_034854.json DELETED
@@ -1,159 +0,0 @@
1
- {
2
- "metadata": {
3
- "timestamp": "2026-07-08T03:49:28.572783",
4
- "image_count": 2,
5
- "ground_truth": "benchmark/ground_truth.json",
6
- "output_path": "C:\\Users\\drago\\Downloads\\architecture-poc\\backend\\benchmark\\results\\benchmark_20260708_034854.json",
7
- "pipeline_version": "1.0.0",
8
- "ocr_model": "paddleocr"
9
- },
10
- "images": [
11
- {
12
- "filename": "floor_plan_level1.jpg",
13
- "size_bytes": 233218,
14
- "dimensions": {
15
- "width": 1400,
16
- "height": 1000
17
- },
18
- "stages": {
19
- "enhancement": {
20
- "success": true,
21
- "time_s": 1.087
22
- },
23
- "line_extraction": {
24
- "success": true,
25
- "time_s": 0.059,
26
- "lines_detected": 27,
27
- "horizontal": 8,
28
- "vertical": 9,
29
- "diagonal": 10
30
- },
31
- "ocr": {
32
- "success": true,
33
- "time_s": 20.128,
34
- "element_count": 14,
35
- "full_text": "12,000mm\nBedroom 1\nKitchen\nBathroom\n2,730 × 4,000\n3,640 × 3,640\n1,820 × 3,000\nBedroom 2\nLiving Room\n3,640 × 3,640\n8,190 × 4,000\nSCALE 1:100,600mm\nFLOOR PLAN - LEVEL 1\n6,400mm",
36
- "classified": {
37
- "dimensions": [
38
- "12,000mm",
39
- "2,730 × 4,000",
40
- "3,640 × 3,640",
41
- "1,820 × 3,000",
42
- "3,640 × 3,640",
43
- "8,190 × 4,000",
44
- "6,400mm"
45
- ],
46
- "room_names": [
47
- "Bedroom 1",
48
- "Kitchen",
49
- "Bathroom",
50
- "Bedroom 2",
51
- "Living Room"
52
- ],
53
- "numbers": [],
54
- "labels": [
55
- "SCALE 1:100,600mm",
56
- "FLOOR PLAN - LEVEL 1"
57
- ]
58
- }
59
- },
60
- "svg": {
61
- "success": true,
62
- "time_s": 0.004,
63
- "svg_size_bytes": 3925
64
- }
65
- },
66
- "errors": [],
67
- "total_time_s": 21.278,
68
- "accuracy": {
69
- "ocr_cer": 0.6,
70
- "ocr_wer": 0.8077,
71
- "ocr_token_recall": 0.3846,
72
- "ocr_token_precision": 0.3571,
73
- "ocr_token_f1": 0.3704,
74
- "room_name_recall": 1.0,
75
- "line_count_accuracy": 1.0
76
- }
77
- },
78
- {
79
- "filename": "synthetic_floor_plan.png",
80
- "size_bytes": 12406,
81
- "dimensions": {
82
- "width": 1200,
83
- "height": 900
84
- },
85
- "stages": {
86
- "enhancement": {
87
- "success": true,
88
- "time_s": 0.882
89
- },
90
- "line_extraction": {
91
- "success": true,
92
- "time_s": 0.048,
93
- "lines_detected": 6,
94
- "horizontal": 3,
95
- "vertical": 3,
96
- "diagonal": 0
97
- },
98
- "ocr": {
99
- "success": true,
100
- "time_s": 12.043,
101
- "element_count": 4,
102
- "full_text": "Bedroom\n3640mm\nLiving Room\n1:100",
103
- "classified": {
104
- "dimensions": [
105
- "3640mm"
106
- ],
107
- "room_names": [
108
- "Bedroom",
109
- "Living Room"
110
- ],
111
- "numbers": [],
112
- "labels": [
113
- "1:100"
114
- ]
115
- }
116
- },
117
- "svg": {
118
- "success": true,
119
- "time_s": 0.001,
120
- "svg_size_bytes": 1202
121
- }
122
- },
123
- "errors": [],
124
- "total_time_s": 12.974,
125
- "accuracy": {
126
- "ocr_cer": 0.5,
127
- "ocr_wer": 0.4,
128
- "ocr_token_recall": 1.0,
129
- "ocr_token_precision": 1.0,
130
- "ocr_token_f1": 1.0,
131
- "room_name_recall": 1.0,
132
- "line_count_accuracy": 1.0
133
- }
134
- }
135
- ],
136
- "aggregate": {
137
- "avg_ocr_cer": 0.55,
138
- "min_ocr_cer": 0.5,
139
- "max_ocr_cer": 0.6,
140
- "avg_ocr_wer": 0.6038,
141
- "min_ocr_wer": 0.4,
142
- "max_ocr_wer": 0.8077,
143
- "avg_ocr_token_recall": 0.6923,
144
- "min_ocr_token_recall": 0.3846,
145
- "max_ocr_token_recall": 1.0,
146
- "avg_ocr_token_precision": 0.6785,
147
- "min_ocr_token_precision": 0.3571,
148
- "max_ocr_token_precision": 1.0,
149
- "avg_ocr_token_f1": 0.6852,
150
- "min_ocr_token_f1": 0.3704,
151
- "max_ocr_token_f1": 1.0,
152
- "avg_room_name_recall": 1.0,
153
- "min_room_name_recall": 1.0,
154
- "max_room_name_recall": 1.0,
155
- "avg_line_count_accuracy": 1.0,
156
- "min_line_count_accuracy": 1.0,
157
- "max_line_count_accuracy": 1.0
158
- }
159
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
benchmark/results/benchmark_20260708_035238.json DELETED
@@ -1,159 +0,0 @@
1
- {
2
- "metadata": {
3
- "timestamp": "2026-07-08T03:53:16.189753",
4
- "image_count": 2,
5
- "ground_truth": "benchmark/ground_truth.json",
6
- "output_path": "C:\\Users\\drago\\Downloads\\architecture-poc\\backend\\benchmark\\results\\benchmark_20260708_035238.json",
7
- "pipeline_version": "1.0.0",
8
- "ocr_model": "paddleocr"
9
- },
10
- "images": [
11
- {
12
- "filename": "floor_plan_level1.jpg",
13
- "size_bytes": 233218,
14
- "dimensions": {
15
- "width": 1400,
16
- "height": 1000
17
- },
18
- "stages": {
19
- "enhancement": {
20
- "success": true,
21
- "time_s": 1.163
22
- },
23
- "line_extraction": {
24
- "success": true,
25
- "time_s": 0.063,
26
- "lines_detected": 27,
27
- "horizontal": 8,
28
- "vertical": 9,
29
- "diagonal": 10
30
- },
31
- "ocr": {
32
- "success": true,
33
- "time_s": 22.366,
34
- "element_count": 14,
35
- "full_text": "12,000mm\nBedroom 1\nKitchen\nBathroom\n2,730 × 4,000\n3,640 × 3,640\n1,820 × 3,000\nBedroom 2\nLiving Room\n3,640 × 3,640\n8,190 × 4,000\nSCALE 1:100,600mm\nFLOOR PLAN - LEVEL 1\n6,400mm",
36
- "classified": {
37
- "dimensions": [
38
- "12,000mm",
39
- "2,730 × 4,000",
40
- "3,640 × 3,640",
41
- "1,820 × 3,000",
42
- "3,640 × 3,640",
43
- "8,190 × 4,000",
44
- "6,400mm"
45
- ],
46
- "room_names": [
47
- "Bedroom 1",
48
- "Kitchen",
49
- "Bathroom",
50
- "Bedroom 2",
51
- "Living Room"
52
- ],
53
- "numbers": [],
54
- "labels": [
55
- "SCALE 1:100,600mm",
56
- "FLOOR PLAN - LEVEL 1"
57
- ]
58
- }
59
- },
60
- "svg": {
61
- "success": true,
62
- "time_s": 0.003,
63
- "svg_size_bytes": 3925
64
- }
65
- },
66
- "errors": [],
67
- "total_time_s": 23.595,
68
- "accuracy": {
69
- "ocr_cer": 0.5704,
70
- "ocr_wer": 0.6538,
71
- "ocr_token_recall": 0.6923,
72
- "ocr_token_precision": 0.7143,
73
- "ocr_token_f1": 0.7031,
74
- "room_name_recall": 1.0,
75
- "line_count_accuracy": 1.0
76
- }
77
- },
78
- {
79
- "filename": "synthetic_floor_plan.png",
80
- "size_bytes": 12406,
81
- "dimensions": {
82
- "width": 1200,
83
- "height": 900
84
- },
85
- "stages": {
86
- "enhancement": {
87
- "success": true,
88
- "time_s": 0.982
89
- },
90
- "line_extraction": {
91
- "success": true,
92
- "time_s": 0.055,
93
- "lines_detected": 6,
94
- "horizontal": 3,
95
- "vertical": 3,
96
- "diagonal": 0
97
- },
98
- "ocr": {
99
- "success": true,
100
- "time_s": 13.425,
101
- "element_count": 4,
102
- "full_text": "Bedroom\n3640mm\nLiving Room\n1:100",
103
- "classified": {
104
- "dimensions": [
105
- "3640mm"
106
- ],
107
- "room_names": [
108
- "Bedroom",
109
- "Living Room"
110
- ],
111
- "numbers": [],
112
- "labels": [
113
- "1:100"
114
- ]
115
- }
116
- },
117
- "svg": {
118
- "success": true,
119
- "time_s": 0.001,
120
- "svg_size_bytes": 1202
121
- }
122
- },
123
- "errors": [],
124
- "total_time_s": 14.463,
125
- "accuracy": {
126
- "ocr_cer": 0.5,
127
- "ocr_wer": 0.4,
128
- "ocr_token_recall": 1.0,
129
- "ocr_token_precision": 1.0,
130
- "ocr_token_f1": 1.0,
131
- "room_name_recall": 1.0,
132
- "line_count_accuracy": 1.0
133
- }
134
- }
135
- ],
136
- "aggregate": {
137
- "avg_ocr_cer": 0.5352,
138
- "min_ocr_cer": 0.5,
139
- "max_ocr_cer": 0.5704,
140
- "avg_ocr_wer": 0.5269,
141
- "min_ocr_wer": 0.4,
142
- "max_ocr_wer": 0.6538,
143
- "avg_ocr_token_recall": 0.8461,
144
- "min_ocr_token_recall": 0.6923,
145
- "max_ocr_token_recall": 1.0,
146
- "avg_ocr_token_precision": 0.8572,
147
- "min_ocr_token_precision": 0.7143,
148
- "max_ocr_token_precision": 1.0,
149
- "avg_ocr_token_f1": 0.8516,
150
- "min_ocr_token_f1": 0.7031,
151
- "max_ocr_token_f1": 1.0,
152
- "avg_room_name_recall": 1.0,
153
- "min_room_name_recall": 1.0,
154
- "max_room_name_recall": 1.0,
155
- "avg_line_count_accuracy": 1.0,
156
- "min_line_count_accuracy": 1.0,
157
- "max_line_count_accuracy": 1.0
158
- }
159
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
benchmark/results/benchmark_20260708_035329.json DELETED
@@ -1,135 +0,0 @@
1
- {
2
- "metadata": {
3
- "timestamp": "2026-07-08T03:53:32.005797",
4
- "image_count": 2,
5
- "ground_truth": "benchmark/ground_truth.json",
6
- "output_path": "C:\\Users\\drago\\Downloads\\architecture-poc\\backend\\benchmark\\results\\benchmark_20260708_035329.json",
7
- "pipeline_version": "1.0.0",
8
- "ocr_model": "tesseract"
9
- },
10
- "images": [
11
- {
12
- "filename": "floor_plan_level1.jpg",
13
- "size_bytes": 233218,
14
- "dimensions": {
15
- "width": 1400,
16
- "height": 1000
17
- },
18
- "stages": {
19
- "enhancement": {
20
- "success": true,
21
- "time_s": 1.153
22
- },
23
- "line_extraction": {
24
- "success": true,
25
- "time_s": 0.062,
26
- "lines_detected": 27,
27
- "horizontal": 8,
28
- "vertical": 9,
29
- "diagonal": 10
30
- },
31
- "ocr": {
32
- "success": true,
33
- "time_s": 0.085,
34
- "element_count": 0,
35
- "full_text": "",
36
- "classified": {
37
- "dimensions": [],
38
- "room_names": [],
39
- "numbers": [],
40
- "labels": []
41
- }
42
- },
43
- "svg": {
44
- "success": true,
45
- "time_s": 0.002,
46
- "svg_size_bytes": 2463
47
- }
48
- },
49
- "errors": [],
50
- "total_time_s": 1.302,
51
- "accuracy": {
52
- "ocr_cer": 1.0,
53
- "ocr_wer": 1.0,
54
- "ocr_token_recall": 0.0,
55
- "ocr_token_precision": 0.0,
56
- "ocr_token_f1": 0.0,
57
- "room_name_recall": 0.0,
58
- "line_count_accuracy": 1.0
59
- }
60
- },
61
- {
62
- "filename": "synthetic_floor_plan.png",
63
- "size_bytes": 12406,
64
- "dimensions": {
65
- "width": 1200,
66
- "height": 900
67
- },
68
- "stages": {
69
- "enhancement": {
70
- "success": true,
71
- "time_s": 0.987
72
- },
73
- "line_extraction": {
74
- "success": true,
75
- "time_s": 0.064,
76
- "lines_detected": 6,
77
- "horizontal": 3,
78
- "vertical": 3,
79
- "diagonal": 0
80
- },
81
- "ocr": {
82
- "success": true,
83
- "time_s": 0.054,
84
- "element_count": 0,
85
- "full_text": "",
86
- "classified": {
87
- "dimensions": [],
88
- "room_names": [],
89
- "numbers": [],
90
- "labels": []
91
- }
92
- },
93
- "svg": {
94
- "success": true,
95
- "time_s": 0.001,
96
- "svg_size_bytes": 784
97
- }
98
- },
99
- "errors": [],
100
- "total_time_s": 1.106,
101
- "accuracy": {
102
- "ocr_cer": 1.0,
103
- "ocr_wer": 1.0,
104
- "ocr_token_recall": 0.0,
105
- "ocr_token_precision": 0.0,
106
- "ocr_token_f1": 0.0,
107
- "room_name_recall": 0.0,
108
- "line_count_accuracy": 1.0
109
- }
110
- }
111
- ],
112
- "aggregate": {
113
- "avg_ocr_cer": 1.0,
114
- "min_ocr_cer": 1.0,
115
- "max_ocr_cer": 1.0,
116
- "avg_ocr_wer": 1.0,
117
- "min_ocr_wer": 1.0,
118
- "max_ocr_wer": 1.0,
119
- "avg_ocr_token_recall": 0.0,
120
- "min_ocr_token_recall": 0.0,
121
- "max_ocr_token_recall": 0.0,
122
- "avg_ocr_token_precision": 0.0,
123
- "min_ocr_token_precision": 0.0,
124
- "max_ocr_token_precision": 0.0,
125
- "avg_ocr_token_f1": 0.0,
126
- "min_ocr_token_f1": 0.0,
127
- "max_ocr_token_f1": 0.0,
128
- "avg_room_name_recall": 0.0,
129
- "min_room_name_recall": 0.0,
130
- "max_room_name_recall": 0.0,
131
- "avg_line_count_accuracy": 1.0,
132
- "min_line_count_accuracy": 1.0,
133
- "max_line_count_accuracy": 1.0
134
- }
135
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
benchmark/run_benchmark.py DELETED
@@ -1,432 +0,0 @@
1
- #!/usr/bin/env python3
2
- """
3
- Architectural Drawing AI — Benchmark Runner
4
- ============================================
5
- Runs every image in `sample_images/` through the full processing pipeline
6
- and measures accuracy against optional ground truth annotations.
7
-
8
- Usage
9
- -----
10
- # Basic run (no ground truth — measures timing and detection counts only)
11
- cd backend
12
- ./venv/bin/python benchmark/run_benchmark.py
13
-
14
- # With ground truth annotations
15
- ./venv/bin/python benchmark/run_benchmark.py --gt benchmark/ground_truth.json
16
-
17
- # Save results to a specific file
18
- ./venv/bin/python benchmark/run_benchmark.py --gt benchmark/ground_truth.json \
19
- --out benchmark/results/run_2026_06_29.json
20
-
21
- # Verbose output (prints per-image detail)
22
- ./venv/bin/python benchmark/run_benchmark.py -v
23
-
24
- Output
25
- ------
26
- Prints a summary table to stdout and writes a full JSON report to
27
- benchmark/results/benchmark_<timestamp>.json (or --out path).
28
-
29
- The JSON report is structured for import into the feasibility report and for
30
- comparison across runs to track accuracy improvements over time.
31
- """
32
-
33
- from __future__ import annotations
34
-
35
- import argparse
36
- import json
37
- import logging
38
- import os
39
- import sys
40
- import time
41
- from datetime import datetime
42
- from pathlib import Path
43
- from typing import Any, Dict, List, Optional
44
-
45
- import cv2
46
- import numpy as np
47
-
48
- # Make sure backend root is on the path regardless of cwd
49
- ROOT = Path(__file__).resolve().parent.parent
50
- sys.path.insert(0, str(ROOT))
51
-
52
- from processors.image_enhancer import ImageEnhancer
53
- from processors.line_extractor import LineExtractor
54
- from processors.ocr_processor import OCRProcessor
55
- from processors.svg_generator import SVGGenerator
56
- from utils.image_utils import bytes_to_numpy, resize_for_processing
57
- from benchmark.metrics import (
58
- character_error_rate,
59
- word_error_rate,
60
- token_recall,
61
- token_precision,
62
- f1,
63
- line_count_accuracy,
64
- )
65
-
66
- logging.basicConfig(level=logging.WARNING)
67
- logger = logging.getLogger(__name__)
68
-
69
- SAMPLE_DIR = ROOT / "benchmark" / "sample_images"
70
- RESULTS_DIR = ROOT / "benchmark" / "results"
71
- RESULTS_DIR.mkdir(parents=True, exist_ok=True)
72
-
73
- SUPPORTED_EXTENSIONS = {".jpg", ".jpeg", ".png", ".tif", ".tiff", ".bmp", ".webp"}
74
-
75
-
76
- # ---------------------------------------------------------------------------
77
- # Processor singletons
78
- # ---------------------------------------------------------------------------
79
- _enhancer = ImageEnhancer()
80
- _extractor = LineExtractor()
81
- _ocr = OCRProcessor()
82
- _svg = SVGGenerator()
83
- _paddleocr = None # lazy — only imported/instantiated if --ocr-model paddleocr
84
-
85
-
86
- def _get_ocr_processor(ocr_model: Optional[str]):
87
- """Mirrors main.py's _get_ocr_processor: falls back to Tesseract if
88
- PaddleOCR is requested but unavailable."""
89
- global _paddleocr
90
- if ocr_model == "paddleocr":
91
- if _paddleocr is None:
92
- from processors.paddleocr_processor import PaddleOCRProcessor
93
- _paddleocr = PaddleOCRProcessor()
94
- if _paddleocr.is_available:
95
- return _paddleocr
96
- logger.warning(
97
- "PaddleOCR requested but unavailable (%s) — falling back to Tesseract",
98
- _paddleocr.unavailable_reason,
99
- )
100
- return _ocr
101
-
102
-
103
- # ---------------------------------------------------------------------------
104
- # Per-image processing
105
- # ---------------------------------------------------------------------------
106
-
107
- def process_image(image_path: Path, ocr_model: Optional[str] = None) -> Dict[str, Any]:
108
- """
109
- Run the full pipeline on one image.
110
- Returns a dict with timings, counts, and all raw results.
111
- """
112
- raw = image_path.read_bytes()
113
- image = bytes_to_numpy(raw)
114
- if image is None:
115
- return {"error": "Cannot decode image", "path": str(image_path)}
116
-
117
- image = resize_for_processing(image, max_dimension=3000)
118
- result: Dict[str, Any] = {
119
- "filename": image_path.name,
120
- "size_bytes": len(raw),
121
- "dimensions": {"width": image.shape[1], "height": image.shape[0]},
122
- "stages": {},
123
- "errors": [],
124
- }
125
-
126
- # Stage 1 — Enhancement
127
- t0 = time.perf_counter()
128
- try:
129
- enhanced = _enhancer.enhance(image)
130
- result["stages"]["enhancement"] = {
131
- "success": True,
132
- "time_s": round(time.perf_counter() - t0, 3),
133
- }
134
- except Exception as exc:
135
- enhanced = image
136
- result["stages"]["enhancement"] = {"success": False, "error": str(exc)}
137
- result["errors"].append(f"enhancement: {exc}")
138
-
139
- # Stage 2 — Line extraction
140
- t0 = time.perf_counter()
141
- try:
142
- _, lines = _extractor.extract(enhanced)
143
- result["stages"]["line_extraction"] = {
144
- "success": True,
145
- "time_s": round(time.perf_counter() - t0, 3),
146
- "lines_detected": len(lines),
147
- "horizontal": sum(1 for l in lines if abs(l["angle"]) <= 15 or abs(l["angle"]) >= 165),
148
- "vertical": sum(1 for l in lines if 75 <= abs(l["angle"]) <= 105),
149
- "diagonal": sum(1 for l in lines if not (abs(l["angle"]) <= 15 or abs(l["angle"]) >= 165 or 75 <= abs(l["angle"]) <= 105)),
150
- }
151
- except Exception as exc:
152
- lines = []
153
- result["stages"]["line_extraction"] = {"success": False, "error": str(exc)}
154
- result["errors"].append(f"line_extraction: {exc}")
155
-
156
- # Stage 3 — OCR
157
- t0 = time.perf_counter()
158
- try:
159
- ocr_result = _get_ocr_processor(ocr_model).extract_text(enhanced)
160
- result["stages"]["ocr"] = {
161
- "success": True,
162
- "time_s": round(time.perf_counter() - t0, 3),
163
- "element_count": ocr_result["element_count"],
164
- "full_text": ocr_result["full_text"],
165
- "classified": {
166
- "dimensions": [e["text"] for e in ocr_result["classified"]["dimensions"]],
167
- "room_names": [e["text"] for e in ocr_result["classified"]["room_names"]],
168
- "numbers": [e["text"] for e in ocr_result["classified"]["numbers"]],
169
- "labels": [e["text"] for e in ocr_result["classified"]["labels"]],
170
- },
171
- }
172
- except Exception as exc:
173
- ocr_result = None
174
- result["stages"]["ocr"] = {"success": False, "error": str(exc)}
175
- result["errors"].append(f"ocr: {exc}")
176
-
177
- # Stage 4 — SVG (always run to verify generation)
178
- t0 = time.perf_counter()
179
- try:
180
- svg = _svg.generate(lines, image.shape[1], image.shape[0], ocr_result)
181
- result["stages"]["svg"] = {
182
- "success": True,
183
- "time_s": round(time.perf_counter() - t0, 3),
184
- "svg_size_bytes": len(svg.encode()),
185
- }
186
- except Exception as exc:
187
- result["stages"]["svg"] = {"success": False, "error": str(exc)}
188
- result["errors"].append(f"svg: {exc}")
189
-
190
- result["total_time_s"] = round(
191
- sum(s.get("time_s", 0) for s in result["stages"].values()), 3
192
- )
193
- return result
194
-
195
-
196
- # ---------------------------------------------------------------------------
197
- # Accuracy scoring against ground truth
198
- # ---------------------------------------------------------------------------
199
-
200
- def score_against_ground_truth(
201
- image_result: Dict[str, Any],
202
- gt: Dict[str, Any],
203
- ) -> Dict[str, Any]:
204
- """
205
- Compares pipeline output against a ground truth entry.
206
- Returns a dict of accuracy scores for this image.
207
- """
208
- scores: Dict[str, Any] = {}
209
-
210
- ocr_stage = image_result["stages"].get("ocr", {})
211
- line_stage = image_result["stages"].get("line_extraction", {})
212
-
213
- # OCR — full text CER and WER
214
- if ocr_stage.get("success") and "expected_text" in gt:
215
- expected_str = " ".join(gt["expected_text"])
216
- predicted_str = ocr_stage.get("full_text", "")
217
-
218
- scores["ocr_cer"] = round(character_error_rate(predicted_str, expected_str), 4)
219
- scores["ocr_wer"] = round(word_error_rate(predicted_str, expected_str), 4)
220
-
221
- # Token-level recall/precision for key labels
222
- all_tokens = (
223
- ocr_stage["classified"]["dimensions"]
224
- + ocr_stage["classified"]["room_names"]
225
- + ocr_stage["classified"]["numbers"]
226
- + ocr_stage["classified"]["labels"]
227
- )
228
- rec = token_recall(all_tokens, gt["expected_text"])
229
- prec = token_precision(all_tokens, gt["expected_text"])
230
- scores["ocr_token_recall"] = round(rec, 4)
231
- scores["ocr_token_precision"] = round(prec, 4)
232
- scores["ocr_token_f1"] = round(f1(prec, rec), 4)
233
-
234
- # Room name recall
235
- if ocr_stage.get("success") and "expected_room_names" in gt:
236
- rooms = ocr_stage["classified"]["room_names"]
237
- scores["room_name_recall"] = round(
238
- token_recall(rooms, gt["expected_room_names"]), 4
239
- )
240
-
241
- # Line count accuracy
242
- if line_stage.get("success") and "expected_line_count_min" in gt:
243
- scores["line_count_accuracy"] = round(
244
- line_count_accuracy(
245
- line_stage["lines_detected"],
246
- gt["expected_line_count_min"],
247
- gt["expected_line_count_max"],
248
- ),
249
- 4,
250
- )
251
-
252
- return scores
253
-
254
-
255
- # ---------------------------------------------------------------------------
256
- # Summary statistics
257
- # ---------------------------------------------------------------------------
258
-
259
- def aggregate(all_scores: List[Dict[str, Any]]) -> Dict[str, Any]:
260
- """Average all numeric score fields across images."""
261
- if not all_scores:
262
- return {}
263
- keys = [k for k in all_scores[0] if isinstance(all_scores[0][k], float)]
264
- agg: Dict[str, Any] = {}
265
- for k in keys:
266
- vals = [s[k] for s in all_scores if k in s]
267
- if vals:
268
- agg[f"avg_{k}"] = round(sum(vals) / len(vals), 4)
269
- agg[f"min_{k}"] = round(min(vals), 4)
270
- agg[f"max_{k}"] = round(max(vals), 4)
271
- return agg
272
-
273
-
274
- def print_summary(report: Dict[str, Any]) -> None:
275
- """Print a human-readable table to stdout."""
276
- images = report["images"]
277
- agg = report.get("aggregate", {})
278
- metadata = report["metadata"]
279
-
280
- print("\n" + "=" * 70)
281
- print(f" Architectural Drawing AI — Benchmark Results")
282
- print(f" {metadata['timestamp']} | {metadata['image_count']} image(s)")
283
- print("=" * 70)
284
-
285
- col = "{:<28} {:>7} {:>7} {:>8} {:>8}"
286
- print(col.format("Image", "Lines", "OCR", "Time(s)", "Errors"))
287
- print("-" * 70)
288
-
289
- for img in images:
290
- line_s = img["stages"].get("line_extraction", {})
291
- ocr_s = img["stages"].get("ocr", {})
292
- lines = line_s.get("lines_detected", "-") if line_s.get("success") else "ERR"
293
- tokens = ocr_s.get("element_count", "-") if ocr_s.get("success") else "ERR"
294
- errors = len(img.get("errors", []))
295
- print(col.format(
296
- img["filename"][:27],
297
- str(lines),
298
- str(tokens),
299
- str(img.get("total_time_s", "-")),
300
- str(errors) if errors else "",
301
- ))
302
-
303
- if agg:
304
- print("-" * 70)
305
- print("\n Accuracy (averaged across all images with ground truth):\n")
306
- metric_labels = {
307
- "avg_ocr_cer": "OCR Character Error Rate (lower=better)",
308
- "avg_ocr_wer": "OCR Word Error Rate (lower=better)",
309
- "avg_ocr_token_f1": "OCR Token F1 (higher=better)",
310
- "avg_ocr_token_recall": "OCR Token Recall",
311
- "avg_ocr_token_precision": "OCR Token Precision",
312
- "avg_room_name_recall": "Room Name Recall",
313
- "avg_line_count_accuracy": "Line Count Accuracy",
314
- }
315
- for key, label in metric_labels.items():
316
- if key in agg:
317
- pct = f"{agg[key]*100:.1f}%"
318
- print(f" {label:<45} {pct:>7}")
319
-
320
- print("\n Results saved to:", report["metadata"].get("output_path", "—"))
321
- print("=" * 70 + "\n")
322
-
323
-
324
- # ---------------------------------------------------------------------------
325
- # Entry point
326
- # ---------------------------------------------------------------------------
327
-
328
- def main() -> None:
329
- parser = argparse.ArgumentParser(description="Benchmark the drawing pipeline")
330
- parser.add_argument(
331
- "--images", default=str(SAMPLE_DIR),
332
- help="Directory of test images (default: benchmark/sample_images/)",
333
- )
334
- parser.add_argument(
335
- "--gt", default=None,
336
- help="Path to ground_truth.json for accuracy scoring",
337
- )
338
- parser.add_argument(
339
- "--out", default=None,
340
- help="Output JSON path (default: benchmark/results/benchmark_<ts>.json)",
341
- )
342
- parser.add_argument(
343
- "-v", "--verbose", action="store_true",
344
- help="Print per-image stage detail",
345
- )
346
- parser.add_argument(
347
- "--ocr-model", default=None, choices=["tesseract", "paddleocr"],
348
- help="OCR engine to benchmark (default: tesseract). Falls back to "
349
- "tesseract if paddleocr is requested but unavailable.",
350
- )
351
- args = parser.parse_args()
352
-
353
- images_dir = Path(args.images)
354
- if not images_dir.exists():
355
- print(f"ERROR: images directory not found: {images_dir}")
356
- sys.exit(1)
357
-
358
- image_paths = sorted(
359
- p for p in images_dir.iterdir()
360
- if p.suffix.lower() in SUPPORTED_EXTENSIONS
361
- )
362
-
363
- if not image_paths:
364
- print(f"\nNo images found in {images_dir}")
365
- print("Add JPEG/PNG architectural drawings to that directory and re-run.")
366
- sys.exit(0)
367
-
368
- ground_truth: Dict[str, Any] = {}
369
- if args.gt:
370
- gt_path = Path(args.gt)
371
- if gt_path.exists():
372
- with gt_path.open(encoding="utf-8") as f:
373
- raw_gt = json.load(f)
374
- # Strip comment keys
375
- ground_truth = {k: v for k, v in raw_gt.items() if not k.startswith("_")}
376
- print(f"Loaded ground truth for {len(ground_truth)} image(s).")
377
- else:
378
- print(f"WARNING: ground truth file not found: {gt_path}")
379
-
380
- ts = datetime.now().strftime("%Y%m%d_%H%M%S")
381
- out_path = Path(args.out) if args.out else RESULTS_DIR / f"benchmark_{ts}.json"
382
-
383
- print(f"\nProcessing {len(image_paths)} image(s) from {images_dir} …\n")
384
-
385
- image_results = []
386
- all_scores = []
387
-
388
- for path in image_paths:
389
- print(f" [{image_paths.index(path)+1}/{len(image_paths)}] {path.name} …", end=" ", flush=True)
390
- result = process_image(path, ocr_model=args.ocr_model)
391
-
392
- if "error" in result:
393
- print(f"FAILED — {result['error']}")
394
- else:
395
- print(f"OK ({result['total_time_s']}s, "
396
- f"{result['stages'].get('line_extraction', {}).get('lines_detected', '?')} lines, "
397
- f"{result['stages'].get('ocr', {}).get('element_count', '?')} OCR tokens)")
398
-
399
- if args.verbose:
400
- for stage, data in result.get("stages", {}).items():
401
- print(f" {stage}: {data}")
402
-
403
- gt_entry = ground_truth.get(path.name)
404
- if gt_entry:
405
- scores = score_against_ground_truth(result, gt_entry)
406
- result["accuracy"] = scores
407
- all_scores.append(scores)
408
-
409
- image_results.append(result)
410
-
411
- report = {
412
- "metadata": {
413
- "timestamp": datetime.now().isoformat(),
414
- "image_count": len(image_results),
415
- "ground_truth": args.gt,
416
- "output_path": str(out_path),
417
- "pipeline_version": "1.0.0",
418
- "ocr_model": args.ocr_model or "tesseract",
419
- },
420
- "images": image_results,
421
- "aggregate": aggregate(all_scores) if all_scores else {},
422
- }
423
-
424
- out_path.parent.mkdir(parents=True, exist_ok=True)
425
- with out_path.open("w", encoding="utf-8") as f:
426
- json.dump(report, f, indent=2, ensure_ascii=False)
427
-
428
- print_summary(report)
429
-
430
-
431
- if __name__ == "__main__":
432
- main()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
processors/enhancement/__init__.py DELETED
@@ -1,12 +0,0 @@
1
- """
2
- Enhancement provider package.
3
- Import `registry` for all provider access.
4
- """
5
- from .registry import registry
6
- from .base import EnhancementProvider
7
- from .pipeline import build_preset_pipelines
8
-
9
- # Register composite pipelines after all individual providers are loaded
10
- build_preset_pipelines(registry)
11
-
12
- __all__ = ["registry", "EnhancementProvider"]
 
 
 
 
 
 
 
 
 
 
 
 
 
processors/enhancement/base.py DELETED
@@ -1,92 +0,0 @@
1
- """
2
- Abstract base class for all image enhancement providers.
3
-
4
- Every provider must implement:
5
- - name : str — unique slug used in the API (e.g. "classical", "real_esrgan")
6
- - display_name: str — human-readable name shown in /api/info
7
- - is_available: bool — False if required dependencies are not installed
8
- - enhance(image) -> np.ndarray
9
-
10
- Optional overrides:
11
- - description : str
12
- - enhance_to_grayscale(image) -> np.ndarray (for intermediate display)
13
- """
14
-
15
- from __future__ import annotations
16
- from abc import ABC, abstractmethod
17
- from typing import Optional
18
- import numpy as np
19
-
20
-
21
- class EnhancementProvider(ABC):
22
-
23
- # ------------------------------------------------------------------ #
24
- # Identity — subclasses set these as class attributes #
25
- # ------------------------------------------------------------------ #
26
-
27
- name: str = ""
28
- display_name: str = ""
29
- description: str = ""
30
-
31
- # ------------------------------------------------------------------ #
32
- # Availability #
33
- # ------------------------------------------------------------------ #
34
-
35
- @property
36
- @abstractmethod
37
- def is_available(self) -> bool:
38
- """
39
- Return True only if all required packages are installed and any
40
- required model weights are accessible. Called once at startup.
41
- """
42
-
43
- @property
44
- def unavailable_reason(self) -> Optional[str]:
45
- """Human-readable explanation shown in /api/info when unavailable."""
46
- return None
47
-
48
- # ------------------------------------------------------------------ #
49
- # Core interface #
50
- # ------------------------------------------------------------------ #
51
-
52
- @abstractmethod
53
- def enhance(self, image: np.ndarray) -> np.ndarray:
54
- """
55
- Enhance the image.
56
-
57
- Parameters
58
- ----------
59
- image : np.ndarray
60
- BGR or grayscale uint8 image as loaded by OpenCV.
61
-
62
- Returns
63
- -------
64
- np.ndarray
65
- Enhanced image — same dtype (uint8), same or different shape.
66
- Must be suitable for direct input to LineExtractor and OCRProcessor.
67
- """
68
-
69
- def enhance_to_grayscale(self, image: np.ndarray) -> np.ndarray:
70
- """
71
- Return a grayscale (non-binary) enhanced image for display purposes.
72
- Default: call enhance() and convert to grayscale if needed.
73
- Override in subclasses if the provider has a natural grayscale output.
74
- """
75
- import cv2
76
- result = self.enhance(image)
77
- if len(result.shape) == 3:
78
- return cv2.cvtColor(result, cv2.COLOR_BGR2GRAY)
79
- return result
80
-
81
- # ------------------------------------------------------------------ #
82
- # Metadata for /api/info #
83
- # ------------------------------------------------------------------ #
84
-
85
- def info(self) -> dict:
86
- return {
87
- "name": self.name,
88
- "display_name": self.display_name,
89
- "description": self.description,
90
- "available": self.is_available,
91
- "unavailable_reason": self.unavailable_reason if not self.is_available else None,
92
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
processors/enhancement/classical.py DELETED
@@ -1,35 +0,0 @@
1
- """
2
- Classical OpenCV enhancement provider.
3
- Always available — no extra dependencies beyond the existing requirements.
4
- Wraps the existing ImageEnhancer pipeline.
5
- """
6
-
7
- from __future__ import annotations
8
- import numpy as np
9
-
10
- from .base import EnhancementProvider
11
- from processors.image_enhancer import ImageEnhancer
12
-
13
-
14
- class ClassicalCVProvider(EnhancementProvider):
15
-
16
- name = "classical"
17
- display_name = "Classical CV (OpenCV)"
18
- description = (
19
- "6-stage OpenCV pipeline: grayscale → NLM denoise → background removal "
20
- "→ CLAHE contrast → adaptive threshold → morphological cleanup. "
21
- "Fast, no GPU required, works on any hardware."
22
- )
23
-
24
- def __init__(self) -> None:
25
- self._enhancer = ImageEnhancer()
26
-
27
- @property
28
- def is_available(self) -> bool:
29
- return True # OpenCV is a hard dependency of the whole backend
30
-
31
- def enhance(self, image: np.ndarray) -> np.ndarray:
32
- return self._enhancer.enhance(image)
33
-
34
- def enhance_to_grayscale(self, image: np.ndarray) -> np.ndarray:
35
- return self._enhancer.enhance_grayscale(image)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
processors/enhancement/groq_guided.py DELETED
@@ -1,288 +0,0 @@
1
- """
2
- Groq AI-Guided Enhancement Provider
3
- =====================================
4
- Uses Groq's Llama 4 Scout vision model to assess the input drawing's
5
- quality, type, and degradation level, then runs the classical OpenCV
6
- pipeline with parameters tuned to that assessment.
7
-
8
- What Groq contributes
9
- ---------------------
10
- Groq is a text/vision inference API — it cannot modify pixel values.
11
- Its role here is PARAMETER SELECTION:
12
-
13
- • quality_score → how aggressively to denoise / boost contrast
14
- • drawing_type → e.g. "detail" drawings need sharper lines
15
- • ocr_difficulty → drive CLAHE strength for text regions
16
-
17
- All actual image processing is performed by OpenCV via _TunableEnhancer,
18
- a subclass of ImageEnhancer that accepts the parameter dict from Groq's
19
- analysis instead of hardcoded constants.
20
-
21
- Fallback
22
- --------
23
- If the Groq call fails for any reason (network error, rate limit, bad JSON),
24
- the provider silently falls back to default parameters — equivalent to the
25
- standard classical pipeline — so processing never fails.
26
- """
27
-
28
- from __future__ import annotations
29
-
30
- import base64
31
- import json
32
- import logging
33
- import os
34
- import warnings
35
- from typing import Optional
36
-
37
- import cv2
38
- import numpy as np
39
-
40
- from .base import EnhancementProvider
41
- from processors.image_enhancer import ImageEnhancer
42
-
43
- logger = logging.getLogger(__name__)
44
-
45
- # ── Groq analysis prompt ──────────────────────────────────────────────────────
46
- # Short, specific — we only need the fields we can act on.
47
- _PARAM_PROMPT = (
48
- "You are assessing a scanned architectural or engineering drawing for "
49
- "automated image restoration. Respond ONLY with valid JSON (no markdown "
50
- "fences) using exactly these fields:\n"
51
- "{\n"
52
- ' "drawing_type": "floor plan | elevation | section | detail | '
53
- 'site plan | railway | electrical | mechanical | other",\n'
54
- ' "quality_score": <integer 1-10>,\n'
55
- ' "degradation": ["list from: faded, yellowed, stained, noisy, blurred, '
56
- 'folded, torn, foxing, compression artifacts"],\n'
57
- ' "has_color_fills": <true or false>,\n'
58
- ' "has_japanese_text": <true or false>,\n'
59
- ' "ocr_difficulty": "easy | moderate | hard | very hard"\n'
60
- "}"
61
- )
62
-
63
-
64
- # ── Parameter mapping ─────────────────────────────────────────────────────────
65
-
66
- def _default_params() -> dict:
67
- """Matches the hardcoded defaults in ImageEnhancer — safe fallback."""
68
- return {
69
- "bilateral_sigma": 40,
70
- "clahe_clip": 2.0,
71
- "content_clip": 3.0,
72
- "sharpen_strength": 0.30,
73
- "bg_brightness": 235,
74
- "vivify_factor": 1.30,
75
- }
76
-
77
-
78
- def _params_from_analysis(analysis: dict) -> dict:
79
- """
80
- Convert Groq's quality analysis into a parameter dict.
81
-
82
- Quality tier → base settings
83
- OCR difficulty → lifts CLAHE in content zones
84
- Drawing type → increases sharpening for detail/section drawings
85
- """
86
- params = _default_params()
87
- quality = int(analysis.get("quality_score") or 5)
88
- ocr = (analysis.get("ocr_difficulty") or "moderate").lower()
89
- dtype = (analysis.get("drawing_type") or "").lower()
90
-
91
- # ── Quality tier ──
92
- if quality <= 3:
93
- # Severely degraded: maximum denoising + contrast
94
- params.update(bilateral_sigma=55, clahe_clip=3.5,
95
- content_clip=4.5, sharpen_strength=0.45, bg_brightness=228)
96
- elif quality <= 6:
97
- # Moderately degraded: standard-plus settings
98
- params.update(bilateral_sigma=40, clahe_clip=2.5,
99
- content_clip=3.5, sharpen_strength=0.35, bg_brightness=232)
100
- elif quality <= 8:
101
- # Good: default settings
102
- params.update(bilateral_sigma=30, clahe_clip=2.0,
103
- content_clip=3.0, sharpen_strength=0.30, bg_brightness=236)
104
- else:
105
- # Excellent: minimal processing to avoid over-cooking
106
- params.update(bilateral_sigma=22, clahe_clip=1.5,
107
- content_clip=2.5, sharpen_strength=0.20, bg_brightness=242)
108
-
109
- # ── OCR difficulty modifier ──
110
- if ocr in ("hard", "very hard"):
111
- params["clahe_clip"] = min(params["clahe_clip"] + 0.5, 5.0)
112
- params["content_clip"] = min(params["content_clip"] + 0.5, 6.0)
113
- if ocr == "very hard":
114
- params["bilateral_sigma"] = min(params["bilateral_sigma"] + 10, 65)
115
-
116
- # ── Drawing type modifier ──
117
- if any(t in dtype for t in ("detail", "section")):
118
- # Precise linework drawings benefit from stronger sharpening
119
- params["sharpen_strength"] = min(params["sharpen_strength"] + 0.10, 0.60)
120
-
121
- logger.info(
122
- "Groq-guided params — quality=%d ocr=%s type=%s "
123
- "σ=%d clahe=%.1f content_clahe=%.1f sharpen=%.2f",
124
- quality, ocr, dtype or "unknown",
125
- params["bilateral_sigma"], params["clahe_clip"],
126
- params["content_clip"], params["sharpen_strength"],
127
- )
128
- return params
129
-
130
-
131
- # ── Tunable pipeline ──────────────────────────────────────────────────────────
132
-
133
- class _TunableEnhancer(ImageEnhancer):
134
- """
135
- ImageEnhancer subclass where the four parametric stages read their
136
- settings from a dict instead of hardcoded constants.
137
-
138
- Non-parametric stages (crop, upscale, border, deskew, background
139
- normalisation, speck removal) are inherited unchanged.
140
- """
141
-
142
- def __init__(self, params: dict) -> None:
143
- self._p = params
144
-
145
- def _bilateral_denoise(self, image: np.ndarray) -> np.ndarray:
146
- s = self._p["bilateral_sigma"]
147
- d1 = cv2.bilateralFilter(image, d=9, sigmaColor=s, sigmaSpace=15)
148
- return cv2.bilateralFilter(d1, d=9, sigmaColor=max(10, s // 2), sigmaSpace=10)
149
-
150
- def _enhance_contrast(self, image: np.ndarray) -> np.ndarray:
151
- clip = self._p["clahe_clip"]
152
- if len(image.shape) == 2:
153
- return cv2.createCLAHE(clipLimit=clip, tileGridSize=(8, 8)).apply(image)
154
- lab = cv2.cvtColor(image, cv2.COLOR_BGR2LAB)
155
- l, a, b = cv2.split(lab)
156
- l = cv2.createCLAHE(clipLimit=clip, tileGridSize=(8, 8)).apply(l)
157
- return cv2.cvtColor(cv2.merge([l, a, b]), cv2.COLOR_LAB2BGR)
158
-
159
- def _content_enhance(self, image: np.ndarray) -> np.ndarray:
160
- clip = self._p["content_clip"]
161
- gray = image if len(image.shape) == 2 else cv2.cvtColor(image, cv2.COLOR_BGR2GRAY)
162
- content_px = (gray < 200).astype(np.uint8)
163
- kern = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (21, 21))
164
- content_zone = cv2.dilate(content_px, kern).astype(bool)
165
- clahe = cv2.createCLAHE(clipLimit=clip, tileGridSize=(8, 8))
166
- if len(image.shape) == 2:
167
- return np.where(content_zone, clahe.apply(image), image).astype(np.uint8)
168
- lab = cv2.cvtColor(image, cv2.COLOR_BGR2LAB)
169
- l, a, b = cv2.split(lab)
170
- l_out = np.where(content_zone, clahe.apply(l), l).astype(np.uint8)
171
- return cv2.cvtColor(cv2.merge([l_out, a, b]), cv2.COLOR_LAB2BGR)
172
-
173
- def _sharpen_linework(self, image: np.ndarray) -> np.ndarray:
174
- strength = self._p["sharpen_strength"]
175
- gray = image if len(image.shape) == 2 else cv2.cvtColor(image, cv2.COLOR_BGR2GRAY)
176
- gx = cv2.Sobel(gray, cv2.CV_32F, 1, 0, ksize=3)
177
- gy = cv2.Sobel(gray, cv2.CV_32F, 0, 1, ksize=3)
178
- grad = np.sqrt(gx ** 2 + gy ** 2)
179
- edge = ((gray < 180) & (grad > 15)).astype(np.uint8)
180
- kern = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (5, 5))
181
- zone = cv2.dilate(edge, kern).astype(bool)
182
- blur = cv2.GaussianBlur(image, (3, 3), sigmaX=0.8)
183
- sharpened = cv2.addWeighted(image, 1.0 + strength, blur, -strength, 0)
184
- if len(image.shape) == 3:
185
- mask3 = np.stack([zone, zone, zone], axis=2)
186
- return np.where(mask3, sharpened, image).astype(np.uint8)
187
- return np.where(zone, sharpened, image).astype(np.uint8)
188
-
189
- def _clean_background(self, image: np.ndarray) -> np.ndarray:
190
- thresh = self._p["bg_brightness"]
191
- gray = image if len(image.shape) == 2 else cv2.cvtColor(image, cv2.COLOR_BGR2GRAY)
192
- gray_f = gray.astype(np.float32)
193
- variance = np.maximum(
194
- cv2.blur(gray_f ** 2, (9, 9)) - cv2.blur(gray_f, (9, 9)) ** 2, 0.0
195
- )
196
- bg_mask = (gray > thresh) & (variance < 60)
197
- result = image.copy()
198
- if len(image.shape) == 2:
199
- result[bg_mask] = 255
200
- else:
201
- result[bg_mask] = (255, 255, 255)
202
- logger.info("BG cleanup (thresh=%d): %d px → white", thresh, int(bg_mask.sum()))
203
- return result
204
-
205
-
206
- # ── Provider class ────────────────────────────────────────────────────────────
207
-
208
- class GroqGuidedProvider(EnhancementProvider):
209
- """
210
- Enhancement provider that uses Groq's vision model to assess the
211
- drawing and then runs a parameter-tuned classical CV pipeline.
212
- """
213
-
214
- name = "groq_guided"
215
- display_name = "Groq AI-Guided (Llama 4 Scout + OpenCV)"
216
- description = (
217
- "Groq Llama 4 Scout vision assesses drawing quality, type, and "
218
- "degradation — then a tuned OpenCV pipeline applies the right "
219
- "enhancement strength. Requires GROQ_API_KEY in backend/.env."
220
- )
221
-
222
- @property
223
- def is_available(self) -> bool:
224
- return bool(os.getenv("GROQ_API_KEY", "").strip())
225
-
226
- @property
227
- def unavailable_reason(self) -> Optional[str]:
228
- return None if self.is_available else "GROQ_API_KEY not set in backend/.env"
229
-
230
- def enhance(self, image: np.ndarray) -> np.ndarray:
231
- analysis = self._analyze(image)
232
- params = _params_from_analysis(analysis)
233
- return _TunableEnhancer(params).enhance(image)
234
-
235
- # ── Groq call ──────────────────────────────────────────────────────────
236
-
237
- def _analyze(self, image: np.ndarray) -> dict:
238
- """
239
- Send an 800 px thumbnail to Groq for quality assessment.
240
- Returns empty dict (→ default params) on any failure.
241
- """
242
- thumb = self._thumbnail(image, 800)
243
- _, jpeg = cv2.imencode(".jpg", thumb, [cv2.IMWRITE_JPEG_QUALITY, 85])
244
- b64 = base64.b64encode(jpeg.tobytes()).decode()
245
-
246
- try:
247
- raw = self._call_groq(b64)
248
- text = raw.strip()
249
- if text.startswith("```"):
250
- text = text.split("```")[1].lstrip("json").strip()
251
- result = json.loads(text)
252
- logger.info("Groq analysis: %s", result)
253
- return result
254
- except Exception as exc:
255
- logger.warning("Groq analysis failed (%s) — using default params", exc)
256
- return {}
257
-
258
- def _call_groq(self, b64: str) -> str:
259
- import httpx
260
- from groq import Groq
261
-
262
- api_key = os.getenv("GROQ_API_KEY", "").strip()
263
- with warnings.catch_warnings():
264
- warnings.simplefilter("ignore")
265
- client = Groq(api_key=api_key, http_client=httpx.Client(verify=False)) # noqa: S501 # NOSONAR
266
-
267
- resp = client.chat.completions.create(
268
- model="meta-llama/llama-4-scout-17b-16e-instruct",
269
- max_tokens=256,
270
- messages=[{
271
- "role": "user",
272
- "content": [
273
- {"type": "image_url",
274
- "image_url": {"url": f"data:image/jpeg;base64,{b64}"}},
275
- {"type": "text", "text": _PARAM_PROMPT},
276
- ],
277
- }],
278
- )
279
- return resp.choices[0].message.content
280
-
281
- @staticmethod
282
- def _thumbnail(image: np.ndarray, max_side: int) -> np.ndarray:
283
- h, w = image.shape[:2]
284
- if max(h, w) <= max_side:
285
- return image
286
- scale = max_side / max(h, w)
287
- return cv2.resize(image, (int(w * scale), int(h * scale)),
288
- interpolation=cv2.INTER_AREA)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
processors/enhancement/huggingface.py DELETED
@@ -1,130 +0,0 @@
1
- """
2
- HuggingFace enhancement provider.
3
- ===================================
4
- Loads any image-to-image model from HuggingFace Hub and applies it as an
5
- enhancement step before classical binarisation.
6
-
7
- Recommended models for architectural drawing restoration:
8
- - "microsoft/swin2SR-compressed-SR-x2-64" (lightweight super-resolution)
9
- - "caidas/swin2SR-realworld-sr-x4-64-bsrgan-psnr" (real-world degradation)
10
- - "eugenesiow/srcnn-scale2" (fast, minimal memory)
11
-
12
- Installation
13
- ------------
14
- pip install transformers Pillow torch
15
-
16
- Usage (via API)
17
- ---------------
18
- POST /api/process
19
- {
20
- "enhancement_model": "huggingface",
21
- "hf_model_id": "microsoft/swin2SR-compressed-SR-x2-64"
22
- }
23
-
24
- Or set a default via env var:
25
- HF_ENHANCEMENT_MODEL=microsoft/swin2SR-compressed-SR-x2-64
26
- """
27
-
28
- from __future__ import annotations
29
- import logging
30
- import os
31
- from typing import Optional
32
-
33
- import numpy as np
34
-
35
- from .base import EnhancementProvider
36
-
37
- logger = logging.getLogger(__name__)
38
-
39
- _HF_AVAILABLE = False
40
- _HF_ERROR: Optional[str] = None
41
-
42
- try:
43
- from transformers import pipeline as hf_pipeline # noqa: F401
44
- from PIL import Image # noqa: F401
45
- _HF_AVAILABLE = True
46
- except ImportError as _e:
47
- _HF_ERROR = f"transformers / Pillow not installed: {_e}"
48
-
49
- _DEFAULT_HF_MODEL = os.environ.get(
50
- "HF_ENHANCEMENT_MODEL",
51
- "caidas/swin2SR-realworld-sr-x4-64-bsrgan-psnr",
52
- )
53
-
54
-
55
- class HuggingFaceProvider(EnhancementProvider):
56
-
57
- name = "huggingface"
58
- display_name = "HuggingFace (configurable model)"
59
- description = (
60
- "Applies any image-to-image HuggingFace model as a pre-processing step. "
61
- "Pass 'hf_model_id' in the request to select a model, or set the "
62
- "HF_ENHANCEMENT_MODEL env var. Falls back to classical binarisation after "
63
- "the model runs. Install: pip install transformers Pillow torch"
64
- )
65
-
66
- def __init__(self, model_id: Optional[str] = None) -> None:
67
- self._model_id = model_id or _DEFAULT_HF_MODEL
68
- self._pipe = None # lazy-loaded
69
-
70
- @property
71
- def is_available(self) -> bool:
72
- return _HF_AVAILABLE
73
-
74
- @property
75
- def unavailable_reason(self) -> Optional[str]:
76
- return _HF_ERROR
77
-
78
- def with_model(self, model_id: str) -> "HuggingFaceProvider":
79
- """Return a new provider instance using the specified model."""
80
- return HuggingFaceProvider(model_id=model_id)
81
-
82
- def enhance(self, image: np.ndarray) -> np.ndarray:
83
- import cv2
84
- from PIL import Image as PILImage
85
- from processors.image_enhancer import ImageEnhancer
86
-
87
- pipe = self._get_pipeline()
88
-
89
- # Convert to PIL RGB for the transformer pipeline
90
- if len(image.shape) == 2:
91
- pil_img = PILImage.fromarray(image).convert("RGB")
92
- else:
93
- rgb = cv2.cvtColor(image, cv2.COLOR_BGR2RGB)
94
- pil_img = PILImage.fromarray(rgb)
95
-
96
- logger.info("HuggingFace enhancing with model: %s", self._model_id)
97
- result = pipe(pil_img)
98
-
99
- # Pipeline output varies by model — handle both PIL and list-of-dict
100
- if isinstance(result, list):
101
- out_pil = result[0].get("image", pil_img) if isinstance(result[0], dict) else result[0]
102
- elif hasattr(result, "images"):
103
- out_pil = result.images[0]
104
- else:
105
- out_pil = result
106
-
107
- out_np = np.array(out_pil.convert("L")) # grayscale uint8
108
-
109
- # Finish with classical binarisation so output is compatible
110
- # with downstream LineExtractor + OCR processors
111
- enh = ImageEnhancer()
112
- contrast = enh._enhance_contrast(out_np)
113
- binary = enh._binarize(contrast)
114
- return enh._morphological_cleanup(binary)
115
-
116
- def _get_pipeline(self):
117
- if self._pipe is not None:
118
- return self._pipe
119
- from transformers import pipeline as hf_pipeline
120
- logger.info("Loading HuggingFace pipeline: %s", self._model_id)
121
- self._pipe = hf_pipeline(
122
- "image-to-image",
123
- model=self._model_id,
124
- )
125
- return self._pipe
126
-
127
- def info(self) -> dict:
128
- base = super().info()
129
- base["active_model"] = self._model_id
130
- return base
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
processors/enhancement/huggingface_api.py DELETED
@@ -1,140 +0,0 @@
1
- """
2
- HuggingFace Serverless Inference API — Cloud Enhancement Provider
3
- ==================================================================
4
- Sends the image to a HuggingFace-hosted super-resolution model via their
5
- free Serverless Inference API.
6
-
7
- Setup (free)
8
- ------------
9
- 1. Create a free account at https://huggingface.co
10
- 2. Go to https://huggingface.co/settings/tokens → Create new token
11
- 3. Under "User Permissions" enable "Make calls to Inference Providers"
12
- 4. Add to backend/.env:
13
- HF_API_TOKEN=hf_...
14
-
15
- Optional — override the default model:
16
- HF_API_MODEL=eugenesiow/srcnn-scale2
17
-
18
- Default model
19
- -------------
20
- caidas/swin2SR-realworld-sr-x4-64-bsrgan-psnr
21
- 4x super-resolution trained on real-world degraded images.
22
- Works well on scanned/photocopied documents.
23
-
24
- Limitations of the free tier
25
- -----------------------------
26
- ~10 requests/minute on shared infrastructure
27
- Max image size: ~4 MB — provider auto-resizes before sending
28
- SSL verification is disabled to work through corporate proxies
29
- """
30
-
31
- from __future__ import annotations
32
- import io
33
- import logging
34
- import os
35
- import warnings
36
- from typing import Optional
37
-
38
- import numpy as np
39
-
40
- from .base import EnhancementProvider
41
-
42
- logger = logging.getLogger(__name__)
43
-
44
- _DEFAULT_MODEL = os.environ.get(
45
- "HF_API_MODEL",
46
- "caidas/swin2SR-realworld-sr-x4-64-bsrgan-psnr",
47
- )
48
-
49
- # New HuggingFace Inference API endpoint (replaces api-inference.huggingface.co)
50
- _HF_ROUTER_BASE = "https://router.huggingface.co/hf-inference/models"
51
-
52
-
53
- class HuggingFaceAPIProvider(EnhancementProvider):
54
-
55
- name = "huggingface_api"
56
- display_name = "HuggingFace Cloud API (free)"
57
- description = (
58
- "Cloud super-resolution via HuggingFace Serverless Inference API. "
59
- "Free account required — add HF_API_TOKEN to backend/.env with "
60
- "'Make calls to Inference Providers' permission enabled."
61
- )
62
-
63
- def __init__(self, model_id: Optional[str] = None) -> None:
64
- self._model_id = model_id or _DEFAULT_MODEL
65
-
66
- @property
67
- def is_available(self) -> bool:
68
- return bool(os.environ.get("HF_API_TOKEN", "").strip())
69
-
70
- @property
71
- def unavailable_reason(self) -> Optional[str]:
72
- if not self.is_available:
73
- return (
74
- "HF_API_TOKEN not set. "
75
- "Get a free token at https://huggingface.co/settings/tokens "
76
- "(enable 'Make calls to Inference Providers') "
77
- "and add it to backend/.env"
78
- )
79
- return None
80
-
81
- def with_model(self, model_id: str) -> "HuggingFaceAPIProvider":
82
- return HuggingFaceAPIProvider(model_id=model_id)
83
-
84
- def enhance(self, image: np.ndarray) -> np.ndarray:
85
- import cv2
86
- import requests
87
- from PIL import Image as PILImage
88
- from processors.image_enhancer import ImageEnhancer
89
-
90
- token = os.environ.get("HF_API_TOKEN", "").strip()
91
- url = f"{_HF_ROUTER_BASE}/{self._model_id}"
92
-
93
- # Convert to BGR then resize for free-tier payload limit (~4 MB)
94
- bgr = image if len(image.shape) == 3 else cv2.cvtColor(image, cv2.COLOR_GRAY2BGR)
95
- h, w = bgr.shape[:2]
96
- if max(h, w) > 1500:
97
- scale = 1500 / max(h, w)
98
- bgr = cv2.resize(bgr, (int(w * scale), int(h * scale)),
99
- interpolation=cv2.INTER_AREA)
100
-
101
- _, enc = cv2.imencode(".jpg", bgr, [cv2.IMWRITE_JPEG_QUALITY, 85])
102
- img_bytes = enc.tobytes()
103
-
104
- logger.info("HF API request: model=%s input=%dx%d size=%.1f KB",
105
- self._model_id, bgr.shape[1], bgr.shape[0], len(img_bytes) / 1024)
106
-
107
- # verify=False to work through corporate proxies with self-signed certs
108
- with warnings.catch_warnings():
109
- warnings.simplefilter("ignore")
110
- resp = requests.post(
111
- url,
112
- headers={
113
- "Authorization": f"Bearer {token}",
114
- "Content-Type": "image/jpeg",
115
- },
116
- data=img_bytes,
117
- verify=False, # noqa: S501 — intentional: corporate proxy uses self-signed cert
118
- timeout=120,
119
- )
120
-
121
- if resp.status_code != 200:
122
- raise RuntimeError(
123
- f"HuggingFace API {resp.status_code}: {resp.text[:400]}"
124
- )
125
-
126
- out_pil = PILImage.open(io.BytesIO(resp.content)).convert("RGB")
127
- out_bgr = cv2.cvtColor(np.array(out_pil), cv2.COLOR_RGB2BGR)
128
- logger.info("HF API response: %dx%d", out_bgr.shape[1], out_bgr.shape[0])
129
-
130
- # Binarise so output is compatible with LineExtractor + OCRProcessor
131
- enh = ImageEnhancer()
132
- gray = cv2.cvtColor(out_bgr, cv2.COLOR_BGR2GRAY)
133
- contrast = enh._enhance_contrast(gray)
134
- binary = enh._binarize(contrast)
135
- return enh._morphological_cleanup(binary)
136
-
137
- def info(self) -> dict:
138
- base = super().info()
139
- base["active_model"] = self._model_id
140
- return base
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
processors/enhancement/pipeline.py DELETED
@@ -1,124 +0,0 @@
1
- """
2
- Enhancement Pipeline
3
- ====================
4
- Chains multiple providers in sequence. The output of each provider becomes
5
- the input of the next. Useful for combining AI super-resolution with
6
- classical binarisation, or for running experimental multi-stage workflows.
7
-
8
- Example chains defined here:
9
- "ai_then_classical" — Real-ESRGAN upscale → classical binarise
10
- "hf_then_classical" — HuggingFace model → classical binarise
11
-
12
- Custom chains can be built at runtime:
13
- from processors.enhancement.pipeline import EnhancementPipeline
14
- from processors.enhancement.registry import registry
15
-
16
- pipe = EnhancementPipeline([
17
- registry.get("real_esrgan"),
18
- registry.get("classical"),
19
- ])
20
- result = pipe.enhance(image)
21
- """
22
-
23
- from __future__ import annotations
24
- import logging
25
- from typing import List, Optional
26
-
27
- import numpy as np
28
-
29
- from .base import EnhancementProvider
30
-
31
- logger = logging.getLogger(__name__)
32
-
33
-
34
- class EnhancementPipeline(EnhancementProvider):
35
- """
36
- Meta-provider that runs a list of providers in sequence.
37
- Reports as available only if ALL constituent providers are available.
38
- """
39
-
40
- def __init__(
41
- self,
42
- providers: List[EnhancementProvider],
43
- name: str = "pipeline",
44
- display_name: str = "Custom Pipeline",
45
- description: str = "",
46
- ) -> None:
47
- if not providers:
48
- raise ValueError("EnhancementPipeline requires at least one provider")
49
- self._providers = providers
50
- self.name = name
51
- self.display_name = display_name
52
- self.description = description or " → ".join(p.display_name for p in providers)
53
-
54
- @property
55
- def is_available(self) -> bool:
56
- return all(p.is_available for p in self._providers)
57
-
58
- @property
59
- def unavailable_reason(self) -> Optional[str]:
60
- reasons = [
61
- f"{p.name}: {p.unavailable_reason}"
62
- for p in self._providers
63
- if not p.is_available
64
- ]
65
- return "; ".join(reasons) if reasons else None
66
-
67
- def enhance(self, image: np.ndarray) -> np.ndarray:
68
- result = image
69
- for provider in self._providers:
70
- logger.info("Pipeline stage: %s", provider.name)
71
- result = provider.enhance(result)
72
- return result
73
-
74
- def info(self) -> dict:
75
- base = super().info()
76
- base["stages"] = [p.name for p in self._providers]
77
- return base
78
-
79
-
80
- # ---------------------------------------------------------------------------
81
- # Pre-built named chains registered via the registry
82
- # ---------------------------------------------------------------------------
83
-
84
- def build_preset_pipelines(registry) -> None:
85
- """
86
- Register composite pipeline presets into an existing registry.
87
- Called once after the registry has loaded all individual providers.
88
-
89
- This is intentionally separated so the registry doesn't need to
90
- import pipeline — avoiding circular imports.
91
- """
92
- try:
93
- esrgan = registry._providers.get("real_esrgan")
94
- hf = registry._providers.get("huggingface")
95
- classical = registry._providers.get("classical")
96
-
97
- if esrgan and classical:
98
- pipe = EnhancementPipeline(
99
- providers=[esrgan, classical],
100
- name="ai_then_classical",
101
- display_name="AI Super-Resolution + Classical Binarise",
102
- description=(
103
- "Real-ESRGAN 4× upscale and denoise, followed by classical "
104
- "adaptive threshold binarisation. Best for degraded photographic scans."
105
- ),
106
- )
107
- registry._providers[pipe.name] = pipe
108
- logger.info("Pipeline registered: %s", pipe.name)
109
-
110
- if hf and classical:
111
- pipe = EnhancementPipeline(
112
- providers=[hf, classical],
113
- name="hf_then_classical",
114
- display_name="HuggingFace Model + Classical Binarise",
115
- description=(
116
- "Configurable HuggingFace image model followed by classical "
117
- "binarisation. Set HF_ENHANCEMENT_MODEL env var to choose model."
118
- ),
119
- )
120
- registry._providers[pipe.name] = pipe
121
- logger.info("Pipeline registered: %s", pipe.name)
122
-
123
- except Exception as exc:
124
- logger.warning("Failed to build preset pipelines: %s", exc)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
processors/enhancement/real_esrgan.py DELETED
@@ -1,153 +0,0 @@
1
- """
2
- Real-ESRGAN enhancement provider.
3
- =================================
4
- Uses Real-ESRGAN (https://github.com/xinntao/Real-ESRGAN) to upscale and
5
- denoise scanned documents before applying classical binarisation.
6
-
7
- This is the recommended provider for degraded photographic scans where
8
- classical CV loses thin lines and text detail.
9
-
10
- Installation
11
- ------------
12
- pip install basicsr realesrgan torch torchvision
13
-
14
- The model weights (~64 MB) are downloaded automatically on first use to
15
- ~/.cache/realesrgan/.
16
-
17
- Why this model
18
- --------------
19
- Real-ESRGAN was trained on degraded images including scan noise, JPEG
20
- compression, and blurring. It produces 4× upscaled output with clean edges —
21
- ideal as a pre-processing step before adaptive thresholding.
22
- """
23
-
24
- from __future__ import annotations
25
- import logging
26
- import os
27
- from typing import Optional
28
-
29
- import numpy as np
30
-
31
- from .base import EnhancementProvider
32
-
33
- logger = logging.getLogger(__name__)
34
-
35
- _REALESRGAN_AVAILABLE = False
36
- _REALESRGAN_ERROR: Optional[str] = None
37
-
38
- try:
39
- from basicsr.archs.rrdbnet_arch import RRDBNet # noqa: F401
40
- from realesrgan import RealESRGANer # noqa: F401
41
- _REALESRGAN_AVAILABLE = True
42
- except ImportError as _e:
43
- _REALESRGAN_ERROR = f"realesrgan / basicsr not installed: {_e}"
44
- except Exception as _e:
45
- _REALESRGAN_ERROR = f"Unexpected import error: {_e}"
46
-
47
-
48
- class RealESRGANProvider(EnhancementProvider):
49
-
50
- name = "real_esrgan"
51
- display_name = "Real-ESRGAN (AI Super-Resolution)"
52
- description = (
53
- "4× AI upscaling + denoising using Real-ESRGAN. Recovers thin lines "
54
- "and legible text from heavily degraded photographic scans. "
55
- "Requires PyTorch (GPU recommended, CPU supported). "
56
- "Install: pip install basicsr realesrgan torch torchvision"
57
- )
58
-
59
- # Model variant — x4plus is the best general-purpose option.
60
- # Alternatives: RealESRGAN_x4plus_anime_6B (smaller), RealESRGAN_x2plus
61
- _MODEL_NAME = "RealESRGAN_x4plus"
62
- _SCALE = 4
63
-
64
- def __init__(self) -> None:
65
- self._upsampler = None # lazy-loaded on first enhance() call
66
-
67
- @property
68
- def is_available(self) -> bool:
69
- return _REALESRGAN_AVAILABLE
70
-
71
- @property
72
- def unavailable_reason(self) -> Optional[str]:
73
- return _REALESRGAN_ERROR
74
-
75
- # Deliver at 2× the original rather than 4× — downstream OCR and line
76
- # detection run 4× faster with no perceptible quality loss on the binary.
77
- _OUTPUT_SCALE = 2
78
-
79
- def enhance(self, image: np.ndarray) -> np.ndarray:
80
- import cv2
81
- upsampler = self._get_upsampler()
82
-
83
- # Real-ESRGAN expects BGR uint8
84
- if len(image.shape) == 2:
85
- bgr = cv2.cvtColor(image, cv2.COLOR_GRAY2BGR)
86
- else:
87
- bgr = image.copy()
88
-
89
- # 4× AI super-resolution
90
- output, _ = upsampler.enhance(bgr, outscale=self._SCALE)
91
-
92
- # Downscale to _OUTPUT_SCALE× to keep OCR/line detection manageable
93
- if self._OUTPUT_SCALE < self._SCALE:
94
- factor = self._OUTPUT_SCALE / self._SCALE
95
- h4, w4 = output.shape[:2]
96
- output = cv2.resize(
97
- output,
98
- (int(w4 * factor), int(h4 * factor)),
99
- interpolation=cv2.INTER_AREA,
100
- )
101
-
102
- logger.info("Post-AI output: %dx%d", output.shape[1], output.shape[0])
103
-
104
- # Grayscale → contrast → binarise → cleanup
105
- # (skip NL-means denoising and background removal — AI output is already clean)
106
- from processors.image_enhancer import ImageEnhancer
107
- gray = cv2.cvtColor(output, cv2.COLOR_BGR2GRAY)
108
- enh = ImageEnhancer()
109
- contrast = enh._enhance_contrast(gray)
110
- binary = enh._binarize(contrast)
111
- return enh._morphological_cleanup(binary)
112
-
113
- def _get_upsampler(self):
114
- """Lazy-load the model weights on first call."""
115
- if self._upsampler is not None:
116
- return self._upsampler
117
-
118
- import torch
119
- from basicsr.archs.rrdbnet_arch import RRDBNet
120
- from realesrgan import RealESRGANer
121
-
122
- model = RRDBNet(
123
- num_in_ch=3, num_out_ch=3,
124
- num_feat=64, num_block=23, num_grow_ch=32, scale=self._SCALE,
125
- )
126
- # Weights are downloaded automatically by RealESRGANer on first use
127
- weights_url = (
128
- "https://github.com/xinntao/Real-ESRGAN/releases/download/"
129
- "v0.1.0/RealESRGAN_x4plus.pth"
130
- )
131
- cache_dir = os.path.expanduser("~/.cache/realesrgan")
132
- os.makedirs(cache_dir, exist_ok=True)
133
- weights_path = os.path.join(cache_dir, f"{self._MODEL_NAME}.pth")
134
-
135
- if torch.cuda.is_available():
136
- device = torch.device("cuda")
137
- elif torch.backends.mps.is_available():
138
- device = torch.device("mps")
139
- else:
140
- device = torch.device("cpu")
141
- logger.info("Real-ESRGAN loading on %s", device)
142
-
143
- self._upsampler = RealESRGANer(
144
- scale=self._SCALE,
145
- model_path=weights_path if os.path.exists(weights_path) else weights_url,
146
- model=model,
147
- tile=512, # tile processing avoids OOM on large images
148
- tile_pad=10,
149
- pre_pad=0,
150
- half=False, # fp16 unstable on MPS/CPU; keep fp32
151
- device=device,
152
- )
153
- return self._upsampler
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
processors/enhancement/registry.py DELETED
@@ -1,133 +0,0 @@
1
- """
2
- Enhancement Provider Registry
3
- ==============================
4
- Auto-discovers all EnhancementProvider subclasses in this package and
5
- exposes them by name. Handles unavailable providers gracefully.
6
-
7
- Usage
8
- -----
9
- from processors.enhancement.registry import registry
10
-
11
- provider = registry.get("real_esrgan") # raises if not found/unavailable
12
- provider = registry.get_or_default("real_esrgan") # falls back to classical
13
-
14
- registry.list_all() # all providers (available + unavailable)
15
- registry.list_available() # only available providers
16
- """
17
-
18
- from __future__ import annotations
19
- import importlib
20
- import logging
21
- from typing import Dict, List, Optional
22
-
23
- from .base import EnhancementProvider
24
-
25
- logger = logging.getLogger(__name__)
26
-
27
- # Explicit list of provider modules in this package.
28
- # Add a new module name here when adding a new provider.
29
- _PROVIDER_MODULES = [
30
- "processors.enhancement.classical",
31
- "processors.enhancement.groq_guided", # cloud — needs GROQ_API_KEY
32
- "processors.enhancement.huggingface_api", # cloud — free HF token
33
- "processors.enhancement.real_esrgan", # local — needs GPU + torch
34
- "processors.enhancement.huggingface", # local — needs torch
35
- ]
36
-
37
- _DEFAULT_PROVIDER = "classical"
38
-
39
-
40
- class EnhancementRegistry:
41
- """Singleton registry of all enhancement providers."""
42
-
43
- def __init__(self) -> None:
44
- self._providers: Dict[str, EnhancementProvider] = {}
45
- self._load_all()
46
-
47
- def _load_all(self) -> None:
48
- for module_path in _PROVIDER_MODULES:
49
- mod = self._import_module(module_path)
50
- if mod is not None:
51
- self._register_providers_from(mod)
52
-
53
- def _import_module(self, module_path: str):
54
- try:
55
- return importlib.import_module(module_path)
56
- except ImportError as exc:
57
- logger.debug("Skipping provider module %s: %s", module_path, exc)
58
- return None
59
-
60
- def _register_providers_from(self, mod) -> None:
61
- for attr_name in dir(mod):
62
- cls = getattr(mod, attr_name)
63
- if self._is_provider_class(cls):
64
- self._instantiate_and_register(cls, attr_name)
65
-
66
- @staticmethod
67
- def _is_provider_class(obj) -> bool:
68
- return (
69
- isinstance(obj, type)
70
- and issubclass(obj, EnhancementProvider)
71
- and obj is not EnhancementProvider
72
- and bool(obj.name)
73
- )
74
-
75
- def _instantiate_and_register(self, cls, attr_name: str) -> None:
76
- try:
77
- instance = cls()
78
- self._providers[instance.name] = instance
79
- status = "available" if instance.is_available else "unavailable"
80
- logger.info("Enhancement provider loaded: %s (%s)", instance.name, status)
81
- except Exception as exc:
82
- logger.warning("Failed to instantiate provider %s: %s", attr_name, exc)
83
-
84
- # ------------------------------------------------------------------ #
85
- # Public API #
86
- # ------------------------------------------------------------------ #
87
-
88
- def get(self, name: str) -> EnhancementProvider:
89
- """
90
- Return the provider for `name`.
91
- Raises ValueError if unknown or unavailable.
92
- """
93
- provider = self._providers.get(name)
94
- if provider is None:
95
- available = ", ".join(self._providers.keys())
96
- raise ValueError(
97
- f"Unknown enhancement model '{name}'. "
98
- f"Available: {available}"
99
- )
100
- if not provider.is_available:
101
- raise ValueError(
102
- f"Enhancement model '{name}' is not available: "
103
- f"{provider.unavailable_reason}"
104
- )
105
- return provider
106
-
107
- def get_or_default(self, name: Optional[str]) -> EnhancementProvider:
108
- """
109
- Return the provider for `name`, falling back to the default classical
110
- provider if `name` is None, unknown, or unavailable.
111
- """
112
- if name is None:
113
- return self.get(_DEFAULT_PROVIDER)
114
- try:
115
- return self.get(name)
116
- except ValueError as exc:
117
- logger.warning("%s — falling back to classical provider", exc)
118
- return self.get(_DEFAULT_PROVIDER)
119
-
120
- def list_all(self) -> List[dict]:
121
- """Return info dicts for every registered provider."""
122
- return [p.info() for p in self._providers.values()]
123
-
124
- def list_available(self) -> List[dict]:
125
- """Return info dicts for available providers only."""
126
- return [p.info() for p in self._providers.values() if p.is_available]
127
-
128
- def names(self) -> List[str]:
129
- return list(self._providers.keys())
130
-
131
-
132
- # Module-level singleton — import this everywhere
133
- registry = EnhancementRegistry()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
processors/ocr_classification.py DELETED
@@ -1,82 +0,0 @@
1
- """
2
- OCR Classification
3
- ===================
4
- Shared text-classification rules used by every OCR provider (Tesseract,
5
- PaddleOCR, ...) so a phrase like "3,640 x 3,640" or "SCALE" is bucketed the
6
- same way regardless of which engine recognised it.
7
-
8
- Classification buckets:
9
- - dimensions : numeric strings WITH a recognised unit suffix (mm/cm/m/ft/in/'/")
10
- or a "WIDTH x HEIGHT" pair (with or without a unit suffix)
11
- - room_names : multi-character capitalised phrases (title-block keywords excluded)
12
- - numbers : bare numeric tokens (no unit suffix)
13
- - labels : everything else with sufficient confidence
14
- """
15
-
16
- import re
17
- from typing import Any, Dict, List
18
-
19
- # Patterns for classification.
20
- # Digits may use comma thousands-separators (e.g. "3,640"), common in
21
- # architectural dimension callouts.
22
- _NUM = r"\d+(?:,\d{3})*(?:\.\d+)?"
23
- _DIM_RE = re.compile(rf"^{_NUM}\s*(mm|cm|m|ft|in|'|\")$", re.IGNORECASE)
24
- _DIM_PAIR_RE = re.compile(
25
- rf"^{_NUM}\s*[x×]\s*{_NUM}\s*(mm|cm|m|ft|in|'|\")?$", re.IGNORECASE
26
- )
27
- _NUM_RE = re.compile(rf"^{_NUM}$")
28
-
29
- # Title-block / drawing-metadata words that are capitalised (and would
30
- # otherwise pass the room-name heuristic) but are not room names.
31
- _TITLE_BLOCK_WORDS = {
32
- "scale", "floor", "plan", "level", "section", "elevation", "detail",
33
- "site", "drawn", "checked", "approved", "date", "rev", "revision",
34
- "sheet", "north", "title", "project", "drawing", "no", "nts", "n.t.s",
35
- "scale:", "client", "architect", "job",
36
- }
37
-
38
-
39
- def classify(elements: List[Dict[str, Any]]) -> Dict[str, List]:
40
- classified: Dict[str, List] = {
41
- "dimensions": [],
42
- "room_names": [],
43
- "numbers": [],
44
- "labels": [],
45
- }
46
-
47
- for elem in elements:
48
- text = elem["text"]
49
-
50
- if _DIM_PAIR_RE.match(text) or _DIM_RE.match(text):
51
- classified["dimensions"].append(elem)
52
- elif _NUM_RE.match(text):
53
- classified["numbers"].append(elem)
54
- elif is_title_block(text):
55
- classified["labels"].append(elem)
56
- elif len(text) > 2 and text[0].isupper():
57
- classified["room_names"].append(elem)
58
- else:
59
- classified["labels"].append(elem)
60
-
61
- return classified
62
-
63
-
64
- def is_title_block(text: str) -> bool:
65
- """True if any word in the phrase is a drawing-metadata keyword."""
66
- words = re.findall(r"[A-Za-z.:]+", text.lower())
67
- return any(w in _TITLE_BLOCK_WORDS for w in words)
68
-
69
-
70
- def empty_result(reason: str) -> Dict[str, Any]:
71
- return {
72
- "full_text": "",
73
- "element_count": 0,
74
- "elements": [],
75
- "classified": {
76
- "dimensions": [],
77
- "room_names": [],
78
- "numbers": [],
79
- "labels": [],
80
- },
81
- "error": reason,
82
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
processors/paddleocr_processor.py DELETED
@@ -1,157 +0,0 @@
1
- """
2
- PaddleOCR Processor
3
- ===================
4
- Alternative to Tesseract (see ocr_processor.py) backed by PaddleOCR
5
- (Apache 2.0, https://github.com/PaddlePaddle/PaddleOCR).
6
-
7
- Why this is a separate, simpler class rather than another Tesseract config:
8
- PaddleOCR's detector locates text at the phrase/line level directly, so none
9
- of ocr_processor.py's word->phrase grouping, gap-splitting, or tick-mark
10
- height filtering is needed here — those all exist specifically to work
11
- around Tesseract returning individual words with no semantic grouping.
12
- Classification (dimensions/room_names/numbers/labels) is shared via
13
- ocr_classification.py so both engines bucket text identically.
14
-
15
- Status: NOT YET BENCHMARKED. Model weights could not be downloaded in the
16
- sandboxed dev environment this was written in — see CLAUDE.md for why
17
- (corporate TLS-inspecting proxy whose root CA isn't in Python's default
18
- trust store). The API calls below are verified against the installed
19
- paddleocr==3.7.0 / paddlex package source directly (constructor params,
20
- and the exact result schema: `rec_texts`/`rec_scores`/`rec_boxes`, with
21
- `rec_boxes[i] == [left, top, right, bottom]` — see
22
- venv/lib/python3.12/site-packages/paddlex/inference/pipelines/ocr/pipeline.py
23
- and .../components/common/convert_points_and_boxes.py), but real accuracy
24
- on this project's sample images has not been measured. Run
25
- `benchmark/run_benchmark.py` before relying on this over Tesseract.
26
- """
27
-
28
- from __future__ import annotations
29
- import logging
30
- import os
31
- from typing import Any, Dict, List, Optional
32
-
33
- import numpy as np
34
-
35
- from .ocr_classification import classify, empty_result
36
-
37
- logger = logging.getLogger(__name__)
38
-
39
- # PP-OCRv6's detection/recognition models crash under paddlex's default CPU
40
- # MKLDNN/PIR execution path on this Windows/CPU combo:
41
- # NotImplementedError: (Unimplemented) ConvertPirAttribute2RuntimeAttribute
42
- # not support [pir::ArrayAttribute<pir::DoubleAttribute>]
43
- # Neither model is in paddlex's MKLDNN_BLOCKLIST (an upstream gap), so the
44
- # workaround is disabling MKLDNN-by-default before paddlex's flags module
45
- # is imported. Must happen before `import paddleocr`, not after.
46
- os.environ.setdefault("PADDLE_PDX_ENABLE_MKLDNN_BYDEFAULT", "False")
47
-
48
- try:
49
- from paddleocr import PaddleOCR as _PaddleOCREngine
50
- _PADDLEOCR_AVAILABLE = True
51
- _PADDLEOCR_ERROR: Optional[str] = None
52
- except ImportError as _e:
53
- _PADDLEOCR_AVAILABLE = False
54
- _PADDLEOCR_ERROR = f"paddleocr / paddlepaddle not installed: {_e}"
55
-
56
-
57
- class PaddleOCRProcessor:
58
- """
59
- Drop-in alternative to OCRProcessor — same extract_text() contract
60
- (full_text / element_count / elements / classified), so main.py can
61
- swap between engines without any other code changing.
62
- """
63
-
64
- def __init__(self, lang: str = "en") -> None:
65
- # PaddleOCR loads one recognition model per language (unlike
66
- # Tesseract's combined "jpn+eng"); "en" covers this project's
67
- # sample images, pass lang="japan" for Japanese-only drawings.
68
- self._lang = lang
69
- self._engine = None # lazy-loaded on first use — downloads weights
70
-
71
- @property
72
- def is_available(self) -> bool:
73
- return _PADDLEOCR_AVAILABLE
74
-
75
- @property
76
- def unavailable_reason(self) -> Optional[str]:
77
- return _PADDLEOCR_ERROR
78
-
79
- def extract_text(self, image: np.ndarray) -> Dict[str, Any]:
80
- """
81
- Parameters
82
- ----------
83
- image : enhanced binary or grayscale (or BGR) image
84
-
85
- Returns
86
- -------
87
- Same schema as OCRProcessor.extract_text() — see that module's
88
- docstring.
89
- """
90
- if not _PADDLEOCR_AVAILABLE:
91
- logger.warning("paddleocr not installed – OCR skipped")
92
- return empty_result("paddleocr not installed")
93
-
94
- try:
95
- engine = self._get_engine()
96
- results = engine.predict(image)
97
- except Exception as exc:
98
- logger.exception("PaddleOCR error")
99
- return empty_result(str(exc))
100
-
101
- if not results:
102
- return empty_result("no text regions detected")
103
-
104
- result = results[0]
105
- elements = self._to_elements(
106
- result.get("rec_texts", []),
107
- result.get("rec_scores", []),
108
- result.get("rec_boxes", []),
109
- )
110
- classified = classify(elements)
111
-
112
- return {
113
- "full_text": "\n".join(e["text"] for e in elements),
114
- "element_count": len(elements),
115
- "elements": elements,
116
- "classified": classified,
117
- }
118
-
119
- # ------------------------------------------------------------------ #
120
- # Private helpers #
121
- # ------------------------------------------------------------------ #
122
-
123
- def _get_engine(self):
124
- if self._engine is not None:
125
- return self._engine
126
- logger.info("Loading PaddleOCR engine (lang=%s) — downloads weights on first use", self._lang)
127
- self._engine = _PaddleOCREngine(
128
- lang=self._lang,
129
- # Our own enhancement pipeline already deskews/upscales the
130
- # image, so skip PaddleOCR's own document-orientation
131
- # classification and unwarping sub-pipelines — one less model
132
- # to download and one less inference pass per image.
133
- use_doc_orientation_classify=False,
134
- use_doc_unwarping=False,
135
- use_textline_orientation=False,
136
- )
137
- return self._engine
138
-
139
- @staticmethod
140
- def _to_elements(
141
- texts: List[str], scores: List[float], boxes: Any
142
- ) -> List[Dict[str, Any]]:
143
- elements: List[Dict[str, Any]] = []
144
- for text, score, box in zip(texts, scores, boxes):
145
- text = str(text).strip()
146
- if not text:
147
- continue
148
- x1, y1, x2, y2 = (float(v) for v in box)
149
- elements.append({
150
- "text": text,
151
- "confidence": round(float(score) * 100), # match Tesseract's 0-100 scale
152
- "x": int(round(x1)),
153
- "y": int(round(y1)),
154
- "width": int(round(x2 - x1)),
155
- "height": int(round(y2 - y1)),
156
- })
157
- return elements
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
requirements.cloud.txt DELETED
@@ -1,19 +0,0 @@
1
- # Cloud deployment — excludes torch/Real-ESRGAN (too large for serverless)
2
- # Real-ESRGAN is available when running locally only.
3
- fastapi==0.115.0
4
- uvicorn[standard]==0.30.6
5
- python-multipart==0.0.9
6
- opencv-python-headless==4.10.0.84
7
- numpy>=1.26.4
8
- Pillow==10.4.0
9
- pytesseract==0.3.13
10
- anthropic==0.34.2
11
- openai>=1.0.0
12
- google-genai>=1.0.0
13
- groq>=0.9.0
14
- python-dotenv==1.0.1
15
- svgwrite==1.4.3
16
- scipy==1.13.1
17
- huggingface_hub>=1.21.0
18
- requests>=2.31.0
19
- scikit-image>=0.21.0
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
tests/__init__.py DELETED
File without changes
tests/conftest.py DELETED
@@ -1,53 +0,0 @@
1
- """
2
- Shared pytest fixtures for backend tests.
3
- """
4
-
5
- import io
6
- import sys
7
- import os
8
-
9
- import cv2
10
- import numpy as np
11
- import pytest
12
-
13
- # Make sure the backend package is importable
14
- sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
15
-
16
-
17
- @pytest.fixture
18
- def synthetic_drawing() -> np.ndarray:
19
- """800×600 grayscale floor plan with walls, interior lines and text."""
20
- img = np.ones((600, 800), dtype=np.uint8) * 240
21
- cv2.rectangle(img, (50, 50), (750, 550), 0, 3)
22
- cv2.line(img, (400, 50), (400, 350), 0, 2)
23
- cv2.line(img, (50, 300), (400, 300), 0, 2)
24
- font = cv2.FONT_HERSHEY_SIMPLEX
25
- cv2.putText(img, "3640", (180, 285), font, 1.2, 0, 2)
26
- cv2.putText(img, "Living Room", (100, 430), font, 1.0, 0, 2)
27
- cv2.putText(img, "Bedroom", (470, 220), font, 1.0, 0, 2)
28
- return img
29
-
30
-
31
- @pytest.fixture
32
- def synthetic_drawing_bgr(synthetic_drawing) -> np.ndarray:
33
- return cv2.cvtColor(synthetic_drawing, cv2.COLOR_GRAY2BGR)
34
-
35
-
36
- @pytest.fixture
37
- def drawing_bytes(synthetic_drawing) -> bytes:
38
- """PNG-encoded bytes of the synthetic drawing."""
39
- _, buf = cv2.imencode(".png", synthetic_drawing)
40
- return buf.tobytes()
41
-
42
-
43
- @pytest.fixture
44
- def drawing_file(drawing_bytes):
45
- """File-like object for use with FastAPI TestClient."""
46
- return ("drawing.png", io.BytesIO(drawing_bytes), "image/png")
47
-
48
-
49
- @pytest.fixture
50
- def client():
51
- from fastapi.testclient import TestClient
52
- from main import app
53
- return TestClient(app)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
tests/test_api.py DELETED
@@ -1,122 +0,0 @@
1
- """
2
- Integration tests for the FastAPI endpoints using TestClient.
3
- """
4
-
5
- import io
6
- import pytest
7
- from fastapi.testclient import TestClient
8
-
9
-
10
- class TestHealthEndpoint:
11
- def test_returns_200(self, client):
12
- resp = client.get("/health")
13
- assert resp.status_code == 200
14
-
15
- def test_status_is_healthy(self, client):
16
- data = client.get("/health").json()
17
- assert data["status"] == "healthy"
18
-
19
- def test_all_processors_listed(self, client):
20
- data = client.get("/health").json()
21
- processors = data["processors"]
22
- for name in ("image_enhancer", "line_extractor", "ocr_processor", "svg_generator"):
23
- assert name in processors
24
-
25
-
26
- class TestInfoEndpoint:
27
- def test_returns_200(self, client):
28
- assert client.get("/api/info").status_code == 200
29
-
30
- def test_returns_processing_options(self, client):
31
- data = client.get("/api/info").json()
32
- assert "processing_options" in data
33
-
34
- def test_all_options_listed(self, client):
35
- opts = client.get("/api/info").json()["processing_options"]
36
- for key in ("enhance", "extract_lines", "run_ocr", "generate_svg", "run_ai"):
37
- assert key in opts
38
-
39
-
40
- class TestProcessEndpoint:
41
- def test_returns_200_with_valid_image(self, client, drawing_file):
42
- resp = client.post("/api/process", files={"file": drawing_file})
43
- assert resp.status_code == 200
44
-
45
- def test_response_has_required_fields(self, client, drawing_file):
46
- data = client.post("/api/process", files={"file": drawing_file}).json()
47
- for field in ("filename", "original_size", "processing_steps", "processing_time_s"):
48
- assert field in data
49
-
50
- def test_enhance_step_runs_by_default(self, client, drawing_file):
51
- data = client.post("/api/process", files={"file": drawing_file}).json()
52
- assert "image_enhancement" in data["processing_steps"]
53
- assert "enhanced_image" in data
54
-
55
- def test_line_extraction_step_runs_by_default(self, client, drawing_file):
56
- data = client.post("/api/process", files={"file": drawing_file}).json()
57
- assert "line_extraction" in data["processing_steps"]
58
- assert "detected_lines" in data
59
-
60
- def test_ocr_step_runs_by_default(self, client, drawing_file):
61
- data = client.post("/api/process", files={"file": drawing_file}).json()
62
- assert "ocr" in data["processing_steps"]
63
- assert "ocr_results" in data
64
-
65
- def test_svg_not_generated_by_default(self, client, drawing_file):
66
- data = client.post("/api/process", files={"file": drawing_file}).json()
67
- assert "svg_generation" not in data["processing_steps"]
68
-
69
- def test_svg_generated_when_requested(self, client, drawing_file):
70
- resp = client.post(
71
- "/api/process",
72
- files={"file": drawing_file},
73
- data={"generate_svg": "true"},
74
- )
75
- data = resp.json()
76
- assert "svg_generation" in data["processing_steps"]
77
- assert "svg_output" in data
78
-
79
- def test_enhance_only_skips_other_steps(self, client, drawing_file):
80
- resp = client.post(
81
- "/api/process",
82
- files={"file": drawing_file},
83
- data={"enhance": "true", "extract_lines": "false", "run_ocr": "false"},
84
- )
85
- data = resp.json()
86
- assert "image_enhancement" in data["processing_steps"]
87
- assert "line_extraction" not in data["processing_steps"]
88
- assert "ocr" not in data["processing_steps"]
89
-
90
- def test_detected_lines_is_integer(self, client, drawing_file):
91
- data = client.post("/api/process", files={"file": drawing_file}).json()
92
- assert isinstance(data.get("detected_lines"), int)
93
-
94
- def test_processing_time_is_float(self, client, drawing_file):
95
- data = client.post("/api/process", files={"file": drawing_file}).json()
96
- assert isinstance(data["processing_time_s"], float)
97
- assert data["processing_time_s"] > 0
98
-
99
- def test_invalid_file_returns_400(self, client):
100
- fake_file = ("bad.png", io.BytesIO(b"not an image"), "image/png")
101
- resp = client.post("/api/process", files={"file": fake_file})
102
- assert resp.status_code == 400
103
-
104
- def test_original_size_matches_image(self, client, drawing_bytes):
105
- # Our synthetic image is 800×600
106
- file = ("drawing.png", io.BytesIO(drawing_bytes), "image/png")
107
- data = client.post("/api/process", files={"file": file}).json()
108
- size = data["original_size"]
109
- assert size["width"] == 800
110
- assert size["height"] == 600
111
-
112
-
113
- class TestAnalyzeEndpoint:
114
- def test_returns_501_without_api_key(self, client, drawing_file, monkeypatch):
115
- monkeypatch.setenv("ANTHROPIC_API_KEY", "")
116
- resp = client.post("/api/analyze", files={"file": drawing_file})
117
- assert resp.status_code == 501
118
-
119
- def test_error_message_mentions_api_key(self, client, drawing_file, monkeypatch):
120
- monkeypatch.setenv("ANTHROPIC_API_KEY", "")
121
- data = client.post("/api/analyze", files={"file": drawing_file}).json()
122
- assert "ANTHROPIC_API_KEY" in data["detail"]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
tests/test_image_enhancer.py DELETED
@@ -1,52 +0,0 @@
1
- """
2
- Unit tests for ImageEnhancer.
3
- """
4
-
5
- import cv2
6
- import numpy as np
7
- import pytest
8
-
9
- from processors.image_enhancer import ImageEnhancer
10
-
11
-
12
- @pytest.fixture
13
- def enhancer():
14
- return ImageEnhancer()
15
-
16
-
17
- class TestEnhance:
18
- def test_output_same_spatial_size(self, enhancer, synthetic_drawing):
19
- result = enhancer.enhance(synthetic_drawing)
20
- assert result.shape[:2] == synthetic_drawing.shape[:2]
21
-
22
- def test_output_is_binary(self, enhancer, synthetic_drawing):
23
- result = enhancer.enhance(synthetic_drawing)
24
- unique = set(np.unique(result).tolist())
25
- assert unique.issubset({0, 255}), f"Expected only 0/255, got {unique}"
26
-
27
- def test_accepts_grayscale_input(self, enhancer, synthetic_drawing):
28
- assert len(synthetic_drawing.shape) == 2
29
- result = enhancer.enhance(synthetic_drawing)
30
- assert result is not None
31
-
32
- def test_accepts_bgr_input(self, enhancer, synthetic_drawing_bgr):
33
- assert synthetic_drawing_bgr.shape[2] == 3
34
- result = enhancer.enhance(synthetic_drawing_bgr)
35
- assert result is not None
36
- assert len(result.shape) == 2 # always returns grayscale
37
-
38
- def test_output_dtype_is_uint8(self, enhancer, synthetic_drawing):
39
- result = enhancer.enhance(synthetic_drawing)
40
- assert result.dtype == np.uint8
41
-
42
- def test_enhancer_reduces_noise(self, enhancer):
43
- noisy = np.random.randint(0, 256, (200, 200), dtype=np.uint8)
44
- result = enhancer.enhance(noisy)
45
- # Binary output should have far fewer unique grey levels than random noise
46
- assert len(np.unique(result)) <= 2
47
-
48
- def test_enhance_grayscale_method_not_binary(self, enhancer, synthetic_drawing):
49
- result = enhancer.enhance_grayscale(synthetic_drawing)
50
- unique_values = np.unique(result)
51
- # Grayscale output should have more than 2 grey levels
52
- assert len(unique_values) > 2
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
tests/test_line_extractor.py DELETED
@@ -1,81 +0,0 @@
1
- """
2
- Unit tests for LineExtractor.
3
- """
4
-
5
- import cv2
6
- import numpy as np
7
- import pytest
8
-
9
- from processors.line_extractor import LineExtractor
10
-
11
-
12
- @pytest.fixture
13
- def extractor():
14
- return LineExtractor()
15
-
16
-
17
- @pytest.fixture
18
- def clear_line_drawing():
19
- """White canvas with a few crisp black lines — guaranteed to be detected."""
20
- img = np.ones((400, 600), dtype=np.uint8) * 255
21
- cv2.line(img, (50, 200), (550, 200), 0, 3) # horizontal
22
- cv2.line(img, (300, 50), (300, 350), 0, 3) # vertical
23
- cv2.line(img, (50, 50), (550, 350), 0, 3) # diagonal
24
- return img
25
-
26
-
27
- class TestExtract:
28
- def test_returns_tuple(self, extractor, synthetic_drawing):
29
- result = extractor.extract(synthetic_drawing)
30
- assert isinstance(result, tuple) and len(result) == 2
31
-
32
- def test_visualisation_is_bgr(self, extractor, clear_line_drawing):
33
- vis, _ = extractor.extract(clear_line_drawing)
34
- assert len(vis.shape) == 3 and vis.shape[2] == 3
35
-
36
- def test_visualisation_same_size(self, extractor, synthetic_drawing):
37
- vis, _ = extractor.extract(synthetic_drawing)
38
- assert vis.shape[:2] == synthetic_drawing.shape[:2]
39
-
40
- def test_detects_lines_in_drawing(self, extractor, clear_line_drawing):
41
- _, lines = extractor.extract(clear_line_drawing, hough_threshold=30)
42
- assert len(lines) >= 3, f"Expected ≥3 lines, got {len(lines)}"
43
-
44
- def test_blank_image_returns_no_lines(self, extractor):
45
- blank = np.ones((300, 400), dtype=np.uint8) * 255
46
- _, lines = extractor.extract(blank)
47
- assert lines == []
48
-
49
- def test_line_dict_has_required_keys(self, extractor, clear_line_drawing):
50
- _, lines = extractor.extract(clear_line_drawing, hough_threshold=30)
51
- assert lines, "No lines detected — cannot check keys"
52
- for line in lines:
53
- for key in ("x1", "y1", "x2", "y2", "angle", "length"):
54
- assert key in line, f"Missing key '{key}' in line dict"
55
-
56
- def test_line_length_is_positive(self, extractor, clear_line_drawing):
57
- _, lines = extractor.extract(clear_line_drawing, hough_threshold=30)
58
- for line in lines:
59
- assert line["length"] > 0
60
-
61
- def test_accepts_bgr_input(self, extractor, synthetic_drawing_bgr):
62
- vis, lines = extractor.extract(synthetic_drawing_bgr)
63
- assert vis is not None
64
-
65
-
66
- class TestClassifyAngle:
67
- def test_horizontal(self, extractor):
68
- assert extractor._classify_angle(0) == "horizontal"
69
- assert extractor._classify_angle(10) == "horizontal"
70
- assert extractor._classify_angle(-10) == "horizontal"
71
- assert extractor._classify_angle(170) == "horizontal"
72
-
73
- def test_vertical(self, extractor):
74
- assert extractor._classify_angle(90) == "vertical"
75
- assert extractor._classify_angle(80) == "vertical"
76
- assert extractor._classify_angle(100) == "vertical"
77
-
78
- def test_diagonal(self, extractor):
79
- assert extractor._classify_angle(45) == "diagonal"
80
- assert extractor._classify_angle(-45) == "diagonal"
81
- assert extractor._classify_angle(135) == "diagonal"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
tests/test_ocr_processor.py DELETED
@@ -1,106 +0,0 @@
1
- """
2
- Unit tests for OCRProcessor.
3
- """
4
-
5
- import cv2
6
- import numpy as np
7
- import pytest
8
-
9
- from processors.ocr_processor import OCRProcessor
10
-
11
-
12
- @pytest.fixture
13
- def processor():
14
- return OCRProcessor()
15
-
16
-
17
- @pytest.fixture
18
- def text_drawing():
19
- """Large, clear image with well-spaced text for reliable OCR."""
20
- img = np.ones((900, 1200), dtype=np.uint8) * 255
21
- font = cv2.FONT_HERSHEY_SIMPLEX
22
- cv2.putText(img, "Living Room", (100, 200), font, 2.0, 0, 4)
23
- cv2.putText(img, "3640", (100, 450), font, 2.0, 0, 4)
24
- cv2.putText(img, "Bedroom", (100, 700), font, 2.0, 0, 4)
25
- return img
26
-
27
-
28
- class TestExtractText:
29
- def test_returns_dict_with_expected_keys(self, processor, synthetic_drawing):
30
- result = processor.extract_text(synthetic_drawing)
31
- for key in ("full_text", "element_count", "elements", "classified"):
32
- assert key in result, f"Missing key: {key}"
33
-
34
- def test_classified_has_correct_categories(self, processor, synthetic_drawing):
35
- result = processor.extract_text(synthetic_drawing)
36
- for cat in ("dimensions", "room_names", "numbers", "labels"):
37
- assert cat in result["classified"]
38
-
39
- def test_element_count_matches_elements_list(self, processor, text_drawing):
40
- result = processor.extract_text(text_drawing)
41
- assert result["element_count"] == len(result["elements"])
42
-
43
- def test_each_element_has_required_fields(self, processor, text_drawing):
44
- result = processor.extract_text(text_drawing)
45
- for el in result["elements"]:
46
- for field in ("text", "confidence", "x", "y", "width", "height"):
47
- assert field in el, f"Missing field '{field}' in element"
48
-
49
- def test_confidence_values_in_range(self, processor, text_drawing):
50
- result = processor.extract_text(text_drawing)
51
- for el in result["elements"]:
52
- assert 0 <= el["confidence"] <= 100
53
-
54
- def test_detects_dimension_number(self, processor, text_drawing):
55
- result = processor.extract_text(text_drawing)
56
- all_texts = [el["text"] for el in result["elements"]]
57
- assert any("3640" in t for t in all_texts), \
58
- f"Expected '3640' in OCR output, got: {all_texts}"
59
-
60
- def test_classifies_bare_number_as_number(self, processor, text_drawing):
61
- result = processor.extract_text(text_drawing)
62
- num_texts = [el["text"] for el in result["classified"]["numbers"]]
63
- assert "3640" in num_texts, \
64
- f"'3640' (no unit) should be in numbers, got: {num_texts}"
65
-
66
- def test_blank_image_returns_empty(self, processor):
67
- blank = np.ones((400, 600), dtype=np.uint8) * 255
68
- result = processor.extract_text(blank)
69
- assert result["element_count"] == 0
70
- assert result["elements"] == []
71
-
72
- def test_full_text_is_string(self, processor, synthetic_drawing):
73
- result = processor.extract_text(synthetic_drawing)
74
- assert isinstance(result["full_text"], str)
75
-
76
-
77
- class TestClassify:
78
- def test_dimension_pattern(self, processor):
79
- elements = [{"text": "3640mm", "confidence": 90, "x": 0, "y": 0, "width": 50, "height": 20}]
80
- classified = processor._classify(elements)
81
- assert any(e["text"] == "3640mm" for e in classified["dimensions"])
82
-
83
- def test_room_name_pattern(self, processor):
84
- elements = [{"text": "Kitchen", "confidence": 90, "x": 0, "y": 0, "width": 80, "height": 20}]
85
- classified = processor._classify(elements)
86
- assert any(e["text"] == "Kitchen" for e in classified["room_names"])
87
-
88
- def test_number_pattern(self, processor):
89
- elements = [{"text": "42", "confidence": 90, "x": 0, "y": 0, "width": 30, "height": 20}]
90
- classified = processor._classify(elements)
91
- assert any(e["text"] == "42" for e in classified["numbers"])
92
-
93
- def test_dimension_with_unit(self, processor):
94
- elements = [{"text": "3640mm", "confidence": 90, "x": 0, "y": 0, "width": 60, "height": 20}]
95
- classified = processor._classify(elements)
96
- assert any(e["text"] == "3640mm" for e in classified["dimensions"])
97
-
98
- def test_bare_number_not_in_dimensions(self, processor):
99
- elements = [{"text": "100", "confidence": 90, "x": 0, "y": 0, "width": 40, "height": 20}]
100
- classified = processor._classify(elements)
101
- assert not any(e["text"] == "100" for e in classified["dimensions"])
102
-
103
- def test_label_catchall(self, processor):
104
- elements = [{"text": "note", "confidence": 90, "x": 0, "y": 0, "width": 40, "height": 20}]
105
- classified = processor._classify(elements)
106
- assert any(e["text"] == "note" for e in classified["labels"])
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
tests/test_svg_generator.py DELETED
@@ -1,75 +0,0 @@
1
- """
2
- Unit tests for SVGGenerator.
3
- """
4
-
5
- import pytest
6
-
7
- from processors.svg_generator import SVGGenerator
8
-
9
-
10
- @pytest.fixture
11
- def generator():
12
- return SVGGenerator()
13
-
14
-
15
- @pytest.fixture
16
- def sample_lines():
17
- return [
18
- {"x1": 10, "y1": 10, "x2": 200, "y2": 10, "angle": 0.0, "length": 190.0},
19
- {"x1": 10, "y1": 10, "x2": 10, "y2": 200, "angle": 90.0, "length": 190.0},
20
- {"x1": 10, "y1": 10, "x2": 200, "y2": 200, "angle": 45.0, "length": 268.7},
21
- ]
22
-
23
-
24
- @pytest.fixture
25
- def sample_ocr():
26
- return {
27
- "elements": [
28
- {"text": "Living Room", "confidence": 85, "x": 50, "y": 100, "width": 120, "height": 20},
29
- {"text": "3640", "confidence": 90, "x": 50, "y": 150, "width": 60, "height": 20},
30
- ]
31
- }
32
-
33
-
34
- class TestGenerate:
35
- def test_returns_string(self, generator, sample_lines):
36
- result = generator.generate(sample_lines, 800, 600)
37
- assert isinstance(result, str)
38
-
39
- def test_output_is_valid_svg(self, generator, sample_lines):
40
- result = generator.generate(sample_lines, 800, 600)
41
- assert result.strip().startswith("<svg") or "<svg" in result
42
-
43
- def test_svg_contains_correct_dimensions(self, generator, sample_lines):
44
- result = generator.generate(sample_lines, 800, 600)
45
- assert "800" in result
46
- assert "600" in result
47
-
48
- def test_svg_contains_line_elements(self, generator, sample_lines):
49
- result = generator.generate(sample_lines, 800, 600)
50
- assert "<line" in result
51
-
52
- def test_wall_lines_group_present(self, generator, sample_lines):
53
- result = generator.generate(sample_lines, 800, 600)
54
- assert "wall-lines" in result
55
-
56
- def test_detail_lines_group_present(self, generator, sample_lines):
57
- result = generator.generate(sample_lines, 800, 600)
58
- assert "detail-lines" in result
59
-
60
- def test_svg_includes_ocr_text(self, generator, sample_lines, sample_ocr):
61
- result = generator.generate(sample_lines, 800, 600, ocr_results=sample_ocr)
62
- assert "Living Room" in result
63
- assert "text-labels" in result
64
-
65
- def test_svg_without_ocr_has_no_text_labels(self, generator, sample_lines):
66
- result = generator.generate(sample_lines, 800, 600, ocr_results=None)
67
- assert "text-labels" not in result
68
-
69
- def test_empty_lines_produces_svg(self, generator):
70
- result = generator.generate([], 400, 300)
71
- assert "<svg" in result
72
-
73
- def test_white_background_rect_present(self, generator, sample_lines):
74
- result = generator.generate(sample_lines, 800, 600)
75
- assert 'fill="white"' in result