File size: 7,849 Bytes
546c207
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
"""Machine Part Finder -- Streamlit front end for the BQ188 spare-parts catalogue.



    streamlit run app.py



Every answer comes from `data/catalog.db` and the cached diagrams that the

offline build produced. Nothing here runs OCR, computer vision or PDF parsing:

a search is a SQLite read plus a few rectangles drawn on a copy of a PNG.

"""

from __future__ import annotations

import streamlit as st

from spareparts import config, index_db, lookup, overlay

st.set_page_config(page_title="Machine Part Finder", page_icon=":mag:", layout="wide")


@st.cache_resource
def _connection():
    # Streamlit reruns the script on a different thread each time, so the
    # cached connection must not be pinned to the thread that opened it.
    return index_db.connect(config.DB_PATH, same_thread=False)


@st.cache_data(show_spinner=False)
def _annotated_png(png_path: str, boxes: tuple, part_no: str, show_callout: bool) -> bytes:
    """Render and cache one annotated diagram.



    Keyed on plain values so Streamlit can cache it; the boxes come straight

    from the database.

    """
    from spareparts.lookup import Highlight

    highlights = [
        Highlight(
            instance=i,
            label_box=tuple(label),
            component_box=tuple(component) if component else None,
            method="", confidence=0.0, note="",
        )
        for i, (label, component) in enumerate(boxes)
    ]
    image = overlay.load_diagram(png_path)
    return overlay.encode_png(overlay.annotate(image, highlights, part_no, show_callout))


def _render_occurrence(part, occurrence, show_callout: bool) -> None:
    """Show one catalogue page: its metadata, then the annotated diagram."""
    left, middle, right = st.columns([2, 2, 3])
    left.metric("PDF page", occurrence.pdf_page)
    middle.metric("Catalogue section", occurrence.section_code or "-")
    right.markdown(
        f"**{occurrence.section_en or '-'}**  \n"
        f"<span style='color:#666'>{occurrence.section_de or ''}</span>",
        unsafe_allow_html=True,
    )

    if not occurrence.listed_in_table:
        st.info(
            f"Part {part.part_no} is drawn on this diagram but is not listed in this "
            "page's own parts table. The callout was matched against the full "
            "catalogue, so it is genuine."
        )

    if not occurrence.is_drawn:
        st.warning(
            f"Part {part.part_no} is listed in this page's parts table but no callout "
            "for it was found on the diagram. The catalogue does not draw every "
            "listed part. The page is shown below without a highlight."
        )
        st.image(
            overlay.to_rgb(overlay.load_diagram(occurrence.png_path)),
            width="stretch",
            caption=f"PDF page {occurrence.pdf_page} - no highlight available",
        )
        return

    # Localization quality, stated plainly rather than implied.
    for position, highlight in enumerate(occurrence.highlights, start=1):
        prefix = f"#{position}  " if len(occurrence.highlights) > 1 else ""
        if highlight.component_box is None:
            st.warning(
                f"{prefix}Callout found, but no component could be resolved. "
                f"{highlight.explanation}"
            )
        elif highlight.method in ("leader", "override"):
            st.caption(
                f"{prefix}**{highlight.status}** - {highlight.explanation} "
                f"Confidence {highlight.confidence:.2f}."
            )
        else:
            note = f" ({highlight.note})" if highlight.note else ""
            st.caption(
                f":orange[{prefix}**{highlight.status}**] - {highlight.explanation}"
                f"{note} Confidence {highlight.confidence:.2f}."
            )

    if len(occurrence.highlights) > 1:
        st.caption(
            f"This part is drawn {len(occurrence.highlights)} times on this page; "
            "each instance is boxed and numbered."
        )

    boxes = tuple(
        (h.label_box, h.component_box) for h in occurrence.highlights
    )
    png = _annotated_png(str(occurrence.png_path), boxes, part.part_no, show_callout)
    st.image(png, width="stretch")
    st.download_button(
        "Download annotated diagram",
        data=png,
        file_name=f"{part.part_no}_page{occurrence.pdf_page}.png",
        mime="image/png",
        key=f"dl_{part.part_no}_{occurrence.pdf_page}",
    )


def main() -> None:
    st.title("Machine Part Finder")
    st.write(
        "Search the **BQ188 warp tying machine** spare-parts catalogue by part "
        "number. The matching exploded-view diagram is shown with the actual "
        "component outlined in red."
    )

    ready, problem = lookup.index_is_ready(config.DB_PATH)
    if not ready:
        st.error(problem)
        st.code("python -m spareparts.build_index", language="bash")
        st.caption("Run the offline build once, then reload this page.")
        return

    conn = _connection()
    stats = lookup.catalogue_stats(conn)

    with st.form("search"):
        columns = st.columns([4, 1])
        query = columns[0].text_input(
            "Part number",
            placeholder="e.g. 30642",
            help=f"{stats['parts']} part numbers across {stats['pages']} catalogue pages.",
        )
        columns[1].markdown("<div style='height:1.85rem'></div>", unsafe_allow_html=True)
        submitted = columns[1].form_submit_button("Search", width="stretch")

    show_callout = st.sidebar.checkbox(
        "Show the printed callout too",
        value=True,
        help="Outlines the part number printed on the diagram in blue, so you can "
             "see which callout the red box was derived from.",
    )
    st.sidebar.caption(
        f"Catalogue: {stats['parts']} parts, {stats['pages']} diagram pages, "
        f"{stats['callouts']} located callouts."
    )

    if not (submitted or query):
        st.caption("Enter a part number above and press Enter or click Search.")
        return

    part_no = lookup.normalize_query(query)
    if not part_no:
        st.warning("Please enter a part number, for example 30642.")
        return

    part = lookup.find_part(conn, part_no)
    if part is None:
        st.error(f"Part number {part_no} not found in the catalogue.")
        near = lookup.suggest(conn, part_no)
        if near:
            st.caption("Did you mean: " + ", ".join(f"`{n}`" for n in near) + "?")
        return

    if part_no != (query or "").strip():
        st.caption(f"Searched for `{part_no}`.")

    st.subheader(f"{part.part_no} - {part.display_name}")
    st.write(
        f"**German** {part.name_de or '-'}    |    **French** {part.name_fr or '-'}"
    )
    if part.is_malformed:
        st.warning(
            "This part number is printed with an unusual number of digits in the "
            "source catalogue and may be a typo in the original document."
        )

    occurrences = part.occurrences
    if not occurrences:
        st.error("This part is in the catalogue index but no page references it.")
        return

    if len(occurrences) == 1:
        _render_occurrence(part, occurrences[0], show_callout)
        return

    st.info(
        f"This part appears on {len(occurrences)} catalogue pages. "
        "Choose which one to view."
    )
    options = list(range(len(occurrences)))
    chosen = st.radio(
        "Occurrence",
        options,
        format_func=lambda i: occurrences[i].describe(i + 1),
        horizontal=False,
    )
    st.divider()
    _render_occurrence(part, occurrences[chosen], show_callout)


if __name__ == "__main__":
    main()