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/__init__.py +1 -0
- laboard/__main__.py +5 -0
- laboard/app.py +528 -0
- laboard/assets/fonts/Figtree-Black.ttf +0 -0
- laboard/assets/fonts/Figtree-Regular.ttf +0 -0
- laboard/assets/fonts/Inter-latin-var.woff2 +0 -0
- laboard/assets/logo/laboard_splash.svg +2 -0
- laboard/charts.py +631 -0
- laboard/labels.py +189 -0
- laboard/modelinfo.py +218 -0
- laboard/netronview.py +107 -0
- laboard/newproject.py +287 -0
- laboard/pages/__init__.py +0 -0
- laboard/pages/data.py +352 -0
- laboard/pages/home.py +242 -0
- laboard/pages/hyperparameter.py +154 -0
- laboard/pages/model.py +78 -0
- laboard/pages/result.py +707 -0
- laboard/pages/setting.py +272 -0
- laboard/project.py +1127 -0
- laboard/readers.py +156 -0
- laboard/remote.py +343 -0
- laboard/sidebar.py +242 -0
- laboard/store.py +38 -0
- laboard/theme.py +111 -0
- laboard/tomledit.py +262 -0
- laboard/torchgraph.py +102 -0
- laboard/widgets.py +1128 -0
- laboard-1.0.0.dist-info/METADATA +400 -0
- laboard-1.0.0.dist-info/RECORD +34 -0
- laboard-1.0.0.dist-info/WHEEL +5 -0
- laboard-1.0.0.dist-info/entry_points.txt +2 -0
- laboard-1.0.0.dist-info/licenses/LICENSE +8 -0
- laboard-1.0.0.dist-info/top_level.txt +1 -0
laboard/newproject.py
ADDED
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
|
|
3
|
+
from PyQt6.QtCore import QPointF, Qt, QTimer
|
|
4
|
+
from PyQt6.QtGui import QColor
|
|
5
|
+
from PyQt6.QtWidgets import QGraphicsDropShadowEffect, QGraphicsOpacityEffect
|
|
6
|
+
|
|
7
|
+
from . import store, tomledit
|
|
8
|
+
from .theme import NEGATIVE
|
|
9
|
+
from .widgets import (CROSS, Box, Button, Flow, HLine, Label, LineEdit, Overlay, Scroll, Segmented, Stroke, card,
|
|
10
|
+
clear_layout, col, hbox, vbox, wrap)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _text(key, desc, ph="", req=False):
|
|
14
|
+
return dict(key=key, desc=desc, ph=ph, req=req, kind="text")
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _enum(key, desc):
|
|
18
|
+
return dict(key=key, desc=desc, kind="enum", opts=("CSV", "DB"))
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
SCHEMA = (
|
|
22
|
+
("Project", "Raw data is shared by every task; each task has its own labels and predictions.", (
|
|
23
|
+
_text("Name", "Project name. Empty: the root folder’s name.", "cifar10-resnet"),
|
|
24
|
+
_text("Root", "Folder of the project’s git repository.", "~/projects/cifar10-resnet"),
|
|
25
|
+
dict(key="Task", desc="One task or several. Saved comma-separated.", kind="chips", opts=tomledit.TASKS),
|
|
26
|
+
dict(key="DB", desc='True when [Data], [Label] or [Prediction] uses Type = "DB".', kind="bool"))),
|
|
27
|
+
("DB", "Database connection. Shown and used when [Project] DB = True.", (
|
|
28
|
+
_text("DB", "Database type, e.g. supabase.", "supabase"),
|
|
29
|
+
_text("DB_ip", "Database address.", "http://127.0.0.1:54323/ or postgresql://user:password@host:5432/db"))),
|
|
30
|
+
("Git", "Fallbacks when git can’t tell.", (
|
|
31
|
+
_text("Remote", "Remote URL.", "github.com:user/repo.git"),
|
|
32
|
+
_text("Branch", "Branch name.", "main"))),
|
|
33
|
+
("Path", "Each may hold one folder per task, e.g. Results/classification/<model>/.", (
|
|
34
|
+
_text("Models", "Folder with model checkpoints.", "models/"),
|
|
35
|
+
_text("Hyperparameter", "Folder with one sub-folder per training run.", "runs/"),
|
|
36
|
+
_text("Results", "Folder with result files.", "results/"))),
|
|
37
|
+
("Data", "The raw data files, one list for every task.", (
|
|
38
|
+
_enum("Type", "Where the file list comes from."),
|
|
39
|
+
_text("File_path", "CSV: the csv listing the files.", "data/files.csv"),
|
|
40
|
+
_text("Table", "DB: table with one row per file.", "raw_files"),
|
|
41
|
+
_text("Raw", "CSV / DB: column with each raw file’s path.", "file_path"))),
|
|
42
|
+
("Label", "Ground truth, one column for every task.", (
|
|
43
|
+
_enum("Type", "Where the labels come from."),
|
|
44
|
+
_text("File_path", "CSV: the labels csv.", "data/labels.csv"),
|
|
45
|
+
_text("Table", "DB: table with the labels.", "labels"),
|
|
46
|
+
_text("Label", "Its column. A task column picks each task’s row.", "label"))),
|
|
47
|
+
("Prediction", "Any key can be set for one task as <Task>_<Key>, e.g. Instance_segmentation_Predict.", (
|
|
48
|
+
_enum("Type", "Where the predictions come from."),
|
|
49
|
+
_text("File_path", "CSV: result file name / glob under Path.Results/<task>.", "predictions_*.csv"),
|
|
50
|
+
_text("Table", "DB: table with the predictions.", "predictions"),
|
|
51
|
+
_text("Predict", "Predicted-class column.", "pred"),
|
|
52
|
+
_text("Predict_score", "Score column.", "score"))),
|
|
53
|
+
("gcloud", "gcloud compute ssh <instance_name> --zone <zone>", (
|
|
54
|
+
_text("instance_name", "The VM instance’s name.", "instance name", True),
|
|
55
|
+
_text("zone", "Instance zone.", "us-west1-b"))),
|
|
56
|
+
("az", "az ssh vm --name <vm_name> --resource-group <resource-group>", (
|
|
57
|
+
_text("vm_name", "The Azure VM’s name.", "VM name"),
|
|
58
|
+
_text("resource-group", "Its resource group.", "resource group"))),
|
|
59
|
+
("aws", "Sends the public key (EC2 Instance Connect), then ssh -i <private key> <user>@<public ip>.", (
|
|
60
|
+
_text("instance-id", "EC2 instance ID.", "i-0123456789abcdef0", True),
|
|
61
|
+
_text("instance-os-user", "The login user, e.g. ec2-user, ubuntu.", "ec2-user", True),
|
|
62
|
+
_text("ssh-public-key", "Public key. The private key is this file without .pub.", "file://~/.ssh/id_ed25519.pub",
|
|
63
|
+
True),
|
|
64
|
+
_text("availability-zone", "Its region finds the instance’s public ip.", "us-east-1a"))),
|
|
65
|
+
("ssh", "ssh -i ~/.ssh/<key_name> <user_name>@<ip>", (
|
|
66
|
+
_text("key_name", "A key under ~/.ssh, or the key file’s own path when it has a \"/\".", "id_ed25519"),
|
|
67
|
+
_text("user_name", "Login user. Empty: ssh’s default.", "ubuntu"),
|
|
68
|
+
_text("ip", "Server address (ip or host name).", "192.168.0.10", True))),
|
|
69
|
+
)
|
|
70
|
+
CLOUD = ("gcloud", "az", "aws", "ssh")
|
|
71
|
+
SHOWN = {"gcloud": "Google Cloud - gcloud", "az": "Azure - az", "aws": "AWS - aws", "ssh": "SSH - ssh"}
|
|
72
|
+
REQUIRED = ("Project",)
|
|
73
|
+
OFF = set(CLOUD)
|
|
74
|
+
TITLE_BAR, TITLE_FG, TITLE_MUTE, CLOSE_BG = "#0e0f0c", "#e8ebe6", "#868685", "#2a2b28"
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _group(section):
|
|
78
|
+
return "Cloud" if section in CLOUD else section
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _free_path(folder):
|
|
82
|
+
f, n = folder / "laboard.toml", 2
|
|
83
|
+
while f.exists():
|
|
84
|
+
f, n = folder / f"laboard_{n}.toml", n + 1
|
|
85
|
+
return f
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
class CreateProject(Overlay):
|
|
89
|
+
W, H = 1180, 900
|
|
90
|
+
|
|
91
|
+
def __init__(self, app):
|
|
92
|
+
super().__init__(app)
|
|
93
|
+
self.app, self.values, self.group = app, {}, "Project"
|
|
94
|
+
self.on = {name: name not in OFF for name, _, _ in SCHEMA}
|
|
95
|
+
loaded = store.PROJECT.get("toml")
|
|
96
|
+
self.folder = Path(loaded).parent if loaded else Path.home()
|
|
97
|
+
|
|
98
|
+
close = Box(bg=CLOSE_BG, radius=12, on_click=self.close,
|
|
99
|
+
layout=hbox(Stroke(CROSS, 10, TITLE_FG, 1.5), align=Qt.AlignmentFlag.AlignCenter))
|
|
100
|
+
close.setFixedSize(24, 24)
|
|
101
|
+
bar = Box(bg=TITLE_BAR, radius=0, layout=hbox(
|
|
102
|
+
(Label("Laboard", 13, 600, TITLE_FG), 1),
|
|
103
|
+
(Label("Create project file — laboard.toml", 13, 400, TITLE_MUTE, align=Qt.AlignmentFlag.AlignCenter), 2),
|
|
104
|
+
(hbox("stretch", close), 1), spacing=12, margins=(16, 0, 10, 0), align=Qt.AlignmentFlag.AlignVCenter))
|
|
105
|
+
bar.setFixedHeight(38)
|
|
106
|
+
bar.setStyleSheet(f"QFrame#{bar.objectName()}{{background:{TITLE_BAR};border-top-left-radius:12px;"
|
|
107
|
+
"border-top-right-radius:12px;}")
|
|
108
|
+
|
|
109
|
+
self.nav = vbox(spacing=2)
|
|
110
|
+
side = Box(bg="surf", radius=16, layout=vbox(Label("Sections", 16, 600, lh=24), 8, self.nav, "stretch",
|
|
111
|
+
margins=(8, 16, 8, 16)))
|
|
112
|
+
side.layout().itemAt(0).widget().setContentsMargins(10, 0, 10, 0)
|
|
113
|
+
side.setFixedWidth(230)
|
|
114
|
+
|
|
115
|
+
self.cards = vbox(spacing=12)
|
|
116
|
+
self.scroll = Scroll(wrap(vbox(self.cards, "stretch")))
|
|
117
|
+
self.path = LineEdit("", size=13, pad=(8, 12), mono=True)
|
|
118
|
+
self.path.setText(str(_free_path(self.folder)))
|
|
119
|
+
self.path.setFixedHeight(40)
|
|
120
|
+
self.path.returnPressed.connect(self.create)
|
|
121
|
+
self.error = Label("", 12, 400, NEGATIVE, wrap=True)
|
|
122
|
+
self.error.hide()
|
|
123
|
+
foot = Box(bg="surf", radius=16, layout=hbox(
|
|
124
|
+
(vbox(Label("Save to", 13, 600, lh=18), 6, self.path, self.error, spacing=0), 1), 12,
|
|
125
|
+
vbox("stretch", hbox(Button("Cancel", "tertiary", on_click=self.close), 10,
|
|
126
|
+
Button("Create and load", "primary", on_click=self.create))),
|
|
127
|
+
margins=(20, 14, 20, 14)))
|
|
128
|
+
right = vbox((self.scroll, 1), 12, foot)
|
|
129
|
+
|
|
130
|
+
self.panel = Box(bg="bg", radius=12, layout=vbox(bar, hbox(side, 12, (right, 1), margins=(12, 12, 12, 12)), 1))
|
|
131
|
+
self.panel.setGraphicsEffect(QGraphicsDropShadowEffect(blurRadius=64, offset=QPointF(0, 24),
|
|
132
|
+
color=QColor(14, 15, 12, 102)))
|
|
133
|
+
self.setLayout(vbox("stretch", hbox("stretch", self.panel, "stretch"), "stretch"))
|
|
134
|
+
self._fit()
|
|
135
|
+
self.show_group("Project")
|
|
136
|
+
|
|
137
|
+
def _fit(self):
|
|
138
|
+
super()._fit()
|
|
139
|
+
if getattr(self, "panel", None) is not None:
|
|
140
|
+
self.panel.setFixedSize(min(self.W, self.width() - 48), min(self.H, self.height() - 48))
|
|
141
|
+
|
|
142
|
+
def enabled(self, name):
|
|
143
|
+
if name in REQUIRED:
|
|
144
|
+
return True
|
|
145
|
+
return self.values.get("Project.DB") is True if name == "DB" else self.on[name]
|
|
146
|
+
|
|
147
|
+
def switch(self, name, on):
|
|
148
|
+
if name == "DB":
|
|
149
|
+
self.values["Project.DB"] = on
|
|
150
|
+
else:
|
|
151
|
+
self.on[name] = on
|
|
152
|
+
self.show_group(self.group, keep_scroll=True)
|
|
153
|
+
|
|
154
|
+
def show_group(self, group, keep_scroll=False):
|
|
155
|
+
at = self.scroll.verticalScrollBar().value() if keep_scroll else 0
|
|
156
|
+
groups = list(dict.fromkeys(_group(s[0]) for s in SCHEMA))
|
|
157
|
+
self.group = group if group in groups else "Project"
|
|
158
|
+
clear_layout(self.nav)
|
|
159
|
+
for g in groups:
|
|
160
|
+
secs = [s for s in SCHEMA if _group(s[0]) == g]
|
|
161
|
+
on, n = g == self.group, sum(len(s[2]) for s in secs)
|
|
162
|
+
used = any(self.enabled(s[0]) for s in secs)
|
|
163
|
+
self.nav.addWidget(Box(bg="sel" if on else None, radius=10, hover=None if on else "surf2",
|
|
164
|
+
on_click=lambda g=g: self.show_group(g), layout=hbox(
|
|
165
|
+
(Label(g, 14, 600 if on else 400, "selfg" if on else "ink" if used else "mute",
|
|
166
|
+
lh=20), 1), 8,
|
|
167
|
+
Label(str(n), 12, 400, "mute"), margins=(10, 7, 10, 7),
|
|
168
|
+
align=Qt.AlignmentFlag.AlignVCenter)))
|
|
169
|
+
clear_layout(self.cards)
|
|
170
|
+
for name, desc, fields in SCHEMA:
|
|
171
|
+
if _group(name) == self.group:
|
|
172
|
+
self.cards.addWidget(self._card(name, desc, fields))
|
|
173
|
+
QTimer.singleShot(0, lambda: self.scroll.verticalScrollBar().setValue(at))
|
|
174
|
+
|
|
175
|
+
def _card(self, name, desc, fields):
|
|
176
|
+
on = self.enabled(name)
|
|
177
|
+
head = hbox(Label(SHOWN.get(name, name), 18, 600, "ink" if on else "mute", mono=True, lh=24), spacing=12,
|
|
178
|
+
align=Qt.AlignmentFlag.AlignVCenter)
|
|
179
|
+
if name in REQUIRED:
|
|
180
|
+
head.addWidget(Label("required", 11, 600, NEGATIVE, lh=14))
|
|
181
|
+
head.addStretch(1)
|
|
182
|
+
if name not in REQUIRED:
|
|
183
|
+
head.addWidget(Segmented([(False, "False"), (True, "True")], on, lambda v, n=name: self.switch(n, v),
|
|
184
|
+
size=12, pad=(4, 14), outer=3, gap=2))
|
|
185
|
+
rows = vbox()
|
|
186
|
+
for f in fields:
|
|
187
|
+
key = hbox(Label(f["key"], 13, 600, mono=True, lh=18), spacing=6, align=Qt.AlignmentFlag.AlignVCenter)
|
|
188
|
+
key.addStretch(1)
|
|
189
|
+
left = vbox("stretch", key, 2, Label(f["desc"], 12, 400, "mute", wrap=True), "stretch")
|
|
190
|
+
rows.addWidget(HLine())
|
|
191
|
+
rows.addLayout(hbox((left, 10), 20, (self._editor(name, f), 13), margins=(0, 10, 0, 10),
|
|
192
|
+
align=Qt.AlignmentFlag.AlignVCenter))
|
|
193
|
+
body = wrap(vbox(Label(desc, 13, 400, "body", wrap=True), 8, rows))
|
|
194
|
+
if not on:
|
|
195
|
+
body.setEnabled(False)
|
|
196
|
+
fade = QGraphicsOpacityEffect(body)
|
|
197
|
+
fade.setOpacity(0.4)
|
|
198
|
+
body.setGraphicsEffect(fade)
|
|
199
|
+
return card(head, 4, body, pad=(24, 20, 24, 20))
|
|
200
|
+
|
|
201
|
+
def _editor(self, section, f):
|
|
202
|
+
fid = f"{section}.{f['key']}"
|
|
203
|
+
if f["kind"] == "text":
|
|
204
|
+
box = LineEdit(f.get("ph", ""), size=13, pad=(8, 12), mono=True)
|
|
205
|
+
box.setFixedHeight(40)
|
|
206
|
+
box.setText(self.values.get(fid, ""))
|
|
207
|
+
box.textChanged.connect(lambda t: self.values.__setitem__(fid, t))
|
|
208
|
+
return box
|
|
209
|
+
if f["kind"] in ("bool", "enum"):
|
|
210
|
+
opts = [(False, "False"), (True, "True")] if f["kind"] == "bool" else [(o, o) for o in f["opts"]]
|
|
211
|
+
cur = self.values.get(fid, False if f["kind"] == "bool" else None)
|
|
212
|
+
|
|
213
|
+
def pick(v):
|
|
214
|
+
self.values[fid] = v
|
|
215
|
+
seg.select(v)
|
|
216
|
+
if fid == "Project.DB":
|
|
217
|
+
self.show_group(self.group, keep_scroll=True)
|
|
218
|
+
seg = Segmented(opts, cur, pick, size=13, pad=(6, 16))
|
|
219
|
+
return wrap(hbox(seg, "stretch"))
|
|
220
|
+
chips = Flow(spacing=6, fill=False)
|
|
221
|
+
for o in f["opts"]:
|
|
222
|
+
chips.add(self._chip(fid, o), stretch=0)
|
|
223
|
+
return wrap(chips)
|
|
224
|
+
|
|
225
|
+
def _chip(self, fid, option):
|
|
226
|
+
lbl = Label(option, 12, 600, lh=18)
|
|
227
|
+
chip = Box(bg=None, radius=999, layout=hbox(lbl, margins=(12, 5, 12, 5)))
|
|
228
|
+
|
|
229
|
+
def paint():
|
|
230
|
+
on = option in self.values.get(fid, ())
|
|
231
|
+
chip.restyle(bg="tab" if on else None, border=f"1px solid {col('tab' if on else 'ink')}")
|
|
232
|
+
lbl.setStyleSheet(f"color:{col('tabfg' if on else 'ink')};background:transparent;")
|
|
233
|
+
|
|
234
|
+
def flip():
|
|
235
|
+
picked = set(self.values.get(fid, ()))
|
|
236
|
+
picked ^= {option}
|
|
237
|
+
self.values[fid] = picked
|
|
238
|
+
paint()
|
|
239
|
+
chip.clicked.connect(flip)
|
|
240
|
+
chip.setCursor(Qt.CursorShape.PointingHandCursor)
|
|
241
|
+
paint()
|
|
242
|
+
return chip
|
|
243
|
+
|
|
244
|
+
def missing(self):
|
|
245
|
+
return [f"[{name}] {f['key']}" for name, _, fields in SCHEMA if self.enabled(name)
|
|
246
|
+
for f in fields if f.get("req") and not str(self.values.get(f"{name}.{f['key']}") or "").strip()]
|
|
247
|
+
|
|
248
|
+
def text(self):
|
|
249
|
+
lines = ["# Laboard project file, made with Setting → New. Empty values (\"\") count as not set."]
|
|
250
|
+
for name, _, fields in SCHEMA:
|
|
251
|
+
if not self.enabled(name):
|
|
252
|
+
continue
|
|
253
|
+
lines += ["", f"[{name}]"]
|
|
254
|
+
for f in fields:
|
|
255
|
+
v = self.values.get(f"{name}.{f['key']}")
|
|
256
|
+
if f["kind"] == "bool":
|
|
257
|
+
value = tomledit.written(bool(v))
|
|
258
|
+
elif f["kind"] == "chips":
|
|
259
|
+
value = tomledit.literal(", ".join(t for t in f["opts"] if t in (v or ())))
|
|
260
|
+
else:
|
|
261
|
+
value = tomledit.literal((v or "").strip())
|
|
262
|
+
lines.append(f"{f['key']} = {value}")
|
|
263
|
+
return "\n".join(lines) + "\n"
|
|
264
|
+
|
|
265
|
+
def create(self):
|
|
266
|
+
raw = self.path.text().strip()
|
|
267
|
+
if not raw:
|
|
268
|
+
return self._fail("Type where to save the project file.")
|
|
269
|
+
if self.missing():
|
|
270
|
+
return self._fail("Fill in the required keys, or switch their section off: " + ", ".join(self.missing()))
|
|
271
|
+
f = Path(raw).expanduser()
|
|
272
|
+
f = f if f.is_absolute() else self.folder / f
|
|
273
|
+
f = f if f.suffix == ".toml" else f.with_name(f.name + ".toml")
|
|
274
|
+
if f.exists():
|
|
275
|
+
return self._fail(f"{f} already exists; it is never overwritten. Choose another name.")
|
|
276
|
+
try:
|
|
277
|
+
f.parent.mkdir(parents=True, exist_ok=True)
|
|
278
|
+
f.write_text(self.text())
|
|
279
|
+
except OSError as e:
|
|
280
|
+
return self._fail(f"Could not write {f}: {e}")
|
|
281
|
+
self.close()
|
|
282
|
+
self.app.s.pending_toml = None
|
|
283
|
+
self.app.load_project(str(f))
|
|
284
|
+
|
|
285
|
+
def _fail(self, message):
|
|
286
|
+
self.error.setText(message)
|
|
287
|
+
self.error.show()
|
|
File without changes
|
laboard/pages/data.py
ADDED
|
@@ -0,0 +1,352 @@
|
|
|
1
|
+
import os
|
|
2
|
+
from datetime import datetime
|
|
3
|
+
|
|
4
|
+
from PyQt6 import sip
|
|
5
|
+
from PyQt6.QtCore import QRect, QRectF, QSize, Qt
|
|
6
|
+
from PyQt6.QtGui import QImageReader, QPainter, QPainterPath, QPixmap
|
|
7
|
+
from PyQt6.QtWidgets import QScrollArea, QSizePolicy, QWidget
|
|
8
|
+
|
|
9
|
+
from .. import labels as label_values, project, remote, store
|
|
10
|
+
from ..charts import Stage, StackedBar, ZoomView
|
|
11
|
+
from ..theme import T, font, qc
|
|
12
|
+
from ..widgets import (Box, Button, Combo, Dot, HLine, Label, Scroll, Segmented, badge, card, col, elided, empty_state, hbox,
|
|
13
|
+
kv_row, scrollbar_css, stat_tile, vbox, wrap)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class _Slot(QWidget):
|
|
17
|
+
def __init__(self, child):
|
|
18
|
+
super().__init__()
|
|
19
|
+
self.setLayout(vbox())
|
|
20
|
+
self.child = child
|
|
21
|
+
self.layout().addWidget(child, 1)
|
|
22
|
+
|
|
23
|
+
def swap(self, child):
|
|
24
|
+
old, self.child = self.child, child
|
|
25
|
+
self.layout().replaceWidget(old, child)
|
|
26
|
+
old.hide()
|
|
27
|
+
old.deleteLater()
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class StageArea(QWidget):
|
|
31
|
+
def __init__(self, panes, mode, bg):
|
|
32
|
+
super().__init__()
|
|
33
|
+
self.panes, self.mode, self.bg = panes, mode, bg
|
|
34
|
+
for p in panes:
|
|
35
|
+
p.setParent(self)
|
|
36
|
+
self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)
|
|
37
|
+
self.setMinimumSize(200, 200)
|
|
38
|
+
|
|
39
|
+
def paintEvent(self, e):
|
|
40
|
+
if self.bg and len(self.panes) == 2:
|
|
41
|
+
left, right = self.panes[0].geometry(), self.panes[1].geometry()
|
|
42
|
+
QPainter(self).fillRect(QRect(left.right() + 1, 0, right.left() - left.right() - 1, self.height()), qc(self.bg))
|
|
43
|
+
|
|
44
|
+
def resizeEvent(self, e):
|
|
45
|
+
w, h = self.width(), self.height()
|
|
46
|
+
if self.mode == "both":
|
|
47
|
+
half = (w - 2) // 2
|
|
48
|
+
self.panes[0].setGeometry(0, 0, half, h)
|
|
49
|
+
self.panes[1].setGeometry(w - half, 0, half, h)
|
|
50
|
+
else:
|
|
51
|
+
self.panes[0].setGeometry(0, 0, w, h)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
_thumbs = {}
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _thumb(path):
|
|
58
|
+
if path.rsplit(".", 1)[-1].lower() not in project.IMAGE_EXT:
|
|
59
|
+
return None
|
|
60
|
+
if path not in _thumbs:
|
|
61
|
+
r = QImageReader(remote.local(path))
|
|
62
|
+
r.setScaledSize(QSize(76, 76))
|
|
63
|
+
_thumbs[path] = QPixmap.fromImage(r.read())
|
|
64
|
+
return _thumbs[path]
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class Thumb(QWidget):
|
|
68
|
+
WINDOW = 30
|
|
69
|
+
|
|
70
|
+
def __init__(self, short, dot, selected, on_click, path=None):
|
|
71
|
+
super().__init__()
|
|
72
|
+
self.short, self.dot, self.selected, self.on_click = short, dot, selected, on_click
|
|
73
|
+
self.pix = _thumb(path) if path else None
|
|
74
|
+
self.setFixedSize(80, 80)
|
|
75
|
+
self.setCursor(Qt.CursorShape.PointingHandCursor)
|
|
76
|
+
|
|
77
|
+
def mouseReleaseEvent(self, e):
|
|
78
|
+
self.on_click()
|
|
79
|
+
|
|
80
|
+
def paintEvent(self, e):
|
|
81
|
+
p = QPainter(self)
|
|
82
|
+
p.setRenderHint(QPainter.RenderHint.Antialiasing)
|
|
83
|
+
r = QRectF(2, 2, 76, 76)
|
|
84
|
+
if self.selected:
|
|
85
|
+
p.setPen(Qt.PenStyle.NoPen)
|
|
86
|
+
p.setBrush(qc(T.ink))
|
|
87
|
+
p.drawRoundedRect(r.adjusted(-2, -2, 2, 2), 10, 10)
|
|
88
|
+
path = QPainterPath()
|
|
89
|
+
path.addRoundedRect(r, 8, 8)
|
|
90
|
+
p.save()
|
|
91
|
+
p.setClipPath(path)
|
|
92
|
+
p.fillRect(r, qc(T.bg))
|
|
93
|
+
if self.pix and not self.pix.isNull():
|
|
94
|
+
p.drawPixmap(r.toRect(), self.pix)
|
|
95
|
+
else:
|
|
96
|
+
p.translate(r.center())
|
|
97
|
+
p.rotate(-45)
|
|
98
|
+
p.setPen(Qt.PenStyle.NoPen)
|
|
99
|
+
p.setBrush(qc(T.surf2))
|
|
100
|
+
k = -120
|
|
101
|
+
while k < 120:
|
|
102
|
+
p.drawRect(QRectF(k, -120, 8, 240))
|
|
103
|
+
k += 16
|
|
104
|
+
p.restore()
|
|
105
|
+
p.setFont(font(10, 600, mono=True))
|
|
106
|
+
tw = p.fontMetrics().horizontalAdvance(self.short)
|
|
107
|
+
chip = QRectF(r.x() + 4, r.bottom() - 4 - 16, tw + 10 + 10, 16)
|
|
108
|
+
p.setPen(Qt.PenStyle.NoPen)
|
|
109
|
+
p.setBrush(qc(T.surf))
|
|
110
|
+
p.drawRoundedRect(chip, 4, 4)
|
|
111
|
+
p.setBrush(qc(col(self.dot)))
|
|
112
|
+
p.drawEllipse(QRectF(chip.x() + 5, chip.center().y() - 3, 6, 6))
|
|
113
|
+
p.setPen(qc(T.body))
|
|
114
|
+
p.drawText(chip.adjusted(15, 0, 0, 0), Qt.AlignmentFlag.AlignVCenter, self.short)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
GOOD, BAD, PLAIN = "#2ead4b", "#d03238", None
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _task(app):
|
|
121
|
+
return app.s.task if app.s.task in store.TASKS else (store.TASKS[0] if store.TASKS else None)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _sources(app):
|
|
125
|
+
task = _task(app)
|
|
126
|
+
return {k: v for k, v in store.INFERENCE.items() if v.get("task") in (None, task)}
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _source(app):
|
|
130
|
+
srcs = _sources(app)
|
|
131
|
+
name = app.s.infer_source if app.s.infer_source in srcs else next(iter(srcs), None)
|
|
132
|
+
return name, srcs.get(name)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _label(app, row):
|
|
136
|
+
task = _task(app)
|
|
137
|
+
return row["labels"].get(task) if task and "labels" in row else row["label"]
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _result(app, row):
|
|
141
|
+
src = _source(app)[1]
|
|
142
|
+
if not src:
|
|
143
|
+
return None
|
|
144
|
+
return src["by_path"].get(os.path.normpath(row["path"])) or src["by_name"].get(os.path.basename(row["path"]))
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _label_and_prediction(app, row):
|
|
148
|
+
if store.INFERENCE:
|
|
149
|
+
name, src = _source(app)
|
|
150
|
+
if src is None:
|
|
151
|
+
return _label(app, row) or "n/a", [("", f"no predictions for {_task(app)}", PLAIN)]
|
|
152
|
+
rec = _result(app, row)
|
|
153
|
+
if rec is None:
|
|
154
|
+
return _label(app, row) or "n/a", [("", f"not in {src['rel']}", PLAIN)]
|
|
155
|
+
label, classes = _label(app, row), set(store.CLASSES)
|
|
156
|
+
chips = []
|
|
157
|
+
for key, v in rec["values"].items():
|
|
158
|
+
tone = PLAIN if v in (None, "") else GOOD if str(v) == str(label) else BAD if v in classes else PLAIN
|
|
159
|
+
chips.append((key, "—" if v in (None, "") else label_values.summary(v), tone))
|
|
160
|
+
return label or "n/a", chips
|
|
161
|
+
label = _label(app, row)
|
|
162
|
+
if row["predict"] in (None, ""):
|
|
163
|
+
return label or "n/a", [("pred", "none · predict column is empty", PLAIN)]
|
|
164
|
+
return label or "n/a", [("pred", str(row["predict"]), GOOD if str(row["predict"]) == str(label) else BAD)]
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _overlays(app, row):
|
|
168
|
+
rec = _result(app, row) if store.INFERENCE else None
|
|
169
|
+
pred = next((v for v in rec["values"].values() if isinstance(label_values.parse(v), (list, dict))), None) if rec else None
|
|
170
|
+
return label_values.Overlay(_label(app, row)), label_values.Overlay(pred)
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def _dims(path):
|
|
174
|
+
if path.rsplit(".", 1)[-1].lower() not in project.IMAGE_EXT:
|
|
175
|
+
return "—"
|
|
176
|
+
size = QImageReader(remote.local(path)).size()
|
|
177
|
+
return f"{size.width()}×{size.height()}" if size.isValid() else "—"
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def _viewer(app, fd, files, di, sel):
|
|
181
|
+
s = app.s
|
|
182
|
+
row = fd["rows"][di]
|
|
183
|
+
sp = store.split_of(s.folder, di)
|
|
184
|
+
split_label, split_dot = store.SPLITS[sp]
|
|
185
|
+
mode = s.view_mode
|
|
186
|
+
names = [n for n, _ in files]
|
|
187
|
+
pos = names.index(sel) if sel in names else 0
|
|
188
|
+
lo = max(0, pos - Thumb.WINDOW)
|
|
189
|
+
remote.fetch([row["path"]] + [fd["rows"][i]["path"] for _, i in files[lo:pos + Thumb.WINDOW + 1]])
|
|
190
|
+
ext = row["path"].rsplit(".", 1)[-1].upper() if "." in row["path"] else "FILE"
|
|
191
|
+
path = remote.local(row["path"]) if ext.lower() in project.IMAGE_EXT else None
|
|
192
|
+
label, chips = _label_and_prediction(app, row)
|
|
193
|
+
meta, pred = f"{_dims(row['path'])} {ext}", dict(chips=chips, top=[])
|
|
194
|
+
label_shapes, pred_shapes = _overlays(app, row) if path else (None, None)
|
|
195
|
+
both = mode == "both"
|
|
196
|
+
if not s.zoom or s.zoom[0] != row["path"]:
|
|
197
|
+
s.zoom = (row["path"], dict(raw=ZoomView(), inference=ZoomView()))
|
|
198
|
+
views = s.zoom[1]
|
|
199
|
+
for v in views.values():
|
|
200
|
+
v.panes = []
|
|
201
|
+
panes = []
|
|
202
|
+
if mode != "inference":
|
|
203
|
+
panes.append(Stage(sel, meta, "raw", label=label_values.summary(label), path=path, radius=12, view=views["raw"],
|
|
204
|
+
overlay=label_shapes))
|
|
205
|
+
if mode != "raw":
|
|
206
|
+
panes.append(Stage(sel, meta, "inference", pred=pred, path=path, radius=12, view=views["inference"],
|
|
207
|
+
overlay=pred_shapes))
|
|
208
|
+
area = StageArea(panes, mode, "#868685" if both else None)
|
|
209
|
+
stage = Box(bg="stage", radius=12, border=f"1px solid {T.line}", layout=vbox(area, margins=(1, 1, 1, 1)))
|
|
210
|
+
|
|
211
|
+
split_pill = Box(bg="surf2", radius=12, layout=hbox(Dot(split_dot, 8), 6, Label(split_label, 12, 600),
|
|
212
|
+
margins=(10, 4, 10, 4), align=Qt.AlignmentFlag.AlignVCenter))
|
|
213
|
+
modes = Segmented([("raw", "Raw"), ("inference", "Predict"), ("both", "Both")], mode,
|
|
214
|
+
lambda k: restate(app, view_mode=k), size=13, pad=(6, 16))
|
|
215
|
+
srcs, task = _sources(app), _task(app)
|
|
216
|
+
if len(srcs) > 1:
|
|
217
|
+
shown = lambda k: k.removeprefix(f"{task} · ") if task else k
|
|
218
|
+
source = Combo([(k, shown(k)) for k in srcs], _source(app)[0], lambda k: restate(app, infer_source=k))
|
|
219
|
+
modes = hbox(Label("Results", 13, 400, "body"), 8, source, 12, modes, align=Qt.AlignmentFlag.AlignVCenter)
|
|
220
|
+
if len(store.TASKS) > 1:
|
|
221
|
+
pick = Combo([(t, t[:1].upper() + t[1:]) for t in store.TASKS], task, lambda k: restate(app, task=k, infer_source=None))
|
|
222
|
+
modes = hbox(Label("Task", 13, 400, "body"), 8, pick, 12, modes, align=Qt.AlignmentFlag.AlignVCenter)
|
|
223
|
+
head = hbox(elided(sel, 16, 600, lh=24), 10, split_pill, "stretch", 16, modes, margins=(20, 14, 20, 14), align=Qt.AlignmentFlag.AlignVCenter)
|
|
224
|
+
|
|
225
|
+
step = lambda d: names and app.pick("data", names[(pos + d) % len(names)])
|
|
226
|
+
hint = elided("Scroll to zoom · Shift+scroll or drag to pan · double-click to fit" if path else "", 12, 400, "mute")
|
|
227
|
+
reset = Button("Reset", on_click=lambda: [v.reset() for v in views.values()]) if path else None
|
|
228
|
+
nav = hbox(reset, 12 if reset else 0, hint, "stretch", 12, Label(f"{di + 1} of {fd['n']}", 13, 600, "body"), 12,
|
|
229
|
+
Button("Previous", on_click=lambda: step(-1)), 8, Button("Next", on_click=lambda: step(1)),
|
|
230
|
+
margins=(20, 12, 20, 0), align=Qt.AlignmentFlag.AlignVCenter)
|
|
231
|
+
|
|
232
|
+
thumbs = hbox(spacing=4, margins=(18, 12, 18, 12))
|
|
233
|
+
for n, i in files[lo:pos + Thumb.WINDOW + 1]:
|
|
234
|
+
thumbs.addWidget(Thumb(n.rsplit(".", 1)[0][-6:], store.SPLITS[store.split_of(s.folder, i)][1], n == sel,
|
|
235
|
+
lambda n=n: app.pick("data", n), path=fd["rows"][i]["path"]))
|
|
236
|
+
thumbs.addStretch(1)
|
|
237
|
+
strip = QScrollArea()
|
|
238
|
+
strip.setWidget(wrap(thumbs))
|
|
239
|
+
strip.widget().setAutoFillBackground(False)
|
|
240
|
+
strip.viewport().setAutoFillBackground(False)
|
|
241
|
+
strip.setWidgetResizable(True)
|
|
242
|
+
strip.setFrameShape(QScrollArea.Shape.NoFrame)
|
|
243
|
+
strip.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
|
|
244
|
+
strip.setStyleSheet(scrollbar_css())
|
|
245
|
+
strip.setFixedHeight(80 + 24 + 10)
|
|
246
|
+
|
|
247
|
+
return Box(bg="surf", radius=16, layout=vbox(head, (hbox(stage, margins=(20, 0, 20, 0)), 1), nav, strip))
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def _splits_card(app):
|
|
251
|
+
sf = app.s.split_filter
|
|
252
|
+
total = sum(n for _, n, _ in store.SPLIT_TOTALS)
|
|
253
|
+
src, desc = store.SPLIT_SOURCE
|
|
254
|
+
body = vbox(vbox(hbox(Label("Data splits", 16, 600, lh=24), "stretch", Label(src, 12, 400, "mute", mono=True)), 4,
|
|
255
|
+
Label(desc, 12, 400, "body", wrap=True), 12,
|
|
256
|
+
StackedBar([(n / total, store.SPLITS[k][1]) for k, n, _ in store.SPLIT_TOTALS]), 12, margins=(8, 0, 8, 0)))
|
|
257
|
+
for k, n, frm in store.SPLIT_TOTALS:
|
|
258
|
+
name, dot = store.SPLITS[k]
|
|
259
|
+
pct = n / total
|
|
260
|
+
row = hbox(Dot(dot, 10), 10, (vbox(Label(name, 13, 600), Label(frm, 11.5, 400, "mute", elide=True)), 1), 10,
|
|
261
|
+
Label(f"{n:,}", 13, 600), 10,
|
|
262
|
+
Label("<0.1%" if pct < 0.001 else f"{pct * 100:.1f}%", 12, 400, "body", align=Qt.AlignmentFlag.AlignRight),
|
|
263
|
+
margins=(8, 7, 8, 7), align=Qt.AlignmentFlag.AlignVCenter)
|
|
264
|
+
row.itemAt(row.count() - 1).widget().setFixedWidth(44)
|
|
265
|
+
body.addWidget(Box(bg="surf2" if sf == k else None, radius=10, hover=None if sf == k else "surf2", layout=row,
|
|
266
|
+
on_click=lambda k=k: app.set(split_filter="all" if sf == k else k)))
|
|
267
|
+
if store.SPLIT_OVERLAP:
|
|
268
|
+
check, result, tone = store.SPLIT_OVERLAP
|
|
269
|
+
body.addLayout(vbox(10, HLine(), hbox(Label(check, 13, 400, "body"), "stretch", badge(result, tone),
|
|
270
|
+
margins=(0, 12, 0, 0)), margins=(8, 0, 8, 0)))
|
|
271
|
+
return card(body, pad=(12, 20, 12, 20))
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def _info_card(app, fd, di, sel):
|
|
275
|
+
s = app.s
|
|
276
|
+
sp = store.split_of(s.folder, di)
|
|
277
|
+
r = fd["rows"][di]
|
|
278
|
+
ext = r["path"].rsplit(".", 1)[-1].upper() if "." in r["path"] else "—"
|
|
279
|
+
label, _ = _label_and_prediction(app, r)
|
|
280
|
+
if len(store.TASKS) > 1:
|
|
281
|
+
labels = [(f"Label · {t}", label_values.summary(r["labels"].get(t)) or "—") for t in store.TASKS]
|
|
282
|
+
else:
|
|
283
|
+
labels = [("Label", label_values.summary(label))]
|
|
284
|
+
rows = [("Path", r["path"]), ("Split", store.SPLITS[sp][0]), ("Dimensions", _dims(r["path"])), ("Format", ext),
|
|
285
|
+
*labels, ("Size", f"{r['bytes'] / 1024:.0f} KB" if r["bytes"] else "missing"),
|
|
286
|
+
("Modified", datetime.fromtimestamp(r["mtime"]).strftime("%b %d, %H:%M") if r["mtime"] else "—")]
|
|
287
|
+
if r["created"]:
|
|
288
|
+
rows.append(("Created", r["created"].strftime("%b %d, %Y %H:%M")))
|
|
289
|
+
if r["trained"] is not None:
|
|
290
|
+
rows.append(("Trained", "yes" if r["trained"] else "no"))
|
|
291
|
+
if r["id"] is not None:
|
|
292
|
+
rows.append(("Row", f"{store.SPLIT_SOURCE[0]} · id {r['id']}"))
|
|
293
|
+
src = _source(app)[1]
|
|
294
|
+
if src:
|
|
295
|
+
rec = _result(app, r)
|
|
296
|
+
for key, column in src["shows"].items():
|
|
297
|
+
v = rec["values"].get(key) if rec else None
|
|
298
|
+
rows.append((f"{key} · {column}", "—" if v in (None, "") else label_values.summary(v)))
|
|
299
|
+
rows.append(("Result file", src["rel"] if rec else f"{src['rel']} · no row"))
|
|
300
|
+
body = vbox(Label("File info", 16, 600, lh=24), 10)
|
|
301
|
+
for k, v in rows:
|
|
302
|
+
body.addWidget(kv_row(k, v))
|
|
303
|
+
return card(body)
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
def _folder_card(fd):
|
|
307
|
+
tiles = hbox(stat_tile("Files", f"{fd['n']:,}", value_size=22, lh=30), 12, stat_tile("Size", fd["size"], value_size=22, lh=30))
|
|
308
|
+
folders = {os.path.dirname(r["path"]) for r in fd["rows"]}
|
|
309
|
+
note = Label(f"From {store.SPLIT_SOURCE[0]} · {len(folders)} director{'y' if len(folders) == 1 else 'ies'} on disk",
|
|
310
|
+
13, 400, "body", wrap=True)
|
|
311
|
+
return card(Label("Folder summary", 16, 600, lh=24), 12, tiles, 12, note)
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
def build(app):
|
|
315
|
+
s = app.s
|
|
316
|
+
if s.folder not in store.DATA_FOLDERS:
|
|
317
|
+
return empty_state("No data", "Load a project file in Setting. Data comes from its [db] table or paths.data.")
|
|
318
|
+
fd = store.DATA_FOLDERS[s.folder]
|
|
319
|
+
sel = s.sel["data"] if s.sel["data"] in fd["files"] else fd["files"][0]
|
|
320
|
+
di = fd["files"].index(sel)
|
|
321
|
+
files = [(n, i) for i, n in enumerate(fd["files"]) if s.split_filter in ("all", store.split_of(s.folder, i))]
|
|
322
|
+
viewer, info = _Slot(_viewer(app, fd, files, di, sel)), _Slot(_info_card(app, fd, di, sel))
|
|
323
|
+
app.data_slots = (viewer, info)
|
|
324
|
+
side = Scroll(wrap(vbox(_splits_card(app), info, _folder_card(fd), "stretch", spacing=12)))
|
|
325
|
+
side.setFixedWidth(340)
|
|
326
|
+
return viewer, side
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
def select(app, name):
|
|
330
|
+
fd = store.DATA_FOLDERS.get(app.s.folder)
|
|
331
|
+
slots = getattr(app, "data_slots", None)
|
|
332
|
+
if not fd or name not in fd["files"] or not slots or any(sip.isdeleted(x) for x in slots):
|
|
333
|
+
return False
|
|
334
|
+
_swap(app, fd, name)
|
|
335
|
+
return True
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
def restate(app, **kw):
|
|
339
|
+
fd = store.DATA_FOLDERS.get(app.s.folder)
|
|
340
|
+
slots = getattr(app, "data_slots", None)
|
|
341
|
+
if not fd or not slots or any(sip.isdeleted(x) for x in slots):
|
|
342
|
+
return app.set(**kw)
|
|
343
|
+
vars(app.s).update(kw)
|
|
344
|
+
_swap(app, fd, app.s.sel["data"] if app.s.sel["data"] in fd["files"] else fd["files"][0])
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
def _swap(app, fd, name):
|
|
348
|
+
s, di = app.s, fd["files"].index(name)
|
|
349
|
+
files = [(n, i) for i, n in enumerate(fd["files"]) if s.split_filter in ("all", store.split_of(s.folder, i))]
|
|
350
|
+
viewer, info = app.data_slots
|
|
351
|
+
viewer.swap(_viewer(app, fd, files, di, name))
|
|
352
|
+
info.swap(_info_card(app, fd, di, name))
|