planlens 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- planlens-0.1.0/LICENSE +21 -0
- planlens-0.1.0/PKG-INFO +157 -0
- planlens-0.1.0/README.md +130 -0
- planlens-0.1.0/planlens/__init__.py +8 -0
- planlens-0.1.0/planlens/dxf/__init__.py +10 -0
- planlens-0.1.0/planlens/dxf/truth.py +153 -0
- planlens-0.1.0/planlens/dxf/units.py +92 -0
- planlens-0.1.0/planlens/ir/__init__.py +65 -0
- planlens-0.1.0/planlens/ir/align.py +359 -0
- planlens-0.1.0/planlens/ir/ingest.py +409 -0
- planlens-0.1.0/planlens/ir/queries.py +2271 -0
- planlens-0.1.0/planlens/ir/raster.py +247 -0
- planlens-0.1.0/planlens/ir/render.py +235 -0
- planlens-0.1.0/planlens/ir/results.py +621 -0
- planlens-0.1.0/planlens/ocr.py +315 -0
- planlens-0.1.0/planlens/pdf/__init__.py +53 -0
- planlens-0.1.0/planlens/pdf/cleanup.py +211 -0
- planlens-0.1.0/planlens/pdf/crosscheck.py +162 -0
- planlens-0.1.0/planlens/pdf/extractor.py +354 -0
- planlens-0.1.0/planlens/pdf/labels.py +219 -0
- planlens-0.1.0/planlens/pdf/results.py +99 -0
- planlens-0.1.0/planlens/pdf/scale.py +222 -0
- planlens-0.1.0/planlens/pdf/vision.py +380 -0
- planlens-0.1.0/planlens.egg-info/PKG-INFO +157 -0
- planlens-0.1.0/planlens.egg-info/SOURCES.txt +28 -0
- planlens-0.1.0/planlens.egg-info/dependency_links.txt +1 -0
- planlens-0.1.0/planlens.egg-info/requires.txt +9 -0
- planlens-0.1.0/planlens.egg-info/top_level.txt +1 -0
- planlens-0.1.0/pyproject.toml +53 -0
- planlens-0.1.0/setup.cfg +4 -0
planlens-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2024-2026 Sean O'Connell
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
planlens-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: planlens
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Drawing & submittal intelligence — deterministic geometry extraction and confidence-scored annotation constructs (leaders, dimensions, title blocks, bubbles, revision clouds) from PDF and DXF construction drawings
|
|
5
|
+
Author-email: Sean O'Connell <soconnell345@gmail.com>
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Classifier: Development Status :: 4 - Beta
|
|
8
|
+
Classifier: Intended Audience :: Science/Research
|
|
9
|
+
Classifier: Programming Language :: Python :: 3
|
|
10
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
11
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
15
|
+
Classifier: Topic :: Scientific/Engineering
|
|
16
|
+
Requires-Python: >=3.10
|
|
17
|
+
Description-Content-Type: text/markdown
|
|
18
|
+
License-File: LICENSE
|
|
19
|
+
Requires-Dist: numpy>=2.0
|
|
20
|
+
Requires-Dist: ezdxf>=1.4
|
|
21
|
+
Requires-Dist: PyMuPDF>=1.23
|
|
22
|
+
Provides-Extra: raster
|
|
23
|
+
Requires-Dist: opencv-python-headless>=4.8; extra == "raster"
|
|
24
|
+
Provides-Extra: ocr
|
|
25
|
+
Requires-Dist: rapidocr-onnxruntime<1.3,>=1.2; extra == "ocr"
|
|
26
|
+
Dynamic: license-file
|
|
27
|
+
|
|
28
|
+
# planlens
|
|
29
|
+
|
|
30
|
+
Drawing & submittal intelligence: deterministic geometry extraction plus
|
|
31
|
+
confidence-scored annotation constructs from PDF and DXF construction
|
|
32
|
+
drawings.
|
|
33
|
+
|
|
34
|
+
## Architecture
|
|
35
|
+
|
|
36
|
+
Two layers, one principle — **geometry says WHERE, vision says WHAT**.
|
|
37
|
+
LLM/VLM vision is unreliable on precise geometry, so a deterministic
|
|
38
|
+
extractor owns every coordinate and an LLM (if you attach one) owns only
|
|
39
|
+
semantics.
|
|
40
|
+
|
|
41
|
+
1. **Primitive layer** (`planlens.ir`): a unified intermediate
|
|
42
|
+
representation — Line / Polyline / Arc / Circle / Text with
|
|
43
|
+
coordinates, layer, provenance, and confidence — ingested from DXF
|
|
44
|
+
(`ezdxf`, confidence 1.0), vector PDF (`planlens.pdf`, confidence
|
|
45
|
+
1.0), or raster images (OpenCV, confidence < 1.0, `raster` extra).
|
|
46
|
+
Slice queries (bbox / angle / text / layer / nearest / endpoint) let
|
|
47
|
+
a caller request exactly the geometry it needs.
|
|
48
|
+
2. **Composition layer** (`planlens.ir.queries`): named annotation
|
|
49
|
+
constructs assembled from primitives as confidence-scored
|
|
50
|
+
**proposals**, never asserted facts — leaders, dimensions, title
|
|
51
|
+
blocks, bubble callouts (keynotes, grid bubbles, detail marks), and
|
|
52
|
+
best-effort revision clouds. Every proposal carries the evidence it
|
|
53
|
+
was built from.
|
|
54
|
+
|
|
55
|
+
`planlens.ir.render.render_region` snips any region of a sheet to a
|
|
56
|
+
high-DPI PNG (optionally with numbered set-of-marks overlays) so a
|
|
57
|
+
vision model can answer "what is this pointing at" about a location the
|
|
58
|
+
geometry layer pinned down.
|
|
59
|
+
|
|
60
|
+
`planlens.ocr` (optional `[ocr]` extra) reads lettering optically off
|
|
61
|
+
rendered sheets — many production plots letter with stroked outlines
|
|
62
|
+
(no text layer at all) — and merges the results into the IR as
|
|
63
|
+
confidence-scored text entities in the same coordinate frame
|
|
64
|
+
(auto-detects sideways-plotted sheets and PDF page rotation, and
|
|
65
|
+
corrects the engine's silent corner-order rotation on flipped or
|
|
66
|
+
vertical lines — verified within ~2 pt of the vector-ingest frame on
|
|
67
|
+
/Rotate=0/90/180/270 and both vertical reading directions).
|
|
68
|
+
|
|
69
|
+
`planlens.ir.align.fit_plot_transform` fits the model-space-to-plot
|
|
70
|
+
transform (axis rotation + scale + offset) from anchor geometry, so
|
|
71
|
+
native CAD entities can be located on the plotted page.
|
|
72
|
+
|
|
73
|
+
## Capability status (kept honest)
|
|
74
|
+
|
|
75
|
+
- **Proven on real agency sheets**: bubble callouts (40/40 count match
|
|
76
|
+
on a dense municipal standard detail), region rendering, endpoint /
|
|
77
|
+
text-anchored queries, multi-page drawing-set search, OCR text
|
|
78
|
+
recovery on no-text-layer plots (88-100% truth-text coverage, median
|
|
79
|
+
coordinate error 2.2-15.5 pt per the committed ocr_coverage_check
|
|
80
|
+
convention on the validation sheets), plot-transform
|
|
81
|
+
fitting (0.02-0.03 pt rms on rotated real plots; guarded against the
|
|
82
|
+
degenerate-scale and chance-match regimes — random anchors on a dense
|
|
83
|
+
10k-entity sheet now return None in 60/60 trials while every true fit
|
|
84
|
+
still passes), and native DXF annotation ingest (LEADER/MULTILEADER/
|
|
85
|
+
DIMENSION/ATTRIB as first-class entities at confidence 1.0, surfaced
|
|
86
|
+
by find_leaders/find_dimensions as evidence "native_dxf"; the
|
|
87
|
+
ground-truth extractor lives at planlens.dxf.truth and reproduces the
|
|
88
|
+
committed corpus's MODEL-SPACE annotation content exactly — it does
|
|
89
|
+
not yet extract paper-space layouts, which the committed files also
|
|
90
|
+
carry, so regenerating the corpus with it would drop those blocks).
|
|
91
|
+
- **Partially proven on real sheets**: leader detection reaches 21/25
|
|
92
|
+
native-truth tips on the validation set (11/25 before the 2026-09-05
|
|
93
|
+
arrowhead-representation work; 0/25 before the plot-transform fit).
|
|
94
|
+
The 2026-09-05 gain came from accepting 3-vertex OPEN arrow chains —
|
|
95
|
+
real plotters draw an arrow outline minus one whole edge, in both
|
|
96
|
+
base+leg and chevron flavors — behind a shape gate calibrated to
|
|
97
|
+
measured real arrows. PRECISION IS SHEET-DEPENDENT and verified by
|
|
98
|
+
rendering: the gate does NOT filter SHX letterforms on
|
|
99
|
+
annotation-free, lettering-heavy sheets (hundreds of
|
|
100
|
+
letterform leader proposals at default confidence there — treat
|
|
101
|
+
leader RECALL as proven and leader precision as unproven outside
|
|
102
|
+
annotation-rich sheets). Dimension detection on the same sheets
|
|
103
|
+
reaches 13/16 native defpoints (from 1/16) via a split-shaft
|
|
104
|
+
pairing leg: two collinear opposed-arrow half-shafts around a
|
|
105
|
+
centered text gap, plus the outside-arrows narrow style, both with
|
|
106
|
+
witness-line corroboration; proposal ends are the arrow apexes (the
|
|
107
|
+
CAD defpoints). Witness lines require arrowhead-scale length and
|
|
108
|
+
un-corroborated no-text proposals cap at confidence 0.45 — the
|
|
109
|
+
worst sheet's default output went from 44 proposals at ~0 precision
|
|
110
|
+
to 11 with 8 touching native truth. Independent render-adjudication
|
|
111
|
+
of every non-matching detection (2026-09-05): on the curb-ramp
|
|
112
|
+
sheet 9 of 10 were REAL manually-drafted dimensions the native
|
|
113
|
+
truth cannot record (measured precision ~14/15); on note-heavy SHX
|
|
114
|
+
sheets confidence 1.0 does NOT preclude glyph junk (21.01: ~5/14
|
|
115
|
+
semantic precision) — verify visually there. Residual misses:
|
|
116
|
+
tips with no plotted arrow fragments, sparse dots inside stipple,
|
|
117
|
+
and witness-crossing vertical dimension layouts (not yet modeled).
|
|
118
|
+
- **Best-effort tier**: revision clouds (drafting-practice dependent).
|
|
119
|
+
|
|
120
|
+
## Install
|
|
121
|
+
|
|
122
|
+
```
|
|
123
|
+
pip install planlens # DXF + vector-PDF ingest
|
|
124
|
+
pip install "planlens[raster]" # + raster/scanned-sheet tracing
|
|
125
|
+
pip install "planlens[ocr]" # + optical text for stroked/scanned sheets
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
The `[ocr]` extra installs RapidOCR + onnxruntime with PP-OCR models
|
|
129
|
+
inside the wheel (no runtime downloads; all-permissive licenses:
|
|
130
|
+
Apache-2.0/MIT/BSD).
|
|
131
|
+
|
|
132
|
+
**OpenCV variants — pick per environment.** Every published rapidocr
|
|
133
|
+
distribution (`rapidocr-onnxruntime` 1.x and the unified `rapidocr`
|
|
134
|
+
2/3.x alike) hard-requires the full GUI `opencv-python` (~112 MB),
|
|
135
|
+
while the `[raster]` extra uses `opencv-python-headless`; pip cannot
|
|
136
|
+
express "either variant", and installing both leaves two distributions
|
|
137
|
+
owning the `cv2` namespace (works, but uninstalling either can break
|
|
138
|
+
the other). Decision:
|
|
139
|
+
|
|
140
|
+
- **Desktop / notebook**: `pip install "planlens[raster,ocr]"` as
|
|
141
|
+
above — the GUI build wins the namespace and everything works.
|
|
142
|
+
- **Server / headless deploy** (Databricks, TinyApps — no GUI libs):
|
|
143
|
+
skip the `[ocr]` extra and install the engine without its metadata
|
|
144
|
+
deps; the OCR leg needs only the cv2 APIs headless provides
|
|
145
|
+
(verified end-to-end in a clean headless-only venv, 2026-09-05):
|
|
146
|
+
|
|
147
|
+
```
|
|
148
|
+
pip install "planlens[raster]"
|
|
149
|
+
pip install --no-deps "rapidocr-onnxruntime==1.2.3"
|
|
150
|
+
pip install "onnxruntime>=1.7" pyclipper shapely pillow pyyaml six
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
Pin the rapidocr version you validated — `--no-deps` means ITS
|
|
154
|
+
dependency list is being supplied by hand, so an unpinned upgrade
|
|
155
|
+
could silently need something new. (1.2.3 is the newest wheel that
|
|
156
|
+
installs on Python 3.14 today; newer versions keep the same runtime
|
|
157
|
+
set — re-verify when bumping.)
|
planlens-0.1.0/README.md
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
# planlens
|
|
2
|
+
|
|
3
|
+
Drawing & submittal intelligence: deterministic geometry extraction plus
|
|
4
|
+
confidence-scored annotation constructs from PDF and DXF construction
|
|
5
|
+
drawings.
|
|
6
|
+
|
|
7
|
+
## Architecture
|
|
8
|
+
|
|
9
|
+
Two layers, one principle — **geometry says WHERE, vision says WHAT**.
|
|
10
|
+
LLM/VLM vision is unreliable on precise geometry, so a deterministic
|
|
11
|
+
extractor owns every coordinate and an LLM (if you attach one) owns only
|
|
12
|
+
semantics.
|
|
13
|
+
|
|
14
|
+
1. **Primitive layer** (`planlens.ir`): a unified intermediate
|
|
15
|
+
representation — Line / Polyline / Arc / Circle / Text with
|
|
16
|
+
coordinates, layer, provenance, and confidence — ingested from DXF
|
|
17
|
+
(`ezdxf`, confidence 1.0), vector PDF (`planlens.pdf`, confidence
|
|
18
|
+
1.0), or raster images (OpenCV, confidence < 1.0, `raster` extra).
|
|
19
|
+
Slice queries (bbox / angle / text / layer / nearest / endpoint) let
|
|
20
|
+
a caller request exactly the geometry it needs.
|
|
21
|
+
2. **Composition layer** (`planlens.ir.queries`): named annotation
|
|
22
|
+
constructs assembled from primitives as confidence-scored
|
|
23
|
+
**proposals**, never asserted facts — leaders, dimensions, title
|
|
24
|
+
blocks, bubble callouts (keynotes, grid bubbles, detail marks), and
|
|
25
|
+
best-effort revision clouds. Every proposal carries the evidence it
|
|
26
|
+
was built from.
|
|
27
|
+
|
|
28
|
+
`planlens.ir.render.render_region` snips any region of a sheet to a
|
|
29
|
+
high-DPI PNG (optionally with numbered set-of-marks overlays) so a
|
|
30
|
+
vision model can answer "what is this pointing at" about a location the
|
|
31
|
+
geometry layer pinned down.
|
|
32
|
+
|
|
33
|
+
`planlens.ocr` (optional `[ocr]` extra) reads lettering optically off
|
|
34
|
+
rendered sheets — many production plots letter with stroked outlines
|
|
35
|
+
(no text layer at all) — and merges the results into the IR as
|
|
36
|
+
confidence-scored text entities in the same coordinate frame
|
|
37
|
+
(auto-detects sideways-plotted sheets and PDF page rotation, and
|
|
38
|
+
corrects the engine's silent corner-order rotation on flipped or
|
|
39
|
+
vertical lines — verified within ~2 pt of the vector-ingest frame on
|
|
40
|
+
/Rotate=0/90/180/270 and both vertical reading directions).
|
|
41
|
+
|
|
42
|
+
`planlens.ir.align.fit_plot_transform` fits the model-space-to-plot
|
|
43
|
+
transform (axis rotation + scale + offset) from anchor geometry, so
|
|
44
|
+
native CAD entities can be located on the plotted page.
|
|
45
|
+
|
|
46
|
+
## Capability status (kept honest)
|
|
47
|
+
|
|
48
|
+
- **Proven on real agency sheets**: bubble callouts (40/40 count match
|
|
49
|
+
on a dense municipal standard detail), region rendering, endpoint /
|
|
50
|
+
text-anchored queries, multi-page drawing-set search, OCR text
|
|
51
|
+
recovery on no-text-layer plots (88-100% truth-text coverage, median
|
|
52
|
+
coordinate error 2.2-15.5 pt per the committed ocr_coverage_check
|
|
53
|
+
convention on the validation sheets), plot-transform
|
|
54
|
+
fitting (0.02-0.03 pt rms on rotated real plots; guarded against the
|
|
55
|
+
degenerate-scale and chance-match regimes — random anchors on a dense
|
|
56
|
+
10k-entity sheet now return None in 60/60 trials while every true fit
|
|
57
|
+
still passes), and native DXF annotation ingest (LEADER/MULTILEADER/
|
|
58
|
+
DIMENSION/ATTRIB as first-class entities at confidence 1.0, surfaced
|
|
59
|
+
by find_leaders/find_dimensions as evidence "native_dxf"; the
|
|
60
|
+
ground-truth extractor lives at planlens.dxf.truth and reproduces the
|
|
61
|
+
committed corpus's MODEL-SPACE annotation content exactly — it does
|
|
62
|
+
not yet extract paper-space layouts, which the committed files also
|
|
63
|
+
carry, so regenerating the corpus with it would drop those blocks).
|
|
64
|
+
- **Partially proven on real sheets**: leader detection reaches 21/25
|
|
65
|
+
native-truth tips on the validation set (11/25 before the 2026-09-05
|
|
66
|
+
arrowhead-representation work; 0/25 before the plot-transform fit).
|
|
67
|
+
The 2026-09-05 gain came from accepting 3-vertex OPEN arrow chains —
|
|
68
|
+
real plotters draw an arrow outline minus one whole edge, in both
|
|
69
|
+
base+leg and chevron flavors — behind a shape gate calibrated to
|
|
70
|
+
measured real arrows. PRECISION IS SHEET-DEPENDENT and verified by
|
|
71
|
+
rendering: the gate does NOT filter SHX letterforms on
|
|
72
|
+
annotation-free, lettering-heavy sheets (hundreds of
|
|
73
|
+
letterform leader proposals at default confidence there — treat
|
|
74
|
+
leader RECALL as proven and leader precision as unproven outside
|
|
75
|
+
annotation-rich sheets). Dimension detection on the same sheets
|
|
76
|
+
reaches 13/16 native defpoints (from 1/16) via a split-shaft
|
|
77
|
+
pairing leg: two collinear opposed-arrow half-shafts around a
|
|
78
|
+
centered text gap, plus the outside-arrows narrow style, both with
|
|
79
|
+
witness-line corroboration; proposal ends are the arrow apexes (the
|
|
80
|
+
CAD defpoints). Witness lines require arrowhead-scale length and
|
|
81
|
+
un-corroborated no-text proposals cap at confidence 0.45 — the
|
|
82
|
+
worst sheet's default output went from 44 proposals at ~0 precision
|
|
83
|
+
to 11 with 8 touching native truth. Independent render-adjudication
|
|
84
|
+
of every non-matching detection (2026-09-05): on the curb-ramp
|
|
85
|
+
sheet 9 of 10 were REAL manually-drafted dimensions the native
|
|
86
|
+
truth cannot record (measured precision ~14/15); on note-heavy SHX
|
|
87
|
+
sheets confidence 1.0 does NOT preclude glyph junk (21.01: ~5/14
|
|
88
|
+
semantic precision) — verify visually there. Residual misses:
|
|
89
|
+
tips with no plotted arrow fragments, sparse dots inside stipple,
|
|
90
|
+
and witness-crossing vertical dimension layouts (not yet modeled).
|
|
91
|
+
- **Best-effort tier**: revision clouds (drafting-practice dependent).
|
|
92
|
+
|
|
93
|
+
## Install
|
|
94
|
+
|
|
95
|
+
```
|
|
96
|
+
pip install planlens # DXF + vector-PDF ingest
|
|
97
|
+
pip install "planlens[raster]" # + raster/scanned-sheet tracing
|
|
98
|
+
pip install "planlens[ocr]" # + optical text for stroked/scanned sheets
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
The `[ocr]` extra installs RapidOCR + onnxruntime with PP-OCR models
|
|
102
|
+
inside the wheel (no runtime downloads; all-permissive licenses:
|
|
103
|
+
Apache-2.0/MIT/BSD).
|
|
104
|
+
|
|
105
|
+
**OpenCV variants — pick per environment.** Every published rapidocr
|
|
106
|
+
distribution (`rapidocr-onnxruntime` 1.x and the unified `rapidocr`
|
|
107
|
+
2/3.x alike) hard-requires the full GUI `opencv-python` (~112 MB),
|
|
108
|
+
while the `[raster]` extra uses `opencv-python-headless`; pip cannot
|
|
109
|
+
express "either variant", and installing both leaves two distributions
|
|
110
|
+
owning the `cv2` namespace (works, but uninstalling either can break
|
|
111
|
+
the other). Decision:
|
|
112
|
+
|
|
113
|
+
- **Desktop / notebook**: `pip install "planlens[raster,ocr]"` as
|
|
114
|
+
above — the GUI build wins the namespace and everything works.
|
|
115
|
+
- **Server / headless deploy** (Databricks, TinyApps — no GUI libs):
|
|
116
|
+
skip the `[ocr]` extra and install the engine without its metadata
|
|
117
|
+
deps; the OCR leg needs only the cv2 APIs headless provides
|
|
118
|
+
(verified end-to-end in a clean headless-only venv, 2026-09-05):
|
|
119
|
+
|
|
120
|
+
```
|
|
121
|
+
pip install "planlens[raster]"
|
|
122
|
+
pip install --no-deps "rapidocr-onnxruntime==1.2.3"
|
|
123
|
+
pip install "onnxruntime>=1.7" pyclipper shapely pillow pyyaml six
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
Pin the rapidocr version you validated — `--no-deps` means ITS
|
|
127
|
+
dependency list is being supplied by hand, so an unpinned upgrade
|
|
128
|
+
could silently need something new. (1.2.3 is the newest wheel that
|
|
129
|
+
installs on Python 3.14 today; newer versions keep the same runtime
|
|
130
|
+
set — re-verify when bumping.)
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
"""planlens — drawing & submittal intelligence.
|
|
2
|
+
|
|
3
|
+
Deterministic geometry extraction (:mod:`planlens.ir`, :mod:`planlens.pdf`,
|
|
4
|
+
:mod:`planlens.dxf`) plus confidence-scored annotation constructs
|
|
5
|
+
(:mod:`planlens.ir.queries`) from PDF/DXF construction drawings.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
__version__ = "0.1.0"
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
"""DXF-side utilities: unit detection/conversion + native-annotation truth
|
|
2
|
+
extraction (:mod:`planlens.dxf.truth`). Native LEADER / MULTILEADER /
|
|
3
|
+
DIMENSION / ATTRIB ingest into the IR lives in :func:`planlens.ir.from_dxf`."""
|
|
4
|
+
|
|
5
|
+
from planlens.dxf.units import ( # noqa: F401
|
|
6
|
+
UNIT_FACTORS,
|
|
7
|
+
convert_coords,
|
|
8
|
+
detect_units_from_header,
|
|
9
|
+
)
|
|
10
|
+
from planlens.dxf.truth import extract_native_annotations # noqa: F401
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
"""Native-annotation extraction from a DXF — the ground-truth API.
|
|
2
|
+
|
|
3
|
+
Extracts the annotation entities a CAD file DECLARES — LEADER,
|
|
4
|
+
MULTILEADER, DIMENSION, TEXT/MTEXT, INSERT — as plain JSON-able dicts in
|
|
5
|
+
raw model-space coordinates (no unit conversion, no IR wrapping). This is
|
|
6
|
+
the extractor behind the drawing-intelligence ground-truth corpus: the
|
|
7
|
+
composition heuristics are scored against what the native CAD file says
|
|
8
|
+
was drawn, so this module deliberately reports raw values (MTEXT
|
|
9
|
+
formatting codes retained, raw group-70 ``dimtype``, coordinates rounded
|
|
10
|
+
to 4 decimals) exactly as the committed ``*.truth.json`` files do.
|
|
11
|
+
|
|
12
|
+
For the IR-level view of the same entities (unit-converted, plain text,
|
|
13
|
+
first-class :class:`planlens.ir.results.Leader` / ``Dimension`` entities
|
|
14
|
+
at confidence 1.0) use :func:`planlens.ir.from_dxf` — that is the surface
|
|
15
|
+
queries and agents consume; this one is for scoring and corpus building.
|
|
16
|
+
|
|
17
|
+
Usage::
|
|
18
|
+
|
|
19
|
+
from planlens.dxf.truth import extract_native_annotations
|
|
20
|
+
truth = extract_native_annotations("sheet.dxf")
|
|
21
|
+
truth["spaces"]["model"]["dimensions"][0]["defpoint"]
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
import os
|
|
27
|
+
from typing import Any, Dict, List, Optional
|
|
28
|
+
|
|
29
|
+
__all__ = ["extract_native_annotations"]
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _xy(v, nd: int = 4) -> List[float]:
|
|
33
|
+
try:
|
|
34
|
+
x, y = v.x, v.y
|
|
35
|
+
except AttributeError: # plain tuple/list vertex (older ezdxf paths)
|
|
36
|
+
x, y = v[0], v[1]
|
|
37
|
+
return [round(float(x), nd), round(float(y), nd)]
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _space_annotations(space) -> Dict[str, List[Dict[str, Any]]]:
|
|
41
|
+
out: Dict[str, List[Dict[str, Any]]] = {
|
|
42
|
+
"leaders": [], "multileaders": [], "dimensions": [],
|
|
43
|
+
"text": [], "inserts": [],
|
|
44
|
+
}
|
|
45
|
+
for e in space:
|
|
46
|
+
etype = e.dxftype()
|
|
47
|
+
layer = e.dxf.get("layer", "0")
|
|
48
|
+
if etype == "LEADER":
|
|
49
|
+
out["leaders"].append({
|
|
50
|
+
"vertices": [_xy(v) for v in e.vertices],
|
|
51
|
+
"layer": layer,
|
|
52
|
+
"has_arrowhead": bool(e.dxf.get("has_arrowhead", 1)),
|
|
53
|
+
"annotation_handle": e.dxf.get("annotation_handle", None)
|
|
54
|
+
or "",
|
|
55
|
+
})
|
|
56
|
+
elif etype == "MULTILEADER":
|
|
57
|
+
try:
|
|
58
|
+
ctx = e.context
|
|
59
|
+
mtext = getattr(ctx, "mtext", None)
|
|
60
|
+
text = (mtext.default_content
|
|
61
|
+
if mtext is not None else "") or ""
|
|
62
|
+
lines = [[_xy(v) for v in ln.vertices]
|
|
63
|
+
for ml in ctx.leaders for ln in ml.lines]
|
|
64
|
+
except Exception:
|
|
65
|
+
text, lines = "", []
|
|
66
|
+
out["multileaders"].append({
|
|
67
|
+
"text": text,
|
|
68
|
+
"leader_lines": lines,
|
|
69
|
+
"layer": layer,
|
|
70
|
+
})
|
|
71
|
+
elif etype == "DIMENSION":
|
|
72
|
+
try:
|
|
73
|
+
meas = round(float(e.get_measurement()), 4)
|
|
74
|
+
except Exception:
|
|
75
|
+
meas = 0.0
|
|
76
|
+
dp = e.dxf.get("defpoint", None)
|
|
77
|
+
tm = e.dxf.get("text_midpoint", None)
|
|
78
|
+
out["dimensions"].append({
|
|
79
|
+
"dimtype": int(e.dxf.get("dimtype", 0) or 0),
|
|
80
|
+
"text": e.dxf.get("text", ""),
|
|
81
|
+
"defpoint": _xy(dp) if dp is not None else None,
|
|
82
|
+
"text_midpoint": _xy(tm) if tm is not None else None,
|
|
83
|
+
"measurement": meas,
|
|
84
|
+
"layer": layer,
|
|
85
|
+
})
|
|
86
|
+
elif etype in ("TEXT", "MTEXT"):
|
|
87
|
+
ins = e.dxf.get("insert", None)
|
|
88
|
+
if etype == "TEXT":
|
|
89
|
+
content = e.dxf.get("text", "")
|
|
90
|
+
height = e.dxf.get("height", 0.0)
|
|
91
|
+
else:
|
|
92
|
+
content = e.text
|
|
93
|
+
height = e.dxf.get("char_height", 0.0)
|
|
94
|
+
out["text"].append({
|
|
95
|
+
"type": etype,
|
|
96
|
+
"text": content,
|
|
97
|
+
"insert": _xy(ins) if ins is not None else None,
|
|
98
|
+
"height": round(float(height), 4),
|
|
99
|
+
"rotation": round(float(e.dxf.get("rotation", 0.0)), 2),
|
|
100
|
+
"layer": layer,
|
|
101
|
+
})
|
|
102
|
+
elif etype == "INSERT":
|
|
103
|
+
ins = e.dxf.get("insert", None)
|
|
104
|
+
rec: Dict[str, Any] = {
|
|
105
|
+
"name": e.dxf.get("name", ""),
|
|
106
|
+
"insert": _xy(ins) if ins is not None else None,
|
|
107
|
+
"layer": layer,
|
|
108
|
+
}
|
|
109
|
+
attribs = [{"tag": a.dxf.get("tag", ""),
|
|
110
|
+
"text": a.dxf.get("text", ""),
|
|
111
|
+
"insert": _xy(a.dxf.insert),
|
|
112
|
+
"height": round(float(a.dxf.get("height", 0.0)), 4)}
|
|
113
|
+
for a in e.attribs if a.dxf.get("text", "")]
|
|
114
|
+
if attribs:
|
|
115
|
+
rec["attribs"] = attribs
|
|
116
|
+
out["inserts"].append(rec)
|
|
117
|
+
return out
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def extract_native_annotations(filepath: Optional[str] = None,
|
|
121
|
+
doc=None) -> Dict[str, Any]:
|
|
122
|
+
"""Extract native annotation entities from a DXF, truth-file schema.
|
|
123
|
+
|
|
124
|
+
Pass a ``filepath`` or an already-open ezdxf ``doc``. Returns::
|
|
125
|
+
|
|
126
|
+
{"source_dxf": <basename>,
|
|
127
|
+
"spaces": {"model": {"leaders": [...], "multileaders": [...],
|
|
128
|
+
"dimensions": [...], "text": [...],
|
|
129
|
+
"inserts": [...]}}}
|
|
130
|
+
|
|
131
|
+
Model-space coordinates are RAW drawing units rounded to 4 decimals;
|
|
132
|
+
MTEXT keeps its inline formatting codes; ``dimtype`` is the raw DXF
|
|
133
|
+
group-70 value. ``inserts`` additionally carry an ``attribs`` list
|
|
134
|
+
(tag/text/insert/height) when the reference has attribute values — a
|
|
135
|
+
superset of the original truth schema, additive only.
|
|
136
|
+
"""
|
|
137
|
+
if (filepath is None) == (doc is None):
|
|
138
|
+
raise ValueError("pass exactly one of filepath / doc")
|
|
139
|
+
if doc is None:
|
|
140
|
+
try:
|
|
141
|
+
import ezdxf
|
|
142
|
+
except ImportError as exc:
|
|
143
|
+
raise ImportError("ezdxf is required: pip install ezdxf>=1.4"
|
|
144
|
+
) from exc
|
|
145
|
+
doc = ezdxf.readfile(filepath)
|
|
146
|
+
name = os.path.basename(filepath)
|
|
147
|
+
else:
|
|
148
|
+
name = getattr(doc, "filename", None) or ""
|
|
149
|
+
name = os.path.basename(name) if name else ""
|
|
150
|
+
return {
|
|
151
|
+
"source_dxf": name,
|
|
152
|
+
"spaces": {"model": _space_annotations(doc.modelspace())},
|
|
153
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Unit conversion utilities for DXF geometry import.
|
|
3
|
+
|
|
4
|
+
Converts coordinates from DXF drawing units (mm, cm, ft, in) to SI meters.
|
|
5
|
+
Also detects units from the $INSUNITS DXF header variable.
|
|
6
|
+
|
|
7
|
+
$INSUNITS values (per DXF spec):
|
|
8
|
+
0 = Unitless, 1 = Inches, 2 = Feet, 3 = Miles, 4 = Millimeters,
|
|
9
|
+
5 = Centimeters, 6 = Meters, 7 = Kilometers, ...
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from typing import List, Optional, Tuple
|
|
13
|
+
|
|
14
|
+
# Conversion factors to meters
|
|
15
|
+
UNIT_FACTORS = {
|
|
16
|
+
"m": 1.0,
|
|
17
|
+
"mm": 0.001,
|
|
18
|
+
"cm": 0.01,
|
|
19
|
+
"ft": 0.3048,
|
|
20
|
+
"in": 0.0254,
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
# DXF $INSUNITS header variable mapping
|
|
24
|
+
_INSUNITS_MAP = {
|
|
25
|
+
1: "in",
|
|
26
|
+
2: "ft",
|
|
27
|
+
4: "mm",
|
|
28
|
+
5: "cm",
|
|
29
|
+
6: "m",
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def convert_coords(
|
|
34
|
+
points: List[Tuple[float, float]],
|
|
35
|
+
from_units: str,
|
|
36
|
+
to_units: str = "m",
|
|
37
|
+
) -> List[Tuple[float, float]]:
|
|
38
|
+
"""Convert a list of (x, y) coordinate pairs between unit systems.
|
|
39
|
+
|
|
40
|
+
Parameters
|
|
41
|
+
----------
|
|
42
|
+
points : list of (float, float)
|
|
43
|
+
Input coordinates.
|
|
44
|
+
from_units : str
|
|
45
|
+
Source units ('m', 'mm', 'cm', 'ft', 'in').
|
|
46
|
+
to_units : str
|
|
47
|
+
Target units. Default 'm'.
|
|
48
|
+
|
|
49
|
+
Returns
|
|
50
|
+
-------
|
|
51
|
+
list of (float, float)
|
|
52
|
+
Converted coordinates.
|
|
53
|
+
|
|
54
|
+
Raises
|
|
55
|
+
------
|
|
56
|
+
ValueError
|
|
57
|
+
If either unit string is not recognized.
|
|
58
|
+
"""
|
|
59
|
+
if from_units not in UNIT_FACTORS:
|
|
60
|
+
raise ValueError(
|
|
61
|
+
f"Unknown source unit '{from_units}'. "
|
|
62
|
+
f"Supported: {sorted(UNIT_FACTORS.keys())}"
|
|
63
|
+
)
|
|
64
|
+
if to_units not in UNIT_FACTORS:
|
|
65
|
+
raise ValueError(
|
|
66
|
+
f"Unknown target unit '{to_units}'. "
|
|
67
|
+
f"Supported: {sorted(UNIT_FACTORS.keys())}"
|
|
68
|
+
)
|
|
69
|
+
if from_units == to_units:
|
|
70
|
+
return list(points)
|
|
71
|
+
factor = UNIT_FACTORS[from_units] / UNIT_FACTORS[to_units]
|
|
72
|
+
return [(x * factor, y * factor) for x, y in points]
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def detect_units_from_header(doc) -> Optional[str]:
|
|
76
|
+
"""Read $INSUNITS from a DXF document header and return unit string.
|
|
77
|
+
|
|
78
|
+
Parameters
|
|
79
|
+
----------
|
|
80
|
+
doc : ezdxf.document.Drawing
|
|
81
|
+
An ezdxf document object.
|
|
82
|
+
|
|
83
|
+
Returns
|
|
84
|
+
-------
|
|
85
|
+
str or None
|
|
86
|
+
Unit string ('m', 'ft', 'mm', etc.) or None if not set / unknown.
|
|
87
|
+
"""
|
|
88
|
+
try:
|
|
89
|
+
insunits = doc.header.get("$INSUNITS", 0)
|
|
90
|
+
except Exception:
|
|
91
|
+
return None
|
|
92
|
+
return _INSUNITS_MAP.get(insunits, None)
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Drawing IR — an LLM-ready intermediate representation for a drawing page.
|
|
3
|
+
|
|
4
|
+
Design north star: LLM vision is unreliable on precise geometry, so the
|
|
5
|
+
DETERMINISTIC extractor owns coordinates and the LLM owns semantics. A drawing
|
|
6
|
+
(DXF, PDF vector, or raster scan) is digitized once into a unified
|
|
7
|
+
:class:`DrawingIR` — every line/polyline/arc/circle/text with coordinates,
|
|
8
|
+
provenance and per-entity confidence — and the LLM then requests *slices* of it
|
|
9
|
+
through :mod:`planlens.ir.queries` (spatial windows, angle bands, text near an
|
|
10
|
+
entity, layer/color groups) instead of interpreting pixels.
|
|
11
|
+
|
|
12
|
+
Quick start::
|
|
13
|
+
|
|
14
|
+
from planlens.ir import from_dxf, queries
|
|
15
|
+
|
|
16
|
+
ir = from_dxf("section.dxf") # exact CAD geometry -> DrawingIR
|
|
17
|
+
print(ir.summary())
|
|
18
|
+
surf = queries.candidate_ground_surface(ir) # a PROPOSAL to confirm
|
|
19
|
+
labels = queries.text_items(ir, pattern="clay")
|
|
20
|
+
|
|
21
|
+
Ingest legs: :func:`from_dxf` (ezdxf, confidence 1.0), :func:`from_pdf_vector`
|
|
22
|
+
(PyMuPDF + the pdf_import scale module, confidence 1.0), :func:`from_raster`
|
|
23
|
+
(OpenCV tracing, confidence < 1.0). See ``DESIGN.md`` for schema, confidence /
|
|
24
|
+
provenance semantics, and the raster leg's honest limits.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
from planlens.ir.results import (
|
|
28
|
+
Arc,
|
|
29
|
+
Circle,
|
|
30
|
+
Dimension,
|
|
31
|
+
DrawingIR,
|
|
32
|
+
Entity,
|
|
33
|
+
Leader,
|
|
34
|
+
Line,
|
|
35
|
+
Polyline,
|
|
36
|
+
Region,
|
|
37
|
+
TextItem,
|
|
38
|
+
entity_from_dict,
|
|
39
|
+
)
|
|
40
|
+
from planlens.ir.ingest import from_dxf, from_pdf_vector, from_raster
|
|
41
|
+
from planlens.ir.render import render_region
|
|
42
|
+
from planlens.ir import queries
|
|
43
|
+
|
|
44
|
+
__all__ = [
|
|
45
|
+
# schema
|
|
46
|
+
"DrawingIR",
|
|
47
|
+
"Entity",
|
|
48
|
+
"Line",
|
|
49
|
+
"Polyline",
|
|
50
|
+
"Arc",
|
|
51
|
+
"Circle",
|
|
52
|
+
"TextItem",
|
|
53
|
+
"Region",
|
|
54
|
+
"Leader",
|
|
55
|
+
"Dimension",
|
|
56
|
+
"entity_from_dict",
|
|
57
|
+
# ingest
|
|
58
|
+
"from_dxf",
|
|
59
|
+
"from_pdf_vector",
|
|
60
|
+
"from_raster",
|
|
61
|
+
# region-snip vision primitive (PyMuPDF imported lazily inside)
|
|
62
|
+
"render_region",
|
|
63
|
+
# queries (module)
|
|
64
|
+
"queries",
|
|
65
|
+
]
|