visual-parser 2.2.0__py3-none-any.whl → 2.2.1__py3-none-any.whl
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.
- visual_parser/__init__.py +1 -1
- visual_parser/cli.py +41 -9
- visual_parser/cli_main.py +40 -9
- visual_parser/config.py +28 -2
- visual_parser/figure_describer.py +14 -1
- visual_parser/image_describer.py +329 -0
- visual_parser/image_tracker.py +71 -0
- visual_parser/jsonl_writer.py +64 -3
- visual_parser/kb_redo.py +148 -0
- visual_parser/metadata_extractor.py +87 -13
- visual_parser/pdf_tracker.py +25 -0
- visual_parser/pipeline.py +138 -24
- visual_parser/prompts.py +78 -0
- visual_parser/text_extractor.py +15 -28
- visual_parser/vision_llm.py +46 -22
- {visual_parser-2.2.0.dist-info → visual_parser-2.2.1.dist-info}/METADATA +1 -1
- visual_parser-2.2.1.dist-info/RECORD +26 -0
- visual_parser-2.2.0.dist-info/RECORD +0 -23
- {visual_parser-2.2.0.dist-info → visual_parser-2.2.1.dist-info}/WHEEL +0 -0
- {visual_parser-2.2.0.dist-info → visual_parser-2.2.1.dist-info}/entry_points.txt +0 -0
- {visual_parser-2.2.0.dist-info → visual_parser-2.2.1.dist-info}/licenses/LICENSE +0 -0
- {visual_parser-2.2.0.dist-info → visual_parser-2.2.1.dist-info}/top_level.txt +0 -0
visual_parser/__init__.py
CHANGED
visual_parser/cli.py
CHANGED
|
@@ -44,11 +44,13 @@ def _build_arg_parser() -> argparse.ArgumentParser:
|
|
|
44
44
|
p = argparse.ArgumentParser(
|
|
45
45
|
prog="visual-parser",
|
|
46
46
|
description=(
|
|
47
|
-
"Visual-RAG PDF Parser — detects new PDFs
|
|
48
|
-
"figure descriptions, and writes
|
|
49
|
-
"
|
|
50
|
-
"
|
|
51
|
-
"
|
|
47
|
+
"Visual-RAG PDF Parser — detects new PDFs and standalone images, "
|
|
48
|
+
"extracts text and figure/image descriptions, and writes JSONL "
|
|
49
|
+
"knowledge bases:\n"
|
|
50
|
+
" 01_chunks_kb.jsonl text chunks\n"
|
|
51
|
+
" 02_visuals_kb.jsonl PDF figure descriptions\n"
|
|
52
|
+
" 03_metadata_kb.jsonl document metadata\n"
|
|
53
|
+
" image_descriptions.jsonl standalone image descriptions"
|
|
52
54
|
),
|
|
53
55
|
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
54
56
|
epilog=USAGE_EXAMPLES,
|
|
@@ -67,8 +69,9 @@ def _build_arg_parser() -> argparse.ArgumentParser:
|
|
|
67
69
|
"--input-dir", "-i",
|
|
68
70
|
required=False,
|
|
69
71
|
metavar="DIR",
|
|
70
|
-
help="Directory to scan for PDF files
|
|
71
|
-
"
|
|
72
|
+
help="Directory to scan for PDF files and standalone images "
|
|
73
|
+
"(searched recursively, auto-detected by extension -- a single "
|
|
74
|
+
"directory may mix both). Required unless --list-models is given.",
|
|
72
75
|
)
|
|
73
76
|
io_group.add_argument(
|
|
74
77
|
"--output-dir", "-o",
|
|
@@ -204,8 +207,22 @@ def _build_arg_parser() -> argparse.ArgumentParser:
|
|
|
204
207
|
"--rebuild",
|
|
205
208
|
action="store_true",
|
|
206
209
|
help=(
|
|
207
|
-
"
|
|
208
|
-
"Use after changing prompts, chunking strategy, or switching models."
|
|
210
|
+
"Clear and reprocess every PDF/image currently in --input-dir. "
|
|
211
|
+
"Use after changing prompts, chunking strategy, or switching models. "
|
|
212
|
+
"No duplicate rows -- old entries are removed before reprocessing."
|
|
213
|
+
),
|
|
214
|
+
)
|
|
215
|
+
misc_group.add_argument(
|
|
216
|
+
"--redo",
|
|
217
|
+
action="append",
|
|
218
|
+
default=None,
|
|
219
|
+
metavar="NAME",
|
|
220
|
+
help=(
|
|
221
|
+
"Clear ALL existing KB rows and registry entries for this PDF or "
|
|
222
|
+
"image basename (case-sensitive, path-stripped), then let normal "
|
|
223
|
+
"discovery reprocess it in this same run -- no duplicates. "
|
|
224
|
+
"Repeatable: --redo doc1.pdf --redo diagram2.png. Combine with "
|
|
225
|
+
"--rebuild to clear+reprocess everything in --input-dir instead."
|
|
209
226
|
),
|
|
210
227
|
)
|
|
211
228
|
misc_group.add_argument(
|
|
@@ -226,6 +243,19 @@ def _build_arg_parser() -> argparse.ArgumentParser:
|
|
|
226
243
|
help="Verbosity level written to 05_pipeline.log (default: ERROR).",
|
|
227
244
|
)
|
|
228
245
|
|
|
246
|
+
# ---- Image processing ----------------------------------------------------
|
|
247
|
+
image_group = p.add_argument_group("Image processing")
|
|
248
|
+
image_group.add_argument(
|
|
249
|
+
"--skip-images",
|
|
250
|
+
action="store_true",
|
|
251
|
+
help=(
|
|
252
|
+
"Skip standalone-image processing entirely, even if image files "
|
|
253
|
+
"(.png/.jpg/.jpeg/.tif/.tiff/.bmp) are found in --input-dir. "
|
|
254
|
+
"Reuses --vision-provider/--vision-model/--vision-detail/"
|
|
255
|
+
"--reasoning-effort/--max-workers -- no separate image vision flags."
|
|
256
|
+
),
|
|
257
|
+
)
|
|
258
|
+
|
|
229
259
|
return p
|
|
230
260
|
|
|
231
261
|
|
|
@@ -346,7 +376,9 @@ def main(argv=None) -> int:
|
|
|
346
376
|
vision_context_pages = args.vision_context_pages,
|
|
347
377
|
max_workers = args.max_workers,
|
|
348
378
|
rebuild = args.rebuild,
|
|
379
|
+
redo_names = args.redo or [],
|
|
349
380
|
skip_text = args.skip_text,
|
|
381
|
+
skip_images = args.skip_images,
|
|
350
382
|
log_level = args.log_level,
|
|
351
383
|
)
|
|
352
384
|
|
visual_parser/cli_main.py
CHANGED
|
@@ -42,11 +42,13 @@ def _build_arg_parser() -> argparse.ArgumentParser:
|
|
|
42
42
|
parser = argparse.ArgumentParser(
|
|
43
43
|
prog="visual-parser",
|
|
44
44
|
description=(
|
|
45
|
-
"Visual-RAG PDF Parser - detects new PDFs
|
|
46
|
-
"figure descriptions, and writes
|
|
47
|
-
"
|
|
48
|
-
"
|
|
49
|
-
"
|
|
45
|
+
"Visual-RAG PDF Parser - detects new PDFs and standalone images, "
|
|
46
|
+
"extracts text and figure/image descriptions, and writes JSONL "
|
|
47
|
+
"knowledge bases:\n"
|
|
48
|
+
" 01_chunks_kb.jsonl text chunks\n"
|
|
49
|
+
" 02_visuals_kb.jsonl PDF figure descriptions\n"
|
|
50
|
+
" 03_metadata_kb.jsonl document metadata\n"
|
|
51
|
+
" image_descriptions.jsonl standalone image descriptions"
|
|
50
52
|
),
|
|
51
53
|
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
52
54
|
epilog=USAGE_EXAMPLES,
|
|
@@ -65,8 +67,9 @@ def _build_arg_parser() -> argparse.ArgumentParser:
|
|
|
65
67
|
"-i",
|
|
66
68
|
required=False,
|
|
67
69
|
metavar="DIR",
|
|
68
|
-
help="Directory to scan for PDF files
|
|
69
|
-
"
|
|
70
|
+
help="Directory to scan for PDF files and standalone images "
|
|
71
|
+
"(searched recursively, auto-detected by extension -- a single "
|
|
72
|
+
"directory may mix both). Required unless --list-models is given.",
|
|
70
73
|
)
|
|
71
74
|
io_group.add_argument(
|
|
72
75
|
"--output-dir",
|
|
@@ -174,6 +177,18 @@ def _build_arg_parser() -> argparse.ArgumentParser:
|
|
|
174
177
|
help="Number of front pages sent to the vision LLM for metadata extraction (default: 2).",
|
|
175
178
|
)
|
|
176
179
|
|
|
180
|
+
image_group = parser.add_argument_group("Image processing")
|
|
181
|
+
image_group.add_argument(
|
|
182
|
+
"--skip-images",
|
|
183
|
+
action="store_true",
|
|
184
|
+
help=(
|
|
185
|
+
"Skip standalone-image processing entirely, even if image files "
|
|
186
|
+
"(.png/.jpg/.jpeg/.tif/.tiff/.bmp) are found in --input-dir. "
|
|
187
|
+
"Reuses --vision-provider/--vision-model/--vision-detail/"
|
|
188
|
+
"--reasoning-effort/--max-workers -- no separate image vision flags."
|
|
189
|
+
),
|
|
190
|
+
)
|
|
191
|
+
|
|
177
192
|
perf_group = parser.add_argument_group("Performance")
|
|
178
193
|
perf_group.add_argument(
|
|
179
194
|
"--max-workers",
|
|
@@ -199,8 +214,22 @@ def _build_arg_parser() -> argparse.ArgumentParser:
|
|
|
199
214
|
"--rebuild",
|
|
200
215
|
action="store_true",
|
|
201
216
|
help=(
|
|
202
|
-
"
|
|
203
|
-
"Use after changing prompts, chunking strategy, or switching models."
|
|
217
|
+
"Clear and reprocess every PDF/image currently in --input-dir. "
|
|
218
|
+
"Use after changing prompts, chunking strategy, or switching models. "
|
|
219
|
+
"No duplicate rows -- old entries are removed before reprocessing."
|
|
220
|
+
),
|
|
221
|
+
)
|
|
222
|
+
misc_group.add_argument(
|
|
223
|
+
"--redo",
|
|
224
|
+
action="append",
|
|
225
|
+
default=None,
|
|
226
|
+
metavar="NAME",
|
|
227
|
+
help=(
|
|
228
|
+
"Clear ALL existing KB rows and registry entries for this PDF or "
|
|
229
|
+
"image basename (case-sensitive, path-stripped), then let normal "
|
|
230
|
+
"discovery reprocess it in this same run -- no duplicates. "
|
|
231
|
+
"Repeatable: --redo doc1.pdf --redo diagram2.png. Combine with "
|
|
232
|
+
"--rebuild to clear+reprocess everything in --input-dir instead."
|
|
204
233
|
),
|
|
205
234
|
)
|
|
206
235
|
misc_group.add_argument(
|
|
@@ -265,7 +294,9 @@ def main(argv=None) -> int:
|
|
|
265
294
|
vision_context_pages=args.vision_context_pages,
|
|
266
295
|
max_workers=args.max_workers,
|
|
267
296
|
rebuild=args.rebuild,
|
|
297
|
+
redo_names=args.redo or [],
|
|
268
298
|
skip_text=args.skip_text,
|
|
299
|
+
skip_images=args.skip_images,
|
|
269
300
|
log_level=args.log_level,
|
|
270
301
|
)
|
|
271
302
|
|
visual_parser/config.py
CHANGED
|
@@ -12,7 +12,7 @@ from __future__ import annotations
|
|
|
12
12
|
import os
|
|
13
13
|
from dataclasses import dataclass, field
|
|
14
14
|
from pathlib import Path
|
|
15
|
-
from typing import Literal, Optional
|
|
15
|
+
from typing import List, Literal, Optional
|
|
16
16
|
|
|
17
17
|
from dotenv import load_dotenv
|
|
18
18
|
|
|
@@ -139,7 +139,23 @@ class ParserConfig:
|
|
|
139
139
|
|
|
140
140
|
# --- Misc ----------------------------------------------------------------
|
|
141
141
|
rebuild: bool = False
|
|
142
|
-
"""
|
|
142
|
+
"""
|
|
143
|
+
If True, clear and reprocess every PDF/image currently in input_dir,
|
|
144
|
+
regardless of the registries -- implemented via kb_redo.redo_entries()
|
|
145
|
+
(clear KB rows + registry entries first, then let normal discovery pick
|
|
146
|
+
everything back up as new), so this produces no duplicate rows.
|
|
147
|
+
"""
|
|
148
|
+
|
|
149
|
+
redo_names: List[str] = field(default_factory=list)
|
|
150
|
+
"""
|
|
151
|
+
PDF or image basenames (case-sensitive, path-stripped) to clear from
|
|
152
|
+
every KB JSONL file and both registries before this run's discovery
|
|
153
|
+
step, via kb_redo.redo_entries() -- so the normal pipeline naturally
|
|
154
|
+
reprocesses them cleanly in this same run, with no duplicate rows,
|
|
155
|
+
unlike blindly reprocessing over an already-populated KB. A name that's
|
|
156
|
+
no longer present in input_dir degrades gracefully to a clean removal
|
|
157
|
+
(nothing left to reprocess).
|
|
158
|
+
"""
|
|
143
159
|
|
|
144
160
|
skip_text: bool = False
|
|
145
161
|
"""
|
|
@@ -150,6 +166,14 @@ class ParserConfig:
|
|
|
150
166
|
03_metadata_kb.jsonl are skipped automatically.
|
|
151
167
|
"""
|
|
152
168
|
|
|
169
|
+
skip_images: bool = False
|
|
170
|
+
"""
|
|
171
|
+
If True, skip standalone-image processing entirely, even if image files
|
|
172
|
+
are found in input_dir. Mirrors skip_text's opt-out pattern -- an escape
|
|
173
|
+
hatch for existing PDF-only workflows that don't want stray image files
|
|
174
|
+
sitting in input_dir to suddenly start being processed.
|
|
175
|
+
"""
|
|
176
|
+
|
|
153
177
|
log_level: str = "ERROR"
|
|
154
178
|
|
|
155
179
|
# -------------------------------------------------------------------------
|
|
@@ -176,7 +200,9 @@ class ParserConfig:
|
|
|
176
200
|
vision_context_pages = int(os.getenv("VISUAL_PARSER_VISION_CONTEXT_PAGES", "0")),
|
|
177
201
|
max_workers = int(os.getenv("VISUAL_PARSER_MAX_WORKERS", "4")),
|
|
178
202
|
rebuild = os.getenv("VISUAL_PARSER_REBUILD", "false").lower() == "true",
|
|
203
|
+
redo_names = [n.strip() for n in os.getenv("VISUAL_PARSER_REDO_NAMES", "").split(",") if n.strip()],
|
|
179
204
|
skip_text = os.getenv("VISUAL_PARSER_SKIP_TEXT", "false").lower() == "true",
|
|
205
|
+
skip_images = os.getenv("VISUAL_PARSER_SKIP_IMAGES", "false").lower() == "true",
|
|
180
206
|
log_level = os.getenv("VISUAL_PARSER_LOG_LEVEL", "ERROR"),
|
|
181
207
|
)
|
|
182
208
|
|
|
@@ -117,6 +117,13 @@ def describe_figures_for_new_pdfs(
|
|
|
117
117
|
0 (default) reproduces the exact single-page
|
|
118
118
|
behavior this function had before this parameter
|
|
119
119
|
existed.
|
|
120
|
+
|
|
121
|
+
Always resumes from 02_visuals_kb.jsonl's existing content (page-level,
|
|
122
|
+
via the done_pages set below) -- callers that want a page redescribed
|
|
123
|
+
must first remove its existing row(s) (see kb_redo.redo_entries()) and
|
|
124
|
+
its basename from the processed-PDFs registry, then pass it in here as
|
|
125
|
+
if new; this function has no bypass of its own, so there is no path to
|
|
126
|
+
producing a duplicate row.
|
|
120
127
|
"""
|
|
121
128
|
# -----------------------------------------------------------------------
|
|
122
129
|
# Step 1 – Rasterise every page of every new PDF
|
|
@@ -249,7 +256,13 @@ def describe_figures_for_new_pdfs(
|
|
|
249
256
|
if not isinstance(caption, dict):
|
|
250
257
|
continue
|
|
251
258
|
description = caption.get("description")
|
|
252
|
-
if description
|
|
259
|
+
if not isinstance(description, str):
|
|
260
|
+
logger.warning(
|
|
261
|
+
"Figure %d on %s page %d has a non-string 'description' "
|
|
262
|
+
"(got %s) -- model likely returned a malformed/nested "
|
|
263
|
+
"response; skipping.",
|
|
264
|
+
fig_idx, pdf_name, page_number, type(description).__name__,
|
|
265
|
+
)
|
|
253
266
|
continue
|
|
254
267
|
page_rows.append({
|
|
255
268
|
"source": pdf_name,
|
|
@@ -0,0 +1,329 @@
|
|
|
1
|
+
"""
|
|
2
|
+
image_describer.py — Send each standalone image to a Vision LLM for a
|
|
3
|
+
holistic description, one call per image (no page
|
|
4
|
+
rasterisation, no context window).
|
|
5
|
+
|
|
6
|
+
Extracted-pattern sibling of figure_describer.py: same ThreadPoolExecutor +
|
|
7
|
+
as_completed concurrency shape (worker only computes/returns; only the single-
|
|
8
|
+
threaded completion loop calls append_to_jsonl), but for arbitrary user-
|
|
9
|
+
supplied image files instead of PyMuPDF-rasterized PDF pages.
|
|
10
|
+
|
|
11
|
+
Output
|
|
12
|
+
------
|
|
13
|
+
One record per image is appended to ``image_descriptions.jsonl`` in
|
|
14
|
+
*output_dir*:
|
|
15
|
+
|
|
16
|
+
{
|
|
17
|
+
"source": "diagram1.png",
|
|
18
|
+
"image_id": "a1b2c3d4e5f6g7h8",
|
|
19
|
+
"title": "Vertical Parabolic Gate Schematic",
|
|
20
|
+
"description": "**Subject:** ..."
|
|
21
|
+
}
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
import json
|
|
27
|
+
import logging
|
|
28
|
+
import os
|
|
29
|
+
import re
|
|
30
|
+
from collections import defaultdict
|
|
31
|
+
from concurrent.futures import ThreadPoolExecutor, as_completed
|
|
32
|
+
from io import BytesIO
|
|
33
|
+
from typing import Any, Dict, List, Optional
|
|
34
|
+
|
|
35
|
+
from PIL import Image
|
|
36
|
+
|
|
37
|
+
from visual_parser.jsonl_writer import append_to_jsonl, make_document_id
|
|
38
|
+
from visual_parser.prompts import IMAGE_DESCRIPTION_PROMPT
|
|
39
|
+
from visual_parser.vision_llm import call_vision_llm
|
|
40
|
+
|
|
41
|
+
logger = logging.getLogger(__name__)
|
|
42
|
+
|
|
43
|
+
IMAGE_DESCRIPTIONS_FILE = "image_descriptions.jsonl"
|
|
44
|
+
|
|
45
|
+
# Decoupled from figure_describer.py's context-window-scaled formula (10 min
|
|
46
|
+
# for 1 image there): IMAGE_DESCRIPTION_PROMPT's "Verbatim Text Content"
|
|
47
|
+
# requirement can demand much more generated output than FIGURE_PROMPT ever
|
|
48
|
+
# needs, since it asks for full verbatim transcription rather than a bounded
|
|
49
|
+
# structured description. Measured directly against this model/hardware: a
|
|
50
|
+
# dense, text-heavy page took 594s (9.9 min) for a single, uncontended call --
|
|
51
|
+
# real concurrent runs (multiple images via --max-workers) push slower still.
|
|
52
|
+
# 900s (15 min) calibrated from that real baseline, not guessed.
|
|
53
|
+
_OLLAMA_IMAGE_TIMEOUT_SECONDS = 900.0
|
|
54
|
+
|
|
55
|
+
_SUBJECT_RE = re.compile(r"\*\*Subject:\*\*\s*(.*?)(?:\n|$)")
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
# ---------------------------------------------------------------------------
|
|
59
|
+
# JSON response parser
|
|
60
|
+
# ---------------------------------------------------------------------------
|
|
61
|
+
|
|
62
|
+
def _parse_single_image_response(raw: str, image_name: str) -> Optional[Dict]:
|
|
63
|
+
"""
|
|
64
|
+
Parse the Vision LLM's single-JSON-object response for one image.
|
|
65
|
+
|
|
66
|
+
A sibling of figure_describer.py's _parse_llm_response: that function's
|
|
67
|
+
prose-recovery regex is list-shaped (looks for "[...]") and cannot
|
|
68
|
+
recover a bare "{...}" object wrapped in prose, which is the expected
|
|
69
|
+
shape here (one image always yields exactly one description, never a
|
|
70
|
+
list). Also defensively unwraps the inverse case -- a model that ignores
|
|
71
|
+
"NOT a list" and returns a single-element list anyway.
|
|
72
|
+
"""
|
|
73
|
+
body = raw.strip()
|
|
74
|
+
for fence in ("```json", "```"):
|
|
75
|
+
body = body.replace(fence, "")
|
|
76
|
+
|
|
77
|
+
parsed: Any = None
|
|
78
|
+
try:
|
|
79
|
+
parsed = json.loads(body)
|
|
80
|
+
except json.JSONDecodeError:
|
|
81
|
+
# A regex (greedy or lazy) can't reliably find "the" {...} block: the
|
|
82
|
+
# prompt's own OUTPUT FORMAT section embeds a literal, syntactically
|
|
83
|
+
# valid example object, so a model that echoes it alongside its real
|
|
84
|
+
# answer produces two {...} blocks -- greedy spans both into one
|
|
85
|
+
# invalid blob, while lazy stops at the first literal '}' even if
|
|
86
|
+
# that's just a brace character sitting inside a string value (e.g.
|
|
87
|
+
# a description that quotes set notation like "{A, B, C}"). Properly
|
|
88
|
+
# JSON-aware: try decoding starting at each '{' in turn and take the
|
|
89
|
+
# first one that parses as valid JSON, which correctly treats braces
|
|
90
|
+
# inside quoted strings as plain characters, not structure.
|
|
91
|
+
decoder = json.JSONDecoder()
|
|
92
|
+
start = body.find("{")
|
|
93
|
+
while start != -1:
|
|
94
|
+
try:
|
|
95
|
+
parsed, _ = decoder.raw_decode(body, start)
|
|
96
|
+
break
|
|
97
|
+
except json.JSONDecodeError:
|
|
98
|
+
start = body.find("{", start + 1)
|
|
99
|
+
|
|
100
|
+
if isinstance(parsed, list) and len(parsed) == 1:
|
|
101
|
+
parsed = parsed[0]
|
|
102
|
+
|
|
103
|
+
if isinstance(parsed, dict):
|
|
104
|
+
return parsed
|
|
105
|
+
|
|
106
|
+
logger.warning("Could not parse JSON for image %s: %r", image_name, body[:200])
|
|
107
|
+
return None
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _flatten_to_rgb(pil_image: Image.Image) -> Image.Image:
|
|
111
|
+
"""
|
|
112
|
+
Convert *pil_image* to RGB, compositing any transparency onto a white
|
|
113
|
+
background first.
|
|
114
|
+
|
|
115
|
+
Image.convert("RGB") alone does NOT do this -- it just drops the alpha
|
|
116
|
+
channel and exposes whatever RGB values are stored underneath, which are
|
|
117
|
+
commonly zeroed by PNG encoders for fully-transparent pixels. A
|
|
118
|
+
transparent-background PNG (a very common export format for standalone
|
|
119
|
+
diagrams/logos/screenshots -- exactly this feature's target content)
|
|
120
|
+
would silently turn mostly/fully black with no exception or log, giving
|
|
121
|
+
the vision LLM a black image instead of a diagram on a light background.
|
|
122
|
+
"""
|
|
123
|
+
has_alpha = pil_image.mode in ("RGBA", "LA") or (
|
|
124
|
+
pil_image.mode == "P" and "transparency" in pil_image.info
|
|
125
|
+
)
|
|
126
|
+
if not has_alpha:
|
|
127
|
+
return pil_image.convert("RGB")
|
|
128
|
+
|
|
129
|
+
rgba = pil_image.convert("RGBA")
|
|
130
|
+
background = Image.new("RGB", rgba.size, (255, 255, 255))
|
|
131
|
+
background.paste(rgba, mask=rgba.split()[-1])
|
|
132
|
+
return background
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _derive_title(description: str) -> str:
|
|
136
|
+
"""
|
|
137
|
+
Derive a concise title from *description*.
|
|
138
|
+
|
|
139
|
+
The prompt's heading 1 is always "**Subject:** [Title]" -- extract it
|
|
140
|
+
directly rather than truncating raw characters. Falls back to the first
|
|
141
|
+
sentence boundary, then a hard cut, if the model didn't follow the format.
|
|
142
|
+
"""
|
|
143
|
+
match = _SUBJECT_RE.search(description)
|
|
144
|
+
if match:
|
|
145
|
+
subject = match.group(1).strip()
|
|
146
|
+
if subject:
|
|
147
|
+
return subject
|
|
148
|
+
|
|
149
|
+
sentence_end = description.find(". ")
|
|
150
|
+
if 0 < sentence_end <= 150:
|
|
151
|
+
return description[:sentence_end + 1].strip()
|
|
152
|
+
|
|
153
|
+
return description[:100].strip()
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
# ---------------------------------------------------------------------------
|
|
157
|
+
# Main function
|
|
158
|
+
# ---------------------------------------------------------------------------
|
|
159
|
+
|
|
160
|
+
def describe_images(
|
|
161
|
+
new_image_paths: List[str],
|
|
162
|
+
output_dir: str,
|
|
163
|
+
vision_provider: str,
|
|
164
|
+
vision_api_key: str,
|
|
165
|
+
vision_model: str,
|
|
166
|
+
vision_detail: str = "low",
|
|
167
|
+
image_prompt: str = IMAGE_DESCRIPTION_PROMPT,
|
|
168
|
+
reasoning_effort: Optional[str] = "medium",
|
|
169
|
+
max_workers: int = 4,
|
|
170
|
+
) -> List[str]:
|
|
171
|
+
"""
|
|
172
|
+
For each image in *new_image_paths*, call the Vision LLM once (no
|
|
173
|
+
context window -- a single standalone image per call), parse the
|
|
174
|
+
description, and append the result to ``image_descriptions.jsonl`` in
|
|
175
|
+
*output_dir*.
|
|
176
|
+
|
|
177
|
+
Returns the basenames actually written a row for -- NOT the full input
|
|
178
|
+
list. Unlike PyMuPDF-rasterized PDF pages (always well-formed), arbitrary
|
|
179
|
+
user-supplied image files can be corrupt, truncated, or unreadable, and a
|
|
180
|
+
failed vision call must not be marked as processed by the caller, or the
|
|
181
|
+
image would silently disappear from future find_new_images() output with
|
|
182
|
+
no row ever written.
|
|
183
|
+
|
|
184
|
+
Args:
|
|
185
|
+
new_image_paths: Full paths of images to describe.
|
|
186
|
+
output_dir: Directory where ``image_descriptions.jsonl`` is written.
|
|
187
|
+
vision_provider: ``'gpt'``, ``'gemini'``, or ``'ollama'``.
|
|
188
|
+
vision_api_key: API key for the chosen provider (unused for ollama).
|
|
189
|
+
vision_model: Vision model name string.
|
|
190
|
+
vision_detail: Image detail level (GPT/Ollama only).
|
|
191
|
+
image_prompt: The instruction prompt sent with each image.
|
|
192
|
+
max_workers: Thread-pool size for concurrent vision-LLM calls.
|
|
193
|
+
|
|
194
|
+
Always resumes from image_descriptions.jsonl's existing content (via the
|
|
195
|
+
done_images set below) -- callers that want an image redescribed must
|
|
196
|
+
first remove its existing row and its basename from the processed-
|
|
197
|
+
images registry (see kb_redo.redo_entries()), then pass it in here as if
|
|
198
|
+
new; this function has no bypass of its own, so there is no path to
|
|
199
|
+
producing a duplicate row.
|
|
200
|
+
"""
|
|
201
|
+
descriptions_path = os.path.join(output_dir, IMAGE_DESCRIPTIONS_FILE)
|
|
202
|
+
|
|
203
|
+
# -------------------------------------------------------------------
|
|
204
|
+
# Build set of images already on disk (resume safety).
|
|
205
|
+
# -------------------------------------------------------------------
|
|
206
|
+
done_images: set = set()
|
|
207
|
+
if os.path.exists(descriptions_path):
|
|
208
|
+
with open(descriptions_path, encoding="utf-8") as _fh:
|
|
209
|
+
for _line in _fh:
|
|
210
|
+
_line = _line.strip()
|
|
211
|
+
if not _line:
|
|
212
|
+
continue
|
|
213
|
+
try:
|
|
214
|
+
_rec = json.loads(_line)
|
|
215
|
+
_src = _rec.get("source", "")
|
|
216
|
+
if _src:
|
|
217
|
+
done_images.add(_src)
|
|
218
|
+
except Exception:
|
|
219
|
+
pass
|
|
220
|
+
if done_images:
|
|
221
|
+
logger.info(
|
|
222
|
+
"Resuming: %d image(s) already in %s — will skip.",
|
|
223
|
+
len(done_images), IMAGE_DESCRIPTIONS_FILE,
|
|
224
|
+
)
|
|
225
|
+
|
|
226
|
+
tasks = [
|
|
227
|
+
{"path": path, "name": os.path.basename(path)}
|
|
228
|
+
for path in new_image_paths
|
|
229
|
+
if os.path.basename(path) not in done_images
|
|
230
|
+
]
|
|
231
|
+
|
|
232
|
+
if not tasks:
|
|
233
|
+
logger.info("No new images to describe — all already processed.")
|
|
234
|
+
return []
|
|
235
|
+
|
|
236
|
+
# -------------------------------------------------------------------
|
|
237
|
+
# Call Vision LLM per task, concurrently. Each worker only computes
|
|
238
|
+
# and returns a result; only the single-threaded completion loop below
|
|
239
|
+
# calls append_to_jsonl, so the file is never written to from more
|
|
240
|
+
# than one thread at a time (writing it directly from workers would
|
|
241
|
+
# not be safe -- append_to_jsonl opens the file fresh per call).
|
|
242
|
+
# -------------------------------------------------------------------
|
|
243
|
+
def _describe_one(task: Dict[str, Any]) -> Dict[str, Any]:
|
|
244
|
+
image_name = task["name"]
|
|
245
|
+
try:
|
|
246
|
+
with open(task["path"], "rb") as fh:
|
|
247
|
+
raw_bytes = fh.read()
|
|
248
|
+
# Re-encode to PNG in memory, mirroring figure_describer.py's own
|
|
249
|
+
# pix.tobytes("png") step. Not cosmetic: OpenAI's vision API does
|
|
250
|
+
# not accept BMP or TIFF at all, regardless of what the data: URL
|
|
251
|
+
# claims, so a raw passthrough would fail outright for those.
|
|
252
|
+
pil_image = _flatten_to_rgb(Image.open(BytesIO(raw_bytes)))
|
|
253
|
+
buf = BytesIO()
|
|
254
|
+
pil_image.save(buf, format="PNG")
|
|
255
|
+
png_bytes = buf.getvalue()
|
|
256
|
+
|
|
257
|
+
raw_response = call_vision_llm(
|
|
258
|
+
images=[png_bytes],
|
|
259
|
+
prompt=image_prompt,
|
|
260
|
+
provider=vision_provider,
|
|
261
|
+
api_key=vision_api_key,
|
|
262
|
+
model=vision_model,
|
|
263
|
+
detail=vision_detail,
|
|
264
|
+
reasoning_effort=reasoning_effort,
|
|
265
|
+
ollama_timeout_seconds=_OLLAMA_IMAGE_TIMEOUT_SECONDS,
|
|
266
|
+
)
|
|
267
|
+
|
|
268
|
+
parsed = _parse_single_image_response(raw_response, image_name)
|
|
269
|
+
if parsed is None:
|
|
270
|
+
return {"image_name": image_name, "row": None}
|
|
271
|
+
|
|
272
|
+
description = parsed.get("description")
|
|
273
|
+
if not isinstance(description, str):
|
|
274
|
+
logger.warning(
|
|
275
|
+
"'description' field for image %s is not a string (got %s) -- "
|
|
276
|
+
"model likely returned a malformed/nested response; skipping.",
|
|
277
|
+
image_name, type(description).__name__,
|
|
278
|
+
)
|
|
279
|
+
return {"image_name": image_name, "row": None}
|
|
280
|
+
|
|
281
|
+
row = {
|
|
282
|
+
"source": image_name,
|
|
283
|
+
"image_id": make_document_id(image_name),
|
|
284
|
+
"title": _derive_title(description),
|
|
285
|
+
"description": description,
|
|
286
|
+
}
|
|
287
|
+
return {"image_name": image_name, "row": row}
|
|
288
|
+
|
|
289
|
+
except Exception as exc:
|
|
290
|
+
logger.error("Vision LLM failed for image %s: %s", image_name, exc)
|
|
291
|
+
return {"image_name": image_name, "row": None}
|
|
292
|
+
|
|
293
|
+
written_basenames: List[str] = []
|
|
294
|
+
|
|
295
|
+
with ThreadPoolExecutor(max_workers=max_workers) as executor:
|
|
296
|
+
futures = {executor.submit(_describe_one, task): task for task in tasks}
|
|
297
|
+
for future in as_completed(futures):
|
|
298
|
+
task = futures[future]
|
|
299
|
+
try:
|
|
300
|
+
result = future.result()
|
|
301
|
+
except Exception as exc:
|
|
302
|
+
logger.error("Error collecting result for image %s: %s", task["name"], exc)
|
|
303
|
+
continue
|
|
304
|
+
|
|
305
|
+
row = result["row"]
|
|
306
|
+
if row is not None:
|
|
307
|
+
# Only mark this basename "written" if the row actually
|
|
308
|
+
# landed on disk -- the caller uses this to persist
|
|
309
|
+
# parsed_images.txt, and unlike the PDF/figure side (which
|
|
310
|
+
# resumes by reading 02_visuals_kb.jsonl's own content back,
|
|
311
|
+
# self-healing after a transient write failure), the image
|
|
312
|
+
# registry is a separate file with no such self-correction.
|
|
313
|
+
# A silently-swallowed disk/permission error here previously
|
|
314
|
+
# still counted as "done" -- permanent data loss with no
|
|
315
|
+
# retry path short of --redo/--rebuild.
|
|
316
|
+
if append_to_jsonl(descriptions_path, [row]):
|
|
317
|
+
written_basenames.append(result["image_name"])
|
|
318
|
+
else:
|
|
319
|
+
logger.error(
|
|
320
|
+
"Failed to write description for %s -- not marking processed, will retry next run.",
|
|
321
|
+
result["image_name"],
|
|
322
|
+
)
|
|
323
|
+
|
|
324
|
+
if written_basenames:
|
|
325
|
+
print(f"[IMAGES] Wrote {len(written_basenames)} image description(s) to {IMAGE_DESCRIPTIONS_FILE}.")
|
|
326
|
+
else:
|
|
327
|
+
logger.info("No new image descriptions written.")
|
|
328
|
+
|
|
329
|
+
return written_basenames
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"""
|
|
2
|
+
image_tracker.py — Utilities for detecting new standalone images and
|
|
3
|
+
persisting the set of already-processed filenames across
|
|
4
|
+
pipeline runs.
|
|
5
|
+
|
|
6
|
+
Mirrors pdf_tracker.py's registry pattern. load_processed_pdfs/
|
|
7
|
+
save_processed_pdfs/mark_as_processed are reused directly from pdf_tracker.py
|
|
8
|
+
(despite the module name, they have no PDF-specific logic at all).
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import logging
|
|
14
|
+
import os
|
|
15
|
+
from typing import List
|
|
16
|
+
|
|
17
|
+
from visual_parser.pdf_tracker import load_processed_pdfs
|
|
18
|
+
|
|
19
|
+
logger = logging.getLogger(__name__)
|
|
20
|
+
|
|
21
|
+
IMAGE_EXTENSIONS = (".png", ".jpg", ".jpeg", ".tif", ".tiff", ".bmp")
|
|
22
|
+
PROCESSED_IMAGES_REGISTRY = "parsed_images.txt"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
# ---------------------------------------------------------------------------
|
|
26
|
+
# Image discovery
|
|
27
|
+
# ---------------------------------------------------------------------------
|
|
28
|
+
|
|
29
|
+
def find_new_images(
|
|
30
|
+
input_dir: str,
|
|
31
|
+
registry_path: str,
|
|
32
|
+
rebuild: bool = False,
|
|
33
|
+
) -> List[str]:
|
|
34
|
+
"""
|
|
35
|
+
Walk *input_dir* recursively and return full paths of standalone images
|
|
36
|
+
that have NOT yet been processed.
|
|
37
|
+
|
|
38
|
+
Unlike find_new_pdfs(), *registry_path* is an explicit parameter rather
|
|
39
|
+
than being joined internally against *input_dir* -- this lets the caller
|
|
40
|
+
anchor the registry at effective_output_dir() unambiguously, sidestepping
|
|
41
|
+
a pre-existing bug on the PDF side where find_new_pdfs() always resolves
|
|
42
|
+
its registry against input_dir while pipeline.py separately writes it
|
|
43
|
+
against output_dir (the two diverge, and PDFs get reprocessed forever,
|
|
44
|
+
whenever --output-dir differs from --input-dir).
|
|
45
|
+
|
|
46
|
+
Args:
|
|
47
|
+
input_dir: Root directory to search for image files.
|
|
48
|
+
registry_path: Full path to the tracking file.
|
|
49
|
+
rebuild: When True, return *all* images regardless of the
|
|
50
|
+
registry (forces a full re-describe).
|
|
51
|
+
|
|
52
|
+
Returns:
|
|
53
|
+
Sorted list of absolute image paths.
|
|
54
|
+
"""
|
|
55
|
+
processed = set() if rebuild else set(load_processed_pdfs(registry_path))
|
|
56
|
+
|
|
57
|
+
new_images = [
|
|
58
|
+
os.path.join(root, filename)
|
|
59
|
+
for root, _, files in os.walk(input_dir)
|
|
60
|
+
for filename in files
|
|
61
|
+
if filename.lower().endswith(IMAGE_EXTENSIONS)
|
|
62
|
+
and os.path.basename(filename) not in processed
|
|
63
|
+
]
|
|
64
|
+
|
|
65
|
+
new_images.sort()
|
|
66
|
+
if new_images:
|
|
67
|
+
logger.info("Found %d new image(s) to process.", len(new_images))
|
|
68
|
+
else:
|
|
69
|
+
logger.info("No new images detected in %s.", input_dir)
|
|
70
|
+
|
|
71
|
+
return new_images
|