@mengruo/dsh-vision-toolkit 0.0.1
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.
- package/LICENSE +21 -0
- package/README.i18n.yaml +6 -0
- package/README.md +356 -0
- package/README.zh.md +354 -0
- package/assets/community-group-qr.png +0 -0
- package/assets/dsh-conversation-artifact.png +0 -0
- package/assets/dsh-conversation-image-qa-top.png +0 -0
- package/assets/dsh-conversation-image-qa.png +0 -0
- package/assets/dsh-conversation-pixel-diff.png +0 -0
- package/assets/dsh-conversation-screenshot-debugging-top.png +0 -0
- package/assets/dsh-conversation-screenshot-debugging.png +0 -0
- package/assets/dsh-conversation-tool-call.png +0 -0
- package/assets/dsh-conversation-vision-trace.png +0 -0
- package/assets/dsh-view-example.png +0 -0
- package/assets/hero-v2.png +0 -0
- package/assets/logo_aihubmix.png +0 -0
- package/assets/python-bootstrap.json +48 -0
- package/assets/skill/SKILL.md +329 -0
- package/assets/skill/UPSTREAM.json +71 -0
- package/assets/skill/references/gui.md +88 -0
- package/assets/skill/references/long-screenshot-ocr.md +77 -0
- package/assets/skill/references/restore-graphic.md +84 -0
- package/assets/skill/references/restore-structure.md +45 -0
- package/assets/skill/references/restore-ui.md +202 -0
- package/assets/social-preview.png +0 -0
- package/assets/upstream/README.md +18 -0
- package/assets/upstream/focus-hint-comparison-1.webp +0 -0
- package/assets/upstream/focus-hint-comparison-2.webp +0 -0
- package/assets/upstream/infographic-reference.webp +0 -0
- package/assets/upstream/infographic-result.webp +0 -0
- package/assets/upstream/ui-fast-restore-reference.webp +0 -0
- package/assets/upstream/ui-fast-restore-result.webp +0 -0
- package/assets/upstream/ui-result.webp +0 -0
- package/assets/upstream/ui-sketch.webp +0 -0
- package/assets/vision-model-test.png +0 -0
- package/assets/vision-settings.png +0 -0
- package/assets/wechat-reward.png +0 -0
- package/cordis.patch.yml +6 -0
- package/docs/aihubmix-gemini-vision.i18n.yaml +6 -0
- package/docs/aihubmix-gemini-vision.md +138 -0
- package/docs/aihubmix-gemini-vision.zh.md +138 -0
- package/docs/assets/aihubmix-api-keys.png +0 -0
- package/docs/assets/aihubmix-create-key.png +0 -0
- package/docs/assets/aihubmix-free-vision-model.png +0 -0
- package/docs/assets/aihubmix-home.png +0 -0
- package/docs/assets/aihubmix-sign-up.png +0 -0
- package/docs/assets/groq-console-home.png +0 -0
- package/docs/assets/groq-console-keys-login.png +0 -0
- package/docs/assets/groq-docs-quickstart.png +0 -0
- package/docs/assets/groq-docs-qwen3.6.png +0 -0
- package/docs/assets/groq-docs-vision.png +0 -0
- package/docs/assets/vision-settings.png +0 -0
- package/docs/dsh-desktop-install.md +67 -0
- package/docs/dsh-desktop-install.zh.md +67 -0
- package/docs/groq-qwen3.6-vision.i18n.yaml +6 -0
- package/docs/groq-qwen3.6-vision.md +236 -0
- package/docs/groq-qwen3.6-vision.zh.md +236 -0
- package/docs/python-runtime.i18n.yaml +6 -0
- package/docs/python-runtime.md +89 -0
- package/docs/python-runtime.zh.md +89 -0
- package/docs/requirements-traceability/README.i18n.yaml +6 -0
- package/docs/requirements-traceability/README.md +75 -0
- package/docs/requirements-traceability/README.zh.md +75 -0
- package/examples/ui-restoration/README.i18n.yaml +6 -0
- package/examples/ui-restoration/README.md +70 -0
- package/examples/ui-restoration/README.zh.md +70 -0
- package/examples/ui-restoration/assets/final-heatmap.png +0 -0
- package/examples/ui-restoration/assets/final-report.json +83 -0
- package/examples/ui-restoration/assets/implementation.png +0 -0
- package/examples/ui-restoration/assets/initial-heatmap.png +0 -0
- package/examples/ui-restoration/assets/initial-report.json +83 -0
- package/examples/ui-restoration/assets/initial.png +0 -0
- package/examples/ui-restoration/assets/metrics.json +12 -0
- package/examples/ui-restoration/assets/reference.png +0 -0
- package/examples/ui-restoration/implementation.html +94 -0
- package/examples/ui-restoration/initial.html +57 -0
- package/lib/artifact-access.js +369 -0
- package/lib/artifact-access.js.map +1 -0
- package/lib/artifacts.js +56 -0
- package/lib/artifacts.js.map +1 -0
- package/lib/client.js +2099 -0
- package/lib/client.js.map +1 -0
- package/lib/config.js +172 -0
- package/lib/config.js.map +1 -0
- package/lib/defaults.js +6 -0
- package/lib/defaults.js.map +1 -0
- package/lib/errors.js +56 -0
- package/lib/errors.js.map +1 -0
- package/lib/evidence-cache.js +350 -0
- package/lib/evidence-cache.js.map +1 -0
- package/lib/exposure.js +247 -0
- package/lib/exposure.js.map +1 -0
- package/lib/image-input-variants.js +882 -0
- package/lib/image-input-variants.js.map +1 -0
- package/lib/index.js +105 -0
- package/lib/index.js.map +1 -0
- package/lib/paste-images.js +217 -0
- package/lib/paste-images.js.map +1 -0
- package/lib/paths.js +354 -0
- package/lib/paths.js.map +1 -0
- package/lib/plugin-update.js +1003 -0
- package/lib/plugin-update.js.map +1 -0
- package/lib/runtime-install.js +964 -0
- package/lib/runtime-install.js.map +1 -0
- package/lib/runtime-manager.js +133 -0
- package/lib/runtime-manager.js.map +1 -0
- package/lib/runtime.js +1678 -0
- package/lib/runtime.js.map +1 -0
- package/lib/skill.js +25 -0
- package/lib/skill.js.map +1 -0
- package/lib/tools.js +549 -0
- package/lib/tools.js.map +1 -0
- package/lib/types/artifact-access.d.ts +61 -0
- package/lib/types/artifact-access.d.ts.map +1 -0
- package/lib/types/artifacts.d.ts +42 -0
- package/lib/types/artifacts.d.ts.map +1 -0
- package/lib/types/client/display-config.d.ts +24 -0
- package/lib/types/client/display-config.d.ts.map +1 -0
- package/lib/types/client/index.d.ts +350 -0
- package/lib/types/client/index.d.ts.map +1 -0
- package/lib/types/client/model-variants-hider.d.ts +40 -0
- package/lib/types/client/model-variants-hider.d.ts.map +1 -0
- package/lib/types/client/paste-images.d.ts +126 -0
- package/lib/types/client/paste-images.d.ts.map +1 -0
- package/lib/types/config.d.ts +123 -0
- package/lib/types/config.d.ts.map +1 -0
- package/lib/types/defaults.d.ts +6 -0
- package/lib/types/defaults.d.ts.map +1 -0
- package/lib/types/errors.d.ts +35 -0
- package/lib/types/errors.d.ts.map +1 -0
- package/lib/types/evidence-cache.d.ts +91 -0
- package/lib/types/evidence-cache.d.ts.map +1 -0
- package/lib/types/exposure.d.ts +50 -0
- package/lib/types/exposure.d.ts.map +1 -0
- package/lib/types/image-input-variants.d.ts +144 -0
- package/lib/types/image-input-variants.d.ts.map +1 -0
- package/lib/types/index.d.ts +19 -0
- package/lib/types/index.d.ts.map +1 -0
- package/lib/types/paste-images.d.ts +73 -0
- package/lib/types/paste-images.d.ts.map +1 -0
- package/lib/types/paths.d.ts +117 -0
- package/lib/types/paths.d.ts.map +1 -0
- package/lib/types/plugin-update.d.ts +119 -0
- package/lib/types/plugin-update.d.ts.map +1 -0
- package/lib/types/runtime-install.d.ts +92 -0
- package/lib/types/runtime-install.d.ts.map +1 -0
- package/lib/types/runtime-manager.d.ts +60 -0
- package/lib/types/runtime-manager.d.ts.map +1 -0
- package/lib/types/runtime.d.ts +412 -0
- package/lib/types/runtime.d.ts.map +1 -0
- package/lib/types/skill.d.ts +14 -0
- package/lib/types/skill.d.ts.map +1 -0
- package/lib/types/tools.d.ts +35 -0
- package/lib/types/tools.d.ts.map +1 -0
- package/lib/types/upstream.d.ts +233 -0
- package/lib/types/upstream.d.ts.map +1 -0
- package/lib/types/version.d.ts +15 -0
- package/lib/types/version.d.ts.map +1 -0
- package/lib/types/web-request.d.ts +11 -0
- package/lib/types/web-request.d.ts.map +1 -0
- package/lib/types/web.d.ts +118 -0
- package/lib/types/web.d.ts.map +1 -0
- package/lib/upstream.js +860 -0
- package/lib/upstream.js.map +1 -0
- package/lib/version.js +18 -0
- package/lib/version.js.map +1 -0
- package/lib/web-request.js +29 -0
- package/lib/web-request.js.map +1 -0
- package/lib/web.js +462 -0
- package/lib/web.js.map +1 -0
- package/package.json +149 -0
- package/patches/vision-tools-dsh.patch +970 -0
- package/runtime/requirements.lock +3 -0
- package/src/artifact-access.ts +386 -0
- package/src/artifacts.ts +85 -0
- package/src/client/display-config.ts +62 -0
- package/src/client/index.tsx +1580 -0
- package/src/client/model-variants-hider.ts +159 -0
- package/src/client/paste-images.tsx +747 -0
- package/src/config.ts +290 -0
- package/src/defaults.ts +5 -0
- package/src/errors.ts +62 -0
- package/src/evidence-cache.ts +418 -0
- package/src/exposure.ts +265 -0
- package/src/image-input-variants.ts +996 -0
- package/src/index.ts +141 -0
- package/src/paste-images.ts +286 -0
- package/src/paths.ts +383 -0
- package/src/plugin-update.ts +1143 -0
- package/src/runtime-install.ts +1161 -0
- package/src/runtime-manager.ts +174 -0
- package/src/runtime.ts +2189 -0
- package/src/skill.ts +35 -0
- package/src/tools.ts +691 -0
- package/src/upstream.ts +1078 -0
- package/src/version.ts +37 -0
- package/src/web-request.ts +27 -0
- package/src/web.ts +594 -0
- package/vendor/agent-vision-toolkit/CHANGELOG.md +30 -0
- package/vendor/agent-vision-toolkit/LICENSE +21 -0
- package/vendor/agent-vision-toolkit/README.md +405 -0
- package/vendor/agent-vision-toolkit/UPSTREAM_MANIFEST.json +94 -0
- package/vendor/agent-vision-toolkit/bin/crop +90 -0
- package/vendor/agent-vision-toolkit/bin/detect +13 -0
- package/vendor/agent-vision-toolkit/bin/glance +93 -0
- package/vendor/agent-vision-toolkit/bin/ground +13 -0
- package/vendor/agent-vision-toolkit/bin/trace +129 -0
- package/vendor/agent-vision-toolkit/detect.py +60 -0
- package/vendor/agent-vision-toolkit/ground.py +267 -0
- package/vendor/agent-vision-toolkit/skills/vision-tools/scripts/dominant_colors.py +224 -0
- package/vendor/agent-vision-toolkit/skills/vision-tools/scripts/extract_fg.py +278 -0
- package/vendor/agent-vision-toolkit/skills/vision-tools/scripts/html_shot.py +420 -0
- package/vendor/agent-vision-toolkit/skills/vision-tools/scripts/long_screenshot_ocr.py +1245 -0
- package/vendor/agent-vision-toolkit/skills/vision-tools/scripts/pixel_diff.py +88 -0
- package/vendor/agent-vision-toolkit/tests/test_vision_client.py +450 -0
- package/vendor/agent-vision-toolkit/vision_client.py +310 -0
|
@@ -0,0 +1,1245 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Safely split a tall screenshot, OCR each chunk with glance, and merge it."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import argparse
|
|
7
|
+
from concurrent.futures import ThreadPoolExecutor, as_completed
|
|
8
|
+
from dataclasses import dataclass, replace
|
|
9
|
+
from difflib import SequenceMatcher
|
|
10
|
+
import hashlib
|
|
11
|
+
import io
|
|
12
|
+
import json
|
|
13
|
+
import math
|
|
14
|
+
import os
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
import re
|
|
17
|
+
import shutil
|
|
18
|
+
import subprocess
|
|
19
|
+
import sys
|
|
20
|
+
import unicodedata
|
|
21
|
+
from typing import Sequence
|
|
22
|
+
|
|
23
|
+
try:
|
|
24
|
+
from PIL import Image, ImageChops, ImageOps
|
|
25
|
+
except ImportError: # Optional dependency; handled by main().
|
|
26
|
+
Image = None
|
|
27
|
+
ImageChops = None
|
|
28
|
+
ImageOps = None
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
ANALYSIS_WIDTH = 900
|
|
32
|
+
SAFE_OCCUPANCY_LEVEL = 20.0
|
|
33
|
+
# Bump when the OCR output contract changes so --resume cannot reuse stale results.
|
|
34
|
+
OCR_PROMPT_VERSION = 5
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass(frozen=True)
|
|
38
|
+
class CoreRange:
|
|
39
|
+
top: int
|
|
40
|
+
bottom: int
|
|
41
|
+
cut_energy: float | None
|
|
42
|
+
cut_quality: float | None
|
|
43
|
+
top_safe_margin: int | None
|
|
44
|
+
bottom_safe_margin: int | None
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@dataclass(frozen=True)
|
|
48
|
+
class Chunk:
|
|
49
|
+
index: int
|
|
50
|
+
core_top: int
|
|
51
|
+
core_bottom: int
|
|
52
|
+
crop_top: int
|
|
53
|
+
crop_bottom: int
|
|
54
|
+
top_overlap: int
|
|
55
|
+
bottom_overlap: int
|
|
56
|
+
cut_energy: float | None
|
|
57
|
+
cut_quality: float | None
|
|
58
|
+
top_safe_margin: int | None
|
|
59
|
+
bottom_safe_margin: int | None
|
|
60
|
+
image_path: Path
|
|
61
|
+
image_sha256: str
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@dataclass(frozen=True)
|
|
65
|
+
class ChatMessage:
|
|
66
|
+
speaker: str
|
|
67
|
+
content: str
|
|
68
|
+
timestamp: str = ""
|
|
69
|
+
message_type: str = "message"
|
|
70
|
+
quoted_speaker: str = ""
|
|
71
|
+
quoted_content: str = ""
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
@dataclass(frozen=True)
|
|
75
|
+
class Transcript:
|
|
76
|
+
chunk: Chunk
|
|
77
|
+
text: str
|
|
78
|
+
output_path: Path
|
|
79
|
+
reused: bool
|
|
80
|
+
messages: tuple[ChatMessage, ...] = ()
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def clamp(value: float, minimum: int, maximum: int) -> int:
|
|
84
|
+
return round(max(minimum, min(maximum, value)))
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def percentile(values: Sequence[float], percent: float) -> float:
|
|
88
|
+
if not values:
|
|
89
|
+
raise ValueError("percentile requires at least one value")
|
|
90
|
+
ordered = sorted(values)
|
|
91
|
+
position = (len(ordered) - 1) * percent / 100
|
|
92
|
+
lower = math.floor(position)
|
|
93
|
+
upper = math.ceil(position)
|
|
94
|
+
if lower == upper:
|
|
95
|
+
return float(ordered[lower])
|
|
96
|
+
fraction = position - lower
|
|
97
|
+
return float(ordered[lower] * (1 - fraction) + ordered[upper] * fraction)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def rolling_mean(values: Sequence[float], radius: int) -> list[float]:
|
|
101
|
+
if radius <= 0 or len(values) <= 1:
|
|
102
|
+
return [float(value) for value in values]
|
|
103
|
+
padded = [float(values[0])] * radius
|
|
104
|
+
padded.extend(float(value) for value in values)
|
|
105
|
+
padded.extend([float(values[-1])] * radius)
|
|
106
|
+
window = radius * 2 + 1
|
|
107
|
+
total = sum(padded[:window])
|
|
108
|
+
result = []
|
|
109
|
+
for index in range(len(values)):
|
|
110
|
+
result.append(total / window)
|
|
111
|
+
if index + window < len(padded):
|
|
112
|
+
total += padded[index + window] - padded[index]
|
|
113
|
+
return result
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def row_energy(image: "Image.Image") -> tuple[list[float], list[float], float]:
|
|
117
|
+
"""Return per-row edge energy and foreground occupancy with Pillow operations."""
|
|
118
|
+
scale = min(1.0, ANALYSIS_WIDTH / image.width)
|
|
119
|
+
analysis_width = max(1, round(image.width * scale))
|
|
120
|
+
analysis_height = max(1, round(image.height * scale))
|
|
121
|
+
analysis = image.convert("RGB")
|
|
122
|
+
if scale < 1.0:
|
|
123
|
+
analysis = analysis.resize(
|
|
124
|
+
(analysis_width, analysis_height), Image.Resampling.BILINEAR
|
|
125
|
+
)
|
|
126
|
+
gray = analysis.convert("L")
|
|
127
|
+
|
|
128
|
+
width, height = gray.size
|
|
129
|
+
shifted_x = Image.new("L", gray.size)
|
|
130
|
+
shifted_x.paste(gray, (1, 0))
|
|
131
|
+
shifted_x.paste(gray.crop((0, 0, 1, height)), (0, 0))
|
|
132
|
+
horizontal = ImageChops.difference(gray, shifted_x)
|
|
133
|
+
|
|
134
|
+
shifted_y = Image.new("L", gray.size)
|
|
135
|
+
shifted_y.paste(gray, (0, 1))
|
|
136
|
+
shifted_y.paste(gray.crop((0, 0, width, 1)), (0, 0))
|
|
137
|
+
vertical = ImageChops.difference(gray, shifted_y)
|
|
138
|
+
|
|
139
|
+
combined = Image.blend(horizontal, vertical, 0.32)
|
|
140
|
+
collapsed = combined.resize((1, height), Image.Resampling.BOX)
|
|
141
|
+
edges = [float(value) for value in collapsed.tobytes()]
|
|
142
|
+
|
|
143
|
+
border_width = max(1, min(24, width // 18))
|
|
144
|
+
left = analysis.crop((0, 0, border_width, height)).resize(
|
|
145
|
+
(1, height), Image.Resampling.BOX
|
|
146
|
+
)
|
|
147
|
+
right = analysis.crop((width - border_width, 0, width, height)).resize(
|
|
148
|
+
(1, height), Image.Resampling.BOX
|
|
149
|
+
)
|
|
150
|
+
edge_reference = Image.new("RGB", (2, height))
|
|
151
|
+
edge_reference.paste(left, (0, 0))
|
|
152
|
+
edge_reference.paste(right, (1, 0))
|
|
153
|
+
background = edge_reference.resize((width, height), Image.Resampling.BILINEAR)
|
|
154
|
+
foreground_difference = ImageChops.difference(analysis, background)
|
|
155
|
+
red_difference, green_difference, blue_difference = foreground_difference.split()
|
|
156
|
+
foreground_distance = ImageChops.lighter(
|
|
157
|
+
ImageChops.lighter(red_difference, green_difference), blue_difference
|
|
158
|
+
)
|
|
159
|
+
foreground_mask = foreground_distance.point(
|
|
160
|
+
lambda value: 255 if value >= 14 else 0,
|
|
161
|
+
mode="L",
|
|
162
|
+
)
|
|
163
|
+
occupancy_column = foreground_mask.resize((1, height), Image.Resampling.BOX)
|
|
164
|
+
occupancy = [float(value) for value in occupancy_column.tobytes()]
|
|
165
|
+
|
|
166
|
+
radius = max(1, round(3 * scale))
|
|
167
|
+
smoothed_edges = rolling_mean(edges, radius)
|
|
168
|
+
smoothed_occupancy = rolling_mean(occupancy, radius)
|
|
169
|
+
energy = [
|
|
170
|
+
edge_value + occupancy_value * 0.55
|
|
171
|
+
for edge_value, occupancy_value in zip(smoothed_edges, smoothed_occupancy)
|
|
172
|
+
]
|
|
173
|
+
return energy, smoothed_occupancy, scale
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def resolve_split_sizes(
|
|
177
|
+
width: int,
|
|
178
|
+
mode: str,
|
|
179
|
+
target_height: int | None,
|
|
180
|
+
min_height: int | None,
|
|
181
|
+
max_height: int | None,
|
|
182
|
+
overlap: int | None,
|
|
183
|
+
) -> tuple[int, int, int, int]:
|
|
184
|
+
automatic_target = clamp(
|
|
185
|
+
width * (1.75 if mode == "chat" else 1.45),
|
|
186
|
+
1400 if mode == "chat" else 1200,
|
|
187
|
+
2400,
|
|
188
|
+
)
|
|
189
|
+
target = target_height or automatic_target
|
|
190
|
+
minimum = min_height or max(600, round(target * 0.58))
|
|
191
|
+
maximum = max_height or min(3400, round(target * 1.42))
|
|
192
|
+
resolved_overlap = overlap if overlap is not None else (64 if mode == "chat" else 40)
|
|
193
|
+
|
|
194
|
+
if min(target, minimum, maximum) <= 0:
|
|
195
|
+
raise ValueError("split heights must be greater than zero")
|
|
196
|
+
if not minimum <= target <= maximum:
|
|
197
|
+
raise ValueError("split heights must satisfy min-height <= target-height <= max-height")
|
|
198
|
+
if resolved_overlap < 0:
|
|
199
|
+
raise ValueError("overlap cannot be negative")
|
|
200
|
+
if resolved_overlap * 2 >= minimum:
|
|
201
|
+
raise ValueError("overlap must be less than half of min-height")
|
|
202
|
+
return target, minimum, maximum, resolved_overlap
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def choose_cut(
|
|
206
|
+
energy: Sequence[float],
|
|
207
|
+
occupancy: Sequence[float],
|
|
208
|
+
start: int,
|
|
209
|
+
target: int,
|
|
210
|
+
minimum: int,
|
|
211
|
+
maximum: int,
|
|
212
|
+
mode: str,
|
|
213
|
+
safe_radius: int,
|
|
214
|
+
) -> tuple[int, float, float, int]:
|
|
215
|
+
image_height = len(energy)
|
|
216
|
+
lower = min(image_height - 1, start + minimum)
|
|
217
|
+
upper = min(image_height - minimum, start + maximum)
|
|
218
|
+
desired = min(image_height - 1, start + target)
|
|
219
|
+
if lower >= upper:
|
|
220
|
+
return upper, float(energy[upper]), 0.0, 0
|
|
221
|
+
|
|
222
|
+
local = [float(value) for value in energy[lower : upper + 1]]
|
|
223
|
+
low = percentile(local, 8)
|
|
224
|
+
high = percentile(local, 92)
|
|
225
|
+
normalized = [(value - low) / max(0.001, high - low) for value in local]
|
|
226
|
+
|
|
227
|
+
threshold = percentile(local, 32 if mode == "chat" else 25)
|
|
228
|
+
low_rows = [
|
|
229
|
+
1.0
|
|
230
|
+
if value <= threshold and occupancy[index] <= SAFE_OCCUPANCY_LEVEL
|
|
231
|
+
else 0.0
|
|
232
|
+
for index, value in enumerate(energy)
|
|
233
|
+
]
|
|
234
|
+
blank_ratio = rolling_mean(low_rows, safe_radius)[lower : upper + 1]
|
|
235
|
+
distance_weight = 0.20 if mode == "chat" else 0.30
|
|
236
|
+
|
|
237
|
+
selected_offset = min(
|
|
238
|
+
range(len(local)),
|
|
239
|
+
key=lambda offset: (
|
|
240
|
+
normalized[offset]
|
|
241
|
+
+ abs((lower + offset) - desired) / max(1, maximum - minimum) * distance_weight
|
|
242
|
+
+ occupancy[lower + offset] / 255 * 0.75
|
|
243
|
+
- blank_ratio[offset] * 0.48
|
|
244
|
+
),
|
|
245
|
+
)
|
|
246
|
+
selected = lower + selected_offset
|
|
247
|
+
|
|
248
|
+
band_threshold = percentile(local, 40)
|
|
249
|
+
band_left = selected
|
|
250
|
+
band_right = selected
|
|
251
|
+
while (
|
|
252
|
+
band_left > lower
|
|
253
|
+
and energy[band_left - 1] <= band_threshold
|
|
254
|
+
and occupancy[band_left - 1] <= SAFE_OCCUPANCY_LEVEL
|
|
255
|
+
):
|
|
256
|
+
band_left -= 1
|
|
257
|
+
while (
|
|
258
|
+
band_right < upper
|
|
259
|
+
and energy[band_right + 1] <= band_threshold
|
|
260
|
+
and occupancy[band_right + 1] <= SAFE_OCCUPANCY_LEVEL
|
|
261
|
+
):
|
|
262
|
+
band_right += 1
|
|
263
|
+
if band_right - band_left >= max(4, safe_radius // 2):
|
|
264
|
+
selected = (band_left + band_right) // 2
|
|
265
|
+
safe_margin = min(selected - band_left, band_right - selected)
|
|
266
|
+
else:
|
|
267
|
+
safe_margin = 0
|
|
268
|
+
|
|
269
|
+
selected_energy = float(energy[selected])
|
|
270
|
+
percentile_rank = sum(value <= selected_energy for value in local) / len(local)
|
|
271
|
+
quality = max(0.0, min(1.0, 1.0 - percentile_rank))
|
|
272
|
+
return selected, selected_energy, quality, safe_margin
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def find_core_ranges(
|
|
276
|
+
image: "Image.Image",
|
|
277
|
+
mode: str,
|
|
278
|
+
target_height: int,
|
|
279
|
+
min_height: int,
|
|
280
|
+
max_height: int,
|
|
281
|
+
) -> tuple[list[CoreRange], dict[str, float]]:
|
|
282
|
+
if image.height <= max_height:
|
|
283
|
+
return [CoreRange(0, image.height, None, None, None, None)], {
|
|
284
|
+
"analysis_scale": 1.0,
|
|
285
|
+
"safe_band_radius_px": 0.0,
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
energy, occupancy, scale = row_energy(image)
|
|
289
|
+
target = max(1, round(target_height * scale))
|
|
290
|
+
minimum = max(1, round(min_height * scale))
|
|
291
|
+
maximum = max(minimum + 1, round(max_height * scale))
|
|
292
|
+
safe_radius = max(2, round(image.width * 0.012 * scale))
|
|
293
|
+
|
|
294
|
+
cuts = [0]
|
|
295
|
+
cut_details: list[tuple[float, float, int]] = []
|
|
296
|
+
analysis_height = len(energy)
|
|
297
|
+
while analysis_height - cuts[-1] > maximum:
|
|
298
|
+
if analysis_height - cuts[-1] < minimum * 2:
|
|
299
|
+
break
|
|
300
|
+
cut, selected_energy, quality, safe_margin = choose_cut(
|
|
301
|
+
energy,
|
|
302
|
+
occupancy,
|
|
303
|
+
cuts[-1],
|
|
304
|
+
target,
|
|
305
|
+
minimum,
|
|
306
|
+
maximum,
|
|
307
|
+
mode,
|
|
308
|
+
safe_radius,
|
|
309
|
+
)
|
|
310
|
+
if cut <= cuts[-1]:
|
|
311
|
+
cut = min(analysis_height, cuts[-1] + target)
|
|
312
|
+
cuts.append(cut)
|
|
313
|
+
cut_details.append((selected_energy, quality, safe_margin))
|
|
314
|
+
cuts.append(analysis_height)
|
|
315
|
+
|
|
316
|
+
original_cuts = [0]
|
|
317
|
+
for cut in cuts[1:-1]:
|
|
318
|
+
mapped = max(original_cuts[-1] + 1, min(image.height - 1, round(cut / scale)))
|
|
319
|
+
original_cuts.append(mapped)
|
|
320
|
+
original_cuts.append(image.height)
|
|
321
|
+
|
|
322
|
+
ranges = []
|
|
323
|
+
for index, (top, bottom) in enumerate(zip(original_cuts, original_cuts[1:])):
|
|
324
|
+
detail = cut_details[index] if index < len(cut_details) else (None, None, None)
|
|
325
|
+
top_safe_margin = (
|
|
326
|
+
round(cut_details[index - 1][2] / scale) if index > 0 else None
|
|
327
|
+
)
|
|
328
|
+
bottom_safe_margin = (
|
|
329
|
+
round(detail[2] / scale) if detail[2] is not None else None
|
|
330
|
+
)
|
|
331
|
+
ranges.append(
|
|
332
|
+
CoreRange(
|
|
333
|
+
top=top,
|
|
334
|
+
bottom=bottom,
|
|
335
|
+
cut_energy=detail[0],
|
|
336
|
+
cut_quality=detail[1],
|
|
337
|
+
top_safe_margin=top_safe_margin,
|
|
338
|
+
bottom_safe_margin=bottom_safe_margin,
|
|
339
|
+
)
|
|
340
|
+
)
|
|
341
|
+
return ranges, {
|
|
342
|
+
"analysis_scale": scale,
|
|
343
|
+
"safe_band_radius_px": safe_radius / scale,
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
def atomic_write_bytes(path: Path, data: bytes) -> None:
|
|
348
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
349
|
+
temporary = path.with_name(path.name + ".tmp")
|
|
350
|
+
temporary.write_bytes(data)
|
|
351
|
+
temporary.replace(path)
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
def atomic_write_text(path: Path, text: str) -> None:
|
|
355
|
+
atomic_write_bytes(path, text.encode("utf-8"))
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
def save_chunks(
|
|
359
|
+
image: "Image.Image",
|
|
360
|
+
ranges: Sequence[CoreRange],
|
|
361
|
+
chunks_dir: Path,
|
|
362
|
+
overlap: int,
|
|
363
|
+
) -> list[Chunk]:
|
|
364
|
+
chunks_dir.mkdir(parents=True, exist_ok=True)
|
|
365
|
+
chunks = []
|
|
366
|
+
digits = max(3, len(str(len(ranges))))
|
|
367
|
+
for index, core in enumerate(ranges, 1):
|
|
368
|
+
top_overlap = (
|
|
369
|
+
0
|
|
370
|
+
if not core.top or (core.top_safe_margin is not None and core.top_safe_margin > 0)
|
|
371
|
+
else overlap
|
|
372
|
+
)
|
|
373
|
+
bottom_overlap = (
|
|
374
|
+
0
|
|
375
|
+
if core.bottom >= image.height
|
|
376
|
+
or (core.bottom_safe_margin is not None and core.bottom_safe_margin > 0)
|
|
377
|
+
else overlap
|
|
378
|
+
)
|
|
379
|
+
crop_top = max(0, core.top - top_overlap)
|
|
380
|
+
crop_bottom = min(image.height, core.bottom + bottom_overlap)
|
|
381
|
+
image_path = chunks_dir / f"chunk_{index:0{digits}d}.png"
|
|
382
|
+
|
|
383
|
+
buffer = io.BytesIO()
|
|
384
|
+
image.crop((0, crop_top, image.width, crop_bottom)).save(buffer, format="PNG")
|
|
385
|
+
image_bytes = buffer.getvalue()
|
|
386
|
+
image_sha256 = hashlib.sha256(image_bytes).hexdigest()
|
|
387
|
+
existing_sha256 = (
|
|
388
|
+
hashlib.sha256(image_path.read_bytes()).hexdigest()
|
|
389
|
+
if image_path.is_file()
|
|
390
|
+
else ""
|
|
391
|
+
)
|
|
392
|
+
if existing_sha256 != image_sha256:
|
|
393
|
+
atomic_write_bytes(image_path, image_bytes)
|
|
394
|
+
|
|
395
|
+
chunks.append(
|
|
396
|
+
Chunk(
|
|
397
|
+
index=index,
|
|
398
|
+
core_top=core.top,
|
|
399
|
+
core_bottom=core.bottom,
|
|
400
|
+
crop_top=crop_top,
|
|
401
|
+
crop_bottom=crop_bottom,
|
|
402
|
+
top_overlap=top_overlap,
|
|
403
|
+
bottom_overlap=bottom_overlap,
|
|
404
|
+
cut_energy=core.cut_energy,
|
|
405
|
+
cut_quality=core.cut_quality,
|
|
406
|
+
top_safe_margin=core.top_safe_margin,
|
|
407
|
+
bottom_safe_margin=core.bottom_safe_margin,
|
|
408
|
+
image_path=image_path,
|
|
409
|
+
image_sha256=image_sha256,
|
|
410
|
+
)
|
|
411
|
+
)
|
|
412
|
+
prune_stale_chunk_files(chunks_dir, chunks)
|
|
413
|
+
return chunks
|
|
414
|
+
|
|
415
|
+
|
|
416
|
+
def prune_stale_chunk_files(chunks_dir: Path, chunks: Sequence[Chunk]) -> None:
|
|
417
|
+
active = set()
|
|
418
|
+
for chunk in chunks:
|
|
419
|
+
active.update(
|
|
420
|
+
{
|
|
421
|
+
chunk.image_path.name,
|
|
422
|
+
chunk.image_path.with_suffix(".ocr.md").name,
|
|
423
|
+
chunk.image_path.with_suffix(".ocr.md").name + ".sha256",
|
|
424
|
+
chunk.image_path.with_suffix(".ocr.json").name,
|
|
425
|
+
chunk.image_path.with_suffix(".ocr.json").name + ".sha256",
|
|
426
|
+
}
|
|
427
|
+
)
|
|
428
|
+
generated = re.compile(
|
|
429
|
+
r"chunk_\d+\.(?:png|ocr\.md(?:\.sha256)?|ocr\.json(?:\.sha256)?|ocr\.sha256)"
|
|
430
|
+
)
|
|
431
|
+
for path in chunks_dir.iterdir():
|
|
432
|
+
if path.is_file() and generated.fullmatch(path.name) and path.name not in active:
|
|
433
|
+
path.unlink()
|
|
434
|
+
|
|
435
|
+
|
|
436
|
+
def command_for_path(path: Path) -> list[str]:
|
|
437
|
+
suffix = path.suffix.lower()
|
|
438
|
+
if suffix in {".py", ".pyw"}:
|
|
439
|
+
return [sys.executable, str(path)]
|
|
440
|
+
if os.name == "nt":
|
|
441
|
+
# Windows cannot exec a shebang script directly; a bare script on PATH
|
|
442
|
+
# (or the repo's own bin/glance) must be run through the interpreter.
|
|
443
|
+
if suffix:
|
|
444
|
+
return [str(path)]
|
|
445
|
+
try:
|
|
446
|
+
with open(path, "rb") as handle:
|
|
447
|
+
first = handle.readline(256).decode("utf-8", errors="replace")
|
|
448
|
+
except OSError:
|
|
449
|
+
first = ""
|
|
450
|
+
if first.lstrip().startswith("#!") and "python" in first.lower():
|
|
451
|
+
return [sys.executable, str(path)]
|
|
452
|
+
return [str(path)]
|
|
453
|
+
if not os.access(path, os.X_OK):
|
|
454
|
+
return [sys.executable, str(path)]
|
|
455
|
+
return [str(path)]
|
|
456
|
+
|
|
457
|
+
|
|
458
|
+
def resolve_glance_command() -> list[str]:
|
|
459
|
+
discovered = shutil.which("glance")
|
|
460
|
+
if discovered:
|
|
461
|
+
return command_for_path(Path(discovered))
|
|
462
|
+
repository_glance = Path(__file__).resolve().parents[3] / "bin" / "glance"
|
|
463
|
+
if repository_glance.is_file():
|
|
464
|
+
return command_for_path(repository_glance)
|
|
465
|
+
raise FileNotFoundError("glance is not on PATH; install the toolkit first")
|
|
466
|
+
|
|
467
|
+
|
|
468
|
+
def ocr_prompt(mode: str, index: int, total: int, custom: str | None) -> str:
|
|
469
|
+
if mode == "chat":
|
|
470
|
+
instructions = (
|
|
471
|
+
"Transcribe this chat screenshot chunk in strict top-to-bottom message order. "
|
|
472
|
+
"Return only one valid JSON object with this exact shape: "
|
|
473
|
+
'{"messages":[{"speaker":"visible name","content":"message text",'
|
|
474
|
+
'"timestamp":"","message_type":"message","quoted_speaker":"",'
|
|
475
|
+
'"quoted_content":""}]}. '
|
|
476
|
+
"Give every message a speaker and copy the visible nickname exactly; never replace "
|
|
477
|
+
"it with roles such as customer, support, me, or other. If the screenshot shows a "
|
|
478
|
+
"question-mark square glyph in a nickname, preserve it as Unicode U+25A1. When a "
|
|
479
|
+
"chat UI clearly marks an outgoing self-message by alignment and bubble style but "
|
|
480
|
+
"omits its nickname, use You as the speaker. Ignore app chrome such as the status "
|
|
481
|
+
"bar, chat title, pinned-message banner, and composer. Inside the chat history, "
|
|
482
|
+
"transcribe every date separator, service notice, and unread divider as a system "
|
|
483
|
+
"message. Merge "
|
|
484
|
+
"screen-width wrapping back into the same message. Each rounded message bubble is "
|
|
485
|
+
"exactly one message: keep code blocks, bullet lists, attachment filenames, and file "
|
|
486
|
+
"metadata inside that bubble's content instead of creating a second message. Put file "
|
|
487
|
+
"and voice-card titles and metadata on separate lines. For polls, include the visible "
|
|
488
|
+
"poll label and write each option as a bullet line. For photo messages, include visible "
|
|
489
|
+
"overlay text before the caption. Preserve intentional code and list line breaks. "
|
|
490
|
+
"Put replied-to text in "
|
|
491
|
+
"quoted_speaker and quoted_content while keeping the new message in speaker and "
|
|
492
|
+
"content. Fill timestamp only when the entire timestamp is clearly visible; "
|
|
493
|
+
"otherwise leave it empty. message_type must be message, system, image, or file. "
|
|
494
|
+
"Do not summarize, rewrite, translate, or infer clipped text. Use [unreadable] for "
|
|
495
|
+
"visible text that cannot be read and [clipped] for a visibly cut-off message."
|
|
496
|
+
)
|
|
497
|
+
else:
|
|
498
|
+
instructions = (
|
|
499
|
+
"Keep the visible top-to-bottom reading order and preserve wording, punctuation, "
|
|
500
|
+
"line breaks, labels, timestamps, headings, lists, tables, code, quoted text, and "
|
|
501
|
+
"paragraph order. Do not infer clipped or hidden content; write [unreadable] only "
|
|
502
|
+
"where visible text cannot be read."
|
|
503
|
+
)
|
|
504
|
+
chunk_note = f" This is chunk {index} of {total} from one vertically scrolling screenshot."
|
|
505
|
+
custom_note = f" {custom.strip()}" if custom and custom.strip() else ""
|
|
506
|
+
return instructions + chunk_note + custom_note
|
|
507
|
+
|
|
508
|
+
|
|
509
|
+
def join_visual_wraps(content: str, preserve_lines: bool = False) -> str:
|
|
510
|
+
content = content.replace("\r\n", "\n").replace("\r", "\n").strip()
|
|
511
|
+
if not content:
|
|
512
|
+
return ""
|
|
513
|
+
paragraphs = re.split(r"\n\s*\n", content)
|
|
514
|
+
normalized = []
|
|
515
|
+
list_pattern = re.compile(r"^(?:[-*+\u2022] |\d+[.)] )")
|
|
516
|
+
code_line_pattern = re.compile(r"^[A-Za-z0-9_.-]+:\s+\S")
|
|
517
|
+
for paragraph in paragraphs:
|
|
518
|
+
lines = [re.sub(r"[ \t]+", " ", line.strip()) for line in paragraph.splitlines()]
|
|
519
|
+
lines = [line for line in lines if line]
|
|
520
|
+
if not lines:
|
|
521
|
+
continue
|
|
522
|
+
structured_card = any(line.casefold() == "anonymous poll" for line in lines)
|
|
523
|
+
if preserve_lines or structured_card:
|
|
524
|
+
normalized.append("\n".join(lines))
|
|
525
|
+
continue
|
|
526
|
+
merged = lines[0]
|
|
527
|
+
for line in lines[1:]:
|
|
528
|
+
if list_pattern.match(line) or code_line_pattern.match(line):
|
|
529
|
+
merged += "\n" + line
|
|
530
|
+
continue
|
|
531
|
+
separator = (
|
|
532
|
+
" "
|
|
533
|
+
if re.search(r"[A-Za-z0-9]$", merged) and re.match(r"[A-Za-z0-9]", line)
|
|
534
|
+
else ""
|
|
535
|
+
)
|
|
536
|
+
merged += separator + line
|
|
537
|
+
normalized.append(merged)
|
|
538
|
+
return "\n\n".join(normalized)
|
|
539
|
+
|
|
540
|
+
|
|
541
|
+
def normalize_timestamp(value: object) -> str:
|
|
542
|
+
timestamp = str(value or "").strip()
|
|
543
|
+
lowered = timestamp.casefold()
|
|
544
|
+
if any(marker in lowered for marker in ("[unreadable]", "[clipped]")):
|
|
545
|
+
return ""
|
|
546
|
+
if re.search(r"[:\uFF1A]\d$", timestamp):
|
|
547
|
+
return ""
|
|
548
|
+
return timestamp
|
|
549
|
+
|
|
550
|
+
|
|
551
|
+
def parse_chat_messages(raw_text: str) -> tuple[ChatMessage, ...]:
|
|
552
|
+
text = raw_text.strip()
|
|
553
|
+
if text.startswith("```"):
|
|
554
|
+
text = re.sub(r"^```(?:json)?\s*", "", text, flags=re.IGNORECASE)
|
|
555
|
+
text = re.sub(r"\s*```$", "", text)
|
|
556
|
+
start = text.find("{")
|
|
557
|
+
if start < 0:
|
|
558
|
+
raise ValueError("chat OCR did not return a JSON object")
|
|
559
|
+
try:
|
|
560
|
+
payload, _end = json.JSONDecoder().raw_decode(text[start:])
|
|
561
|
+
except json.JSONDecodeError as exc:
|
|
562
|
+
raise ValueError(f"chat OCR returned invalid JSON: {exc.msg}") from exc
|
|
563
|
+
records = payload.get("messages") if isinstance(payload, dict) else None
|
|
564
|
+
if not isinstance(records, list):
|
|
565
|
+
raise ValueError("chat OCR JSON is missing a messages array")
|
|
566
|
+
|
|
567
|
+
messages = []
|
|
568
|
+
for record in records:
|
|
569
|
+
if not isinstance(record, dict):
|
|
570
|
+
continue
|
|
571
|
+
message_type = str(record.get("message_type") or "message").strip().lower()
|
|
572
|
+
if message_type not in {"message", "system", "image", "file"}:
|
|
573
|
+
message_type = "message"
|
|
574
|
+
content = join_visual_wraps(
|
|
575
|
+
str(record.get("content") or ""),
|
|
576
|
+
preserve_lines=message_type in {"image", "file"},
|
|
577
|
+
)
|
|
578
|
+
if not content:
|
|
579
|
+
continue
|
|
580
|
+
speaker = str(record.get("speaker") or "").strip()
|
|
581
|
+
speaker = re.sub(r"\u25a1\s+\u3002", "\u25a1\u3002", speaker)
|
|
582
|
+
if message_type == "system":
|
|
583
|
+
speaker = "system"
|
|
584
|
+
if not speaker:
|
|
585
|
+
speaker = "[unreadable speaker]"
|
|
586
|
+
messages.append(
|
|
587
|
+
ChatMessage(
|
|
588
|
+
speaker=speaker,
|
|
589
|
+
content=content,
|
|
590
|
+
timestamp=normalize_timestamp(record.get("timestamp")),
|
|
591
|
+
message_type=message_type,
|
|
592
|
+
quoted_speaker=str(record.get("quoted_speaker") or "").strip(),
|
|
593
|
+
quoted_content=join_visual_wraps(str(record.get("quoted_content") or "")),
|
|
594
|
+
)
|
|
595
|
+
)
|
|
596
|
+
if not messages:
|
|
597
|
+
raise ValueError("chat OCR JSON contains no readable messages")
|
|
598
|
+
return tuple(messages)
|
|
599
|
+
|
|
600
|
+
|
|
601
|
+
def chat_message_record(message: ChatMessage) -> dict[str, str]:
|
|
602
|
+
return {
|
|
603
|
+
"speaker": message.speaker,
|
|
604
|
+
"content": message.content,
|
|
605
|
+
"timestamp": message.timestamp,
|
|
606
|
+
"message_type": message.message_type,
|
|
607
|
+
"quoted_speaker": message.quoted_speaker,
|
|
608
|
+
"quoted_content": message.quoted_content,
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
|
|
612
|
+
def render_chat_messages(messages: Sequence[ChatMessage]) -> str:
|
|
613
|
+
rendered = []
|
|
614
|
+
for message in messages:
|
|
615
|
+
timestamp = f" ({message.timestamp})" if message.timestamp else ""
|
|
616
|
+
blocks = []
|
|
617
|
+
if message.quoted_content:
|
|
618
|
+
quoted_speaker = message.quoted_speaker or "[quoted speaker]"
|
|
619
|
+
quoted_text = message.quoted_content.replace("\n", "\n> ")
|
|
620
|
+
blocks.append(f"> **{quoted_speaker}**: {quoted_text}")
|
|
621
|
+
blocks.append(f"**{message.speaker}**{timestamp}: {message.content}")
|
|
622
|
+
rendered.append("\n\n".join(blocks))
|
|
623
|
+
return "\n\n".join(rendered)
|
|
624
|
+
|
|
625
|
+
|
|
626
|
+
def recognition_fingerprint(
|
|
627
|
+
chunk: Chunk,
|
|
628
|
+
total: int,
|
|
629
|
+
mode: str,
|
|
630
|
+
custom_prompt: str | None,
|
|
631
|
+
) -> str:
|
|
632
|
+
payload = {
|
|
633
|
+
"prompt_version": OCR_PROMPT_VERSION,
|
|
634
|
+
"image_sha256": chunk.image_sha256,
|
|
635
|
+
"chunk_index": chunk.index,
|
|
636
|
+
"chunk_total": total,
|
|
637
|
+
"mode": mode,
|
|
638
|
+
"custom_prompt": custom_prompt.strip() if custom_prompt else "",
|
|
639
|
+
}
|
|
640
|
+
encoded = json.dumps(payload, ensure_ascii=False, sort_keys=True).encode("utf-8")
|
|
641
|
+
return hashlib.sha256(encoded).hexdigest()
|
|
642
|
+
|
|
643
|
+
|
|
644
|
+
def run_glance(command: Sequence[str], timeout: float, chunk_index: int) -> str:
|
|
645
|
+
completed = subprocess.run(
|
|
646
|
+
command,
|
|
647
|
+
text=True,
|
|
648
|
+
capture_output=True,
|
|
649
|
+
timeout=timeout,
|
|
650
|
+
)
|
|
651
|
+
if completed.returncode != 0:
|
|
652
|
+
detail = completed.stderr.strip() or completed.stdout.strip() or "unknown error"
|
|
653
|
+
raise RuntimeError(f"chunk {chunk_index}: glance failed: {detail}")
|
|
654
|
+
text = completed.stdout.strip()
|
|
655
|
+
if not text:
|
|
656
|
+
raise RuntimeError(f"chunk {chunk_index}: glance returned an empty transcription")
|
|
657
|
+
return text
|
|
658
|
+
|
|
659
|
+
|
|
660
|
+
def recognize_chunk(
|
|
661
|
+
chunk: Chunk,
|
|
662
|
+
total: int,
|
|
663
|
+
glance_command: Sequence[str],
|
|
664
|
+
mode: str,
|
|
665
|
+
custom_prompt: str | None,
|
|
666
|
+
timeout: float,
|
|
667
|
+
resume: bool,
|
|
668
|
+
) -> Transcript:
|
|
669
|
+
output_path = chunk.image_path.with_suffix(".ocr.json" if mode == "chat" else ".ocr.md")
|
|
670
|
+
hash_path = output_path.with_name(output_path.name + ".sha256")
|
|
671
|
+
fingerprint = recognition_fingerprint(chunk, total, mode, custom_prompt)
|
|
672
|
+
if (
|
|
673
|
+
resume
|
|
674
|
+
and output_path.is_file()
|
|
675
|
+
and hash_path.is_file()
|
|
676
|
+
and hash_path.read_text(encoding="utf-8").strip() == fingerprint
|
|
677
|
+
):
|
|
678
|
+
stored = output_path.read_text(encoding="utf-8").strip()
|
|
679
|
+
if mode == "chat":
|
|
680
|
+
messages = parse_chat_messages(stored)
|
|
681
|
+
return Transcript(chunk, render_chat_messages(messages), output_path, True, messages)
|
|
682
|
+
return Transcript(chunk, stored, output_path, True)
|
|
683
|
+
|
|
684
|
+
prompt = ocr_prompt(mode, chunk.index, total, custom_prompt)
|
|
685
|
+
messages: tuple[ChatMessage, ...] = ()
|
|
686
|
+
if mode == "chat":
|
|
687
|
+
retry_note = (
|
|
688
|
+
" Return compact valid JSON only. Escape every newline inside a JSON string as "
|
|
689
|
+
"\\n, close every quote and brace, and do not use a Markdown code fence."
|
|
690
|
+
)
|
|
691
|
+
parse_error: ValueError | None = None
|
|
692
|
+
for attempt in range(2):
|
|
693
|
+
attempt_prompt = prompt + (retry_note if attempt else "")
|
|
694
|
+
command = [*glance_command, str(chunk.image_path), "--query", attempt_prompt]
|
|
695
|
+
text = run_glance(command, timeout, chunk.index)
|
|
696
|
+
try:
|
|
697
|
+
messages = parse_chat_messages(text)
|
|
698
|
+
break
|
|
699
|
+
except ValueError as exc:
|
|
700
|
+
parse_error = exc
|
|
701
|
+
if attempt == 0:
|
|
702
|
+
print(
|
|
703
|
+
f"retrying chunk {chunk.index}/{total} after invalid chat JSON",
|
|
704
|
+
file=sys.stderr,
|
|
705
|
+
)
|
|
706
|
+
else:
|
|
707
|
+
raise RuntimeError(f"chunk {chunk.index}: {parse_error}") from parse_error
|
|
708
|
+
else:
|
|
709
|
+
command = [*glance_command, str(chunk.image_path), "--ocr", prompt]
|
|
710
|
+
text = run_glance(command, timeout, chunk.index)
|
|
711
|
+
if mode == "chat":
|
|
712
|
+
stored = json.dumps(
|
|
713
|
+
{"messages": [chat_message_record(message) for message in messages]},
|
|
714
|
+
ensure_ascii=False,
|
|
715
|
+
indent=2,
|
|
716
|
+
)
|
|
717
|
+
atomic_write_text(output_path, stored + "\n")
|
|
718
|
+
text = render_chat_messages(messages)
|
|
719
|
+
else:
|
|
720
|
+
atomic_write_text(output_path, text + "\n")
|
|
721
|
+
atomic_write_text(hash_path, fingerprint + "\n")
|
|
722
|
+
return Transcript(chunk, text, output_path, False, messages)
|
|
723
|
+
|
|
724
|
+
|
|
725
|
+
def recognize_chunks(
|
|
726
|
+
chunks: Sequence[Chunk],
|
|
727
|
+
glance_command: Sequence[str],
|
|
728
|
+
mode: str,
|
|
729
|
+
custom_prompt: str | None,
|
|
730
|
+
timeout: float,
|
|
731
|
+
jobs: int,
|
|
732
|
+
resume: bool,
|
|
733
|
+
) -> list[Transcript]:
|
|
734
|
+
results: dict[int, Transcript] = {}
|
|
735
|
+
worker_count = min(max(1, jobs), len(chunks))
|
|
736
|
+
with ThreadPoolExecutor(max_workers=worker_count) as executor:
|
|
737
|
+
futures = {
|
|
738
|
+
executor.submit(
|
|
739
|
+
recognize_chunk,
|
|
740
|
+
chunk,
|
|
741
|
+
len(chunks),
|
|
742
|
+
glance_command,
|
|
743
|
+
mode,
|
|
744
|
+
custom_prompt,
|
|
745
|
+
timeout,
|
|
746
|
+
resume,
|
|
747
|
+
): chunk
|
|
748
|
+
for chunk in chunks
|
|
749
|
+
}
|
|
750
|
+
try:
|
|
751
|
+
for future in as_completed(futures):
|
|
752
|
+
transcript = future.result()
|
|
753
|
+
results[transcript.chunk.index] = transcript
|
|
754
|
+
state = "reused" if transcript.reused else "recognized"
|
|
755
|
+
print(
|
|
756
|
+
f"{state} chunk {transcript.chunk.index}/{len(chunks)}",
|
|
757
|
+
file=sys.stderr,
|
|
758
|
+
)
|
|
759
|
+
except Exception:
|
|
760
|
+
for future in futures:
|
|
761
|
+
future.cancel()
|
|
762
|
+
raise
|
|
763
|
+
return [results[index] for index in sorted(results)]
|
|
764
|
+
|
|
765
|
+
|
|
766
|
+
def trim_outer_blank_lines(text: str) -> list[str]:
|
|
767
|
+
lines = [line.rstrip() for line in text.replace("\r\n", "\n").replace("\r", "\n").split("\n")]
|
|
768
|
+
while lines and not lines[0].strip():
|
|
769
|
+
lines.pop(0)
|
|
770
|
+
while lines and not lines[-1].strip():
|
|
771
|
+
lines.pop()
|
|
772
|
+
return lines
|
|
773
|
+
|
|
774
|
+
|
|
775
|
+
def normalized_line(line: str) -> str:
|
|
776
|
+
normalized = unicodedata.normalize("NFKC", line)
|
|
777
|
+
return " ".join(normalized.casefold().split())
|
|
778
|
+
|
|
779
|
+
|
|
780
|
+
def find_text_overlap(previous: Sequence[str], current: Sequence[str]) -> tuple[int, str]:
|
|
781
|
+
maximum = min(24, len(previous), len(current))
|
|
782
|
+
for count in range(maximum, 0, -1):
|
|
783
|
+
left = [normalized_line(line) for line in previous[-count:]]
|
|
784
|
+
right = [normalized_line(line) for line in current[:count]]
|
|
785
|
+
if left == right and any(left):
|
|
786
|
+
return count, "exact"
|
|
787
|
+
|
|
788
|
+
for count in range(min(3, maximum), 0, -1):
|
|
789
|
+
left = [normalized_line(line) for line in previous[-count:]]
|
|
790
|
+
right = [normalized_line(line) for line in current[:count]]
|
|
791
|
+
joined_length = sum(len(line) for line in left + right)
|
|
792
|
+
if joined_length < 24 or not all(left) or not all(right):
|
|
793
|
+
continue
|
|
794
|
+
ratios = [SequenceMatcher(None, a, b).ratio() for a, b in zip(left, right)]
|
|
795
|
+
if min(ratios) >= 0.92 and sum(ratios) / len(ratios) >= 0.96:
|
|
796
|
+
return count, "fuzzy"
|
|
797
|
+
return 0, "none"
|
|
798
|
+
|
|
799
|
+
|
|
800
|
+
def message_fingerprint(message: ChatMessage) -> tuple[str, str, str]:
|
|
801
|
+
def simplify(value: str) -> str:
|
|
802
|
+
return re.sub(r"[\W_]+", "", value, flags=re.UNICODE).casefold()
|
|
803
|
+
|
|
804
|
+
return (
|
|
805
|
+
simplify(message.speaker),
|
|
806
|
+
simplify(message.content),
|
|
807
|
+
simplify(message.quoted_content),
|
|
808
|
+
)
|
|
809
|
+
|
|
810
|
+
|
|
811
|
+
def unreadable_speaker(value: str) -> bool:
|
|
812
|
+
lowered = value.casefold()
|
|
813
|
+
return not value.strip() or "unreadable" in lowered or "clipped" in lowered
|
|
814
|
+
|
|
815
|
+
|
|
816
|
+
def high_confidence_message_match(left: ChatMessage, right: ChatMessage) -> bool:
|
|
817
|
+
if (left.message_type == "system") != (right.message_type == "system"):
|
|
818
|
+
return False
|
|
819
|
+
|
|
820
|
+
left_speaker, left_content, left_quote = message_fingerprint(left)
|
|
821
|
+
right_speaker, right_content, right_quote = message_fingerprint(right)
|
|
822
|
+
speakers_match = (
|
|
823
|
+
left_speaker == right_speaker
|
|
824
|
+
or unreadable_speaker(left.speaker)
|
|
825
|
+
or unreadable_speaker(right.speaker)
|
|
826
|
+
)
|
|
827
|
+
timestamps_match = (
|
|
828
|
+
not left.timestamp
|
|
829
|
+
or not right.timestamp
|
|
830
|
+
or left.timestamp == right.timestamp
|
|
831
|
+
)
|
|
832
|
+
quotes_match = (
|
|
833
|
+
not left_quote
|
|
834
|
+
or not right_quote
|
|
835
|
+
or left_quote == right_quote
|
|
836
|
+
)
|
|
837
|
+
if not speakers_match or not timestamps_match or not quotes_match:
|
|
838
|
+
return False
|
|
839
|
+
if left_content == right_content and left_content:
|
|
840
|
+
return True
|
|
841
|
+
if min(len(left_content), len(right_content)) < 32:
|
|
842
|
+
return False
|
|
843
|
+
return SequenceMatcher(None, left_content, right_content).ratio() >= 0.97
|
|
844
|
+
|
|
845
|
+
|
|
846
|
+
def richer_text(left: str, right: str) -> str:
|
|
847
|
+
def score(value: str) -> tuple[int, int, int]:
|
|
848
|
+
lowered = value.casefold()
|
|
849
|
+
marker_penalty = lowered.count("[clipped]") + lowered.count("[unreadable]")
|
|
850
|
+
return (-marker_penalty, value.count("\n"), len(value))
|
|
851
|
+
|
|
852
|
+
return max((left, right), key=score)
|
|
853
|
+
|
|
854
|
+
|
|
855
|
+
def merge_duplicate_message(left: ChatMessage, right: ChatMessage) -> ChatMessage:
|
|
856
|
+
speaker = left.speaker
|
|
857
|
+
if unreadable_speaker(speaker) and not unreadable_speaker(right.speaker):
|
|
858
|
+
speaker = right.speaker
|
|
859
|
+
quoted_speaker = left.quoted_speaker
|
|
860
|
+
if unreadable_speaker(quoted_speaker) and not unreadable_speaker(right.quoted_speaker):
|
|
861
|
+
quoted_speaker = right.quoted_speaker
|
|
862
|
+
return replace(
|
|
863
|
+
left,
|
|
864
|
+
speaker=speaker,
|
|
865
|
+
content=richer_text(left.content, right.content),
|
|
866
|
+
timestamp=left.timestamp or right.timestamp,
|
|
867
|
+
message_type=(
|
|
868
|
+
right.message_type
|
|
869
|
+
if left.message_type == "message" and right.message_type != "message"
|
|
870
|
+
else left.message_type
|
|
871
|
+
),
|
|
872
|
+
quoted_speaker=quoted_speaker,
|
|
873
|
+
quoted_content=richer_text(left.quoted_content, right.quoted_content),
|
|
874
|
+
)
|
|
875
|
+
|
|
876
|
+
|
|
877
|
+
def canonicalize_speakers(messages: Sequence[ChatMessage]) -> list[ChatMessage]:
|
|
878
|
+
def key(value: str) -> str:
|
|
879
|
+
return re.sub(r"[\W_]+", "", value, flags=re.UNICODE).casefold()
|
|
880
|
+
|
|
881
|
+
variants: dict[str, set[str]] = {}
|
|
882
|
+
for message in messages:
|
|
883
|
+
for value in (message.speaker, message.quoted_speaker):
|
|
884
|
+
if value:
|
|
885
|
+
variants.setdefault(key(value), set()).add(value)
|
|
886
|
+
canonical = {
|
|
887
|
+
fingerprint: min(
|
|
888
|
+
choices,
|
|
889
|
+
key=lambda value: (sum(char.isspace() for char in value), len(value), value),
|
|
890
|
+
)
|
|
891
|
+
for fingerprint, choices in variants.items()
|
|
892
|
+
}
|
|
893
|
+
return [
|
|
894
|
+
replace(
|
|
895
|
+
message,
|
|
896
|
+
speaker=canonical.get(key(message.speaker), message.speaker),
|
|
897
|
+
quoted_speaker=(
|
|
898
|
+
canonical.get(key(message.quoted_speaker), message.quoted_speaker)
|
|
899
|
+
if message.quoted_speaker
|
|
900
|
+
else ""
|
|
901
|
+
),
|
|
902
|
+
)
|
|
903
|
+
for message in messages
|
|
904
|
+
]
|
|
905
|
+
|
|
906
|
+
|
|
907
|
+
def find_message_overlap(
|
|
908
|
+
previous: Sequence[ChatMessage], current: Sequence[ChatMessage]
|
|
909
|
+
) -> tuple[int, str]:
|
|
910
|
+
maximum = min(8, len(previous), len(current))
|
|
911
|
+
for count in range(maximum, 0, -1):
|
|
912
|
+
left = [message_fingerprint(message) for message in previous[-count:]]
|
|
913
|
+
right = [message_fingerprint(message) for message in current[:count]]
|
|
914
|
+
if left == right and any(any(part for part in item) for item in left):
|
|
915
|
+
return count, "message-exact"
|
|
916
|
+
for count in range(maximum, 0, -1):
|
|
917
|
+
pairs = zip(previous[-count:], current[:count])
|
|
918
|
+
if all(high_confidence_message_match(left, right) for left, right in pairs):
|
|
919
|
+
return count, "message-fuzzy"
|
|
920
|
+
return 0, "none"
|
|
921
|
+
|
|
922
|
+
|
|
923
|
+
def merge_general_transcripts(
|
|
924
|
+
transcripts: Sequence[Transcript],
|
|
925
|
+
) -> tuple[str, list[dict[str, int | str]]]:
|
|
926
|
+
merged: list[str] = []
|
|
927
|
+
boundaries = []
|
|
928
|
+
for position, transcript in enumerate(transcripts):
|
|
929
|
+
current = trim_outer_blank_lines(transcript.text)
|
|
930
|
+
if position == 0:
|
|
931
|
+
merged.extend(current)
|
|
932
|
+
continue
|
|
933
|
+
previous_chunk = transcripts[position - 1].chunk
|
|
934
|
+
pixel_overlap = previous_chunk.bottom_overlap + transcript.chunk.top_overlap
|
|
935
|
+
if pixel_overlap:
|
|
936
|
+
overlap_lines, method = find_text_overlap(merged, current)
|
|
937
|
+
else:
|
|
938
|
+
overlap_lines, method = 0, "not-needed"
|
|
939
|
+
boundaries.append(
|
|
940
|
+
{
|
|
941
|
+
"after_chunk": transcripts[position - 1].chunk.index,
|
|
942
|
+
"before_chunk": transcript.chunk.index,
|
|
943
|
+
"removed_items": overlap_lines,
|
|
944
|
+
"unit": "lines",
|
|
945
|
+
"method": method,
|
|
946
|
+
}
|
|
947
|
+
)
|
|
948
|
+
merged.extend(current[overlap_lines:])
|
|
949
|
+
return "\n".join(merged).strip() + "\n", boundaries
|
|
950
|
+
|
|
951
|
+
|
|
952
|
+
def merge_chat_transcripts(
|
|
953
|
+
transcripts: Sequence[Transcript],
|
|
954
|
+
) -> tuple[str, list[dict[str, int | str]]]:
|
|
955
|
+
merged: list[ChatMessage] = []
|
|
956
|
+
boundaries = []
|
|
957
|
+
for position, transcript in enumerate(transcripts):
|
|
958
|
+
current = list(transcript.messages)
|
|
959
|
+
if position == 0:
|
|
960
|
+
merged.extend(current)
|
|
961
|
+
continue
|
|
962
|
+
previous_chunk = transcripts[position - 1].chunk
|
|
963
|
+
pixel_overlap = previous_chunk.bottom_overlap + transcript.chunk.top_overlap
|
|
964
|
+
removed, method = (
|
|
965
|
+
find_message_overlap(merged, current) if pixel_overlap else (0, "not-needed")
|
|
966
|
+
)
|
|
967
|
+
if removed:
|
|
968
|
+
merged[-removed:] = [
|
|
969
|
+
merge_duplicate_message(left, right)
|
|
970
|
+
for left, right in zip(merged[-removed:], current[:removed])
|
|
971
|
+
]
|
|
972
|
+
boundaries.append(
|
|
973
|
+
{
|
|
974
|
+
"after_chunk": previous_chunk.index,
|
|
975
|
+
"before_chunk": transcript.chunk.index,
|
|
976
|
+
"removed_items": removed,
|
|
977
|
+
"unit": "messages",
|
|
978
|
+
"method": method,
|
|
979
|
+
}
|
|
980
|
+
)
|
|
981
|
+
merged.extend(current[removed:])
|
|
982
|
+
return render_chat_messages(canonicalize_speakers(merged)).strip() + "\n", boundaries
|
|
983
|
+
|
|
984
|
+
|
|
985
|
+
def merge_transcripts(
|
|
986
|
+
transcripts: Sequence[Transcript],
|
|
987
|
+
) -> tuple[str, list[dict[str, int | str]]]:
|
|
988
|
+
if transcripts and all(transcript.messages for transcript in transcripts):
|
|
989
|
+
return merge_chat_transcripts(transcripts)
|
|
990
|
+
return merge_general_transcripts(transcripts)
|
|
991
|
+
|
|
992
|
+
|
|
993
|
+
def chunk_record(chunk: Chunk, transcript: Transcript | None = None) -> dict[str, object]:
|
|
994
|
+
record: dict[str, object] = {
|
|
995
|
+
"index": chunk.index,
|
|
996
|
+
"image": chunk.image_path.name,
|
|
997
|
+
"image_sha256": chunk.image_sha256,
|
|
998
|
+
"core_top": chunk.core_top,
|
|
999
|
+
"core_bottom": chunk.core_bottom,
|
|
1000
|
+
"crop_top": chunk.crop_top,
|
|
1001
|
+
"crop_bottom": chunk.crop_bottom,
|
|
1002
|
+
"top_overlap": chunk.top_overlap,
|
|
1003
|
+
"bottom_overlap": chunk.bottom_overlap,
|
|
1004
|
+
"cut_energy": chunk.cut_energy,
|
|
1005
|
+
"cut_quality": chunk.cut_quality,
|
|
1006
|
+
"top_safe_margin": chunk.top_safe_margin,
|
|
1007
|
+
"bottom_safe_margin": chunk.bottom_safe_margin,
|
|
1008
|
+
}
|
|
1009
|
+
if transcript is not None:
|
|
1010
|
+
record.update(
|
|
1011
|
+
{
|
|
1012
|
+
"ocr": transcript.output_path.name,
|
|
1013
|
+
"ocr_reused": transcript.reused,
|
|
1014
|
+
}
|
|
1015
|
+
)
|
|
1016
|
+
return record
|
|
1017
|
+
|
|
1018
|
+
|
|
1019
|
+
def write_manifest(
|
|
1020
|
+
path: Path,
|
|
1021
|
+
input_path: Path,
|
|
1022
|
+
image_size: tuple[int, int],
|
|
1023
|
+
mode: str,
|
|
1024
|
+
split_sizes: tuple[int, int, int, int],
|
|
1025
|
+
analysis: dict[str, float],
|
|
1026
|
+
chunks: Sequence[Chunk],
|
|
1027
|
+
transcripts: Sequence[Transcript] | None = None,
|
|
1028
|
+
boundaries: Sequence[dict[str, int | str]] | None = None,
|
|
1029
|
+
output_path: Path | None = None,
|
|
1030
|
+
) -> None:
|
|
1031
|
+
transcript_by_index = {
|
|
1032
|
+
transcript.chunk.index: transcript for transcript in transcripts or []
|
|
1033
|
+
}
|
|
1034
|
+
target, minimum, maximum, overlap = split_sizes
|
|
1035
|
+
payload = {
|
|
1036
|
+
"schema_version": 1,
|
|
1037
|
+
"input": str(input_path),
|
|
1038
|
+
"image_width": image_size[0],
|
|
1039
|
+
"image_height": image_size[1],
|
|
1040
|
+
"mode": mode,
|
|
1041
|
+
"target_height": target,
|
|
1042
|
+
"min_height": minimum,
|
|
1043
|
+
"max_height": maximum,
|
|
1044
|
+
"fallback_overlap": overlap,
|
|
1045
|
+
"analysis": analysis,
|
|
1046
|
+
"chunks": [
|
|
1047
|
+
chunk_record(chunk, transcript_by_index.get(chunk.index)) for chunk in chunks
|
|
1048
|
+
],
|
|
1049
|
+
"merge_boundaries": list(boundaries or []),
|
|
1050
|
+
"output": str(output_path) if output_path else None,
|
|
1051
|
+
"complete": transcripts is not None,
|
|
1052
|
+
}
|
|
1053
|
+
atomic_write_text(path, json.dumps(payload, ensure_ascii=False, indent=2) + "\n")
|
|
1054
|
+
|
|
1055
|
+
|
|
1056
|
+
def write_audit(
|
|
1057
|
+
path: Path,
|
|
1058
|
+
input_path: Path,
|
|
1059
|
+
chunks: Sequence[Chunk],
|
|
1060
|
+
boundaries: Sequence[dict[str, int | str]],
|
|
1061
|
+
) -> None:
|
|
1062
|
+
boundary_by_after = {int(item["after_chunk"]): item for item in boundaries}
|
|
1063
|
+
lines = [
|
|
1064
|
+
"# Long-screenshot OCR audit",
|
|
1065
|
+
"",
|
|
1066
|
+
f"- Source: `{input_path}`",
|
|
1067
|
+
f"- Chunks: {len(chunks)}",
|
|
1068
|
+
"",
|
|
1069
|
+
"| Boundary | Pixel overlap | Removed overlap | Match | Review |",
|
|
1070
|
+
"|---|---:|---:|---|---|",
|
|
1071
|
+
]
|
|
1072
|
+
if len(chunks) == 1:
|
|
1073
|
+
lines.append("| none | 0 px | 0 | none | no |")
|
|
1074
|
+
for position, chunk in enumerate(chunks[:-1]):
|
|
1075
|
+
boundary = boundary_by_after.get(chunk.index, {})
|
|
1076
|
+
pixel_overlap = chunk.bottom_overlap + chunks[position + 1].top_overlap
|
|
1077
|
+
removed = int(boundary.get("removed_items", 0))
|
|
1078
|
+
unit = str(boundary.get("unit", "lines"))
|
|
1079
|
+
method = str(boundary.get("method", "none"))
|
|
1080
|
+
review = "yes" if pixel_overlap > 0 or method == "fuzzy" else "no"
|
|
1081
|
+
lines.append(
|
|
1082
|
+
f"| {chunk.index} -> {chunk.index + 1} | {pixel_overlap}px | "
|
|
1083
|
+
f"{removed} {unit} | {method} | {review} |"
|
|
1084
|
+
)
|
|
1085
|
+
lines.extend(
|
|
1086
|
+
[
|
|
1087
|
+
"",
|
|
1088
|
+
"Review every boundary marked `yes` against the two adjacent chunk images. "
|
|
1089
|
+
"The merger removes only exact or very high-confidence repeated lines or messages.",
|
|
1090
|
+
"",
|
|
1091
|
+
]
|
|
1092
|
+
)
|
|
1093
|
+
atomic_write_text(path, "\n".join(lines))
|
|
1094
|
+
|
|
1095
|
+
|
|
1096
|
+
def parse_args() -> argparse.Namespace:
|
|
1097
|
+
parser = argparse.ArgumentParser(
|
|
1098
|
+
prog="long_screenshot_ocr",
|
|
1099
|
+
description="Safely split a tall screenshot, OCR each chunk with glance, and merge it",
|
|
1100
|
+
)
|
|
1101
|
+
parser.add_argument("input", type=Path, help="long screenshot path")
|
|
1102
|
+
parser.add_argument(
|
|
1103
|
+
"--mode",
|
|
1104
|
+
choices=("general", "chat"),
|
|
1105
|
+
default="general",
|
|
1106
|
+
help="content mode (default: general)",
|
|
1107
|
+
)
|
|
1108
|
+
parser.add_argument("-o", "--output", type=Path, help="merged Markdown output path")
|
|
1109
|
+
parser.add_argument("--chunks-dir", type=Path, help="chunk images and audit directory")
|
|
1110
|
+
parser.add_argument("--target-height", type=int, help="preferred core chunk height")
|
|
1111
|
+
parser.add_argument("--min-height", type=int, help="minimum core chunk height")
|
|
1112
|
+
parser.add_argument("--max-height", type=int, help="maximum core chunk height")
|
|
1113
|
+
parser.add_argument(
|
|
1114
|
+
"--overlap",
|
|
1115
|
+
type=int,
|
|
1116
|
+
help="fallback pixel overlap when no safe low-content cut band is found",
|
|
1117
|
+
)
|
|
1118
|
+
parser.add_argument("--prompt", help="additional OCR requirements passed to glance")
|
|
1119
|
+
parser.add_argument(
|
|
1120
|
+
"--jobs",
|
|
1121
|
+
type=int,
|
|
1122
|
+
default=2,
|
|
1123
|
+
help="parallel glance processes (default: 2)",
|
|
1124
|
+
)
|
|
1125
|
+
parser.add_argument(
|
|
1126
|
+
"--timeout",
|
|
1127
|
+
type=float,
|
|
1128
|
+
default=180,
|
|
1129
|
+
help="timeout in seconds for each glance call (default: 180)",
|
|
1130
|
+
)
|
|
1131
|
+
parser.add_argument(
|
|
1132
|
+
"--split-only",
|
|
1133
|
+
action="store_true",
|
|
1134
|
+
help="write chunks and manifest without calling the vision API",
|
|
1135
|
+
)
|
|
1136
|
+
parser.add_argument(
|
|
1137
|
+
"--resume",
|
|
1138
|
+
action="store_true",
|
|
1139
|
+
help="reuse OCR sidecars whose chunk, mode, and prompt fingerprint still matches",
|
|
1140
|
+
)
|
|
1141
|
+
return parser.parse_args()
|
|
1142
|
+
|
|
1143
|
+
|
|
1144
|
+
def main() -> None:
|
|
1145
|
+
parser = argparse.ArgumentParser(add_help=False)
|
|
1146
|
+
args = parse_args()
|
|
1147
|
+
if Image is None:
|
|
1148
|
+
parser.exit(1, "long_screenshot_ocr: requires Pillow; install pillow first\n")
|
|
1149
|
+
if args.jobs <= 0:
|
|
1150
|
+
parser.exit(1, "long_screenshot_ocr: --jobs must be greater than zero\n")
|
|
1151
|
+
if args.timeout <= 0:
|
|
1152
|
+
parser.exit(1, "long_screenshot_ocr: --timeout must be greater than zero\n")
|
|
1153
|
+
|
|
1154
|
+
input_path = args.input.expanduser().resolve()
|
|
1155
|
+
if not input_path.is_file():
|
|
1156
|
+
parser.exit(1, f"long_screenshot_ocr: image not found: {input_path}\n")
|
|
1157
|
+
output_path = (
|
|
1158
|
+
args.output.expanduser().resolve()
|
|
1159
|
+
if args.output
|
|
1160
|
+
else input_path.with_name(input_path.stem + ".ocr.md")
|
|
1161
|
+
)
|
|
1162
|
+
chunks_dir = (
|
|
1163
|
+
args.chunks_dir.expanduser().resolve()
|
|
1164
|
+
if args.chunks_dir
|
|
1165
|
+
else input_path.with_name(input_path.stem + "_chunks")
|
|
1166
|
+
)
|
|
1167
|
+
manifest_path = chunks_dir / "manifest.json"
|
|
1168
|
+
if output_path == input_path:
|
|
1169
|
+
parser.exit(1, "long_screenshot_ocr: output must not overwrite the source image\n")
|
|
1170
|
+
if chunks_dir.exists() and not chunks_dir.is_dir():
|
|
1171
|
+
parser.exit(1, f"long_screenshot_ocr: chunks path is not a directory: {chunks_dir}\n")
|
|
1172
|
+
reserved_output = output_path.parent == chunks_dir and (
|
|
1173
|
+
output_path.name in {"manifest.json", "ocr_audit.md"}
|
|
1174
|
+
or re.fullmatch(r"chunk_\d+\..+", output_path.name)
|
|
1175
|
+
)
|
|
1176
|
+
if reserved_output:
|
|
1177
|
+
parser.exit(1, "long_screenshot_ocr: output conflicts with generated chunk artifacts\n")
|
|
1178
|
+
|
|
1179
|
+
try:
|
|
1180
|
+
with Image.open(input_path) as source:
|
|
1181
|
+
image = ImageOps.exif_transpose(source)
|
|
1182
|
+
image.load()
|
|
1183
|
+
split_sizes = resolve_split_sizes(
|
|
1184
|
+
image.width,
|
|
1185
|
+
args.mode,
|
|
1186
|
+
args.target_height,
|
|
1187
|
+
args.min_height,
|
|
1188
|
+
args.max_height,
|
|
1189
|
+
args.overlap,
|
|
1190
|
+
)
|
|
1191
|
+
target, minimum, maximum, overlap = split_sizes
|
|
1192
|
+
ranges, analysis = find_core_ranges(
|
|
1193
|
+
image,
|
|
1194
|
+
args.mode,
|
|
1195
|
+
target,
|
|
1196
|
+
minimum,
|
|
1197
|
+
maximum,
|
|
1198
|
+
)
|
|
1199
|
+
chunks = save_chunks(image, ranges, chunks_dir, overlap)
|
|
1200
|
+
write_manifest(
|
|
1201
|
+
manifest_path,
|
|
1202
|
+
input_path,
|
|
1203
|
+
image.size,
|
|
1204
|
+
args.mode,
|
|
1205
|
+
split_sizes,
|
|
1206
|
+
analysis,
|
|
1207
|
+
chunks,
|
|
1208
|
+
)
|
|
1209
|
+
if args.split_only:
|
|
1210
|
+
print(manifest_path)
|
|
1211
|
+
return
|
|
1212
|
+
|
|
1213
|
+
glance_command = resolve_glance_command()
|
|
1214
|
+
transcripts = recognize_chunks(
|
|
1215
|
+
chunks,
|
|
1216
|
+
glance_command,
|
|
1217
|
+
args.mode,
|
|
1218
|
+
args.prompt,
|
|
1219
|
+
args.timeout,
|
|
1220
|
+
args.jobs,
|
|
1221
|
+
args.resume,
|
|
1222
|
+
)
|
|
1223
|
+
output_text, boundaries = merge_transcripts(transcripts)
|
|
1224
|
+
atomic_write_text(output_path, output_text)
|
|
1225
|
+
audit_path = chunks_dir / "ocr_audit.md"
|
|
1226
|
+
write_audit(audit_path, input_path, chunks, boundaries)
|
|
1227
|
+
write_manifest(
|
|
1228
|
+
manifest_path,
|
|
1229
|
+
input_path,
|
|
1230
|
+
image.size,
|
|
1231
|
+
args.mode,
|
|
1232
|
+
split_sizes,
|
|
1233
|
+
analysis,
|
|
1234
|
+
chunks,
|
|
1235
|
+
transcripts,
|
|
1236
|
+
boundaries,
|
|
1237
|
+
output_path,
|
|
1238
|
+
)
|
|
1239
|
+
except (OSError, ValueError, FileNotFoundError, RuntimeError, subprocess.TimeoutExpired) as exc:
|
|
1240
|
+
parser.exit(1, f"long_screenshot_ocr: {exc}\n")
|
|
1241
|
+
print(output_path)
|
|
1242
|
+
|
|
1243
|
+
|
|
1244
|
+
if __name__ == "__main__":
|
|
1245
|
+
main()
|