natocr 2.0.0__tar.gz → 2.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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: natocr
3
- Version: 2.0.0
3
+ Version: 2.1.0
4
4
  Summary: Native OCR library using platform-specific frameworks (macOS Vision, Windows Runtime OCR)
5
5
  Author-email: alfredchiesa <alfred.personal@icloud.com>
6
6
  Maintainer-email: alfredchiesa <alfred.personal@icloud.com>
@@ -58,6 +58,14 @@ than third-party alternatives like Tesseract. **natocr** makes reaching for them
58
58
  painless via one clean Python API instead of wrangling with Objective-C bridges
59
59
  or WinRT async plumbing.
60
60
 
61
+ ## Notable Updates
62
+
63
+ - **v2.0.0** (2026-06-25) - batch & async support: [`recognize_many()`](#batch-and-async) plus awaitable [`arecognize()`](#batch-and-async) / [`arecognize_many()`](#batch-and-async) for concurrent, non-blocking OCR
64
+ - **v1.6.1** (2026-06-04) - animated PNG and multi-image HEIF support
65
+ - **v1.6.0** (2026-06-04) - multi-page documents and DjVu support
66
+ - **v1.5.0** (2026-06-04) - JPEG 2000, JPEG XL, and JPEG XR / HD Photo support
67
+ - **v1.4.0** (2026-06-04) - HEIC / HEIF (iPhone photo) support
68
+
61
69
  ## Install
62
70
 
63
71
  ```bash
@@ -71,6 +79,9 @@ The right native backend (Vision on macOS, Windows Runtime OCR on Windows) is
71
79
  pulled in automatically for your platform - no OS-specific install command to
72
80
  pick.
73
81
 
82
+ natocr ships a `py.typed` marker, so the public API is fully typed - mypy,
83
+ pyright, and your editor pick up the hints with no stubs needed.
84
+
74
85
  ## Quick start
75
86
 
76
87
  ```python
@@ -122,6 +133,32 @@ page.lines # ['Acme Coffee', 'Latte $4.50'] - elements grouped into lines
122
133
  page.words # list of TextElement with non-empty text
123
134
  ```
124
135
 
136
+ Want the confidence and bounds for each line (not just the text)? `text_lines`
137
+ gives you the same grouping as [`TextLine`](https://alfredchiesa.github.io/natocr/api/#natocr.TextLine)
138
+ objects, and `paragraphs` merges lines into blocks by their vertical gaps - both
139
+ aggregate confidence across their elements:
140
+
141
+ ```python
142
+ for line in page.text_lines:
143
+ print(line.text, line.confidence, line.bounds.bounds)
144
+
145
+ for para in page.paragraphs: # lines joined by newlines, confidence averaged
146
+ print(para.confidence, para.text)
147
+ ```
148
+
149
+ ### Filtering by Confidence
150
+
151
+ Drop the low-confidence noise with `filter()` - it hands back a new `OCRResult`
152
+ keeping only detections at or above the threshold:
153
+
154
+ ```python
155
+ clean = page.filter(0.8) # only elements >= 0.8 confidence
156
+ print(clean.text)
157
+ ```
158
+
159
+ Elements without a confidence score (Windows OCR doesn't report one) are kept by
160
+ default since they can't be judged - pass `drop_unknown=True` to drop them too.
161
+
125
162
  ### Detection Language
126
163
 
127
164
  Pick a different recognition language, and inspect what the current platform
@@ -9,6 +9,14 @@ than third-party alternatives like Tesseract. **natocr** makes reaching for them
9
9
  painless via one clean Python API instead of wrangling with Objective-C bridges
10
10
  or WinRT async plumbing.
11
11
 
12
+ ## Notable Updates
13
+
14
+ - **v2.0.0** (2026-06-25) - batch & async support: [`recognize_many()`](#batch-and-async) plus awaitable [`arecognize()`](#batch-and-async) / [`arecognize_many()`](#batch-and-async) for concurrent, non-blocking OCR
15
+ - **v1.6.1** (2026-06-04) - animated PNG and multi-image HEIF support
16
+ - **v1.6.0** (2026-06-04) - multi-page documents and DjVu support
17
+ - **v1.5.0** (2026-06-04) - JPEG 2000, JPEG XL, and JPEG XR / HD Photo support
18
+ - **v1.4.0** (2026-06-04) - HEIC / HEIF (iPhone photo) support
19
+
12
20
  ## Install
13
21
 
14
22
  ```bash
@@ -22,6 +30,9 @@ The right native backend (Vision on macOS, Windows Runtime OCR on Windows) is
22
30
  pulled in automatically for your platform - no OS-specific install command to
23
31
  pick.
24
32
 
33
+ natocr ships a `py.typed` marker, so the public API is fully typed - mypy,
34
+ pyright, and your editor pick up the hints with no stubs needed.
35
+
25
36
  ## Quick start
26
37
 
27
38
  ```python
@@ -73,6 +84,32 @@ page.lines # ['Acme Coffee', 'Latte $4.50'] - elements grouped into lines
73
84
  page.words # list of TextElement with non-empty text
74
85
  ```
75
86
 
87
+ Want the confidence and bounds for each line (not just the text)? `text_lines`
88
+ gives you the same grouping as [`TextLine`](https://alfredchiesa.github.io/natocr/api/#natocr.TextLine)
89
+ objects, and `paragraphs` merges lines into blocks by their vertical gaps - both
90
+ aggregate confidence across their elements:
91
+
92
+ ```python
93
+ for line in page.text_lines:
94
+ print(line.text, line.confidence, line.bounds.bounds)
95
+
96
+ for para in page.paragraphs: # lines joined by newlines, confidence averaged
97
+ print(para.confidence, para.text)
98
+ ```
99
+
100
+ ### Filtering by Confidence
101
+
102
+ Drop the low-confidence noise with `filter()` - it hands back a new `OCRResult`
103
+ keeping only detections at or above the threshold:
104
+
105
+ ```python
106
+ clean = page.filter(0.8) # only elements >= 0.8 confidence
107
+ print(clean.text)
108
+ ```
109
+
110
+ Elements without a confidence score (Windows OCR doesn't report one) are kept by
111
+ default since they can't be judged - pass `drop_unknown=True` to drop them too.
112
+
76
113
  ### Detection Language
77
114
 
78
115
  Pick a different recognition language, and inspect what the current platform
@@ -7,9 +7,9 @@ this package provides ocr functionality using native frameworks:
7
7
  """
8
8
 
9
9
  from .core import OCR
10
- from .models import BoundingBox, OCRResult, TextElement
10
+ from .models import BoundingBox, OCRResult, TextElement, TextLine
11
11
 
12
- __version__ = "2.0.0"
12
+ __version__ = "2.1.0"
13
13
  __author__ = "alfredchiesa"
14
14
  __email__ = "alfred.personal@icloud.com"
15
15
 
@@ -17,5 +17,6 @@ __all__ = [
17
17
  "OCR",
18
18
  "OCRResult",
19
19
  "TextElement",
20
+ "TextLine",
20
21
  "BoundingBox",
21
22
  ]
@@ -0,0 +1,230 @@
1
+ """
2
+ data models for ocr results
3
+ """
4
+
5
+ from dataclasses import dataclass
6
+ from typing import List, Optional, Tuple
7
+
8
+
9
+ @dataclass
10
+ class BoundingBox:
11
+ """Pixel-space bounding box for a piece of detected text.
12
+
13
+ The origin is the top-left of the image, with ``y`` growing downward.
14
+
15
+ Attributes:
16
+ x: left edge, in pixels.
17
+ y: top edge, in pixels.
18
+ width: box width, in pixels.
19
+ height: box height, in pixels.
20
+ """
21
+
22
+ x: float
23
+ y: float
24
+ width: float
25
+ height: float
26
+
27
+ @property
28
+ def bounds(self) -> Tuple[float, float, float, float]:
29
+ """The box as an ``(x, y, width, height)`` tuple."""
30
+ return (self.x, self.y, self.width, self.height)
31
+
32
+
33
+ @dataclass
34
+ class TextElement:
35
+ """A single detected piece of text with its location.
36
+
37
+ Attributes:
38
+ text: the recognized string.
39
+ bounds: where it was found in the image.
40
+ confidence: recognition confidence in ``0.0..1.0``, or ``None`` when the
41
+ backend doesn't report one (Windows OCR doesn't).
42
+ """
43
+
44
+ text: str
45
+ bounds: BoundingBox
46
+ confidence: Optional[float] = None
47
+
48
+
49
+ @dataclass
50
+ class TextLine:
51
+ """A run of detected text grouped into one line.
52
+
53
+ The structured cousin of [`OCRResult.lines`][natocr.OCRResult.lines] - same
54
+ grouping, but each line keeps its elements, an aggregated confidence, and
55
+ the box that wraps them. Also the shape
56
+ [`OCRResult.paragraphs`][natocr.OCRResult.paragraphs] returns.
57
+
58
+ Attributes:
59
+ text: the line's text, its elements joined left-to-right.
60
+ elements: the detections that make up the line.
61
+ confidence: mean confidence across the elements that report one, or
62
+ ``None`` when none do (Windows OCR doesn't).
63
+ bounds: the box enclosing every element in the line.
64
+ """
65
+
66
+ text: str
67
+ elements: List[TextElement]
68
+ confidence: Optional[float]
69
+ bounds: BoundingBox
70
+
71
+
72
+ def _mean_confidence(elements: List[TextElement]) -> Optional[float]:
73
+ """average of the confidences that exist, or none if there aren't any"""
74
+ scores = [e.confidence for e in elements if e.confidence is not None]
75
+ return sum(scores) / len(scores) if scores else None
76
+
77
+
78
+ def _union_bounds(elements: List[TextElement]) -> BoundingBox:
79
+ """smallest box that wraps every element"""
80
+ left = min(e.bounds.x for e in elements)
81
+ top = min(e.bounds.y for e in elements)
82
+ right = max(e.bounds.x + e.bounds.width for e in elements)
83
+ bottom = max(e.bounds.y + e.bounds.height for e in elements)
84
+ return BoundingBox(x=left, y=top, width=right - left, height=bottom - top)
85
+
86
+
87
+ @dataclass
88
+ class OCRResult:
89
+ """Everything an OCR pass found in one image.
90
+
91
+ Attributes:
92
+ text: all detected text joined into a single string.
93
+ confidence: average confidence across detections, or ``None`` if the
94
+ backend doesn't report confidence.
95
+ elements: per-detection breakdown with text, bounds, and confidence.
96
+ """
97
+
98
+ text: str
99
+ confidence: Optional[float] = None
100
+ elements: List[TextElement] = None
101
+
102
+ def __post_init__(self):
103
+ # default the mutable elements list when none was passed
104
+ if self.elements is None:
105
+ self.elements = []
106
+
107
+ @property
108
+ def words(self) -> List[TextElement]:
109
+ """The elements that contain non-whitespace text."""
110
+ return [elem for elem in self.elements if elem.text.strip()]
111
+
112
+ def _group_lines(self) -> List[List[TextElement]]:
113
+ """group elements into lines by vertical proximity, in reading order"""
114
+ groups = []
115
+ current_line = []
116
+ current_y = None
117
+
118
+ for elem in sorted(self.elements, key=lambda e: (e.bounds.y, e.bounds.x)):
119
+ if (
120
+ current_y is None
121
+ or abs(elem.bounds.y - current_y) < elem.bounds.height * 0.5
122
+ ):
123
+ current_line.append(elem)
124
+ current_y = elem.bounds.y
125
+ else:
126
+ if current_line:
127
+ groups.append(current_line)
128
+ current_line = [elem]
129
+ current_y = elem.bounds.y
130
+
131
+ if current_line:
132
+ groups.append(current_line)
133
+
134
+ return groups
135
+
136
+ @property
137
+ def lines(self) -> List[str]:
138
+ """Detected text grouped into lines by vertical position.
139
+
140
+ Elements whose ``y`` are close together are treated as one line and
141
+ joined left-to-right. Falls back to ``[text]`` (or ``[]``) when there
142
+ are no elements.
143
+ """
144
+ if not self.elements:
145
+ return [self.text] if self.text else []
146
+ return [" ".join(e.text for e in group) for group in self._group_lines()]
147
+
148
+ @property
149
+ def text_lines(self) -> List[TextLine]:
150
+ """Detected lines as [TextLine][natocr.TextLine] objects.
151
+
152
+ Same grouping as [lines][natocr.OCRResult.lines], but each line carries
153
+ its elements, an aggregated confidence (mean over the elements that
154
+ report one), and the box that wraps them. Empty when there are no
155
+ positioned elements.
156
+ """
157
+ return [
158
+ TextLine(
159
+ text=" ".join(e.text for e in group),
160
+ elements=group,
161
+ confidence=_mean_confidence(group),
162
+ bounds=_union_bounds(group),
163
+ )
164
+ for group in self._group_lines()
165
+ ]
166
+
167
+ @property
168
+ def paragraphs(self) -> List[TextLine]:
169
+ """Lines merged into paragraphs by the vertical gaps between them.
170
+
171
+ Walks the lines top-to-bottom and starts a new paragraph whenever the
172
+ gap to the next line is more than ~1.5x the line's height. Each
173
+ paragraph comes back in the same [TextLine][natocr.TextLine] shape - its
174
+ ``text`` is the member lines joined by newlines, with ``confidence`` and
175
+ ``bounds`` aggregated across all of their elements.
176
+ """
177
+ lines = self.text_lines
178
+ if not lines:
179
+ return []
180
+
181
+ # break on a big vertical gap, otherwise keep stacking lines
182
+ groups = [[lines[0]]]
183
+ for prev, line in zip(lines, lines[1:]):
184
+ gap = line.bounds.y - (prev.bounds.y + prev.bounds.height)
185
+ if gap > prev.bounds.height * 1.5:
186
+ groups.append([line])
187
+ else:
188
+ groups[-1].append(line)
189
+
190
+ paragraphs = []
191
+ for group in groups:
192
+ elements = [e for line in group for e in line.elements]
193
+ paragraphs.append(
194
+ TextLine(
195
+ text="\n".join(line.text for line in group),
196
+ elements=elements,
197
+ confidence=_mean_confidence(elements),
198
+ bounds=_union_bounds(elements),
199
+ )
200
+ )
201
+ return paragraphs
202
+
203
+ def filter(
204
+ self, min_confidence: float, *, drop_unknown: bool = False
205
+ ) -> "OCRResult":
206
+ """A copy keeping only elements at or above ``min_confidence``.
207
+
208
+ Args:
209
+ min_confidence: lowest confidence to keep, in ``0.0..1.0``.
210
+ drop_unknown: what to do with elements that have no confidence
211
+ (Windows OCR never reports one). ``False`` (the default) keeps
212
+ them, since they can't be judged; ``True`` drops them.
213
+
214
+ Returns:
215
+ A new [OCRResult][natocr.OCRResult] holding the surviving elements,
216
+ with ``text`` and ``confidence`` recomputed from them.
217
+ """
218
+ kept = []
219
+ for elem in self.elements:
220
+ if elem.confidence is None:
221
+ if not drop_unknown:
222
+ kept.append(elem)
223
+ elif elem.confidence >= min_confidence:
224
+ kept.append(elem)
225
+
226
+ return OCRResult(
227
+ text=" ".join(e.text for e in kept),
228
+ confidence=_mean_confidence(kept),
229
+ elements=kept,
230
+ )
File without changes
@@ -123,14 +123,13 @@ class WindowsOCR:
123
123
  """process windows ocr result into ocr result"""
124
124
  elements = []
125
125
  full_text_parts = []
126
- total_confidence = 0.0
127
- valid_lines = 0
128
126
 
129
127
  for line in result.lines:
130
128
  line_text = line.text
131
129
  if line_text.strip():
132
- # get line bounding box
133
- bbox = line.words[0].bounding_rect if line.words else line.bounding_rect
130
+ # word rect when we have one, else the line rect
131
+ source = line.words[0] if line.words else line
132
+ bbox = source.bounding_rect
134
133
 
135
134
  # convert to pixel coordinates
136
135
  x = bbox.x
@@ -138,21 +137,30 @@ class WindowsOCR:
138
137
  width = bbox.width
139
138
  height = bbox.height
140
139
 
140
+ # winrt ocr doesn't expose confidence today, so this is None in
141
+ # practice - but grab it best-effort in case a build ever does
142
+ confidence = getattr(source, "confidence", None)
143
+
141
144
  # create bounding box and text element
142
145
  bounds = BoundingBox(x=x, y=y, width=width, height=height)
143
- element = TextElement(text=line_text, bounds=bounds, confidence=None)
146
+ element = TextElement(
147
+ text=line_text, bounds=bounds, confidence=confidence
148
+ )
144
149
  elements.append(element)
145
150
 
146
151
  # accumulate text
147
152
  full_text_parts.append(line_text)
148
- valid_lines += 1
149
153
 
150
154
  # join text parts
151
155
  full_text = " ".join(full_text_parts)
152
156
 
157
+ # mean of whatever confidences exist (none today), mirroring macos
158
+ scores = [e.confidence for e in elements if e.confidence is not None]
159
+ avg_confidence = sum(scores) / len(scores) if scores else None
160
+
153
161
  return OCRResult(
154
162
  text=full_text,
155
- confidence=None, # windows ocr doesn't provide confidence scores
163
+ confidence=avg_confidence,
156
164
  elements=elements,
157
165
  )
158
166
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: natocr
3
- Version: 2.0.0
3
+ Version: 2.1.0
4
4
  Summary: Native OCR library using platform-specific frameworks (macOS Vision, Windows Runtime OCR)
5
5
  Author-email: alfredchiesa <alfred.personal@icloud.com>
6
6
  Maintainer-email: alfredchiesa <alfred.personal@icloud.com>
@@ -58,6 +58,14 @@ than third-party alternatives like Tesseract. **natocr** makes reaching for them
58
58
  painless via one clean Python API instead of wrangling with Objective-C bridges
59
59
  or WinRT async plumbing.
60
60
 
61
+ ## Notable Updates
62
+
63
+ - **v2.0.0** (2026-06-25) - batch & async support: [`recognize_many()`](#batch-and-async) plus awaitable [`arecognize()`](#batch-and-async) / [`arecognize_many()`](#batch-and-async) for concurrent, non-blocking OCR
64
+ - **v1.6.1** (2026-06-04) - animated PNG and multi-image HEIF support
65
+ - **v1.6.0** (2026-06-04) - multi-page documents and DjVu support
66
+ - **v1.5.0** (2026-06-04) - JPEG 2000, JPEG XL, and JPEG XR / HD Photo support
67
+ - **v1.4.0** (2026-06-04) - HEIC / HEIF (iPhone photo) support
68
+
61
69
  ## Install
62
70
 
63
71
  ```bash
@@ -71,6 +79,9 @@ The right native backend (Vision on macOS, Windows Runtime OCR on Windows) is
71
79
  pulled in automatically for your platform - no OS-specific install command to
72
80
  pick.
73
81
 
82
+ natocr ships a `py.typed` marker, so the public API is fully typed - mypy,
83
+ pyright, and your editor pick up the hints with no stubs needed.
84
+
74
85
  ## Quick start
75
86
 
76
87
  ```python
@@ -122,6 +133,32 @@ page.lines # ['Acme Coffee', 'Latte $4.50'] - elements grouped into lines
122
133
  page.words # list of TextElement with non-empty text
123
134
  ```
124
135
 
136
+ Want the confidence and bounds for each line (not just the text)? `text_lines`
137
+ gives you the same grouping as [`TextLine`](https://alfredchiesa.github.io/natocr/api/#natocr.TextLine)
138
+ objects, and `paragraphs` merges lines into blocks by their vertical gaps - both
139
+ aggregate confidence across their elements:
140
+
141
+ ```python
142
+ for line in page.text_lines:
143
+ print(line.text, line.confidence, line.bounds.bounds)
144
+
145
+ for para in page.paragraphs: # lines joined by newlines, confidence averaged
146
+ print(para.confidence, para.text)
147
+ ```
148
+
149
+ ### Filtering by Confidence
150
+
151
+ Drop the low-confidence noise with `filter()` - it hands back a new `OCRResult`
152
+ keeping only detections at or above the threshold:
153
+
154
+ ```python
155
+ clean = page.filter(0.8) # only elements >= 0.8 confidence
156
+ print(clean.text)
157
+ ```
158
+
159
+ Elements without a confidence score (Windows OCR doesn't report one) are kept by
160
+ default since they can't be judged - pass `drop_unknown=True` to drop them too.
161
+
125
162
  ### Detection Language
126
163
 
127
164
  Pick a different recognition language, and inspect what the current platform
@@ -5,6 +5,7 @@ natocr/__init__.py
5
5
  natocr/core.py
6
6
  natocr/macos.py
7
7
  natocr/models.py
8
+ natocr/py.typed
8
9
  natocr/windows.py
9
10
  natocr.egg-info/PKG-INFO
10
11
  natocr.egg-info/SOURCES.txt
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "natocr"
7
- version = "2.0.0"
7
+ version = "2.1.0"
8
8
  description = "Native OCR library using platform-specific frameworks (macOS Vision, Windows Runtime OCR)"
9
9
  readme = "README.md"
10
10
  license = { text = "MIT" }
@@ -64,6 +64,10 @@ Changelog = "https://github.com/alfredchiesa/natocr/blob/main/CHANGELOG.md"
64
64
  where = ["."]
65
65
  include = ["natocr*"]
66
66
 
67
+ [tool.setuptools.package-data]
68
+ # ship the pep 561 marker so downstream mypy/editors pick up our type hints
69
+ natocr = ["py.typed"]
70
+
67
71
  [tool.pytest.ini_options]
68
72
  testpaths = ["tests"]
69
73
  python_files = ["test_*.py"]
@@ -0,0 +1,141 @@
1
+ """tests for the data models"""
2
+
3
+ import pytest
4
+
5
+ from natocr.models import BoundingBox, OCRResult, TextElement, TextLine
6
+
7
+
8
+ class TestBoundingBox:
9
+ def test_bounds_returns_tuple(self):
10
+ box = BoundingBox(x=1.0, y=2.0, width=3.0, height=4.0)
11
+ assert box.bounds == (1.0, 2.0, 3.0, 4.0)
12
+
13
+
14
+ class TestTextElement:
15
+ def test_defaults_confidence_to_none(self):
16
+ elem = TextElement(text="hi", bounds=BoundingBox(0, 0, 1, 1))
17
+ assert elem.confidence is None
18
+
19
+ def test_stores_confidence(self):
20
+ elem = TextElement(text="hi", bounds=BoundingBox(0, 0, 1, 1), confidence=0.9)
21
+ assert elem.confidence == 0.9
22
+
23
+
24
+ class TestOCRResult:
25
+ def test_post_init_defaults_elements_to_empty_list(self):
26
+ result = OCRResult(text="hi")
27
+ assert result.elements == []
28
+
29
+ def test_post_init_keeps_provided_elements(self):
30
+ elem = TextElement(text="hi", bounds=BoundingBox(0, 0, 1, 1))
31
+ result = OCRResult(text="hi", elements=[elem])
32
+ assert result.elements == [elem]
33
+
34
+ def test_words_filters_whitespace_only(self):
35
+ good = TextElement(text="hi", bounds=BoundingBox(0, 0, 1, 1))
36
+ blank = TextElement(text=" ", bounds=BoundingBox(0, 0, 1, 1))
37
+ result = OCRResult(text="hi", elements=[good, blank])
38
+ assert result.words == [good]
39
+
40
+ def test_lines_no_elements_with_text(self):
41
+ result = OCRResult(text="just text")
42
+ assert result.lines == ["just text"]
43
+
44
+ def test_lines_no_elements_no_text(self):
45
+ result = OCRResult(text="")
46
+ assert result.lines == []
47
+
48
+ def test_lines_groups_close_y_into_single_line(self):
49
+ a = TextElement(text="hello", bounds=BoundingBox(x=0, y=0, width=10, height=10))
50
+ b = TextElement(text="world", bounds=BoundingBox(x=20, y=1, width=10, height=10))
51
+ result = OCRResult(text="ignored", elements=[a, b])
52
+ assert result.lines == ["hello world"]
53
+
54
+ def test_lines_splits_distant_y_into_multiple_lines(self):
55
+ top = TextElement(text="top", bounds=BoundingBox(x=0, y=0, width=10, height=10))
56
+ bottom = TextElement(
57
+ text="bottom", bounds=BoundingBox(x=0, y=100, width=10, height=10)
58
+ )
59
+ # pass out of order to also exercise the (y, x) sort
60
+ result = OCRResult(text="ignored", elements=[bottom, top])
61
+ assert result.lines == ["top", "bottom"]
62
+
63
+
64
+ class TestTextLines:
65
+ def test_aggregates_text_confidence_and_bounds_per_line(self):
66
+ a = TextElement(
67
+ text="hello", bounds=BoundingBox(0, 0, 10, 10), confidence=0.8
68
+ )
69
+ b = TextElement(
70
+ text="world", bounds=BoundingBox(20, 1, 10, 10), confidence=0.6
71
+ )
72
+ result = OCRResult(text="ignored", elements=[a, b])
73
+ lines = result.text_lines
74
+ assert len(lines) == 1
75
+ line = lines[0]
76
+ assert isinstance(line, TextLine)
77
+ assert line.text == "hello world"
78
+ assert line.confidence == pytest.approx(0.7) # mean of 0.8 and 0.6
79
+ # union box wraps both elements
80
+ assert line.bounds.bounds == (0, 0, 30, 11)
81
+ assert line.elements == [a, b]
82
+
83
+ def test_confidence_ignores_missing_scores(self):
84
+ scored = TextElement(text="a", bounds=BoundingBox(0, 0, 10, 10), confidence=0.9)
85
+ unscored = TextElement(text="b", bounds=BoundingBox(12, 0, 10, 10))
86
+ result = OCRResult(text="ignored", elements=[scored, unscored])
87
+ # only the scored element counts toward the mean
88
+ assert result.text_lines[0].confidence == pytest.approx(0.9)
89
+
90
+ def test_confidence_none_when_no_scores(self):
91
+ elem = TextElement(text="a", bounds=BoundingBox(0, 0, 10, 10))
92
+ result = OCRResult(text="ignored", elements=[elem])
93
+ assert result.text_lines[0].confidence is None
94
+
95
+ def test_empty_without_elements(self):
96
+ assert OCRResult(text="just text").text_lines == []
97
+
98
+
99
+ class TestParagraphs:
100
+ def test_groups_lines_by_vertical_gap(self):
101
+ # two stacked lines, then a big gap, then a third line
102
+ l1 = TextElement(text="one", bounds=BoundingBox(0, 0, 10, 10), confidence=0.9)
103
+ l2 = TextElement(text="two", bounds=BoundingBox(0, 12, 10, 10), confidence=0.7)
104
+ l3 = TextElement(text="far", bounds=BoundingBox(0, 100, 10, 10), confidence=0.5)
105
+ result = OCRResult(text="ignored", elements=[l1, l2, l3])
106
+ paras = result.paragraphs
107
+ assert len(paras) == 2
108
+ # first paragraph stacks the two close lines, newline-joined
109
+ assert paras[0].text == "one\ntwo"
110
+ assert paras[0].confidence == pytest.approx(0.8) # mean of 0.9 and 0.7
111
+ assert paras[1].text == "far"
112
+
113
+ def test_empty_without_elements(self):
114
+ assert OCRResult(text="just text").paragraphs == []
115
+
116
+
117
+ class TestFilter:
118
+ def _result(self):
119
+ high = TextElement(text="hi", bounds=BoundingBox(0, 0, 10, 10), confidence=0.9)
120
+ low = TextElement(text="lo", bounds=BoundingBox(0, 20, 10, 10), confidence=0.4)
121
+ unknown = TextElement(text="??", bounds=BoundingBox(0, 40, 10, 10))
122
+ return OCRResult(text="ignored", elements=[high, low, unknown]), high, unknown
123
+
124
+ def test_drops_below_threshold_keeps_unknown_by_default(self):
125
+ result, high, unknown = self._result()
126
+ out = result.filter(0.5)
127
+ # low (0.4) drops; high (0.9) and the unscored one stay
128
+ assert out.elements == [high, unknown]
129
+ assert out.text == "hi ??"
130
+ assert out.confidence == pytest.approx(0.9) # unknown ignored in the mean
131
+
132
+ def test_drop_unknown_removes_unscored_elements(self):
133
+ result, high, _ = self._result()
134
+ out = result.filter(0.5, drop_unknown=True)
135
+ assert out.elements == [high]
136
+
137
+ def test_returns_new_result_without_mutating(self):
138
+ result, _, _ = self._result()
139
+ out = result.filter(0.5)
140
+ assert out is not result
141
+ assert len(result.elements) == 3 # original untouched
@@ -1,5 +1,6 @@
1
1
  """tests for the package's public surface"""
2
2
 
3
+ import os
3
4
  import re
4
5
 
5
6
  import natocr
@@ -12,5 +13,11 @@ def test_version_exposed():
12
13
 
13
14
 
14
15
  def test_public_exports():
15
- for name in ("OCR", "OCRResult", "TextElement", "BoundingBox"):
16
+ for name in ("OCR", "OCRResult", "TextElement", "TextLine", "BoundingBox"):
16
17
  assert hasattr(natocr, name)
18
+
19
+
20
+ def test_ships_py_typed_marker():
21
+ # pep 561 marker so downstream type checkers trust our hints
22
+ marker = os.path.join(os.path.dirname(natocr.__file__), "py.typed")
23
+ assert os.path.isfile(marker)
@@ -1,108 +0,0 @@
1
- """
2
- data models for ocr results
3
- """
4
-
5
- from dataclasses import dataclass
6
- from typing import List, Optional, Tuple
7
-
8
-
9
- @dataclass
10
- class BoundingBox:
11
- """Pixel-space bounding box for a piece of detected text.
12
-
13
- The origin is the top-left of the image, with ``y`` growing downward.
14
-
15
- Attributes:
16
- x: left edge, in pixels.
17
- y: top edge, in pixels.
18
- width: box width, in pixels.
19
- height: box height, in pixels.
20
- """
21
-
22
- x: float
23
- y: float
24
- width: float
25
- height: float
26
-
27
- @property
28
- def bounds(self) -> Tuple[float, float, float, float]:
29
- """The box as an ``(x, y, width, height)`` tuple."""
30
- return (self.x, self.y, self.width, self.height)
31
-
32
-
33
- @dataclass
34
- class TextElement:
35
- """A single detected piece of text with its location.
36
-
37
- Attributes:
38
- text: the recognized string.
39
- bounds: where it was found in the image.
40
- confidence: recognition confidence in ``0.0..1.0``, or ``None`` when the
41
- backend doesn't report one (Windows OCR doesn't).
42
- """
43
-
44
- text: str
45
- bounds: BoundingBox
46
- confidence: Optional[float] = None
47
-
48
-
49
- @dataclass
50
- class OCRResult:
51
- """Everything an OCR pass found in one image.
52
-
53
- Attributes:
54
- text: all detected text joined into a single string.
55
- confidence: average confidence across detections, or ``None`` if the
56
- backend doesn't report confidence.
57
- elements: per-detection breakdown with text, bounds, and confidence.
58
- """
59
-
60
- text: str
61
- confidence: Optional[float] = None
62
- elements: List[TextElement] = None
63
-
64
- def __post_init__(self):
65
- # default the mutable elements list when none was passed
66
- if self.elements is None:
67
- self.elements = []
68
-
69
- @property
70
- def words(self) -> List[TextElement]:
71
- """The elements that contain non-whitespace text."""
72
- return [elem for elem in self.elements if elem.text.strip()]
73
-
74
- @property
75
- def lines(self) -> List[str]:
76
- """Detected text grouped into lines by vertical position.
77
-
78
- Elements whose ``y`` are close together are treated as one line and
79
- joined left-to-right. Falls back to ``[text]`` (or ``[]``) when there
80
- are no elements.
81
- """
82
- if not self.elements:
83
- return [self.text] if self.text else []
84
-
85
- # group elements by approximate y-coordinate for line detection
86
- lines = []
87
- current_line = []
88
- current_y = None
89
-
90
- for elem in sorted(self.elements, key=lambda e: (e.bounds.y, e.bounds.x)):
91
- if (
92
- current_y is None
93
- or abs(elem.bounds.y - current_y) < elem.bounds.height * 0.5
94
- ):
95
- current_line.append(elem)
96
- current_y = elem.bounds.y
97
- else:
98
- if current_line:
99
- line_text = " ".join(elem.text for elem in current_line)
100
- lines.append(line_text)
101
- current_line = [elem]
102
- current_y = elem.bounds.y
103
-
104
- if current_line:
105
- line_text = " ".join(elem.text for elem in current_line)
106
- lines.append(line_text)
107
-
108
- return lines
@@ -1,59 +0,0 @@
1
- """tests for the data models"""
2
-
3
- from natocr.models import BoundingBox, OCRResult, TextElement
4
-
5
-
6
- class TestBoundingBox:
7
- def test_bounds_returns_tuple(self):
8
- box = BoundingBox(x=1.0, y=2.0, width=3.0, height=4.0)
9
- assert box.bounds == (1.0, 2.0, 3.0, 4.0)
10
-
11
-
12
- class TestTextElement:
13
- def test_defaults_confidence_to_none(self):
14
- elem = TextElement(text="hi", bounds=BoundingBox(0, 0, 1, 1))
15
- assert elem.confidence is None
16
-
17
- def test_stores_confidence(self):
18
- elem = TextElement(text="hi", bounds=BoundingBox(0, 0, 1, 1), confidence=0.9)
19
- assert elem.confidence == 0.9
20
-
21
-
22
- class TestOCRResult:
23
- def test_post_init_defaults_elements_to_empty_list(self):
24
- result = OCRResult(text="hi")
25
- assert result.elements == []
26
-
27
- def test_post_init_keeps_provided_elements(self):
28
- elem = TextElement(text="hi", bounds=BoundingBox(0, 0, 1, 1))
29
- result = OCRResult(text="hi", elements=[elem])
30
- assert result.elements == [elem]
31
-
32
- def test_words_filters_whitespace_only(self):
33
- good = TextElement(text="hi", bounds=BoundingBox(0, 0, 1, 1))
34
- blank = TextElement(text=" ", bounds=BoundingBox(0, 0, 1, 1))
35
- result = OCRResult(text="hi", elements=[good, blank])
36
- assert result.words == [good]
37
-
38
- def test_lines_no_elements_with_text(self):
39
- result = OCRResult(text="just text")
40
- assert result.lines == ["just text"]
41
-
42
- def test_lines_no_elements_no_text(self):
43
- result = OCRResult(text="")
44
- assert result.lines == []
45
-
46
- def test_lines_groups_close_y_into_single_line(self):
47
- a = TextElement(text="hello", bounds=BoundingBox(x=0, y=0, width=10, height=10))
48
- b = TextElement(text="world", bounds=BoundingBox(x=20, y=1, width=10, height=10))
49
- result = OCRResult(text="ignored", elements=[a, b])
50
- assert result.lines == ["hello world"]
51
-
52
- def test_lines_splits_distant_y_into_multiple_lines(self):
53
- top = TextElement(text="top", bounds=BoundingBox(x=0, y=0, width=10, height=10))
54
- bottom = TextElement(
55
- text="bottom", bounds=BoundingBox(x=0, y=100, width=10, height=10)
56
- )
57
- # pass out of order to also exercise the (y, x) sort
58
- result = OCRResult(text="ignored", elements=[bottom, top])
59
- assert result.lines == ["top", "bottom"]
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes