# Project Specification Document (PSD) **Project:** Spare-Parts Diagram Locator for the BQ188 Warp Tying Machine catalogue **Source document:** `1.pdf` (project root) **Status:** Phase 1 — specification complete, implementation not started **Last updated:** 2026-08-30 > This document is the permanent source of truth for the project. Every statement in > section 4 ("PDF / document structure discovered") was verified programmatically > against `1.pdf`. Nothing in that section is assumed. --- ## 1. Project overview A small, entirely local desktop tool. The user types a machine spare-part number (e.g. `30642`). The tool finds the catalogue page that contains that part, renders the exploded-view diagram from that page, and draws a **red rectangle around the physical component** the number refers to — not merely around the printed number — with the part number and name shown next to it. The catalogue is a fixed, known, 55-page PDF. The document never changes at runtime. Therefore all expensive work (text extraction, OCR, geometry) happens **once, offline, in a build step**. The interactive application only performs a dictionary lookup and draws an overlay. Lookup is deterministic and instant. This is a **document-processing / computer-vision** project, not a retrieval or question-answering project. See section 19 for what is explicitly out of scope. --- ## 2. Exact problem being solved Given a 5-digit part number `P`: | Step | Requirement | |---|---| | R1 | Determine every catalogue page on which `P` appears. | | R2 | Retrieve the part's name (German / English / French) and the section it belongs to. | | R3 | Render that page's exploded-view diagram as an image. | | R4 | Find every callout label reading `P` inside that diagram. | | R5 | For each label, resolve the **component** the label points to, and produce a bounding box around that component's drawn geometry. | | R6 | Draw a red rectangle on the component, and print `P` (plus name) next to it. | | R7 | Report page number, section number, section title, and part name. | **The hard requirement is R5.** R4 (finding the printed text) is easy and already proven. R5 requires following the drawing's leader line from the label to the part. ### Why this is not a text-search problem The number `30642` appears twice on page 34: once as a row in the machine-readable parts table on the left half of the page, and once as a raster callout inside the diagram on the right half. Boxing either of those occurrences boxes *text*. The component itself — a worm gear (`Schnecke`) — carries no text at all. It is identified only by a thin line drawn from the callout to the part outline. --- ## 3. User workflow ``` 1. User launches the app → streamlit run app.py 2. User types a part number → "30642" 3. App validates / autocompletes → suggests from 705 known numbers 4. App shows result header → 30642 · Schnecke,kpl. / worm,cpl. / vis sans fin,cpl Section 07 "Motor-Antrieb / Motor-drive" · PDF page 34 5. App shows the diagram → full-page diagram image · RED rectangle on the physical component · part number drawn next to the rectangle · thin blue rectangle on the printed callout label (so the user can verify) · confidence badge: "located" / "approximate" / "label only" 6. If the part appears on several pages, a page selector appears (61 parts do). 7. If the part has several instances on one page, all instances are boxed. 8. User can zoom / download the annotated PNG. ``` Failure is always visible, never silent: if the component cannot be resolved, the app still shows the page and boxes the label, and says so. --- ## 4. PDF / document structure discovered All facts below were extracted from `1.pdf` with PyMuPDF 1.28.2. ### 4.1 File-level | Property | Value | |---|---| | Pages | 55 | | Page size | 1190 × 842 pt (A4 landscape), rotation 0, uniform on every page | | PDF version | 1.7 | | Producer / Creator | Microsoft® Excel® 2019 | | Created | 2022-08-21 | | Encrypted | No | | Bookmarks / outline | None (0 entries) | | File size | ~39.8 MB | | Machine | BQ188 Warp Tying Machine — Jingong Industrial Co., Ltd | The PDF was exported from an Excel workbook. This matters: the parts tables are **real vector text in a real table grid**, while every diagram is a **flat embedded JPEG**. There is no vector geometry for the drawings. ### 4.2 Page map | PDF pages | Content | Text layer | Images | |---|---|---|---| | 1 | Title page (BQ188, 4 languages, company address) | Yes (376 chars) | 3 | | 2 – 23 | Scanned **operation manual** — prose + photographs | No real text; only the running footer (98 chars/page) | 2 per page | | 24 | `NEEDLE TABLE` — yarn-count / needle-number table | Yes (vector text, 74 vector drawings) | 0 | | 25 | **Spare-parts catalogue cover** — ordering instructions in DE/EN/FR/ES | Yes | 2 | | 26 – 55 | **Spare-parts catalogue — 30 section pages** | Yes (table) | 1 visible diagram + decoys | **The spare-parts catalogue begins at PDF page 25 (cover); the first section page with parts and a diagram is PDF page 26.** Pages 1–24 contain no part numbers and are out of scope for indexing (they may still be viewable in the UI). ### 4.3 Section structure (pages 26–55) Sections are numbered `01`–`27` with three sub-sections. The mapping is complete and was read from the first table row of each page: | Page | Sec | German | English | Models | |---|---|---|---|---| | 26 | 01 | Abdeckhaube | Cover | PU-ELA-Z | | 27 | 02 | Lampe+Gebläse Kurze Knoten | Lamp+fan short knot | | | 28 | 03 | Abdeckblech,hinten | Cover plate,rear | | | 29 | 04 | Abdeckblech,links+Abschlußblende Kurbeldeckel | Cover plate,left+Crank end cover cap | | | 30 | 04-1 | Abdeckblech,link. für kurze knoten | Cover plate,left+for short knot | | | 31 | 04-2 | Kurze Knoten | Short knot | | | 32 | 05 | Gehäuseplatte,oben,unten | Housing plate,above,below | | | 33 | 06 | Handantrieb+Fadenabstreifer+Schutzblech | Hand drive+Thread wipers+Fender | PU-ELA/PU-ELA-Z/PU-S/PU-DK/PU-DKU | | 34 | 07 | Motor-Antrieb | Motor-drive | | | 35 | 08 | Tastfühler+Tastfühler steuerung | Feeler+Feeler control | PU | | 36 | 09 | Fadenschieber+Bürstenhalter | Thread spool+Brush holder | PU | | 37 | 10 | Kreuzhebel | Cross lever | PU-ELA/PU-ELA-Z/PU-DK/PU-C/PU-CC/PU-S | | 38 | 11 | Führungsplatte | Guide plate | PU | | 39 | 12 | Nadelanschlag+Nadelhebelwelle | Needle+Needle stop lever shaft | | | 40 | 13 | Mittelgruppe-Kipper | Central group-tipper | PU-ELA | | 41 | 13-1 | Mittelgruppe Kipper | Middle group tipper | PU-ELA | | 42 | 14 | Kurze Knoten Schere | Short knot scissors | | | 43 | 15 | Fadenklemme+Klemmenhebellager | Thread clamp+clamping lever bearing | | | 44 | 16 | Knotenhelfer+Knüpferlager+Knüpfernadel | Lever hinge+Knotter support+Knotter needle | | | 45 | 17 | Fadenhalter+Knotenauszieher+Fadenkanal | Thread holder+Knot extractor+Thread channel | | | 46 | 18 | Antriebslager+Transport | Drive bearing+Transport | | | 47 | 19 | Kipperwelle | Tipper Shaft | PU-ELA/PU-ELA-Z/PU-S/PU-DK/PU-DKU | | 48 | 20 | Gehäuseplatte,re. li. | Housing plate,left,right | | | 49 | 21 | Antriebs+Mittel+Untere+Welle | Drive+Medium+Low+Shaft | | | 50 | 22 | Kreuzkurvenscheibe | Cross cam | PU | | 51 | 23 | Knüpfrahmen | Knotting frame | PU | | 52 | 24 | Zubehör für Knüpfrahmen | Accessories for knotting frame | | | 53 | 25 | Innentraverse-Knüpfrahmen | Knotting frame inner traverse | | | 54 | 26 | Maschinenwagen | Machine cart | | | 55 | 27 | Ölwanne | Oil pan | | ### 4.4 Layout of a catalogue page Every one of the 30 pages has the identical two-column layout: ``` (0,0) (1190,0) +-------------------------------+-------------------------------+ | PARTS TABLE (vector text) | EXPLODED DIAGRAM (JPEG) | | x ~ 17 ... 607 | x ~ 607 ... 1140 | | y ~ 28 ... (varies) | y ~ 28 ... 800 | | | | | row 0 : section header | header: model codes, sec no | | row 1 : Art.Nr | Bezeichnung | body: line-art drawing | | | Description | + 5-digit callouts | | | Denomination | + leader lines | | rows 2+: one part per row | footer: drawing path, Seite | +-------------------------------+-------------------------------+ page footer: company / URL (1190,842) ``` ### 4.5 The parts table (left column) — machine-readable * `page.find_tables()` returns exactly **one** table on every catalogue page, always with **4 columns**, and the `Art. Nr.` header is always at row index 1 (row 0 is the section header). This was verified on all 30 pages. * Columns: `Art. Nr.` | `Bezeichnung` (DE) | `Description` (EN) | `Dénomination` (FR). * **861 part rows** in total, **705 distinct part numbers**. * Part numbers are **exactly 5 digits**, no letters, no separators. Verified length distribution: `{5 digits: 860, 6 digits: 1, 2 digits: 27}`. The 27 two-digit values are the section-header cells (`01`…`27`). The single 6-digit value is a typo in the source document: page 47, `351159 / Kipperhebel / tipper / culbuteur` — and the diagram on that same page prints it as `35159`, confirming the table cell is wrong. * Observed number prefixes: `73` (281), `74` (239), `75` (73), `30` (48), `33` (44), `70`, `76`, `78`, `77`, `32`, `35`, `72`. * **61 part numbers appear on more than one page** (62 have more than one occurrence; the extra one is `33275`, listed twice on page 43 — see below). Examples: `73290` on 9 pages (31, 35, 36, 37, 39, 42, 45, 47, 49); `73289` on 5 pages; `70507` on 4. A lookup therefore returns a *set* of pages, not one page. * Only one within-page duplicate table row exists: `33275` on page 43. * **Non-part rows must be filtered.** Pages 42, 51 and 52 end with footnote rows whose `Art. Nr.` cell is empty or contains `*`, `**`, `***`: `* = "nur bei Ausführung kurze Knoten" (only for short knot)`, `** = "Länge angeben" (indicate length)`, `*** = "Länge und Durchmesser (18 oder 17) angeben"`, `S = Sonderzubehör (special accessories)`. Some rows appear *after* the footnotes and are still real parts (e.g. page 52 `30343`–`30346`, page 51 `73013`), so filtering must be per-row, not "stop at first footnote". * The description columns already contain OCR-era typos inherited from whoever built the Excel file: `lsolierplatte` (lowercase L for I), `Kreuzsohnur` for `Kreuzschnur`, `Acoesories`, and full-width `.` instead of `.`. **These are cosmetic. The `Art. Nr.` column is clean.** Descriptions are displayed as-is; they are never used for matching. * A leading `*`, `**` or `***` inside the `Bezeichnung` cell is a footnote marker (e.g. `*Handkamm`, `**Kluppe`) and should be parsed off into a `footnote` field. ### 4.6 The diagram (right column) — raster only * Each catalogue page has **exactly one visible diagram image**, embedded as a **DCTDecode (JPEG), DeviceRGB** object. * Native resolution is **1689 px wide × 2248–2367 px tall** on all 30 pages (1689 × 2352 for page 34). Placed at ≈ 533 × 770 pt → **effective ≈ 228 dpi**. Rendering the *page* at 300 dpi would upsample past native; the correct move is to **extract the embedded image at native resolution** and OCR that. * Despite the RGB container, content is essentially bilevel line art: dark-pixel fraction (`gray < 128`) is **2.2 % – 10.6 %** per page, mid-tones 0.8 % – 3.0 %. There are **no halftone photographs** in the catalogue section (unlike pages 2–23 of the manual). * **Decoy images.** Excel left extra image objects on some pages whose placement rectangles fall partly or wholly **outside** the page box — e.g. page 26 has a second image at `y = 802 … 1576` (page height is 842), pages 29 and 54 have one at negative `y`. Additionally the **same xref is reused across consecutive pages** (xref 152 is the second image of page 36 and the visible diagram of page 37). → **The diagram must be selected as the image rect with the largest intersection area with `page.rect`**, and cached per *page*, not per xref. ### 4.7 How part numbers are represented inside the diagram Verified by rendering and by OCR: * Callouts are the **actual 5-digit part numbers** (`30642`, `74506`, `73631`), drawn in a clean ISO-style technical font, roughly 90 px wide × 30 px tall at native resolution. There is **no separate item-number scheme** — no "item 1, 2, 3" that would need a second lookup table. This is the single most favourable property of this document. * Some callouts carry a leading **asterisk** (`*30638`, `*74012`, `*76352`). The asterisk is the footnote marker described in 4.5 and must be stripped before matching. * Some callouts are **enclosed in a drawn rectangle** (`31269`, `32357`, `33333`, `35037`, `36962`, `30907`, `34983`, `30168`, `30169`). Cross-checking these against the table shows they are consistently the **`kpl.` / `cpl.` (complete assembly)** entries — e.g. `31269 Abdeckhaube,kpl. / covering hood cpl.`, `33333 Mittelgruppe,kpl. / middle group cpl.`. A boxed callout therefore denotes a **sub-assembly**, and its "component" is the whole group shown, not one item. The box also means the label glyphs are *not* isolated components — the drawn rectangle around them must be recognised and excluded, or it will be mistaken for the leader line. * The same part number can appear **several times on one page** when the part is used in several places — page 26 has `73060` ×2, `74014` ×3, `74025` ×2, `79038` ×2; page 34 has `74506` ×4. Every instance must be indexed and boxed. * Non-part text also appears in the diagram and must be rejected: * top-left: model codes — `PU-ELA`, `PU-ELA-Z`, `PU-JET` * top-right: section number — `07`, `K 13` * bottom-left: drawing path — `D / PU-S-12 / PU-ELA / ET / ET- 07 Motor-Antrieb` * bottom-right: the manufacturer's internal page number — `Seite 18` (this is **not** the PDF page number: PDF page 34 is "Seite 18") * small revision/date codes such as `061101`, `051201`, `110702` ### 4.8 How labels relate spatially to components — leader lines This was investigated directly on the pixel data of page 34. * Callouts sit in white space and are joined to their component by a **thin solid leader line**, typically 3–5 px wide at native resolution. Most are a single straight segment; some are kinked polylines; a few are **dashed** (page 40: `73158`, `71058`). * The line starts a few pixels away from the label's bounding box (it does **not** touch the glyphs — verified: the digits of `30642` are their own connected components, distinct from the line) and **terminates touching the outline of the target component**. * **Consequence, and the key finding of Phase 1:** because the leader line touches its target and nothing else, the 8-connected component containing the leader line *is* the target component. Running `cv2.connectedComponentsWithStats` on `gray < 150` for page 34 gave 586 components; the component containing the `30642` leader line has bbox `x[310:520] y[1472:1671]`, which is an exact, tight fit around the worm gear. No template matching, no segmentation model, no training data required. * **Some callouts have no leader line at all** and simply sit adjacent to their part (visible on page 40). These need a nearest-component fallback. * Nothing in the diagram uses arrowheads in the catalogue section (arrowheads do appear in the *manual*, pages 2–23, which is out of scope). So the relationship is confirmed as: ``` part number (table) -> identical printed callout (raster) -> leader line -> component outline ``` ### 4.9 What makes localization difficult (measured, not assumed) Running the prototype over all 34 validated labels on page 34 gave roughly 80 % tight, correct component boxes. The failures are of four kinds: 1. **Leader-line bleed.** If a leader line *crosses* a large neighbouring outline on its way to a small part, the line, the neighbour and the target all merge into one connected component and the box balloons. On page 34, labels `74506`, `74903` and `79105` all resolved to the large gearbox housing component (347 × 533 px, 4.7 % of the image) instead of their small rings. 2. **Frame merge.** Any component that touches the diagram's outer border merges with it. Page 34's border component has a bbox covering 64 % of the image. A size guard is mandatory. 3. **Nearest-neighbour capture.** When another part's outline happens to be closer to the label than the label's own leader line, the wrong component wins. On page 34, `74025` (a screw) captured the bracket that belongs to `76352`. 4. **Assembly callouts.** A boxed `kpl.` callout points at a whole group; the "right" answer is a group of components, and any single-component box is arguably wrong. None of these need machine learning to fix — they need a leader-line-aware tracing step (section 10) plus a small manual-override file. ### 4.10 OCR feasibility — measured OCR was actually run, not assumed. Engine: **RapidOCR (ONNX Runtime, PP-OCRv4 weights)**, CPU only, on the natively extracted JPEG, for all 30 catalogue pages. * Runtime ≈ **11 s per diagram** on this machine; ≈ **6 minutes for all 30 pages**, once, offline. * Page 34: 39 text boxes returned, **every one of the 34 real callouts recognised**, confidence ≥ 0.92 for all of them and 1.00 for most. Only two character-level errors, both trivially repaired by stripping non-digits: `*7.4012` → `74012` (spurious dot), `-74506` → `74506` (leader-line dash absorbed). * **Full-catalogue recall** — distinct part numbers OCR'd from each diagram, matched against that page's own table, summed over all 30 pages: | | | |---|---| | Table part rows, pages 26–55 | **861** | | Matched by OCR in the diagram | **817** | | **Recall** | **94.9 %** | | Pages at 100 % | **9 of 30** | | Worst page | **39 → 40/47 = 85 %** | | Unrecognised tokens, all 30 pages | **69 total (~2.3/page)** | Per-page examples: p28 14/14, p30 19/19, p31 27/27, p36 21/21, p41 25/25, p45 37/37, p50 15/15, p26 33/36, p34 27/29, p40 59/61. The residual "missing" entries are largely parts that are genuinely **listed in the table but not drawn** in that diagram, not OCR misses — the catalogue does this deliberately for hardware variants. **94.9 % is therefore a lower bound on true OCR recall.** * **The reverse case also exists:** callouts appear in a diagram that are absent from that page's table — page 40 shows `73182`, `73636`, `74167`; page 36 shows `33134`, `33141`; page 33 shows `73007`, `70507`. So validating OCR output against the *page's* table alone would silently drop real labels. Validation must be against the **global** 705-number set, with the page's own table used only as a confidence bonus. * The 69 unrecognised tokens are almost entirely the known furniture from 4.7 — `061101`-style revision codes and the `Seite NN` + section-number pair merging into things like `1207` — plus a handful of truncated reads (`4639`, `7497`, `4401`) that recover under edit-distance-1 snapping. **Conclusion: OCR works directly on this document and needs no deskewing, no binarization tuning and no custom training.** Pages do not need to be rendered — the embedded image is extracted at native resolution instead, which is both faster and sharper. --- ## 5. Core technical approach **Build once, look up instantly.** 1. **Offline indexer** (`python -m spareparts.build_index`) processes `1.pdf` end to end and writes a SQLite database plus a PNG cache. Runs in ~10 minutes. Re-runnable, idempotent, and each stage is independently re-runnable so OCR is not repeated when only the localization tuning changes. 2. **Streamlit app** reads the database, does a dictionary lookup, loads the cached PNG, draws the overlay with OpenCV, and displays it. No OCR, no PDF parsing at runtime. Three deliberately deterministic decisions: * **The parts table is the authority.** Names, sections and the canonical part list all come from the PDF's real text layer, never from OCR. * **OCR only ever produces coordinates**, never facts. Every OCR token is snapped to a known part number or discarded. * **Localization is classical CV** — binarize, connected components, leader-line trace. No detector is trained, no model is fine-tuned, nothing is learned from data. --- ## 6. Proposed architecture ``` +-------------------------- BUILD TIME (offline, once) --------------------------+ | | | 1.pdf | | | | | +--> pdf_loader.py classify pages, pick the visible diagram per page, | | | extract it at native resolution -> data/pages/p##.png| | | | | +--> table_parser.py find_tables() -> section header + part rows | | | -> parts, occurrences | | | | | +--> ocr.py RapidOCR on each diagram -> raw text boxes | | | | | +--> matcher.py normalize, reject non-part text, snap to the 705- | | | number set (exact -> edit-distance-1) -> labels | | | | | +--> localizer.py binarize -> connected components -> leader-line trace| | | -> component box + method + confidence -> components | | | | | +--> overrides.json hand corrections, applied last, always win | | | | | +--> index_db.py -> data/catalog.db (SQLite) | | | +--------------------------------------------------------------------------------+ | +---------------------------- RUN TIME (Streamlit) ------------------------------+ | | | app.py --> lookup.py (SQLite queries) --> overlay.py (OpenCV drawing) | | | +--------------------------------------------------------------------------------+ ``` There is no server, no API, no queue, no container. Two entry points: one script, one Streamlit app. --- ## 7. Data flow ### Build ``` 1.pdf +- for page in 26..55: diagram_rect = argmax over image rects of area(rect ∩ page.rect) # 4.6 png = extract_image(xref) -> data/pages/p34.png table = page.find_tables()[0].extract() row0 -> section code / DE / EN / model row1 -> header, skipped rows2.. -> part rows, footnote rows filtered -> parts, occurrences ocr_boxes = RapidOCR(png) strip non-digits; drop len != 5 drop boxes in header/footer strips (top 4 %, bottom 6 %) snap to global part set (exact, then edit-distance 1) -> labels bw = (gray(png) < 150) cc = connectedComponentsWithStats(bw, 8) glyph_ids = components fully inside any label box (+ their enclosing box, if boxed) for each label: anchor = trace_leader_line(label, bw, cc, glyph_ids) box, method, conf = resolve_component(anchor, cc) -> components +- apply overrides.json +- write catalog.db ``` ### Query ``` "30642" +- SELECT part -> name_de/en/fr +- SELECT occurrences -> [page 34, section 07 "Motor-Antrieb"] +- SELECT labels + components -> [{label_box, component_box, conf}] +- load data/pages/p34.png +- draw red rect + caption per instance, blue rect on label +- st.image(annotated) ``` --- ## 8. OCR strategy **Engine: RapidOCR (`rapidocr-onnxruntime`).** Rationale, and why *not* the stack originally guessed: * **PaddleOCR** uses the same PP-OCR models but drags in `paddlepaddle`, which is a large, frequently painful wheel on Windows and pins specific Python versions. RapidOCR ships the identical PP-OCRv4 detection + recognition models as ONNX and runs on `onnxruntime` CPU. It installed cleanly here in one `pip install` with no system dependencies, and was **verified working on this exact document at 94.9 % recall**. If PaddleOCR is later preferred, `ocr.py` exposes one function and the engine is swappable. * **Tesseract** was not chosen: it needs a system binary outside pip, and it is weaker on sparse, isolated numerals scattered on a mostly-empty canvas, which is precisely this document's layout. Settings and rules: * OCR the **natively extracted JPEG** (1689 px wide, ~228 dpi effective), **never** a re-rendered page. No upscaling — measured accuracy is already ≈ 100 % on callouts. * No deskew, no rotation correction: every diagram is upright and axis-aligned. * Run **once per page** at build time; results are cached to `data/ocr_cache/p##.json` so that re-tuning localization never re-runs OCR. * Post-processing pipeline per detected box: 1. `re.sub(r'\D', '', text)` — removes the `*` footnote marker, the spurious `.` from `*7.4012`, and the leading `-` from `-74506`. 2. Reject if length ≠ 5. 3. Reject if the box lies in the header strip (top 4 % of image height) or footer strip (bottom 6 %) — this removes `Seite 18`, drawing paths and revision codes. 4. Snap to the global part set (section 9). * Confidence from the engine is stored per label and surfaced in the UI. --- ## 9. Part-number extraction strategy Two independent sources, with clearly separated authority: **A. Canonical list — from the PDF text layer (authoritative).** `page.find_tables()` on pages 26–55. Per row: `Art. Nr.` must match `^\d{5}$` after stripping whitespace; rows failing that (empty, `*`, `**`, `***`) are recorded as footnotes and excluded from the part list. The `Bezeichnung` cell's leading `*`s are split off into a `footnote` field. This yields **705 distinct part numbers / 861 occurrences**, and is the *only* source of names and sections. One known data defect to carry as-is with a flag: page 47's `351159` is 6 digits and the diagram prints `35159`, so it is a source typo. It is stored, marked `is_malformed = 1`, and excluded from OCR snapping. **B. Label positions — from OCR (positional only).** Every OCR token is snapped to the canonical list before it is allowed to become a label: ``` 1. exact match against the 705-number set -> accept, conf = ocr_conf 2. else Levenshtein distance 1 to exactly one number -> accept, conf = ocr_conf * 0.8 (single candidate only — ambiguous ties are dropped) 3. else -> discard, log to build report ``` A token that also appears in **its own page's table** gets a confidence bonus; a token that appears only in the global list is still accepted, because section 4.10 proved that diagrams legitimately show parts absent from their page's table. Because part numbers are exactly 5 digits with no letters, no checksum and no separators, no other normalization is needed. --- ## 10. Component localization strategy This is the heart of the project. Four tiers, tried in order, each recording which one fired so the UI can be honest about it. ### Preparation (once per page) ``` gray = cv2.cvtColor(img, COLOR_BGR2GRAY) bw = (gray < 150).astype(uint8) # verified threshold; art is bilevel n, cc, stats, _ = cv2.connectedComponentsWithStats(bw, 8) glyph_ids = components whose bbox lies inside a label bbox (+ small pad) frame_ids = components whose bbox area > 25 % of the image (border, page frame) box_ids = for boxed assembly callouts: the thin rectangle enclosing the glyphs ``` ### Tier A — leader-line trace (primary) 1. Cast rays from the label's bounding box outward in 5° increments, from 4 px to ~40 px away from the box edge, looking for dark pixels that belong to a component that is neither a glyph nor the frame. This finds the leader line's near end. 2. Verify the candidate is *line-like*: skeletonize the local neighbourhood and check the stroke width is ≤ ~6 px and locally elongated. This is what distinguishes the real leader line from a neighbouring part's outline that happens to be closer (failure mode 3 in 4.9). 3. **Walk the line.** Follow the stroke pixel by pixel away from the label, allowing direction changes for kinks and a small pixel gap for dashed lines, until the stroke either ends or opens out into a wide (non-line-like) region. That transition point is the **anchor**. 4. Take the connected component containing the anchor. Its bbox is the component box. `method = "leader"`. ### Tier B — bounded fallback (when Tier A's component is implausible) Triggered when the Tier A component's bbox exceeds ~8 % of the image area (leader-line bleed / frame merge, failure modes 1 and 2 in 4.9), or when no line-like stroke is found within 40 px. * If an anchor exists: emit a fixed-radius box centred on the anchor, sized from the page's median component size (roughly 120 × 120 px at native resolution). * If no anchor exists: take the nearest non-glyph, non-frame component to the label, with the same size cap. * `method = "anchor_radius"` / `"nearest"`, and confidence is lowered. The UI shows these as **"approximate"**. ### Tier C — assembly callouts A callout enclosed in a drawn rectangle (`kpl.` / `cpl.` parts, section 4.7) points at a whole group. For these: trace the leader line as in Tier A, then take the **union** of all non-glyph components whose bbox centre lies within a generous radius of the anchor, capped at 30 % of the image. `method = "assembly"`. The UI labels these **"assembly (group)"**. ### Tier D — manual override `data/overrides.json`, keyed `"::"`, holding a box in native-image pixel coordinates plus a note. Applied after everything else and always wins. This is the pragmatic escape hatch for the residual ~10–15 % — a small build-time review tool (section 18, Phase 5) renders every auto box so they can be eyeballed and corrected quickly. ### If a label has no resolvable component at all The record is kept with `component_box = NULL`. The app still shows the page and boxes the **label** in blue, stating "label located, component not resolved". The tool is never allowed to draw a red box it cannot justify. ### Why not other approaches * **Hough transform for leader lines** — considered and rejected as the *primary* method: it finds line segments globally but does not tell you which end is the label end, handles kinked polylines badly, and still needs the component-lookup step. It remains a reasonable Tier-A alternative if pixel-walking proves fragile. * **Training an object detector** — needs annotated data that does not exist, and would be non-deterministic and unexplainable for a 30-page fixed document. * **Full manual annotation of all 861 occurrences** — reliable but ~15 hours of work and defeats the point. The tiered approach automates the ~85 % and leaves a reviewable remainder. --- ## 11. Data / schema design SQLite, single file `data/catalog.db`. **All geometry is stored in native diagram-image pixel coordinates** (origin top-left of the extracted JPEG). PDF-page coordinates are derivable from `pages.rect_*` when needed; keeping one coordinate system avoids an entire class of bugs. ```sql CREATE TABLE pages ( pdf_page INTEGER PRIMARY KEY, -- 1-based, as the user sees it section_code TEXT, -- '07', '13-1' section_de TEXT, section_en TEXT, models TEXT, -- 'PU-ELA/PU-ELA-Z/...' img_xref INTEGER, img_w INTEGER, -- native px, e.g. 1689 img_h INTEGER, -- native px, e.g. 2352 rect_x0 REAL, rect_y0 REAL, -- placement on the PDF page, in points rect_x1 REAL, rect_y1 REAL, png_path TEXT -- 'data/pages/p34.png' ); CREATE TABLE parts ( part_no TEXT PRIMARY KEY, -- '30642' name_de TEXT, name_en TEXT, name_fr TEXT, is_malformed INTEGER DEFAULT 0 -- the 6-digit '351159' ); CREATE TABLE occurrences ( -- part listed in a page's table id INTEGER PRIMARY KEY, part_no TEXT REFERENCES parts(part_no), pdf_page INTEGER REFERENCES pages(pdf_page), row_index INTEGER, footnote TEXT, -- '', '*', '**', '***' UNIQUE(part_no, pdf_page, row_index) ); CREATE TABLE labels ( -- callout found in the diagram raster id INTEGER PRIMARY KEY, pdf_page INTEGER REFERENCES pages(pdf_page), part_no TEXT REFERENCES parts(part_no), instance INTEGER, -- 0,1,2... for repeated callouts on one page lx0 INTEGER, ly0 INTEGER, lx1 INTEGER, ly1 INTEGER, -- label bbox, native px has_asterisk INTEGER, -- callout was drawn as '*30638' is_boxed INTEGER, -- callout enclosed in a rectangle -> assembly ocr_text TEXT, -- raw, before normalization ocr_conf REAL, match_kind TEXT -- 'exact' | 'fuzzy1' ); CREATE TABLE components ( -- resolved physical component label_id INTEGER PRIMARY KEY REFERENCES labels(id), cx0 INTEGER, cy0 INTEGER, cx1 INTEGER, cy1 INTEGER, -- may be NULL anchor_x INTEGER, anchor_y INTEGER, -- leader-line endpoint method TEXT, -- 'leader'|'anchor_radius'|'nearest'|'assembly'|'override' confidence REAL, note TEXT ); CREATE INDEX ix_occ_part ON occurrences(part_no); CREATE INDEX ix_lab_part ON labels(part_no); CREATE INDEX ix_lab_page ON labels(pdf_page); ``` `data/overrides.json`: ```json { "34:74506:1": { "box": [1001, 1448, 1106, 1540], "note": "was capturing the housing" } } ``` `data/build_report.json` — per page: OCR token count, accepted/rejected labels, table parts with no label, labels with no table row, per-method counts. Written on every build; this is the project's own quality dashboard. --- ## 12. Proposed project folder structure ``` ImageOCR/ ├── 1.pdf ├── PSD.md <- this document ├── README.md ├── requirements.txt ├── app.py <- Streamlit entry point ├── spareparts/ │ ├── __init__.py │ ├── config.py <- paths, thresholds, page ranges (one place) │ ├── pdf_loader.py <- page classification, diagram extraction │ ├── table_parser.py <- parts tables -> parts / occurrences │ ├── ocr.py <- RapidOCR wrapper + cache │ ├── matcher.py <- token normalization + snap to part list │ ├── localizer.py <- binarize, CC, leader-line trace, tiers A-D │ ├── index_db.py <- schema, writes, migrations │ ├── lookup.py <- runtime queries │ ├── overlay.py <- OpenCV drawing of boxes + captions │ └── build_index.py <- CLI: python -m spareparts.build_index ├── tools/ │ └── review.py <- build-time QA: render every box, write overrides ├── data/ <- generated, git-ignored except overrides.json │ ├── catalog.db │ ├── pages/p26.png ... p55.png │ ├── ocr_cache/p26.json ... p55.json │ ├── build_report.json │ └── overrides.json <- committed; it is hand-authored input └── tests/ ├── test_table_parser.py ├── test_matcher.py ├── test_localizer.py ├── test_index_integrity.py └── fixtures/ ├── page26_expected.json ├── page34_expected.json └── gold_boxes.json <- manual ground truth for evaluation ``` Flat, one package, two entry points. No `src/` indirection, no plugin system, no service boundaries. --- ## 13. Dependencies `requirements.txt`: ``` pymupdf>=1.24 # PDF parsing, table extraction, image extraction rapidocr-onnxruntime>=1.3 # OCR (PP-OCRv4 via ONNX Runtime, CPU, no system deps) opencv-python-headless>=4.9 numpy>=1.26 pillow>=10 streamlit>=1.30 ``` Dev only: `pytest`. Notes: * **Python 3.11** (the interpreter present in this workspace; 3.11.9 verified). * `opencv-python-headless` rather than `opencv-python`: no GUI windows are ever opened, and headless avoids Qt/GTK baggage. * `scipy` is **not** required — `cv2.connectedComponentsWithStats` covers it. * No `pandas`: the data volumes are trivial and SQLite is the store. * Everything runs **offline on CPU**. No network calls at build or run time. Total install footprint is a few hundred MB, dominated by the ONNX models and OpenCV. * `pymupdf` version note: `find_tables()` prints a suggestion to install `pymupdf-layout`. It is not needed here — the plain extractor produced correct 4-column output on all 30 pages. --- ## 14. UI design Single-page Streamlit app, no navigation, no login. ``` +----------------------------------------------------------------------+ | BQ188 Spare-Parts Locator | | | | Part number [ 30642 v ] [ Find ] | | (autocomplete over 705 known numbers) | +----------------------------------------------------------------------+ | 30642 · Schnecke,kpl. | | EN worm,cpl. FR vis sans fin,cpl | | Section 07 — Motor-Antrieb / Motor-drive PDF page 34 | | 1 instance on this page * located | +----------------------------------------------------------------------+ | [ page 34 ] <- tabs if part is on many | | | | +---------------------------------------------+ | | | | | | | +---------+ | | | | | #######<--- red box on the component | | | | +---------+ | | | | 30642 <-- caption drawn on the image | | | | +------+ | | | | |30642 |<-- blue box on the callout | | | | +------+ | | | +---------------------------------------------+ | | | | [ Download annotated PNG ] [ ] show label boxes [ ] show all | +----------------------------------------------------------------------+ ``` Details: * **Red rectangle**, 4–6 px at native resolution, on the component. **Blue thin rectangle** on the printed callout label (toggleable, on by default) so the user can see *why* the tool chose that component. These two colours are never swapped. * The part number is drawn **on the image** next to the red box, on a white plate for legibility against line art. * **Confidence badge** next to the header, driven by `components.method`: `located` (leader) / `approximate` (anchor_radius, nearest) / `assembly (group)` / `label only` (no component box). * **Multi-page parts** (61 of them) → one Streamlit tab per page. * **Multi-instance parts** → every instance boxed and numbered `#1`, `#2`, … in the caption; a count is shown in the header. * **"Show all parts on this page"** checkbox renders every label on the page at once — genuinely useful for browsing, and it doubles as the QA view. * Unknown input → "No part `xxxxx` in this catalogue" plus the nearest 5 numbers by edit distance. * Images are served from the PNG cache; drawing is a few OpenCV calls, so response is effectively instant. --- ## 15. Error and edge cases | # | Case | Evidence | Handling | |---|---|---|---| | E1 | Part appears on several pages | 61 parts; `73290` on 9 pages | Return all; one UI tab per page | | E2 | Part appears several times on one page | `74506` ×4 on p34 | Index every instance; box all | | E3 | Part in the table but not drawn | p39: 7 of 47 | Show the page, box nothing, say "listed in table; not drawn on this diagram" | | E4 | Callout drawn but not in that page's table | p40 `73182`, `73636`, `74167` | Accept via global part list; flag in build report | | E5 | Callout prefixed `*` | `*30638`, `*74012` | Strip in normalization; store `has_asterisk`; show the footnote text | | E6 | Boxed assembly callout | `31269`, `33333`, `34983` | Tier C; badge "assembly (group)" | | E7 | Footnote rows in the table | p42, p51, p52 (`*`, `**`, `***`, blank) | Filtered out of `parts`; text kept for E5 display | | E8 | Real parts listed *after* the footnote rows | p52 `30343`–`30346`, p51 `73013` | Filter per row, never "stop at first footnote" | | E9 | Malformed part number in source | p47 table `351159` vs diagram `35159` | Stored with `is_malformed`; excluded from OCR snapping | | E10 | Off-page / decoy images | p26 second image at y 802–1576; p29, p54 negative y | Select by max intersection with `page.rect` | | E11 | Same image xref on two pages | xref 152 on p36 and p37 | Cache per page, never per xref | | E12 | OCR noise: `Seite 18`, `061101`, `D / PU-S-12 / …`, `07` | Every page | Reject by header/footer strip + 5-digit rule + part-list snap | | E13 | OCR char errors: `*7.4012`, `-74506`, `4639`, `7497` | p34, p27, p48 | `re.sub(r'\D','')` then exact/fuzzy-1 snap | | E14 | Leader line merges component with the frame | p34 border component = 64 % of image | Frame components (>25 % area) excluded; Tier B size cap | | E15 | Leader line crosses a big neighbour | p34 `74506`/`74903`/`79105` → housing | Line-likeness check; >8 % area triggers Tier B | | E16 | Wrong nearest component wins | p34 `74025` captured `76352`'s bracket | Line-likeness check; then override | | E17 | Callout with no leader line | p40 | Tier B "nearest" | | E18 | Dashed leader line | p40 `73158`, `71058` | Gap-tolerant walk in Tier A | | E19 | User searches a number in the manual pages (1–24) | — | Those pages are not indexed; report "not found" | | E20 | User enters non-numeric / wrong length | — | Validate `^\d{5}$`; suggest nearest known numbers | | E21 | `catalog.db` missing or stale | — | App checks for the DB on start and prints the exact build command | --- ## 16. Testing strategy `pytest`, run against the real `1.pdf` — it is fixed and committed, so it *is* the fixture. **Unit** * `test_table_parser.py` — pages 26, 34 and 52 parsed against committed golden JSON: exact row counts, exact part numbers, correct footnote filtering (52), correct handling of real rows after footnotes (52). Asserts the global totals: 30 pages, 861 rows, 705 distinct numbers. * `test_matcher.py` — `*7.4012 → 74012`, `-74506 → 74506`, `Seite 18 → rejected`, `061101 → rejected`, `07 → rejected`; fuzzy-1 accepts a single candidate and rejects ambiguous ties. * `test_localizer.py` — on synthetic images (a label, a leader line, a target square, a decoy square): the target is chosen, the decoy is not; the frame guard fires; the size cap triggers Tier B; a dashed line is still followed. * `test_pdf_loader.py` — the diagram picked for pages 26, 29, 36, 37 and 54 is the on-page one, not a decoy or a shared xref (this pins E10 and E11). **Integration** * `test_index_integrity.py` — over the built `catalog.db`: * every `labels.part_no` exists in `parts` * every `occurrences.part_no` exists in `parts` * every label's bbox lies inside its page's image bounds * every component box, when present, lies inside the image bounds and is smaller than the Tier-B cap * overall label recall stays ≥ 93 % and no page drops below 85 % — a regression guard on OCR settings, anchored to the Phase-1 measurement of 94.9 % / worst 85 % **Regression** * `test_known_parts.py` — a handful of hand-verified pins that must never break: `30642` → page 34, section 07, `Schnecke,kpl.` / `worm,cpl.`, component box overlapping the worm; `31269` → page 26, boxed assembly; `73290` → 9 pages. **Manual** * `tools/review.py` renders every page with all boxes drawn into `data/review/`, which is how localization is actually judged (section 17) and how `overrides.json` gets written. --- ## 17. Accuracy and evaluation strategy Three metrics, each measured against something real. **M1 — Label detection recall (automatic, no manual work).** For each page: distinct part numbers OCR'd from the diagram ÷ part numbers in that page's table. **Already measured in Phase 1 over all 30 pages: 817 / 861 = 94.9 %, 9 pages at 100 %, worst page 85 % (page 39).** It over-counts misses, because some listed parts are genuinely not drawn, so it is a **lower bound**. Target: **≥ 95 % overall, ≥ 85 % on every page** — i.e. do not regress from the Phase-1 baseline. **M2 — Label precision (automatic).** Fraction of accepted labels that snap exactly to a known part number, and the count of rejected tokens per page (Phase-1 baseline: 69 rejects over 30 pages ≈ 2.3/page, nearly all of them known page furniture). Target: **≥ 98 % exact matches; < 3 rejected tokens/page.** **M3 — Component localization accuracy (manual gold set).** Build a ground-truth set of **80 (page, part, instance) triples** across 8 pages chosen for varied difficulty — 26 (dense hardware), 34 (clean), 39 and 40 (very dense), 45, 49, 51 (accessories), 55 (nearly empty) — by hand-drawing the correct component box in `tests/fixtures/gold_boxes.json` using `tools/review.py`. Scored two ways: * **Strict:** IoU(predicted, gold) ≥ 0.5. * **Useful:** the predicted box's centre lies inside the gold box *and* the predicted area is ≤ 3× the gold area. This is the metric that matters — a slightly loose box that unambiguously indicates the right part still answers the user's question. Phase-1 prototype baseline on page 34, judged by eye: **~80 % correct**, with the four failure modes catalogued in 4.9. Targets after Tier A/B are properly implemented: | Metric | Target | |---|---| | M3 useful, Tier A+B automatic | ≥ 85 % | | M3 strict (IoU ≥ 0.5), automatic | ≥ 70 % | | After `overrides.json` review | ≥ 97 % useful | **Honesty requirement:** the build report records the per-method distribution (`leader` / `anchor_radius` / `nearest` / `assembly` / `override` / `none`), and the UI shows the method for the box the user is looking at. The tool never presents a Tier-B guess as if it were a Tier-A result. --- ## 18. Implementation phases | Phase | Deliverable | Definition of done | |---|---|---| | **1 (done)** | **This PSD** | PDF inspected, structure documented, OCR and localization prototyped and measured | | **2** | Index skeleton: `config`, `pdf_loader`, `table_parser`, `index_db`, `build_index` | `catalog.db` holds 30 pages, 705 parts, 861 occurrences; 30 PNGs cached; `test_table_parser` and `test_pdf_loader` pass | | **3** | `ocr.py` + `matcher.py` + OCR cache | `labels` populated for all 30 pages; M1 ≥ 95 %; build report written | | **4** | `localizer.py` Tiers A–C | `components` populated; per-method counts in the report; no box exceeds the size cap | | **5** | `tools/review.py`, gold set, `overrides.json` | 80-box gold set exists; M3 measured; overrides written for the residual failures | | **6** | `app.py` + `overlay.py` + `lookup.py` | `30642` → page 34 with a red box on the worm; multi-page and multi-instance paths work | | **7** | Polish: README, autocomplete, download, "show all", error states | Fresh clone → `pip install -r requirements.txt` → `python -m spareparts.build_index` → `streamlit run app.py` works end to end | Phases 2–4 are the substance; 5 is what turns "mostly works" into "trustworthy"; 6–7 are a day's work each. --- ## 19. Explicitly excluded technologies and features Excluded by requirement, and none of them would help here — the catalogue is a fixed, fully enumerable 705-row lookup table. * **RAG in any form** — no retrieval-augmented generation. * **LangChain / LlamaIndex** or any agent framework. * **Embeddings, vector databases, semantic or fuzzy "meaning" search** (FAISS, Chroma, Pinecone, pgvector, …). Lookup is an exact 5-digit key. * **LLMs anywhere in the pipeline**, including for OCR correction or description cleanup. * **Cloud services** — no OCR APIs, no storage buckets, no hosted inference. The tool runs offline. * **Docker / Kubernetes / CI infrastructure.** Single developer, single machine, one `pip install`. There is no deployment target that would justify it. * **Microservices, REST APIs, message queues, background workers.** The build is a script; the app is a Streamlit process. * **Training or fine-tuning any model.** No annotated data exists, and a fixed 30-page document does not warrant creating it. * **A general-purpose PDF catalogue engine.** This is scoped to `1.pdf` and its exact layout. Generalization is explicitly a non-goal; the code should read as document-specific, with the document's quirks named in `config.py`. * **User accounts, multi-tenancy, telemetry, persistence of user state.** * **PostgreSQL / MongoDB.** SQLite holds ~1600 rows. * **Indexing pages 1–24** (title, operation manual, needle table). They contain no part numbers. They may optionally be *viewable*, never searchable. --- ## 20. Known uncertainties and risks | # | Risk | Severity | Evidence / mitigation | |---|---|---|---| | **R1** | **Leader-line tracing is less reliable than the page-34 sample suggests.** Denser pages (39, 40, 51 — up to 10.6 % ink, 61 parts) have crossing lines and touching parts; the "line touches only its target" property may break down. | **High — this is the project's central risk** | Measured on one page only (~80 %). Mitigation: Tier B size cap keeps failures *conservative* rather than wrong-and-confident; Tier D overrides; the UI always shows the label box so the user can self-correct. **Validate on pages 39/40/51 first in Phase 4, before investing in Tier A refinements.** | | R2 | Component boxes are systematically too large where the leader line crosses a big outline | Medium | Observed on p34 (`74506`, `74903`, `79105`). Line-likeness check in Tier A; 8 % area cap → Tier B | | R3 | Assembly (`kpl.`) callouts have no single correct box | Medium | ~9 seen on p26/p34/p40. Tier C draws a group box and the UI says "assembly"; accepted as inherently approximate | | R4 | The ~15 % residual needs manual review; a fresh reviewer may find it tedious | Medium | 861 occurrences total; the review tool renders a page at a time. Budget ~3–4 hours in Phase 5 | | R5 | OCR misses on the densest pages | Low | Measured 94.9 % overall, worst page 39 at 40/47, and several of those 7 are genuinely undrawn parts | | R6 | RapidOCR model download on first run | Low | Models ship with the wheel; verified — the Phase-1 run needed no network. Pin the version in `requirements.txt` | | R7 | Binarization threshold 150 is tuned on one page | Low | JPEG artifacts vary slightly. Verified ink fraction 2.2–10.6 % across all 30 pages, all bilevel line art. Make the threshold a `config.py` constant and sanity-check the resulting component count per page in the build report | | R8 | Part numbers appearing on up to 9 pages could confuse a user | Low | UI shows one tab per page with the section title, so the user picks by assembly | | R9 | Source-data defects (`351159`, `lsolierplatte`, `Kreuzsohnur`) | Low | Displayed as-is; flagged, never silently "corrected". Descriptions are never used for matching | | R10 | `pymupdf.find_tables()` behaviour could change across versions | Low | Pin `pymupdf`; `test_table_parser` asserts exact golden output on 3 pages and the global 861/705 totals, so a regression fails loudly | | R11 | Build time (~10 min) is annoying during development | Low | OCR results cached to `data/ocr_cache/`; localization can be re-run alone. Stages are independently invocable | ### Open questions to settle during implementation 1. **Which Tier-A line-following implementation wins** — pixel walk vs. probabilistic Hough restricted to a window around the label. Decide empirically on pages 39/40/51 in Phase 4; do not commit to either now. 2. **The right Tier-B fallback box size.** "Median component bbox on the page" is a guess; tune it against the gold set. 3. **Whether boxed-assembly callouts should draw a union box or simply highlight the whole diagram region** with an explanatory caption. Decide after seeing Tier C on real pages. 4. **Whether to expose the operation manual (pages 2–23) as browsable images** in the UI. Nice-to-have, zero indexing cost, and out of scope until Phase 7.