laboard 1.0.0__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.
laboard/labels.py ADDED
@@ -0,0 +1,189 @@
1
+ import json
2
+ import zlib
3
+ from collections import Counter
4
+ from functools import lru_cache
5
+
6
+ from PyQt6.QtCore import QPointF, QRectF, Qt
7
+ from PyQt6.QtGui import QBitmap, QColor, QImage, QPainter, QPainterPath, QPen, QPixmap, QRegion, QTransform
8
+
9
+ from . import remote
10
+ from .theme import font
11
+
12
+ PALETTE = ("#ff5c5c", "#ffb020", "#f7e14b", "#7bd84a", "#2ec4b6", "#3fa7ff", "#7c6cff", "#d45cff", "#ff6fb1",
13
+ "#00e0a0", "#ff8a3d", "#9fe870")
14
+ COCO_SKELETON = ((16, 14), (14, 12), (17, 15), (15, 13), (12, 13), (6, 12), (7, 13), (6, 7), (6, 8), (7, 9), (8, 10),
15
+ (9, 11), (2, 3), (1, 2), (1, 3), (2, 4), (3, 5), (4, 6), (5, 7))
16
+ MAX_TEXT = 60
17
+
18
+
19
+ @lru_cache(maxsize=256)
20
+ def _loads(text):
21
+ try:
22
+ return json.loads(text)
23
+ except ValueError:
24
+ return text
25
+
26
+
27
+ def parse(value):
28
+ if value is None or isinstance(value, (list, dict)):
29
+ return value
30
+ v = _loads(str(value)) if str(value)[:1] in "[{" else value
31
+ return v if isinstance(v, (list, dict)) else value
32
+
33
+
34
+ def _objects(v):
35
+ if isinstance(v, dict):
36
+ v = v.get("segments_info", [])
37
+ return [o for o in v if isinstance(o, dict)] if isinstance(v, list) else []
38
+
39
+
40
+ def categories(value):
41
+ v = parse(value)
42
+ if v is None:
43
+ return []
44
+ if not isinstance(v, (list, dict)):
45
+ return [str(v)]
46
+ if isinstance(v, list) and all(not isinstance(x, (list, dict)) for x in v):
47
+ return [str(x) for x in v]
48
+ return [str(o["category"]) for o in _objects(v) if o.get("category") is not None]
49
+
50
+
51
+ def summary(value):
52
+ v = parse(value)
53
+ if v is None:
54
+ return None
55
+ if not isinstance(v, (list, dict)):
56
+ text = str(v)
57
+ elif isinstance(v, list) and all(not isinstance(x, (list, dict)) for x in v):
58
+ text = ", ".join(map(str, v)) or "none"
59
+ else:
60
+ objs = _objects(v)
61
+ counts = Counter(str(o.get("category", "?")) for o in objs)
62
+ what = ("segment" if isinstance(v, dict) else "object") + ("" if len(objs) == 1 else "s")
63
+ names = ", ".join(c if n == 1 else f"{c} ×{n}" for c, n in counts.most_common())
64
+ text = f"{len(objs)} {what}" + (f" · {names}" if names else "")
65
+ return text if len(text) <= MAX_TEXT else text[:MAX_TEXT - 1] + "…"
66
+
67
+
68
+ def color(category):
69
+ return PALETTE[zlib.crc32(str(category).encode()) % len(PALETTE)]
70
+
71
+
72
+ def _polygons(seg):
73
+ path = QPainterPath()
74
+ path.setFillRule(Qt.FillRule.WindingFill)
75
+ if isinstance(seg, list):
76
+ for poly in seg:
77
+ if isinstance(poly, list) and len(poly) >= 6:
78
+ path.moveTo(poly[0], poly[1])
79
+ for i in range(2, len(poly) - 1, 2):
80
+ path.lineTo(poly[i], poly[i + 1])
81
+ path.closeSubpath()
82
+ elif isinstance(seg, dict) and isinstance(seg.get("counts"), list) and seg.get("size"):
83
+ h = seg["size"][0]
84
+ at = 0
85
+ for i, n in enumerate(seg["counts"]):
86
+ if i % 2:
87
+ start, end = at, at + n
88
+ while start < end:
89
+ x, y = divmod(start, h)
90
+ run = min(end - start, h - y)
91
+ path.addRect(QRectF(x, y, 1, run))
92
+ start += run
93
+ at += n
94
+ return path
95
+
96
+
97
+ def _panoptic(path, segments):
98
+ ids = QImage(path).convertToFormat(QImage.Format.Format_RGB32)
99
+ if ids.isNull():
100
+ return None
101
+ out = QImage(ids.size(), QImage.Format.Format_ARGB32_Premultiplied)
102
+ out.fill(Qt.GlobalColor.transparent)
103
+ p = QPainter(out)
104
+ for seg in segments:
105
+ sid = seg.get("id")
106
+ if not isinstance(sid, int):
107
+ continue
108
+ mask = ids.createMaskFromColor(QColor(sid % 256, sid // 256 % 256, sid // 65536 % 256).rgb(), Qt.MaskMode.MaskOutColor)
109
+ fill = QColor(color(seg.get("category", "")))
110
+ fill.setAlpha(110)
111
+ p.setClipRegion(QRegion(QBitmap.fromImage(mask)))
112
+ p.fillRect(out.rect(), fill)
113
+ p.end()
114
+ return QPixmap.fromImage(out)
115
+
116
+
117
+ class Overlay:
118
+ def __init__(self, value):
119
+ v = parse(value)
120
+ self.shapes = []
121
+ self.mask = None
122
+ if isinstance(v, dict) and v.get("mask_path"):
123
+ self.mask = _panoptic(remote.local(str(v["mask_path"])), _objects(v))
124
+ for o in _objects(v):
125
+ cat = str(o.get("category", ""))
126
+ if isinstance(v, dict):
127
+ path = QPainterPath()
128
+ if isinstance(o.get("bbox"), list) and len(o["bbox"]) == 4:
129
+ path.addRect(QRectF(*o["bbox"]))
130
+ self.shapes.append((cat, path, None, [], []))
131
+ continue
132
+ path, filled = QPainterPath(), False
133
+ if o.get("segmentation") is not None:
134
+ path, filled = _polygons(o["segmentation"]), True
135
+ elif isinstance(o.get("bbox"), list) and len(o["bbox"]) == 4:
136
+ path.addRect(QRectF(*o["bbox"]))
137
+ points, limbs = [], []
138
+ kp = o.get("keypoints")
139
+ if isinstance(kp, list) and len(kp) >= 3:
140
+ points = [(kp[i], kp[i + 1]) if kp[i + 2] else None for i in range(0, len(kp) - 2, 3)]
141
+ limbs = [(a - 1, b - 1) for a, b in COCO_SKELETON] if len(points) == 17 else []
142
+ self.shapes.append((cat, path, filled, points, limbs))
143
+
144
+ def __bool__(self):
145
+ return bool(self.shapes) or self.mask is not None
146
+
147
+ def paint(self, p, rect, scale):
148
+ p.save()
149
+ to_view = QTransform(scale, 0, 0, scale, rect.x(), rect.y())
150
+ if self.mask is not None:
151
+ p.drawPixmap(rect, self.mask, QRectF(self.mask.rect()))
152
+ names = []
153
+ for cat, path, filled, points, limbs in self.shapes:
154
+ c = QColor(color(cat))
155
+ view = to_view.map(path)
156
+ if filled is not None and not path.isEmpty():
157
+ if filled:
158
+ fill = QColor(c)
159
+ fill.setAlpha(70)
160
+ p.fillPath(view, fill)
161
+ p.setPen(QPen(c, 2))
162
+ p.setBrush(Qt.BrushStyle.NoBrush)
163
+ p.drawPath(view)
164
+ if points:
165
+ p.setPen(QPen(c, 2))
166
+ for a, b in limbs:
167
+ if points[a] and points[b]:
168
+ p.drawLine(to_view.map(QPointF(*points[a])), to_view.map(QPointF(*points[b])))
169
+ p.setPen(QPen(QColor("#ffffff"), 1))
170
+ p.setBrush(c)
171
+ for pt in points:
172
+ if pt:
173
+ p.drawEllipse(to_view.map(QPointF(*pt)), 3.5, 3.5)
174
+ if cat and not path.isEmpty():
175
+ box = view.boundingRect()
176
+ names.append((cat, c, QPointF(box.x(), box.y()) if filled is not None else box.center()))
177
+ p.setFont(font(11, 600))
178
+ fm = p.fontMetrics()
179
+ for cat, c, at in names:
180
+ w = fm.horizontalAdvance(cat) + 10
181
+ chip = QRectF(at.x(), at.y() - 18, w, 18) if at.y() >= rect.y() + 18 else QRectF(at.x(), at.y(), w, 18)
182
+ if self.mask is not None:
183
+ chip.moveCenter(at)
184
+ p.setPen(Qt.PenStyle.NoPen)
185
+ p.setBrush(c)
186
+ p.drawRoundedRect(chip, 4, 4)
187
+ p.setPen(QColor("#0e0f0c"))
188
+ p.drawText(chip, Qt.AlignmentFlag.AlignCenter, cat)
189
+ p.restore()
laboard/modelinfo.py ADDED
@@ -0,0 +1,218 @@
1
+ import json
2
+ import math
3
+ import pickle
4
+ import re
5
+ import struct
6
+ import zipfile
7
+ from collections import Counter, OrderedDict
8
+ from pathlib import Path
9
+
10
+ MAX_LAYERS = 12
11
+ _cache = {}
12
+
13
+
14
+ class Tensor:
15
+ __slots__ = ("shape", "dtype")
16
+
17
+ def __init__(self, shape, dtype):
18
+ self.shape, self.dtype = tuple(int(d) for d in shape), str(dtype)
19
+
20
+ @property
21
+ def size(self):
22
+ return math.prod(self.shape)
23
+
24
+
25
+ def count(n):
26
+ return f"{n / 1e9:.2f}B" if n >= 1e9 else f"{n / 1e6:.2f}M" if n >= 1e6 else f"{n / 1e3:.1f}K" if n >= 1e3 else str(n)
27
+
28
+
29
+ def _onnx(p):
30
+ import onnx
31
+
32
+ m = onnx.load(str(p), load_external_data=False)
33
+ g = m.graph
34
+ dtype = onnx.TensorProto.DataType.Name
35
+ tensors = {t.name: Tensor(t.dims, dtype(t.data_type).lower()) for t in g.initializer}
36
+ for n in g.node:
37
+ if n.op_type == "Constant":
38
+ for a in n.attribute:
39
+ if a.name == "value" and a.t.dims:
40
+ tensors.setdefault(n.output[0], Tensor(a.t.dims, dtype(a.t.data_type).lower()))
41
+ init = set(tensors)
42
+ users = {}
43
+ for n in g.node:
44
+ for i in n.input:
45
+ if i in init and i not in users:
46
+ users[i] = n.name.strip("/").rsplit("/", 1)[0] if "/" in n.name.strip("/") else n.op_type
47
+ tensors = {f"{users.get(k, 'unused')}:{k}": t for k, t in tensors.items()}
48
+
49
+ def shape(v):
50
+ dims = [d.dim_value or d.dim_param or "?" for d in v.type.tensor_type.shape.dim]
51
+ return f"{dtype(v.type.tensor_type.elem_type).lower()}[{','.join(map(str, dims))}]"
52
+
53
+ ops = Counter(n.op_type for n in g.node)
54
+ props = [("Format", f"ONNX IR v{m.ir_version}"), ("Producer", f"{m.producer_name} {m.producer_version}".strip() or "—"),
55
+ ("Opset", ", ".join(f"{o.domain or 'ai.onnx'} {o.version}" for o in m.opset_import)),
56
+ *[("Input", f"{v.name}: {shape(v)}") for v in g.input if v.name not in init],
57
+ *[("Output", f"{v.name}: {shape(v)}") for v in g.output],
58
+ ("Nodes", f"{len(g.node):,}"), ("Top ops", ", ".join(f"{k} {n}" for k, n in ops.most_common(4)))]
59
+ if any(t.data_location == onnx.TensorProto.EXTERNAL for t in g.initializer):
60
+ props.append(("Weights", "external data file (not loaded)"))
61
+ return tensors, props
62
+
63
+
64
+ def _safetensors(p):
65
+ with open(p, "rb") as f:
66
+ n = struct.unpack("<Q", f.read(8))[0]
67
+ header = json.loads(f.read(n))
68
+ meta = header.pop("__metadata__", None) or {}
69
+ tensors = {k: Tensor(v["shape"], v["dtype"].lower()) for k, v in header.items()}
70
+ props = [("Format", "safetensors"), ("Tensors", f"{len(tensors):,}"),
71
+ ("Dtypes", ", ".join(sorted({t.dtype for t in tensors.values()})))]
72
+ props += [(f"meta · {k}", str(v)[:60]) for k, v in list(meta.items())[:4]]
73
+ return tensors, props
74
+
75
+
76
+ def _msgpack(p):
77
+ import msgpack
78
+
79
+ def ext(code, data):
80
+ if code == 1:
81
+ shape, dtype, _ = msgpack.unpackb(data, raw=False)
82
+ return Tensor(shape, dtype)
83
+ if code == 3:
84
+ return Tensor((), msgpack.unpackb(data, raw=False)[0])
85
+ return msgpack.ExtType(code, data)
86
+
87
+ with open(p, "rb") as f:
88
+ tree = msgpack.unpackb(f.read(), ext_hook=ext, raw=False, strict_map_key=False)
89
+ tensors = dict(_leaves(tree))
90
+ return tensors, [("Format", "Flax msgpack"), ("Tensors", f"{len(tensors):,}"),
91
+ ("Dtypes", ", ".join(sorted({t.dtype for t in tensors.values()})))]
92
+
93
+
94
+ class _Obj:
95
+ def __new__(cls, *a, **k):
96
+ return object.__new__(cls)
97
+
98
+ def __init__(self, *a, **k):
99
+ self.args = a
100
+
101
+ def __setstate__(self, state):
102
+ self.state = state
103
+
104
+
105
+ class _Unpickler(pickle.Unpickler):
106
+ def find_class(self, module, name):
107
+ if module == "collections" and name == "OrderedDict":
108
+ return OrderedDict
109
+ if module == "torch._utils" and name.startswith("_rebuild_tensor"):
110
+ return lambda storage, offset, size, *a, **k: Tensor(size, storage)
111
+ if module == "torch._utils" and name == "_rebuild_parameter":
112
+ return lambda data, *a, **k: data
113
+ if module == "torch" and name.endswith("Storage"):
114
+ return name[:-len("Storage")].lower() or "untyped"
115
+ if module == "torch" and name in ("float32", "float16", "bfloat16", "int64", "int32", "uint8", "bool"):
116
+ return name
117
+ return type(name, (_Obj,), {"__module__": "stub." + module})
118
+
119
+ def persistent_load(self, pid):
120
+ return pid[1] if isinstance(pid[1], str) else "untyped"
121
+
122
+
123
+ CHECKPOINT_SKIP = re.compile(r"optim|sched|scaler|rng|history|ema_|swa_", re.I)
124
+
125
+
126
+ def model_tensors(obj):
127
+ if isinstance(obj, dict) and not all(isinstance(v, Tensor) for v in obj.values()):
128
+ parts = {k: OrderedDict(_leaves(v)) for k, v in obj.items()
129
+ if not CHECKPOINT_SKIP.search(str(k)) and not isinstance(v, Tensor)}
130
+ parts = {k: v for k, v in parts.items() if v}
131
+ if parts:
132
+ return max(parts.values(), key=len)
133
+ return OrderedDict(_leaves(obj))
134
+
135
+
136
+ def _torch(p):
137
+ if not zipfile.is_zipfile(p):
138
+ raise ValueError("legacy (non-zip) torch file; re-save with torch>=1.6 to inspect")
139
+ with zipfile.ZipFile(p) as z:
140
+ names = z.namelist()
141
+ obj = _Unpickler(z.open(next(n for n in names if n.endswith("data.pkl")))).load()
142
+ tensors = model_tensors(obj)
143
+ kind = "state_dict" if isinstance(obj, dict) and all(isinstance(v, Tensor) for v in obj.values()) else \
144
+ "checkpoint dict" if isinstance(obj, dict) else type(obj).__name__
145
+ if any("/code/" in n for n in names):
146
+ kind = "TorchScript"
147
+ props = [("Format", f"PyTorch zip · {kind}"), ("Tensors", f"{len(tensors):,}")]
148
+ if isinstance(obj, dict):
149
+ extra = [k for k, v in obj.items() if not isinstance(v, Tensor)][:6]
150
+ if extra:
151
+ props.append(("Keys", ", ".join(map(str, extra))))
152
+ return tensors, props
153
+
154
+
155
+ def _engine(p):
156
+ with open(p, "rb") as f:
157
+ head = f.read(32)
158
+ magic = head[:4].decode("latin-1")
159
+ props = [("Format", "TensorRT engine" if magic == "ftrt" else "TensorRT plan"),
160
+ ("Note", "built for one TensorRT/GPU combination; layers need that runtime")]
161
+ return {}, props
162
+
163
+
164
+ _SKIP = {"_modules", "_parameters", "_buffers", "state", "args", "params"}
165
+
166
+
167
+ def _leaves(obj, prefix=""):
168
+ if isinstance(obj, Tensor):
169
+ yield prefix.rstrip("."), obj
170
+ elif isinstance(obj, dict):
171
+ for k, v in obj.items():
172
+ yield from _leaves(v, prefix if str(k) in _SKIP else f"{prefix}{k}.")
173
+ elif isinstance(obj, (list, tuple)):
174
+ for i, v in enumerate(obj):
175
+ yield from _leaves(v, f"{prefix}{i}.")
176
+ elif isinstance(obj, _Obj):
177
+ yield from _leaves(getattr(obj, "state", None) or getattr(obj, "args", ()), prefix)
178
+
179
+
180
+ def _layers(tensors):
181
+ parts = {k: [s for s in re.split(r"[./:]", k) if s] for k in tensors}
182
+ best = []
183
+ top = Counter()
184
+ for k, t in tensors.items():
185
+ top[parts[k][0] if parts[k] else k] += t.size
186
+ if len(top) > MAX_LAYERS:
187
+ head = top.most_common(MAX_LAYERS - 1)
188
+ return head + [("other", sum(top.values()) - sum(n for _, n in head))]
189
+ for depth in range(1, 8):
190
+ groups = OrderedDict()
191
+ for k, t in tensors.items():
192
+ key = ".".join(parts[k][:depth]) or k
193
+ groups[key] = groups.get(key, 0) + t.size
194
+ if len(groups) > MAX_LAYERS:
195
+ break
196
+ best = list(groups.items())
197
+ if all(len(p) <= depth for p in parts.values()):
198
+ break
199
+ return best
200
+
201
+
202
+ READERS = {".onnx": _onnx, ".safetensors": _safetensors, ".msgpack": _msgpack, ".pt": _torch, ".pth": _torch,
203
+ ".ckpt": _torch, ".bin": _torch, ".engine": _engine, ".plan": _engine, ".trt": _engine}
204
+
205
+
206
+ def inspect(path):
207
+ p = Path(path)
208
+ key = (str(p), p.stat().st_mtime)
209
+ if key not in _cache:
210
+ reader = READERS.get(p.suffix.lower())
211
+ try:
212
+ tensors, props = reader(p) if reader else ({}, [("Format", p.suffix[1:].upper() or "file")])
213
+ except Exception as e:
214
+ tensors, props = {}, [("Could not read", f"{type(e).__name__}: {str(e)[:80]}")]
215
+ total = sum(t.size for t in tensors.values())
216
+ stats = [("Parameters", count(total))] if tensors else []
217
+ _cache[key] = dict(stats=stats, props=props, layers=_layers(tensors))
218
+ return _cache[key]
laboard/netronview.py ADDED
@@ -0,0 +1,107 @@
1
+ import logging
2
+ import os
3
+
4
+ from PyQt6.QtCore import QCoreApplication, Qt, QUrl
5
+ from PyQt6.QtGui import QColor
6
+
7
+ from . import theme
8
+
9
+ try:
10
+ import netron
11
+ from PyQt6 import sip
12
+ from PyQt6.QtWebEngineCore import QWebEngineScript
13
+ from PyQt6.QtWebEngineWidgets import QWebEngineView
14
+ AVAILABLE = True
15
+ except ImportError:
16
+ AVAILABLE = False
17
+
18
+ logging.getLogger("netron").setLevel(logging.WARNING)
19
+ _view, _path, _address = None, None, None
20
+ _win = None
21
+ SIDEBAR_FONT_PX = 14
22
+ SIDEBAR_WIDTH = 0.7
23
+
24
+
25
+ def prepare():
26
+ if AVAILABLE:
27
+ os.environ.setdefault("QTWEBENGINE_CHROMIUM_FLAGS", "--disable-gpu")
28
+ QCoreApplication.setAttribute(Qt.ApplicationAttribute.AA_ShareOpenGLContexts)
29
+
30
+
31
+ def url():
32
+ return f"http://{_address[0]}:{_address[1]}" if _address else ""
33
+
34
+
35
+ def _new_view(parent=None):
36
+ v = QWebEngineView(parent)
37
+ script = QWebEngineScript()
38
+ script.setName("laboard-sidebar-size")
39
+ script.setInjectionPoint(QWebEngineScript.InjectionPoint.DocumentReady)
40
+ script.setWorldId(QWebEngineScript.ScriptWorldId.ApplicationWorld)
41
+ script.setSourceCode(f"""(() => {{ const s = document.createElement('style');
42
+ s.textContent = '.sidebar {{ zoom: {SIDEBAR_FONT_PX / 11:.4f}; '
43
+ + 'width: calc(min(calc(100% * 0.6), 42em) * {SIDEBAR_WIDTH}) !important; }}';
44
+ document.head.appendChild(s); }})();""")
45
+ v.page().scripts().insert(script)
46
+ v.loadFinished.connect(lambda ok: _apply_theme(v))
47
+ return v
48
+
49
+
50
+ def _apply_theme(v):
51
+ dark = theme.current == "dark"
52
+ v.page().setBackgroundColor(QColor(theme.T.stage))
53
+ v.page().runJavaScript(f"""(dark => {{
54
+ const seen = window.laboardDark = window.laboardDark || new Set();
55
+ for (const sheet of document.styleSheets) {{
56
+ let rules;
57
+ try {{ rules = sheet.cssRules; }} catch (e) {{ continue; }}
58
+ for (const r of rules)
59
+ if (r.media && (seen.has(r) || r.media.mediaText.includes('prefers-color-scheme: dark'))) {{
60
+ seen.add(r);
61
+ r.media.mediaText = dark ? 'all' : 'not all';
62
+ }}
63
+ }}
64
+ let s = document.getElementById('laboard-stage');
65
+ if (!s && document.head) {{
66
+ s = document.createElement('style');
67
+ s.id = 'laboard-stage';
68
+ document.head.appendChild(s);
69
+ }}
70
+ if (s) s.textContent = '.default, .target, .welcome {{ background-color: {theme.T.stage} !important; }}';
71
+ }})({'true' if dark else 'false'});""")
72
+
73
+
74
+ def attach(win):
75
+ global _view, _win
76
+ _win = win
77
+ if AVAILABLE and _view is None:
78
+ _view = _new_view(win)
79
+ _view.load(QUrl("about:blank"))
80
+ _stow(_view)
81
+
82
+
83
+ def _stow(v):
84
+ v.setGeometry(0, 0, 1, 1)
85
+ v.show()
86
+ v.lower()
87
+
88
+
89
+ def park(win):
90
+ if _view is not None and not sip.isdeleted(_view) and _view.parent() is not win:
91
+ _view.setParent(win)
92
+ _stow(_view)
93
+
94
+
95
+ def view(path):
96
+ global _view, _path, _address
97
+ if _view is None or sip.isdeleted(_view):
98
+ _view, _path = _new_view(_win), None
99
+ if path != _path:
100
+ if _address:
101
+ netron.stop(_address)
102
+ _address = netron.serve(path, address=("localhost", 0), browse=False)
103
+ _path = path
104
+ _view.load(QUrl(url()))
105
+ _apply_theme(_view)
106
+ _view.show()
107
+ return _view