minetensor commited on
Commit
1b0739e
·
verified ·
1 Parent(s): 3bf097d

SN44 SafetyVision miner trial artifact

Browse files
Files changed (4) hide show
  1. README.md +31 -0
  2. best.onnx +3 -0
  3. chute_config.yml +23 -0
  4. miner.py +209 -0
README.md ADDED
@@ -0,0 +1,31 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # SN44 SafetyVision PPE miner candidate
2
+
3
+ This package targets `manak0/Detect-worksite-ppe-compliance-detection-probe-set`
4
+ on Score/TurboVision SN44. It wraps a 320px ONNX export of the SafetyVision v1
5
+ YOLOv8n model and maps its four relevant labels into the active manifest order:
6
+
7
+ 1. safety helmet
8
+ 2. high-visibility safety vest
9
+ 3. protective gloves
10
+ 4. protective eyewear
11
+
12
+ The runtime confidence threshold is 0.10, selected on the public
13
+ Construction-PPE proxy set using the subnet's released 60% mAP50 / 40%
14
+ false-positive formula. Proxy results are documented in
15
+ `../sn44-opportunity-2026-10-02.md`. They do not establish performance on the
16
+ hidden `synthetic_r2` challenges.
17
+
18
+ The 12.15 MB model was exported from the upstream v1 checkpoint at 320px. It
19
+ was tested with Ultralytics 8.3.190 and ONNX Runtime 1.26.0, the versions
20
+ resolved or pinned by the official TurboVision compliance container. Model source:
21
+ `ayushgupta7777/safetyvision-yolov8` (`best.pt`, v1). The model card states
22
+ that the weights inherit the Ultralytics YOLOv8 AGPL-3.0 license.
23
+
24
+ The miner uses direct NumPy/OpenCV preprocessing and postprocessing rather than
25
+ importing Ultralytics or PyTorch at runtime. It initializes ONNX Runtime in
26
+ sequential mode with one intra-op thread. This keeps it inside the compliance
27
+ worker's 16-process/thread resource limit as well as its two-vCPU latency
28
+ envelope.
29
+
30
+ This directory is a local candidate only. It has not been uploaded, deployed,
31
+ committed on-chain, or registered.
best.onnx ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:b38e9afc18bac6bb1160ccab207f186a9520e846cf522fa4cc0178b70ff3673b
3
+ size 12149191
chute_config.yml ADDED
@@ -0,0 +1,23 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ Image:
2
+ from_base: parachutes/python:3.12
3
+ run_command:
4
+ - pip install --upgrade setuptools wheel
5
+ - pip install huggingface_hub==0.19.4 onnxruntime==1.26.0 opencv-python-headless 'pydantic>=2,<3'
6
+ set_workdir: /app
7
+
8
+ NodeSelector:
9
+ gpu_count: 1
10
+ min_vram_gb_per_gpu: 16
11
+ max_hourly_price_per_gpu: 0.25
12
+ exclude:
13
+ - "5090"
14
+ - b200
15
+ - h200
16
+ - h20
17
+ - mi300x
18
+
19
+ Chute:
20
+ shutdown_after_seconds: 300
21
+ concurrency: 4
22
+ max_instances: 1
23
+ scaling_threshold: 0.5
miner.py ADDED
@@ -0,0 +1,209 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ from pathlib import Path
3
+
4
+ # These must be set before NumPy/Ultralytics load their native runtimes. Score's
5
+ # sandbox permits only 16 processes/threads, while the host libraries otherwise
6
+ # size pools from the underlying machine rather than the two-vCPU quota.
7
+ for thread_variable in (
8
+ "OMP_NUM_THREADS",
9
+ "MKL_NUM_THREADS",
10
+ "OPENBLAS_NUM_THREADS",
11
+ "NUMEXPR_NUM_THREADS",
12
+ "VECLIB_MAXIMUM_THREADS",
13
+ "BLIS_NUM_THREADS",
14
+ ):
15
+ os.environ[thread_variable] = "1"
16
+
17
+ import cv2
18
+ import numpy as np
19
+ import onnxruntime as ort
20
+ from numpy import ndarray
21
+ from pydantic import BaseModel
22
+
23
+
24
+ class BoundingBox(BaseModel):
25
+ x1: int
26
+ y1: int
27
+ x2: int
28
+ y2: int
29
+ cls_id: int
30
+ conf: float
31
+
32
+
33
+ class Polygon(BaseModel):
34
+ cls_id: int
35
+ conf: float
36
+ points: list[tuple[int, int]]
37
+
38
+
39
+ class TVFrameResult(BaseModel):
40
+ frame_id: int
41
+ boxes: list[BoundingBox] | None = None
42
+ polygons: list[Polygon] | None = None
43
+ keypoints: list[tuple[int, int]] | None = None
44
+
45
+
46
+ class Miner:
47
+ """SN44 PPE detector using a 320px SafetyVision v1 ONNX model."""
48
+
49
+ # SafetyVision class ID -> active manifest object index:
50
+ # [helmet, vest, gloves, eyewear]
51
+ SOURCE_TO_MANIFEST = {1: 2, 2: 3, 3: 0, 12: 1}
52
+ CONFIDENCE = 0.10
53
+ NMS_IOU = 0.70
54
+ IMAGE_SIZE = 320
55
+ MAX_DETECTIONS = 300
56
+
57
+ def __init__(self, path_hf_repo: Path) -> None:
58
+ cv2.setNumThreads(1)
59
+ options = ort.SessionOptions()
60
+ options.execution_mode = ort.ExecutionMode.ORT_SEQUENTIAL
61
+ options.intra_op_num_threads = 1
62
+ options.inter_op_num_threads = 1
63
+ options.add_session_config_entry("session.intra_op.allow_spinning", "0")
64
+ self.session = ort.InferenceSession(
65
+ str(path_hf_repo / "best.onnx"),
66
+ sess_options=options,
67
+ providers=["CPUExecutionProvider"],
68
+ )
69
+ self.input_name = self.session.get_inputs()[0].name
70
+
71
+ # This graph was qualified on CPU. Keeping the same provider also
72
+ # avoids provider-dependent output drift during the
73
+ # validator's standardized two-vCPU compliance run.
74
+ self.device = "cpu"
75
+
76
+ def __repr__(self) -> str:
77
+ return (
78
+ "SafetyVision-v1-320 PPE ONNX detector "
79
+ f"(confidence={self.CONFIDENCE}, device={self.device})"
80
+ )
81
+
82
+ @classmethod
83
+ def _prepare_image(cls, image: ndarray) -> tuple[ndarray, float, tuple[int, int]]:
84
+ height, width = image.shape[:2]
85
+ scale = min(cls.IMAGE_SIZE / height, cls.IMAGE_SIZE / width)
86
+ resized_width = round(width * scale)
87
+ resized_height = round(height * scale)
88
+ if (resized_width, resized_height) != (width, height):
89
+ image = cv2.resize(
90
+ image,
91
+ (resized_width, resized_height),
92
+ interpolation=cv2.INTER_LINEAR,
93
+ )
94
+ horizontal = (cls.IMAGE_SIZE - resized_width) / 2
95
+ vertical = (cls.IMAGE_SIZE - resized_height) / 2
96
+ left, right = round(horizontal - 0.1), round(horizontal + 0.1)
97
+ top, bottom = round(vertical - 0.1), round(vertical + 0.1)
98
+ image = cv2.copyMakeBorder(
99
+ image,
100
+ top,
101
+ bottom,
102
+ left,
103
+ right,
104
+ cv2.BORDER_CONSTANT,
105
+ value=(114, 114, 114),
106
+ )
107
+ tensor = np.ascontiguousarray(image[:, :, ::-1].transpose(2, 0, 1))
108
+ tensor = tensor.astype(np.float32) / 255.0
109
+ return tensor[None], scale, (left, top)
110
+
111
+ @staticmethod
112
+ def _nms(boxes: ndarray, scores: ndarray, threshold: float) -> list[int]:
113
+ if len(boxes) == 0:
114
+ return []
115
+ order = np.argsort(-scores)
116
+ areas = np.maximum(0.0, boxes[:, 2] - boxes[:, 0]) * np.maximum(
117
+ 0.0, boxes[:, 3] - boxes[:, 1]
118
+ )
119
+ keep: list[int] = []
120
+ while order.size:
121
+ current = int(order[0])
122
+ keep.append(current)
123
+ if order.size == 1:
124
+ break
125
+ remaining = order[1:]
126
+ x1 = np.maximum(boxes[current, 0], boxes[remaining, 0])
127
+ y1 = np.maximum(boxes[current, 1], boxes[remaining, 1])
128
+ x2 = np.minimum(boxes[current, 2], boxes[remaining, 2])
129
+ y2 = np.minimum(boxes[current, 3], boxes[remaining, 3])
130
+ intersection = np.maximum(0.0, x2 - x1) * np.maximum(0.0, y2 - y1)
131
+ union = areas[current] + areas[remaining] - intersection
132
+ overlap = np.divide(
133
+ intersection,
134
+ union,
135
+ out=np.zeros_like(intersection),
136
+ where=union > 0,
137
+ )
138
+ order = remaining[overlap <= threshold]
139
+ return keep
140
+
141
+ def _detect(self, image: ndarray) -> list[BoundingBox]:
142
+ height, width = image.shape[:2]
143
+ tensor, scale, (pad_x, pad_y) = self._prepare_image(image)
144
+ prediction = self.session.run(None, {self.input_name: tensor})[0][0].T
145
+ source_boxes = prediction[:, :4]
146
+ class_scores = prediction[:, 4:]
147
+ source_classes = np.argmax(class_scores, axis=1)
148
+ confidences = np.max(class_scores, axis=1)
149
+ target_ids = np.fromiter(self.SOURCE_TO_MANIFEST, dtype=np.int64)
150
+ selected = (confidences > self.CONFIDENCE) & np.isin(source_classes, target_ids)
151
+ source_boxes = source_boxes[selected]
152
+ source_classes = source_classes[selected]
153
+ confidences = confidences[selected]
154
+ if not len(source_boxes):
155
+ return []
156
+
157
+ boxes = np.empty_like(source_boxes)
158
+ boxes[:, 0] = source_boxes[:, 0] - source_boxes[:, 2] / 2
159
+ boxes[:, 1] = source_boxes[:, 1] - source_boxes[:, 3] / 2
160
+ boxes[:, 2] = source_boxes[:, 0] + source_boxes[:, 2] / 2
161
+ boxes[:, 3] = source_boxes[:, 1] + source_boxes[:, 3] / 2
162
+
163
+ kept: list[int] = []
164
+ for source_class in self.SOURCE_TO_MANIFEST:
165
+ indices = np.flatnonzero(source_classes == source_class)
166
+ local = self._nms(boxes[indices], confidences[indices], self.NMS_IOU)
167
+ kept.extend(int(indices[index]) for index in local)
168
+ kept.sort(key=lambda index: float(confidences[index]), reverse=True)
169
+
170
+ output: list[BoundingBox] = []
171
+ for index in kept[: self.MAX_DETECTIONS]:
172
+ x1, y1, x2, y2 = boxes[index]
173
+ x1 = np.clip((x1 - pad_x) / scale, 0, width)
174
+ y1 = np.clip((y1 - pad_y) / scale, 0, height)
175
+ x2 = np.clip((x2 - pad_x) / scale, 0, width)
176
+ y2 = np.clip((y2 - pad_y) / scale, 0, height)
177
+ output.append(
178
+ BoundingBox(
179
+ x1=round(float(x1)),
180
+ y1=round(float(y1)),
181
+ x2=round(float(x2)),
182
+ y2=round(float(y2)),
183
+ cls_id=self.SOURCE_TO_MANIFEST[int(source_classes[index])],
184
+ conf=float(confidences[index]),
185
+ )
186
+ )
187
+ return output
188
+
189
+ def predict_batch(
190
+ self,
191
+ batch_images: list[ndarray],
192
+ offset: int,
193
+ n_keypoints: int,
194
+ ) -> list[TVFrameResult]:
195
+ results: list[TVFrameResult] = []
196
+ # The published ONNX graph has a fixed batch dimension of one. The
197
+ # Chute contract may provide multiple frames, so execute them in order
198
+ # rather than passing an invalid larger tensor to ONNX Runtime.
199
+ for index, image in enumerate(batch_images):
200
+ boxes = self._detect(image)
201
+ results.append(
202
+ TVFrameResult(
203
+ frame_id=offset + index,
204
+ boxes=boxes,
205
+ polygons=[],
206
+ keypoints=[(0, 0) for _ in range(n_keypoints)],
207
+ )
208
+ )
209
+ return results