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/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))