phungpx commited on
Commit
409ef8b
·
verified ·
1 Parent(s): 2a2c088

Upload ONNX export

Browse files
Files changed (1) hide show
  1. pp_doclayout_v3_onnx.py +382 -0
pp_doclayout_v3_onnx.py ADDED
@@ -0,0 +1,382 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """PP-DocLayoutV3 inference on ONNX Runtime — no torch, no transformers.
2
+
3
+ Pre- and post-processing are ported from
4
+ transformers/models/pp_doclayout_v3/image_processing_pp_doclayout_v3.py
5
+ so results match the PyTorch pipeline (boxes, labels, reading order, polygons).
6
+
7
+ from pp_doclayout_v3_onnx import PPDocLayoutV3ONNX
8
+
9
+ det = PPDocLayoutV3ONNX("pp_doclayoutv3.onnx", device="cuda")
10
+ for r in det.predict("page.jpg"):
11
+ print(r["order"], r["label"], r["score"], r["box"])
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import time
17
+ from dataclasses import dataclass
18
+ from pathlib import Path
19
+ from typing import Any, Sequence
20
+
21
+ import cv2
22
+ import numpy as np
23
+ import onnxruntime as ort
24
+
25
+ INPUT_SIZE = 800 # image processor resizes to a fixed 800x800 square
26
+ MASK_STRIDE = 4 # mask head resolution is 800/4 = 200
27
+ RESCALE_FACTOR = 1.0 / 255.0
28
+ # image_mean = [0,0,0], image_std = [1,1,1] -> normalisation is a no-op
29
+
30
+ ID2LABEL = {
31
+ 0: "abstract", 1: "algorithm", 2: "aside_text", 3: "chart", 4: "content",
32
+ 5: "formula", 6: "doc_title", 7: "figure_title", 8: "footer", 9: "footer",
33
+ 10: "footnote", 11: "formula_number", 12: "header", 13: "header", 14: "image",
34
+ 15: "formula", 16: "number", 17: "paragraph_title", 18: "reference",
35
+ 19: "reference_content", 20: "seal", 21: "table", 22: "text", 23: "text",
36
+ 24: "vision_footnote",
37
+ }
38
+
39
+
40
+ @dataclass
41
+ class Timings:
42
+ preprocess: float = 0.0
43
+ inference: float = 0.0
44
+ postprocess: float = 0.0
45
+
46
+ @property
47
+ def total(self) -> float:
48
+ return self.preprocess + self.inference + self.postprocess
49
+
50
+ def __str__(self) -> str:
51
+ return (
52
+ f"pre={self.preprocess * 1e3:.1f}ms infer={self.inference * 1e3:.1f}ms "
53
+ f"post={self.postprocess * 1e3:.1f}ms total={self.total * 1e3:.1f}ms"
54
+ )
55
+
56
+
57
+ # --------------------------------------------------------------------------- #
58
+ # Preprocessing
59
+ # --------------------------------------------------------------------------- #
60
+ def load_image_rgb(image: Any) -> np.ndarray:
61
+ """Accept a path, a PIL image, or an HWC array. Returns RGB uint8."""
62
+ if isinstance(image, (str, Path)):
63
+ bgr = cv2.imread(str(image), cv2.IMREAD_COLOR)
64
+ if bgr is None:
65
+ raise FileNotFoundError(f"Could not read image: {image}")
66
+ return cv2.cvtColor(bgr, cv2.COLOR_BGR2RGB)
67
+ if isinstance(image, np.ndarray):
68
+ if image.ndim == 2:
69
+ return cv2.cvtColor(image, cv2.COLOR_GRAY2RGB)
70
+ if image.shape[2] == 4:
71
+ return cv2.cvtColor(image, cv2.COLOR_RGBA2RGB)
72
+ return image
73
+ return np.asarray(image.convert("RGB")) # PIL
74
+
75
+
76
+ def preprocess(images: Sequence[np.ndarray]) -> tuple[np.ndarray, list[tuple[int, int]]]:
77
+ """Resize to 800x800 (bicubic, no antialias) and scale to [0, 1] NCHW float32.
78
+
79
+ The HF processor uses torchvision resize with antialias=False specifically to
80
+ approximate cv2.resize, so cv2 INTER_CUBIC is the reference behaviour here.
81
+ """
82
+ batch = np.empty((len(images), 3, INPUT_SIZE, INPUT_SIZE), dtype=np.float32)
83
+ target_sizes: list[tuple[int, int]] = []
84
+ for i, img in enumerate(images):
85
+ h, w = img.shape[:2]
86
+ target_sizes.append((h, w))
87
+ resized = cv2.resize(img, (INPUT_SIZE, INPUT_SIZE), interpolation=cv2.INTER_CUBIC)
88
+ batch[i] = resized.astype(np.float32).transpose(2, 0, 1) * RESCALE_FACTOR
89
+ return batch, target_sizes
90
+
91
+
92
+ # --------------------------------------------------------------------------- #
93
+ # Post-processing (ported 1:1 from PPDocLayoutV3ImageProcessor)
94
+ # --------------------------------------------------------------------------- #
95
+ def _sigmoid(x: np.ndarray) -> np.ndarray:
96
+ return 1.0 / (1.0 + np.exp(-x, dtype=np.float64)).astype(np.float32)
97
+
98
+
99
+ def get_order_seqs(order_logits: np.ndarray) -> np.ndarray:
100
+ """(B, Q, Q) pointer logits -> (B, Q) reading-order rank per query."""
101
+ scores = _sigmoid(order_logits)
102
+ batch_size, seq_len, _ = scores.shape
103
+
104
+ votes = np.triu(scores, 1).sum(axis=1) + np.tril(
105
+ 1.0 - scores.transpose(0, 2, 1), -1
106
+ ).sum(axis=1)
107
+
108
+ pointers = np.argsort(votes, axis=1, kind="stable")
109
+ order_seq = np.empty_like(pointers)
110
+ ranks = np.broadcast_to(np.arange(seq_len), (batch_size, seq_len))
111
+ np.put_along_axis(order_seq, pointers, ranks, axis=1)
112
+ return order_seq
113
+
114
+
115
+ def _extract_custom_vertices(polygon: np.ndarray, sharp_angle_thresh: float = 45) -> list[tuple]:
116
+ poly = np.array(polygon)
117
+ n = len(poly)
118
+ res = []
119
+ for i in range(n):
120
+ previous_point = poly[(i - 1) % n]
121
+ current_point = poly[i]
122
+ next_point = poly[(i + 1) % n]
123
+ v1 = previous_point - current_point
124
+ v2 = next_point - current_point
125
+ cross = (v1[1] * v2[0]) - (v1[0] * v2[1])
126
+ if cross < 0:
127
+ n1, n2 = np.linalg.norm(v1), np.linalg.norm(v2)
128
+ if n1 == 0 or n2 == 0:
129
+ res.append(tuple(current_point))
130
+ continue
131
+ angle = np.degrees(np.arccos(np.clip((v1 @ v2) / (n1 * n2), -1.0, 1.0)))
132
+ if abs(angle - sharp_angle_thresh) < 1:
133
+ direction = v1 / n1 + v2 / n2
134
+ direction = direction / np.linalg.norm(direction)
135
+ step = (n1 + n2) / 2
136
+ res.append(tuple(current_point + direction * step))
137
+ else:
138
+ res.append(tuple(current_point))
139
+ return res
140
+
141
+
142
+ def _mask2polygon(mask: np.ndarray, epsilon_ratio: float = 0.004):
143
+ contours, _ = cv2.findContours(mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
144
+ if not contours:
145
+ return None
146
+ contour = max(contours, key=cv2.contourArea)
147
+ epsilon = epsilon_ratio * cv2.arcLength(contour, True)
148
+ approx = cv2.approxPolyDP(contour, epsilon, True)
149
+ points = np.atleast_2d(approx.squeeze())
150
+ return _extract_custom_vertices(points)
151
+
152
+
153
+ def _extract_polygons(boxes: np.ndarray, masks: np.ndarray, scale_ratio) -> list:
154
+ scale_w, scale_h = scale_ratio[0] / MASK_STRIDE, scale_ratio[1] / MASK_STRIDE
155
+ mask_h, mask_w = masks.shape[1:]
156
+ polygons = []
157
+
158
+ for i in range(len(boxes)):
159
+ x_min, y_min, x_max, y_max = boxes[i].astype(np.int32)
160
+ box_w, box_h = int(x_max - x_min), int(y_max - y_min)
161
+ rect = np.array(
162
+ [[x_min, y_min], [x_max, y_min], [x_max, y_max], [x_min, y_max]], dtype=np.float32
163
+ )
164
+ if box_w <= 0 or box_h <= 0:
165
+ polygons.append(rect)
166
+ continue
167
+
168
+ x_start, x_end = np.clip(
169
+ [int(round(float(x_min * scale_w))), int(round(float(x_max * scale_w)))], 0, mask_w
170
+ )
171
+ y_start, y_end = np.clip(
172
+ [int(round(float(y_min * scale_h))), int(round(float(y_max * scale_h)))], 0, mask_h
173
+ )
174
+ cropped = masks[i, y_start:y_end, x_start:x_end]
175
+ if cropped.size == 0 or cropped.sum() == 0:
176
+ polygons.append(rect)
177
+ continue
178
+
179
+ resized = cv2.resize(cropped.astype(np.uint8), (box_w, box_h), interpolation=cv2.INTER_NEAREST)
180
+ polygon = _mask2polygon(resized)
181
+ if polygon is None or len(polygon) < 4:
182
+ polygons.append(rect)
183
+ continue
184
+ polygons.append(np.array(polygon, dtype=np.float32) + np.array([x_min, y_min]))
185
+ return polygons
186
+
187
+
188
+ def postprocess(
189
+ logits: np.ndarray,
190
+ pred_boxes: np.ndarray,
191
+ order_logits: np.ndarray,
192
+ out_masks: np.ndarray | None,
193
+ target_sizes: Sequence[tuple[int, int]],
194
+ threshold: float = 0.5,
195
+ ) -> list[list[dict]]:
196
+ """Returns one list of detections per image, already sorted by reading order."""
197
+ order_seqs = get_order_seqs(order_logits)
198
+
199
+ # cxcywh (normalised) -> xyxy (absolute)
200
+ centers, dims = pred_boxes[..., :2], pred_boxes[..., 2:]
201
+ boxes = np.concatenate([centers - 0.5 * dims, centers + 0.5 * dims], axis=-1)
202
+ sizes = np.asarray(target_sizes, dtype=np.float32) # (B, 2) as (h, w)
203
+ scale = np.stack([sizes[:, 1], sizes[:, 0], sizes[:, 1], sizes[:, 0]], axis=1)
204
+ boxes = boxes * scale[:, None, :]
205
+
206
+ batch_size, num_queries, num_classes = logits.shape
207
+ scores_all = _sigmoid(logits)
208
+
209
+ results: list[list[dict]] = []
210
+ for b in range(batch_size):
211
+ flat = scores_all[b].reshape(-1)
212
+ # torch.topk(k=num_queries) over the flattened (query, class) grid
213
+ top = np.argpartition(-flat, num_queries - 1)[:num_queries]
214
+ top = top[np.argsort(-flat[top], kind="stable")]
215
+
216
+ scores = flat[top]
217
+ labels = top % num_classes
218
+ query_idx = top // num_classes
219
+
220
+ keep = scores >= threshold
221
+ scores, labels, query_idx = scores[keep], labels[keep], query_idx[keep]
222
+
223
+ order = order_seqs[b][query_idx]
224
+ srt = np.argsort(order, kind="stable")
225
+ scores, labels, query_idx, order = scores[srt], labels[srt], query_idx[srt], order[srt]
226
+ sel_boxes = boxes[b][query_idx]
227
+
228
+ if out_masks is not None and len(sel_boxes):
229
+ masks = (_sigmoid(out_masks[b][query_idx]) > threshold).astype(np.uint8)
230
+ h, w = target_sizes[b]
231
+ polygons = _extract_polygons(sel_boxes, masks, [INPUT_SIZE / w, INPUT_SIZE / h])
232
+ else:
233
+ polygons = [
234
+ np.array([[x0, y0], [x1, y0], [x1, y1], [x0, y1]], dtype=np.float32)
235
+ for x0, y0, x1, y1 in sel_boxes
236
+ ]
237
+
238
+ results.append(
239
+ [
240
+ {
241
+ "order": int(o),
242
+ "label_id": int(l),
243
+ "label": ID2LABEL.get(int(l), str(l)),
244
+ "score": float(s),
245
+ "box": [round(float(v), 2) for v in box],
246
+ "polygon": poly,
247
+ }
248
+ for o, l, s, box, poly in zip(order, labels, scores, sel_boxes, polygons)
249
+ ]
250
+ )
251
+ return results
252
+
253
+
254
+ # --------------------------------------------------------------------------- #
255
+ # Engine
256
+ # --------------------------------------------------------------------------- #
257
+ class PPDocLayoutV3ONNX:
258
+ """ONNX Runtime session, created once and reused."""
259
+
260
+ def __init__(
261
+ self,
262
+ onnx_path: str | Path,
263
+ *,
264
+ device: str = "cpu",
265
+ device_id: int = 0,
266
+ intra_op_num_threads: int | None = None,
267
+ threshold: float = 0.5,
268
+ trt_cache: str | None = None,
269
+ warmup: bool = True,
270
+ ) -> None:
271
+ so = ort.SessionOptions()
272
+ so.graph_optimization_level = ort.GraphOptimizationLevel.ORT_ENABLE_ALL
273
+ if intra_op_num_threads:
274
+ so.intra_op_num_threads = intra_op_num_threads
275
+
276
+ providers: list[Any] = []
277
+ if device == "tensorrt":
278
+ providers.append((
279
+ "TensorrtExecutionProvider",
280
+ {
281
+ "device_id": device_id,
282
+ "trt_fp16_enable": True,
283
+ "trt_engine_cache_enable": bool(trt_cache),
284
+ "trt_engine_cache_path": trt_cache or "",
285
+ },
286
+ ))
287
+ if device in ("cuda", "tensorrt"):
288
+ providers.append(("CUDAExecutionProvider", {"device_id": device_id}))
289
+ providers.append("CPUExecutionProvider")
290
+
291
+ self.session = ort.InferenceSession(str(onnx_path), sess_options=so, providers=providers)
292
+ self.input_name = self.session.get_inputs()[0].name
293
+ self.output_names = [o.name for o in self.session.get_outputs()]
294
+ self.has_masks = "out_masks" in self.output_names
295
+ self.threshold = threshold
296
+ self.last_timings = Timings()
297
+
298
+ if warmup:
299
+ self.session.run(
300
+ None, {self.input_name: np.zeros((1, 3, INPUT_SIZE, INPUT_SIZE), dtype=np.float32)}
301
+ )
302
+
303
+ @property
304
+ def providers(self) -> list[str]:
305
+ return self.session.get_providers()
306
+
307
+ def predict(
308
+ self, images: Any, threshold: float | None = None
309
+ ) -> list[dict] | list[list[dict]]:
310
+ """One image -> list of detections. A list of images -> list of those lists."""
311
+ single = not isinstance(images, (list, tuple))
312
+ image_list = [images] if single else list(images)
313
+ threshold = self.threshold if threshold is None else threshold
314
+
315
+ t0 = time.perf_counter()
316
+ rgb = [load_image_rgb(im) for im in image_list]
317
+ batch, target_sizes = preprocess(rgb)
318
+
319
+ t1 = time.perf_counter()
320
+ outputs = self.session.run(None, {self.input_name: batch})
321
+
322
+ t2 = time.perf_counter()
323
+ named = dict(zip(self.output_names, outputs))
324
+ results = postprocess(
325
+ named["logits"],
326
+ named["pred_boxes"],
327
+ named["order_logits"],
328
+ named.get("out_masks"),
329
+ target_sizes,
330
+ threshold=threshold,
331
+ )
332
+ t3 = time.perf_counter()
333
+ self.last_timings = Timings(t1 - t0, t2 - t1, t3 - t2)
334
+
335
+ return results[0] if single else results
336
+
337
+
338
+ def draw(image_path: str | Path, detections: list[dict], out_path: str | Path) -> None:
339
+ """Quick visual sanity check: polygons + reading-order index."""
340
+ img = cv2.imread(str(image_path))
341
+ for det in detections:
342
+ poly = np.asarray(det["polygon"], dtype=np.int32).reshape(-1, 1, 2)
343
+ cv2.polylines(img, [poly], True, (0, 165, 255), 2)
344
+ x0, y0 = int(det["box"][0]), int(det["box"][1])
345
+ cv2.putText(
346
+ img, f"{det['order']}:{det['label']} {det['score']:.2f}",
347
+ (x0, max(y0 - 5, 12)), cv2.FONT_HERSHEY_SIMPLEX, 0.6, (0, 0, 255), 2,
348
+ )
349
+ cv2.imwrite(str(out_path), img)
350
+
351
+
352
+ if __name__ == "__main__":
353
+ import argparse
354
+ import json
355
+
356
+ p = argparse.ArgumentParser(description="PP-DocLayoutV3 ONNX Runtime inference")
357
+ p.add_argument("--onnx", required=True)
358
+ p.add_argument("--image", required=True, nargs="+")
359
+ p.add_argument("--device", default="cpu", choices=["cpu", "cuda", "tensorrt"])
360
+ p.add_argument("--threshold", type=float, default=0.5)
361
+ p.add_argument("--threads", type=int, default=None)
362
+ p.add_argument("--draw", default=None, help="write an annotated copy of the first image")
363
+ args = p.parse_args()
364
+
365
+ det = PPDocLayoutV3ONNX(
366
+ args.onnx, device=args.device, intra_op_num_threads=args.threads, threshold=args.threshold
367
+ )
368
+ print(f"providers: {det.providers}")
369
+
370
+ results = det.predict(args.image)
371
+ if not isinstance(results[0], list):
372
+ results = [results]
373
+
374
+ for path, dets in zip(args.image, results):
375
+ print(f"\n=== {path} === ({det.last_timings})")
376
+ for d in dets:
377
+ print(f" Order {d['order'] + 1}: {d['label']} {d['score']:.2f} {d['box']}")
378
+
379
+ if args.draw:
380
+ draw(args.image[0], results[0], args.draw)
381
+ print(f"\nannotated -> {args.draw}")
382
+ print(json.dumps({"count": [len(r) for r in results]}))