BabelDOC 0.1.6__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.
- babeldoc/__init__.py +1 -0
- babeldoc/asynchronize/__init__.py +51 -0
- babeldoc/const.py +7 -0
- babeldoc/converter.py +510 -0
- babeldoc/document_il/__init__.py +43 -0
- babeldoc/document_il/backend/__init__.py +0 -0
- babeldoc/document_il/backend/pdf_creater.py +402 -0
- babeldoc/document_il/frontend/__init__.py +0 -0
- babeldoc/document_il/frontend/il_creater.py +348 -0
- babeldoc/document_il/il_version_1.py +799 -0
- babeldoc/document_il/il_version_1.rnc +141 -0
- babeldoc/document_il/il_version_1.rng +390 -0
- babeldoc/document_il/il_version_1.xsd +235 -0
- babeldoc/document_il/midend/__init__.py +0 -0
- babeldoc/document_il/midend/add_debug_information.py +138 -0
- babeldoc/document_il/midend/il_translator.py +553 -0
- babeldoc/document_il/midend/layout_parser.py +180 -0
- babeldoc/document_il/midend/paragraph_finder.py +452 -0
- babeldoc/document_il/midend/remove_descent.py +168 -0
- babeldoc/document_il/midend/styles_and_formulas.py +701 -0
- babeldoc/document_il/midend/typesetting.py +761 -0
- babeldoc/document_il/translator/__init__.py +0 -0
- babeldoc/document_il/translator/cache.py +145 -0
- babeldoc/document_il/translator/translator.py +267 -0
- babeldoc/document_il/utils/fontmap.py +227 -0
- babeldoc/document_il/utils/layout_helper.py +467 -0
- babeldoc/document_il/utils/style_helper.py +86 -0
- babeldoc/document_il/xml_converter.py +51 -0
- babeldoc/docvision/README.md +0 -0
- babeldoc/docvision/__init__.py +0 -0
- babeldoc/docvision/doclayout.py +278 -0
- babeldoc/docvision/rpc_doclayout.py +162 -0
- babeldoc/high_level.py +468 -0
- babeldoc/main.py +466 -0
- babeldoc/pdfinterp.py +464 -0
- babeldoc/progress_monitor.py +146 -0
- babeldoc/translation_config.py +156 -0
- babeldoc-0.1.6.dist-info/METADATA +283 -0
- babeldoc-0.1.6.dist-info/RECORD +42 -0
- babeldoc-0.1.6.dist-info/WHEEL +4 -0
- babeldoc-0.1.6.dist-info/entry_points.txt +2 -0
- babeldoc-0.1.6.dist-info/licenses/LICENSE +661 -0
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
import abc
|
|
2
|
+
import ast
|
|
3
|
+
import os.path
|
|
4
|
+
import platform
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
import cv2
|
|
8
|
+
import numpy as np
|
|
9
|
+
import onnx
|
|
10
|
+
import onnxruntime
|
|
11
|
+
from huggingface_hub import hf_hub_download
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class DocLayoutModel(abc.ABC):
|
|
15
|
+
@staticmethod
|
|
16
|
+
def load_onnx():
|
|
17
|
+
model = OnnxModel.from_pretrained(
|
|
18
|
+
repo_id="wybxc/DocLayout-YOLO-DocStructBench-onnx",
|
|
19
|
+
filename="doclayout_yolo_docstructbench_imgsz1024.onnx",
|
|
20
|
+
)
|
|
21
|
+
return model
|
|
22
|
+
|
|
23
|
+
@staticmethod
|
|
24
|
+
def load_available():
|
|
25
|
+
return DocLayoutModel.load_onnx()
|
|
26
|
+
|
|
27
|
+
@property
|
|
28
|
+
@abc.abstractmethod
|
|
29
|
+
def stride(self) -> int:
|
|
30
|
+
"""Stride of the model input."""
|
|
31
|
+
pass
|
|
32
|
+
|
|
33
|
+
@abc.abstractmethod
|
|
34
|
+
def predict(self, image, imgsz=1024, **kwargs) -> list:
|
|
35
|
+
"""
|
|
36
|
+
Predict the layout of a document page.
|
|
37
|
+
|
|
38
|
+
Args:
|
|
39
|
+
image: The image of the document page.
|
|
40
|
+
imgsz: Resize the image to this size. Must be a multiple of the stride.
|
|
41
|
+
**kwargs: Additional arguments.
|
|
42
|
+
"""
|
|
43
|
+
pass
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class YoloResult:
|
|
47
|
+
"""Helper class to store detection results from ONNX model."""
|
|
48
|
+
|
|
49
|
+
def __init__(self, names, boxes=None, boxes_data=None):
|
|
50
|
+
if boxes is not None:
|
|
51
|
+
self.boxes = boxes
|
|
52
|
+
else:
|
|
53
|
+
assert boxes_data is not None
|
|
54
|
+
self.boxes = [YoloBox(data=d) for d in boxes_data]
|
|
55
|
+
self.boxes.sort(key=lambda x: x.conf, reverse=True)
|
|
56
|
+
self.names = names
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class YoloBox:
|
|
60
|
+
"""Helper class to store detection results from ONNX model."""
|
|
61
|
+
|
|
62
|
+
def __init__(self, data=None, xyxy=None, conf=None, cls=None):
|
|
63
|
+
if data is not None:
|
|
64
|
+
self.xyxy = data[:4]
|
|
65
|
+
self.conf = data[-2]
|
|
66
|
+
self.cls = data[-1]
|
|
67
|
+
return
|
|
68
|
+
assert xyxy is not None and conf is not None and cls is not None
|
|
69
|
+
self.xyxy = xyxy
|
|
70
|
+
self.conf = conf
|
|
71
|
+
self.cls = cls
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
# 检测操作系统类型
|
|
75
|
+
os_name = platform.system()
|
|
76
|
+
|
|
77
|
+
providers = []
|
|
78
|
+
|
|
79
|
+
if os_name == "Darwin": # Temporarily disable CoreML due to batch inference issues
|
|
80
|
+
providers.append(
|
|
81
|
+
(
|
|
82
|
+
"CoreMLExecutionProvider",
|
|
83
|
+
{
|
|
84
|
+
"ModelFormat": "MLProgram",
|
|
85
|
+
"MLComputeUnits": "ALL",
|
|
86
|
+
"RequireStaticInputShapes": "0",
|
|
87
|
+
"EnableOnSubgraphs": "0",
|
|
88
|
+
},
|
|
89
|
+
),
|
|
90
|
+
)
|
|
91
|
+
# workaround for CoreML batch inference issues
|
|
92
|
+
max_batch_size = 1
|
|
93
|
+
else:
|
|
94
|
+
max_batch_size = 1024
|
|
95
|
+
providers.append("CPUExecutionProvider") # CPU执行提供者作为通用后备选项
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
class OnnxModel(DocLayoutModel):
|
|
99
|
+
def __init__(self, model_path: str):
|
|
100
|
+
self.model_path = model_path
|
|
101
|
+
|
|
102
|
+
model = onnx.load(model_path)
|
|
103
|
+
metadata = {d.key: d.value for d in model.metadata_props}
|
|
104
|
+
self._stride = ast.literal_eval(metadata["stride"])
|
|
105
|
+
self._names = ast.literal_eval(metadata["names"])
|
|
106
|
+
|
|
107
|
+
self.model = onnxruntime.InferenceSession(
|
|
108
|
+
model.SerializeToString(),
|
|
109
|
+
providers=providers,
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
@staticmethod
|
|
113
|
+
def from_pretrained(repo_id: str, filename: str):
|
|
114
|
+
if os.environ.get("USE_MODELSCOPE", "0") == "1":
|
|
115
|
+
repo_mapping = {
|
|
116
|
+
# Edit here to add more models
|
|
117
|
+
"wybxc/DocLayout-YOLO-DocStructBench-onnx": "AI-ModelScope/DocLayout-YOLO-DocStructBench-onnx",
|
|
118
|
+
}
|
|
119
|
+
from modelscope import snapshot_download
|
|
120
|
+
|
|
121
|
+
model_dir = snapshot_download(repo_mapping[repo_id])
|
|
122
|
+
pth = Path(model_dir) / filename
|
|
123
|
+
else:
|
|
124
|
+
try:
|
|
125
|
+
pth = hf_hub_download(
|
|
126
|
+
repo_id=repo_id,
|
|
127
|
+
filename=filename,
|
|
128
|
+
etag_timeout=1,
|
|
129
|
+
local_files_only=True,
|
|
130
|
+
)
|
|
131
|
+
except Exception:
|
|
132
|
+
pth = hf_hub_download(
|
|
133
|
+
repo_id=repo_id,
|
|
134
|
+
filename=filename,
|
|
135
|
+
etag_timeout=1,
|
|
136
|
+
)
|
|
137
|
+
return OnnxModel(pth)
|
|
138
|
+
|
|
139
|
+
@property
|
|
140
|
+
def stride(self):
|
|
141
|
+
return self._stride
|
|
142
|
+
|
|
143
|
+
def resize_and_pad_image(self, image, new_shape):
|
|
144
|
+
"""
|
|
145
|
+
Resize and pad the image to the specified size, ensuring dimensions are multiples of stride.
|
|
146
|
+
|
|
147
|
+
Parameters:
|
|
148
|
+
- image: Input image
|
|
149
|
+
- new_shape: Target size (integer or (height, width) tuple)
|
|
150
|
+
- stride: Padding alignment stride, default 32
|
|
151
|
+
|
|
152
|
+
Returns:
|
|
153
|
+
- Processed image
|
|
154
|
+
"""
|
|
155
|
+
if isinstance(new_shape, int):
|
|
156
|
+
new_shape = (new_shape, new_shape)
|
|
157
|
+
|
|
158
|
+
h, w = image.shape[:2]
|
|
159
|
+
new_h, new_w = new_shape
|
|
160
|
+
|
|
161
|
+
# Calculate scaling ratio
|
|
162
|
+
r = min(new_h / h, new_w / w)
|
|
163
|
+
resized_h, resized_w = int(round(h * r)), int(round(w * r))
|
|
164
|
+
|
|
165
|
+
# Resize image
|
|
166
|
+
image = cv2.resize(
|
|
167
|
+
image,
|
|
168
|
+
(resized_w, resized_h),
|
|
169
|
+
interpolation=cv2.INTER_LINEAR,
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
# Calculate padding size and align to stride multiple
|
|
173
|
+
pad_w = (new_w - resized_w) % self.stride
|
|
174
|
+
pad_h = (new_h - resized_h) % self.stride
|
|
175
|
+
top, bottom = pad_h // 2, pad_h - pad_h // 2
|
|
176
|
+
left, right = pad_w // 2, pad_w - pad_w // 2
|
|
177
|
+
|
|
178
|
+
# Add padding
|
|
179
|
+
image = cv2.copyMakeBorder(
|
|
180
|
+
image,
|
|
181
|
+
top,
|
|
182
|
+
bottom,
|
|
183
|
+
left,
|
|
184
|
+
right,
|
|
185
|
+
cv2.BORDER_CONSTANT,
|
|
186
|
+
value=(114, 114, 114),
|
|
187
|
+
)
|
|
188
|
+
|
|
189
|
+
return image
|
|
190
|
+
|
|
191
|
+
def scale_boxes(self, img1_shape, boxes, img0_shape):
|
|
192
|
+
"""
|
|
193
|
+
Rescales bounding boxes (in the format of xyxy by default) from the shape of the image they were originally
|
|
194
|
+
specified in (img1_shape) to the shape of a different image (img0_shape).
|
|
195
|
+
|
|
196
|
+
Args:
|
|
197
|
+
img1_shape (tuple): The shape of the image that the bounding boxes are for,
|
|
198
|
+
in the format of (height, width).
|
|
199
|
+
boxes (torch.Tensor): the bounding boxes of the objects in the image, in the format of (x1, y1, x2, y2)
|
|
200
|
+
img0_shape (tuple): the shape of the target image, in the format of (height, width).
|
|
201
|
+
|
|
202
|
+
Returns:
|
|
203
|
+
boxes (torch.Tensor): The scaled bounding boxes, in the format of (x1, y1, x2, y2)
|
|
204
|
+
"""
|
|
205
|
+
|
|
206
|
+
# Calculate scaling ratio
|
|
207
|
+
gain = min(img1_shape[0] / img0_shape[0], img1_shape[1] / img0_shape[1])
|
|
208
|
+
|
|
209
|
+
# Calculate padding size
|
|
210
|
+
pad_x = round((img1_shape[1] - img0_shape[1] * gain) / 2 - 0.1)
|
|
211
|
+
pad_y = round((img1_shape[0] - img0_shape[0] * gain) / 2 - 0.1)
|
|
212
|
+
|
|
213
|
+
# Remove padding and scale boxes
|
|
214
|
+
boxes[..., :4] = (boxes[..., :4] - [pad_x, pad_y, pad_x, pad_y]) / gain
|
|
215
|
+
return boxes
|
|
216
|
+
|
|
217
|
+
def predict(self, image, imgsz=1024, batch_size=16, **kwargs):
|
|
218
|
+
"""
|
|
219
|
+
Predict the layout of document pages.
|
|
220
|
+
|
|
221
|
+
Args:
|
|
222
|
+
image: A single image or a list of images of document pages.
|
|
223
|
+
imgsz: Resize the image to this size. Must be a multiple of the stride.
|
|
224
|
+
batch_size: Number of images to process in one batch.
|
|
225
|
+
**kwargs: Additional arguments.
|
|
226
|
+
|
|
227
|
+
Returns:
|
|
228
|
+
A list of YoloResult objects, one for each input image.
|
|
229
|
+
"""
|
|
230
|
+
# Handle single image input
|
|
231
|
+
if isinstance(image, np.ndarray) and len(image.shape) == 3:
|
|
232
|
+
image = [image]
|
|
233
|
+
|
|
234
|
+
total_images = len(image)
|
|
235
|
+
results = []
|
|
236
|
+
batch_size = min(batch_size, max_batch_size)
|
|
237
|
+
|
|
238
|
+
# Process images in batches
|
|
239
|
+
for i in range(0, total_images, batch_size):
|
|
240
|
+
batch_images = image[i : i + batch_size]
|
|
241
|
+
batch_size_actual = len(batch_images)
|
|
242
|
+
|
|
243
|
+
# Calculate target size based on the maximum height in the batch
|
|
244
|
+
max_height = max(img.shape[0] for img in batch_images)
|
|
245
|
+
target_imgsz = int(max_height / 32) * 32
|
|
246
|
+
|
|
247
|
+
# Preprocess batch
|
|
248
|
+
processed_batch = []
|
|
249
|
+
orig_shapes = []
|
|
250
|
+
for img in batch_images:
|
|
251
|
+
orig_h, orig_w = img.shape[:2]
|
|
252
|
+
orig_shapes.append((orig_h, orig_w))
|
|
253
|
+
|
|
254
|
+
pix = self.resize_and_pad_image(img, new_shape=target_imgsz)
|
|
255
|
+
pix = np.transpose(pix, (2, 0, 1)) # CHW
|
|
256
|
+
pix = pix.astype(np.float32) / 255.0 # Normalize to [0, 1]
|
|
257
|
+
processed_batch.append(pix)
|
|
258
|
+
|
|
259
|
+
# Stack batch
|
|
260
|
+
batch_input = np.stack(processed_batch, axis=0) # BCHW
|
|
261
|
+
new_h, new_w = batch_input.shape[2:]
|
|
262
|
+
|
|
263
|
+
# Run inference
|
|
264
|
+
batch_preds = self.model.run(None, {"images": batch_input})[0]
|
|
265
|
+
|
|
266
|
+
# Process each prediction in the batch
|
|
267
|
+
for j in range(batch_size_actual):
|
|
268
|
+
preds = batch_preds[j]
|
|
269
|
+
preds = preds[preds[..., 4] > 0.25]
|
|
270
|
+
if len(preds) > 0:
|
|
271
|
+
preds[..., :4] = self.scale_boxes(
|
|
272
|
+
(new_h, new_w),
|
|
273
|
+
preds[..., :4],
|
|
274
|
+
orig_shapes[j],
|
|
275
|
+
)
|
|
276
|
+
results.append(YoloResult(boxes_data=preds, names=self._names))
|
|
277
|
+
|
|
278
|
+
return results
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
|
|
4
|
+
import cv2
|
|
5
|
+
import httpx
|
|
6
|
+
import msgpack
|
|
7
|
+
import numpy as np
|
|
8
|
+
|
|
9
|
+
from babeldoc.docvision.doclayout import DocLayoutModel
|
|
10
|
+
from babeldoc.docvision.doclayout import YoloBox
|
|
11
|
+
from babeldoc.docvision.doclayout import YoloResult
|
|
12
|
+
|
|
13
|
+
# 设置日志
|
|
14
|
+
logging.basicConfig(level=logging.DEBUG)
|
|
15
|
+
logger = logging.getLogger(__name__)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def encode_image(image) -> bytes:
|
|
19
|
+
"""Read and encode image to bytes
|
|
20
|
+
|
|
21
|
+
Args:
|
|
22
|
+
image: Can be either a file path (str) or numpy array
|
|
23
|
+
"""
|
|
24
|
+
if isinstance(image, str):
|
|
25
|
+
if not Path(image).exists():
|
|
26
|
+
raise FileNotFoundError(f"Image file not found: {image}")
|
|
27
|
+
img = cv2.imread(image)
|
|
28
|
+
if img is None:
|
|
29
|
+
raise ValueError(f"Failed to read image: {image}")
|
|
30
|
+
else:
|
|
31
|
+
img = image
|
|
32
|
+
|
|
33
|
+
# logger.debug(f"Image shape: {img.shape}")
|
|
34
|
+
encoded = cv2.imencode(".jpg", img)[1].tobytes()
|
|
35
|
+
# logger.debug(f"Encoded image size: {len(encoded)} bytes")
|
|
36
|
+
return encoded
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def predict_layout(
|
|
40
|
+
image,
|
|
41
|
+
host: str = "http://localhost:8000",
|
|
42
|
+
imgsz: int = 1024,
|
|
43
|
+
):
|
|
44
|
+
"""
|
|
45
|
+
Predict document layout using the MOSEC service
|
|
46
|
+
|
|
47
|
+
Args:
|
|
48
|
+
image: Can be either a file path (str) or numpy array
|
|
49
|
+
host: Service host URL
|
|
50
|
+
imgsz: Image size for model input
|
|
51
|
+
|
|
52
|
+
Returns:
|
|
53
|
+
List of predictions containing bounding boxes and classes
|
|
54
|
+
"""
|
|
55
|
+
try:
|
|
56
|
+
# Prepare request data
|
|
57
|
+
if not isinstance(image, list):
|
|
58
|
+
image = [image]
|
|
59
|
+
image_data = [encode_image(image) for image in image]
|
|
60
|
+
data = {
|
|
61
|
+
"image": image_data,
|
|
62
|
+
"imgsz": imgsz,
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
# Pack data using msgpack
|
|
66
|
+
packed_data = msgpack.packb(data, use_bin_type=True)
|
|
67
|
+
logger.debug(f"Packed data size: {len(packed_data)} bytes")
|
|
68
|
+
|
|
69
|
+
# Send request
|
|
70
|
+
logger.debug(f"Sending request to {host}/inference")
|
|
71
|
+
response = httpx.post(
|
|
72
|
+
f"{host}/inference",
|
|
73
|
+
data=packed_data,
|
|
74
|
+
headers={
|
|
75
|
+
"Content-Type": "application/msgpack",
|
|
76
|
+
"Accept": "application/msgpack",
|
|
77
|
+
},
|
|
78
|
+
timeout=30,
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
logger.debug(f"Response status: {response.status_code}")
|
|
82
|
+
logger.debug(f"Response headers: {response.headers}")
|
|
83
|
+
|
|
84
|
+
if response.status_code == 200:
|
|
85
|
+
try:
|
|
86
|
+
result = msgpack.unpackb(response.content, raw=False)
|
|
87
|
+
return result
|
|
88
|
+
except Exception as e:
|
|
89
|
+
logger.exception(f"Failed to unpack response: {e!s}")
|
|
90
|
+
raise
|
|
91
|
+
else:
|
|
92
|
+
logger.error(f"Request failed with status {response.status_code}")
|
|
93
|
+
logger.error(f"Response content: {response.content}")
|
|
94
|
+
raise Exception(
|
|
95
|
+
f"Request failed with status {response.status_code}: {response.text}",
|
|
96
|
+
)
|
|
97
|
+
except Exception as e:
|
|
98
|
+
logger.exception(f"Unexpected error: {e!s}")
|
|
99
|
+
raise
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
class RpcDocLayoutModel(DocLayoutModel):
|
|
103
|
+
"""DocLayoutModel implementation that uses RPC service."""
|
|
104
|
+
|
|
105
|
+
def __init__(self, host: str = "http://localhost:8000"):
|
|
106
|
+
"""Initialize RPC model with host address."""
|
|
107
|
+
self.host = host
|
|
108
|
+
self._stride = 32 # Default stride value
|
|
109
|
+
self._names = ["text", "title", "list", "table", "figure"]
|
|
110
|
+
|
|
111
|
+
@property
|
|
112
|
+
def stride(self) -> int:
|
|
113
|
+
"""Stride of the model input."""
|
|
114
|
+
return self._stride
|
|
115
|
+
|
|
116
|
+
def predict(self, image, imgsz=1024, **kwargs) -> list[YoloResult]:
|
|
117
|
+
"""Predict the layout of document pages using RPC service."""
|
|
118
|
+
# Handle single image input
|
|
119
|
+
if isinstance(image, np.ndarray) and len(image.shape) == 3:
|
|
120
|
+
image = [image]
|
|
121
|
+
|
|
122
|
+
results = []
|
|
123
|
+
preds = predict_layout(image, host=self.host, imgsz=imgsz)
|
|
124
|
+
if len(preds) > 0:
|
|
125
|
+
for pred in preds:
|
|
126
|
+
boxes = [
|
|
127
|
+
YoloBox(None, np.array(x["xyxy"]), np.array(x["conf"]), x["cls"])
|
|
128
|
+
for x in pred["boxes"]
|
|
129
|
+
]
|
|
130
|
+
results.append(
|
|
131
|
+
YoloResult(
|
|
132
|
+
boxes=boxes,
|
|
133
|
+
names={int(k): v for k, v in pred["names"].items()},
|
|
134
|
+
),
|
|
135
|
+
)
|
|
136
|
+
else:
|
|
137
|
+
results.append(YoloResult(boxes_data=np.array([]), names=[]))
|
|
138
|
+
|
|
139
|
+
return results
|
|
140
|
+
|
|
141
|
+
@staticmethod
|
|
142
|
+
def from_host(host: str) -> "RpcDocLayoutModel":
|
|
143
|
+
"""Create RpcDocLayoutModel from host address."""
|
|
144
|
+
return RpcDocLayoutModel(host=host)
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
if __name__ == "__main__":
|
|
148
|
+
# Test the service
|
|
149
|
+
try:
|
|
150
|
+
# Use a default test image if example/1.png doesn't exist
|
|
151
|
+
image_path = "example/1.png"
|
|
152
|
+
if not Path(image_path).exists():
|
|
153
|
+
print(f"Warning: {image_path} not found.")
|
|
154
|
+
print("Please provide the path to a test image:")
|
|
155
|
+
image_path = input("> ")
|
|
156
|
+
|
|
157
|
+
logger.info(f"Processing image: {image_path}")
|
|
158
|
+
result = predict_layout(image_path)
|
|
159
|
+
print("Prediction results:")
|
|
160
|
+
print(result)
|
|
161
|
+
except Exception as e:
|
|
162
|
+
print(f"Error: {e!s}")
|