trekr 0.1.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.
trekr/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """TREKR, early release: checks for a computer, its video files, and pose labels."""
2
+
3
+ __version__ = "0.1.0"
trekr/__main__.py ADDED
@@ -0,0 +1,5 @@
1
+ """python -m trekr <command>: see cli.py."""
2
+
3
+ from .cli import main
4
+
5
+ raise SystemExit(main())
trekr/cli.py ADDED
@@ -0,0 +1,44 @@
1
+ """
2
+ trekr check is this computer ready for GPU video analysis?
3
+ trekr videocheck VIDEO [--save REF.json | --against REF.json]
4
+ a video file's fingerprint; is it still the same?
5
+ trekr labels DLC_PROJECT check and edit DeepLabCut labels (a window)
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import argparse
11
+
12
+ from . import __version__
13
+
14
+
15
+ def main(argv=None):
16
+ ap = argparse.ArgumentParser(prog="trekr", description="TREKR (early release): checks for a computer, its "
17
+ "video files, and pose labels.")
18
+ ap.add_argument("--version", action="version", version=f"trekr {__version__}")
19
+ sub = ap.add_subparsers(dest="command", required=True, metavar="command")
20
+ sub.add_parser("check", help="the GPU against the PyTorch build, the driver, and whether OpenCV decodes camera "
21
+ "video with the right colours")
22
+ v = sub.add_parser("videocheck", help="a video file's fingerprint (size, SHA-256 of both ends, stream facts); "
23
+ "save it, or compare the file with a saved one")
24
+ v.add_argument("video")
25
+ g = v.add_mutually_exclusive_group()
26
+ g.add_argument("--save", metavar="REF.json", help="write the fingerprint to this file")
27
+ g.add_argument("--against", metavar="REF.json", help="compare with a saved fingerprint (exit 2 if changed)")
28
+ lab = sub.add_parser("labels", help="find and fix label errors in a DeepLabCut project: every labelled "
29
+ "animal by how well its points fit together, worst first (a window)")
30
+ lab.add_argument("project", help="a DeepLabCut project folder (with config.yaml and labeled-data/), or one "
31
+ "labeled-data folder")
32
+ args = ap.parse_args(argv)
33
+ if args.command == "labels":
34
+ from .labels.app import run
35
+ return run(args.project)
36
+ if args.command == "check":
37
+ from . import envcheck
38
+ return envcheck.run()
39
+ from . import videocheck
40
+ return videocheck.run(args.video, save=args.save, against=args.against)
41
+
42
+
43
+ if __name__ == "__main__":
44
+ raise SystemExit(main())
Binary file
trekr/data/clip.mp4 ADDED
Binary file
trekr/envcheck.py ADDED
@@ -0,0 +1,174 @@
1
+ """
2
+ `trekr check`: is this computer ready for GPU video analysis? Every line says
3
+
4
+ OK fine
5
+ NOTE works, with a caveat (what, and by how much)
6
+ FAIL will not work, or not correctly (why, and what to install)
7
+
8
+ 1. Python, NumPy, OpenCV and (if installed) PyTorch: their versions.
9
+ 2. Each GPU against the PyTorch build: a build carries code for some GPU generations
10
+ only, so a new card (e.g. the RTX 50 series) can need a newer build. And the NVIDIA
11
+ driver against the CUDA version the build was made for.
12
+ 3. Video decoding: a stored H.264 clip encoded as cameras write it (BT.709 colours,
13
+ TV range) against the frames a correct decoder gives. OpenCV 4.11 and 4.12 decode
14
+ such video with the wrong colour matrix: on the stored clip red is off by 5.4 and blue
15
+ by 2.5 grey levels on average, up to 22 (measured 2026-10-07; 4.13 and 5.0 decode it
16
+ exactly). That shifts every colour measured from the video.
17
+
18
+ The exit status is 1 when anything fails.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import ctypes
24
+ import platform
25
+ import re
26
+ import subprocess
27
+ import sys
28
+ from pathlib import Path
29
+
30
+ import numpy as np
31
+
32
+ DATA = Path(__file__).parent / "data"
33
+ CLIP = DATA / "clip.mp4"
34
+ CLIP_EXPECTED = DATA / "clip.expected.npz"
35
+ SAME_PIXELS = 0.5 # mean grey levels up to which a decoded frame counts as the same
36
+
37
+ OK, NOTE, FAIL = "OK", "NOTE", "FAIL"
38
+
39
+
40
+ def driver():
41
+ """(the NVIDIA driver's version, the newest CUDA it runs), each None if unknown."""
42
+ ver = cuda = None
43
+ try: # Linux: no program to start
44
+ m = re.search(r"Kernel Module(?: for \S+)?\s+(\d+(?:\.\d+)+)", Path("/proc/driver/nvidia/version").read_text())
45
+ ver = m.group(1) if m else None
46
+ except OSError:
47
+ pass
48
+ if ver is None:
49
+ try:
50
+ out = subprocess.run(["nvidia-smi", "--query-gpu=driver_version", "--format=csv,noheader"],
51
+ capture_output=True, text=True, timeout=10)
52
+ ver = (out.stdout.strip().splitlines() or [None])[0]
53
+ except (OSError, subprocess.SubprocessError):
54
+ pass
55
+ try:
56
+ lib = ctypes.CDLL("nvcuda.dll" if sys.platform == "win32" else "libcuda.so.1")
57
+ v = ctypes.c_int()
58
+ if lib.cuDriverGetVersion(ctypes.byref(v)) == 0 and v.value:
59
+ cuda = f"{v.value // 1000}.{v.value % 1000 // 10}"
60
+ except (OSError, AttributeError):
61
+ pass
62
+ return ver, cuda
63
+
64
+
65
+ def arch_support(capability, archs):
66
+ """How a PyTorch build (its arch list, e.g. ["sm_80", "sm_86", "compute_90"]) runs on a
67
+ GPU of this compute capability ((8, 9)): "native" (code for it, or for an earlier
68
+ minor of the same major), "jit" (only PTX of an earlier architecture: compiled when
69
+ first used), or None (it cannot run there)."""
70
+ major, minor = capability
71
+ native = jit = False
72
+ for a in archs:
73
+ m = re.fullmatch(r"(sm|compute)_(\d+)(\d)([a-z]?)", a)
74
+ if not m:
75
+ continue
76
+ kind, big, small, suffix = m.group(1), int(m.group(2)), int(m.group(3)), m.group(4)
77
+ if kind == "sm" and big == major and (small == minor or (small < minor and not suffix)):
78
+ native = True
79
+ elif kind == "compute" and not suffix and (big, small) <= (major, minor):
80
+ jit = True
81
+ return "native" if native else "jit" if jit else None
82
+
83
+
84
+ def _span(archs):
85
+ sm = sorted((a for a in archs if a.startswith("sm_")), key=lambda a: int(re.sub(r"\D", "", a)))
86
+ return f"{sm[0]}-{sm[-1]}" if sm else "no GPU"
87
+
88
+
89
+ def decoding():
90
+ """The stored clip as OpenCV decodes it here, against a correct decoder's frames:
91
+ {"frames" (None: cannot be read), "mean" (per channel, R G B), "max", "opencv", "reference"}."""
92
+ import cv2
93
+ e = np.load(CLIP_EXPECTED)
94
+ out = {"opencv": cv2.__version__, "reference": str(e["opencv"]), "frames": None, "mean": None, "max": None}
95
+ cap = cv2.VideoCapture(str(CLIP))
96
+ frames = []
97
+ try:
98
+ while len(frames) < len(e["frames"]):
99
+ ok, f = cap.read()
100
+ if not ok:
101
+ break
102
+ frames.append(cv2.cvtColor(f, cv2.COLOR_BGR2RGB))
103
+ finally:
104
+ cap.release()
105
+ if len(frames) != len(e["frames"]) or any(f.shape != e["frames"][0].shape for f in frames):
106
+ return out
107
+ d = np.abs(np.stack(frames).astype(int) - e["frames"].astype(int))
108
+ out.update(frames=len(frames), mean=d.mean((0, 1, 2)), max=int(d.max()))
109
+ return out
110
+
111
+
112
+ def run(out=None):
113
+ """Print the check; 0 when nothing fails, else 1."""
114
+ out = out or sys.stdout
115
+ fails = []
116
+
117
+ def say(level, text):
118
+ print(f"{level:5s} {text}", file=out)
119
+ if level == FAIL:
120
+ fails.append(text)
121
+
122
+ import cv2
123
+ say(OK, f"Python {platform.python_version()}, NumPy {np.__version__}, OpenCV {cv2.__version__}")
124
+ try:
125
+ import torch
126
+ except ImportError:
127
+ torch = None
128
+ say(NOTE, "PyTorch is not installed: the GPU check is skipped (pytorch.org, a build for your GPU's CUDA)")
129
+ if torch is not None:
130
+ if not torch.cuda.is_available():
131
+ say(FAIL, f"PyTorch {torch.__version__} sees no GPU it can use (built for "
132
+ f"{'CUDA ' + torch.version.cuda if torch.version.cuda else 'the CPU only'})")
133
+ else:
134
+ archs = torch.cuda.get_arch_list()
135
+ for i in range(torch.cuda.device_count()):
136
+ cap = torch.cuda.get_device_capability(i)
137
+ name = (f"GPU {i}: {torch.cuda.get_device_name(i)} (sm_{cap[0]}{cap[1]}, "
138
+ f"{torch.cuda.get_device_properties(i).total_memory / 2 ** 30:.0f} GB)")
139
+ how = arch_support(cap, archs)
140
+ if how == "native":
141
+ say(OK, f"{name}; PyTorch {torch.__version__} has code for {_span(archs)}")
142
+ elif how == "jit":
143
+ say(NOTE, f"{name}: this PyTorch has code for {_span(archs)} only; it is compiled for this "
144
+ f"GPU when first used (a slow start)")
145
+ else:
146
+ say(FAIL, f"{name}: this PyTorch has code for {_span(archs)} only. Install a PyTorch build "
147
+ f"whose architecture list includes sm_{cap[0]}{cap[1]} (pytorch.org; newer GPUs "
148
+ f"need builds for newer CUDA)")
149
+ ver, dcuda = driver()
150
+ rt = torch.version.cuda
151
+ if ver is None or dcuda is None or rt is None:
152
+ say(NOTE, f"NVIDIA driver {ver or 'unknown'}: the CUDA it runs is unknown")
153
+ else:
154
+ d, r = tuple(map(int, dcuda.split("."))), tuple(map(int, rt.split(".")))
155
+ if d[0] < r[0]:
156
+ say(FAIL, f"NVIDIA driver {ver} runs CUDA up to {dcuda}; this PyTorch is built for CUDA {rt}: "
157
+ f"update the driver, or install a PyTorch build for CUDA {d[0]}")
158
+ else:
159
+ say(OK, f"NVIDIA driver {ver} (CUDA {dcuda}); PyTorch built for CUDA {rt}"
160
+ + (" runs on it by CUDA minor-version compatibility" if d < r else ""))
161
+ dec = decoding()
162
+ if dec["frames"] is None:
163
+ say(FAIL, f"OpenCV {dec['opencv']} cannot decode H.264 video (the stored test clip): install "
164
+ f"opencv-python-headless from PyPI, which brings its own decoder")
165
+ elif dec["mean"].max() <= SAME_PIXELS:
166
+ say(OK, f"OpenCV {dec['opencv']} decodes camera video (H.264, BT.709) with the right colours")
167
+ else:
168
+ m = dec["mean"]
169
+ say(FAIL, f"OpenCV {dec['opencv']} decodes camera video (H.264 tagged BT.709) with other colours than a "
170
+ f"correct decoder (OpenCV {dec['reference']}): R {m[0]:.1f}, G {m[1]:.1f}, B {m[2]:.1f} grey "
171
+ f"levels off on average, up to {dec['max']}. OpenCV 4.11 and 4.12 apply the wrong colour "
172
+ f"matrix; use 4.13 or newer")
173
+ print(f"\nNot ready: {len(fails)} problem(s) above (FAIL)." if fails else "\nReady.", file=out)
174
+ return 1 if fails else 0
@@ -0,0 +1 @@
1
+ """`trekr labels`: find and fix label errors in a DeepLabCut project."""
trekr/labels/app.py ADDED
@@ -0,0 +1,385 @@
1
+ """
2
+ `trekr labels`: a window to check and edit DeepLabCut labels.
3
+
4
+ Left: the labelled animals, the one with the point that fits its animal worst first
5
+ (To check; check.py), or every labelled image (Images). Middle: the image with its
6
+ animals. Right: the selected animal's points and their scores.
7
+
8
+ Mouse: hover a point to see which it is; drag a point to move it; right-click a point to
9
+ remove it; click a keypoint in the list, then click in the image to place it; wheel to
10
+ zoom; drag the background to pan.
11
+ Keys: N / Space next to check, P previous; Left / Right images; Delete removes the
12
+ selected point; Ctrl+Z / Ctrl+Y undo / redo; Ctrl+S saves; F5 checks again; F fits.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import sys
18
+ from pathlib import Path
19
+
20
+ import numpy as np
21
+ from PySide6 import QtCore, QtGui, QtWidgets
22
+
23
+ from . import dlc
24
+ from .check import Checker
25
+
26
+ COLOURS = ["#4e9af1", "#f0a33a", "#5cc46b", "#e05d9b", "#a77de8", "#3fc6c0", "#e8d23f", "#ef6c4a",
27
+ "#8fb34a", "#c97bd6", "#5b8def", "#d9a066"]
28
+ HELP = ("Drag a point to move it · right-click removes · pick a keypoint in the list, then click to place · "
29
+ "N / P next / previous to check · ← → images · Ctrl+Z undo · Ctrl+S save · F5 check again")
30
+
31
+
32
+ class View(QtWidgets.QGraphicsView):
33
+ """The image and its points; reports what the mouse does to them."""
34
+ moved = QtCore.Signal(int, int, float, float) # animal, keypoint, x, y (after a drag)
35
+ removed = QtCore.Signal(int, int)
36
+ clicked = QtCore.Signal(float, float) # on the image, not on a point
37
+ picked = QtCore.Signal(int, int)
38
+ hovered = QtCore.Signal(int, int) # the point under the mouse; (-1, -1): none
39
+
40
+ def __init__(self):
41
+ super().__init__()
42
+ self.setScene(QtWidgets.QGraphicsScene(self))
43
+ self.setRenderHint(QtGui.QPainter.Antialiasing)
44
+ self.setTransformationAnchor(QtWidgets.QGraphicsView.AnchorUnderMouse)
45
+ self.setBackgroundBrush(QtGui.QColor("#16181b"))
46
+ self.pix = self.scene().addPixmap(QtGui.QPixmap())
47
+ self.marks, self._drag, self._press, self._over = [], None, None, None
48
+ self.viewport().setMouseTracking(True)
49
+ self.hint = QtWidgets.QLabel(self.viewport()) # bottom left: the point under the mouse
50
+ self.hint.setStyleSheet("background: rgba(17,19,22,210); color: #f2f2f2; border-radius: 4px; padding: 3px 8px;")
51
+ self.hint.hide()
52
+
53
+ def show_hint(self, text):
54
+ if not text:
55
+ return self.hint.hide()
56
+ self.hint.setText(text)
57
+ self.hint.adjustSize()
58
+ self.hint.move(8, self.viewport().height() - self.hint.height() - 8)
59
+ self.hint.show()
60
+ self.hint.raise_()
61
+
62
+ def show_image(self, path):
63
+ pm = QtGui.QPixmap(str(path))
64
+ first = self.pix.pixmap().isNull() or pm.size() != self.pix.pixmap().size()
65
+ self.pix.setPixmap(pm)
66
+ self.scene().setSceneRect(QtCore.QRectF(pm.rect()).adjusted(-2000, -2000, 2000, 2000))
67
+ if first:
68
+ self.fit()
69
+ return not pm.isNull()
70
+
71
+ def fit(self):
72
+ if not self.pix.pixmap().isNull():
73
+ self.fitInView(self.pix, QtCore.Qt.KeepAspectRatio)
74
+
75
+ def draw(self, animals, edges, selected):
76
+ """animals: [(animal index, (K, 2), colour)]; selected: (animal, keypoint) or None."""
77
+ for it in self.marks:
78
+ self.scene().removeItem(it)
79
+ self.marks = []
80
+ for ai, pts, colour in animals:
81
+ pen = QtGui.QPen(QtGui.QColor(colour), 1.5)
82
+ pen.setCosmetic(True)
83
+ for a, b in edges:
84
+ if np.isfinite(pts[[a, b]]).all():
85
+ self.marks.append(self.scene().addLine(*pts[a], *pts[b], pen))
86
+ for k, (x, y) in enumerate(pts):
87
+ if not np.isfinite([x, y]).all():
88
+ continue
89
+ big = (ai, k) == selected
90
+ r = 6 if big else 4
91
+ dot = self.scene().addEllipse(-r, -r, 2 * r, 2 * r, QtGui.QPen(QtGui.QColor("white" if big else "black"), 1),
92
+ QtGui.QBrush(QtGui.QColor(colour)))
93
+ dot.setFlag(QtWidgets.QGraphicsItem.ItemIgnoresTransformations)
94
+ dot.setPos(x, y)
95
+ dot.setZValue(3)
96
+ dot.setData(0, (ai, k))
97
+ self.marks.append(dot)
98
+
99
+ def _point_at(self, pos):
100
+ for it in self.items(pos):
101
+ if it.data(0) is not None:
102
+ return it
103
+ return None
104
+
105
+ def mousePressEvent(self, e):
106
+ it = self._point_at(e.position().toPoint())
107
+ if it is not None and e.button() == QtCore.Qt.RightButton:
108
+ self.removed.emit(*it.data(0))
109
+ return
110
+ if it is not None and e.button() == QtCore.Qt.LeftButton:
111
+ self._drag = it
112
+ self.picked.emit(*it.data(0))
113
+ return
114
+ if e.button() == QtCore.Qt.LeftButton:
115
+ self._press = e.position().toPoint()
116
+ self.setDragMode(QtWidgets.QGraphicsView.ScrollHandDrag)
117
+ super().mousePressEvent(e)
118
+
119
+ def mouseMoveEvent(self, e):
120
+ if self._drag is not None:
121
+ self._drag.setPos(self.mapToScene(e.position().toPoint()))
122
+ return
123
+ it = self._point_at(e.position().toPoint())
124
+ over = it.data(0) if it is not None else None
125
+ if over != self._over and self._press is None:
126
+ self._over = over
127
+ self.viewport().setCursor(QtCore.Qt.OpenHandCursor if over else QtCore.Qt.ArrowCursor)
128
+ self.hovered.emit(*(over or (-1, -1)))
129
+ super().mouseMoveEvent(e)
130
+
131
+ def mouseReleaseEvent(self, e):
132
+ if self._drag is not None:
133
+ p, (ai, k) = self._drag.pos(), self._drag.data(0)
134
+ self._drag = None
135
+ self.moved.emit(ai, k, p.x(), p.y())
136
+ return
137
+ super().mouseReleaseEvent(e)
138
+ self.setDragMode(QtWidgets.QGraphicsView.NoDrag)
139
+ if self._press is not None and (e.position().toPoint() - self._press).manhattanLength() < 4:
140
+ p = self.mapToScene(e.position().toPoint())
141
+ self.clicked.emit(p.x(), p.y())
142
+ self._press = None
143
+
144
+ def wheelEvent(self, e):
145
+ f = 1.25 if e.angleDelta().y() > 0 else 0.8
146
+ self.scale(f, f)
147
+
148
+
149
+ class Window(QtWidgets.QMainWindow):
150
+ def __init__(self, root):
151
+ super().__init__()
152
+ self.root = Path(root)
153
+ paths = dlc.find_files(self.root)
154
+ if not paths:
155
+ raise SystemExit(f"no CollectedData_*.csv under {self.root}")
156
+ self.files = [dlc.LabelFile(p) for p in paths]
157
+ bp = self.files[0].bodyparts
158
+ self.edges = [(bp.index(a), bp.index(b)) for a, b in dlc.skeleton(self.root) if a in bp and b in bp]
159
+ self.images = [(fi, r) for fi, f in enumerate(self.files) for r in range(len(f.rows)) if f.animals(r)]
160
+ self.cur, self.sel, self.undo_stack, self.redo_stack, self.stale = 0, None, [], [], False
161
+ self.setWindowTitle(f"TREKR labels · {self.root.name}")
162
+ self._build()
163
+ self.check_again()
164
+ self.show_image(0)
165
+
166
+ # ------------------------------------------------------------------ layout
167
+ def _build(self):
168
+ self.view = View()
169
+ self.setCentralWidget(self.view)
170
+ self.view.moved.connect(lambda a, k, x, y: self.edit(a, k, (x, y)))
171
+ self.view.removed.connect(lambda a, k: self.edit(a, k, None))
172
+ self.view.picked.connect(self.select)
173
+ self.view.clicked.connect(self.place)
174
+ self.view.hovered.connect(self.hover)
175
+ self.tabs = QtWidgets.QTabWidget()
176
+ self.todo, self.imglist = QtWidgets.QListWidget(), QtWidgets.QListWidget()
177
+ self.tabs.addTab(self.todo, "To check")
178
+ self.tabs.addTab(self.imglist, "Images")
179
+ self.todo.currentRowChanged.connect(self.go_todo)
180
+ self.imglist.currentRowChanged.connect(lambda i: i >= 0 and self.show_image(i))
181
+ for fi, r in self.images:
182
+ self.imglist.addItem(f"{self.files[fi].path.parent.name} / {self.files[fi].name(r)}")
183
+ left = QtWidgets.QDockWidget("Labels")
184
+ left.setWidget(self.tabs)
185
+ self.tabs.setMinimumWidth(330)
186
+ self.addDockWidget(QtCore.Qt.LeftDockWidgetArea, left)
187
+ side = QtWidgets.QWidget()
188
+ lay = QtWidgets.QVBoxLayout(side)
189
+ self.animal = QtWidgets.QLabel("Click a point")
190
+ self.kps = QtWidgets.QListWidget()
191
+ self.kps.itemClicked.connect(lambda it: self.select(self.sel[0], self.kps.row(it)) if self.sel else None)
192
+ save, again = QtWidgets.QPushButton("Save (Ctrl+S)"), QtWidgets.QPushButton("Check again (F5)")
193
+ save.clicked.connect(self.save)
194
+ again.clicked.connect(self.check_again)
195
+ self.info = QtWidgets.QLabel()
196
+ self.info.setWordWrap(True)
197
+ for w in (self.animal, self.kps, save, again, self.info):
198
+ lay.addWidget(w)
199
+ right = QtWidgets.QDockWidget("Animal")
200
+ right.setWidget(side)
201
+ self.addDockWidget(QtCore.Qt.RightDockWidgetArea, right)
202
+ self.statusBar().showMessage(HELP)
203
+ for keys, fn in (("N", self.next_todo), ("Space", self.next_todo), ("P", lambda: self.next_todo(-1)),
204
+ ("Right", lambda: self.show_image(self.cur + 1)), ("Left", lambda: self.show_image(self.cur - 1)),
205
+ ("Delete", self.remove_selected), ("Ctrl+Z", self.undo), ("Ctrl+Y", self.redo),
206
+ ("Ctrl+Shift+Z", self.redo), ("Ctrl+S", self.save), ("F5", self.check_again),
207
+ ("F", self.view.fit)):
208
+ QtGui.QShortcut(QtGui.QKeySequence(keys), self, fn)
209
+ self.resize(1500, 950)
210
+
211
+ # ------------------------------------------------------------------ checking
212
+ def check_again(self):
213
+ self.checker = Checker(self.files)
214
+ self.ranking = self.checker.ranked()
215
+ self.todo.blockSignals(True)
216
+ self.todo.clear()
217
+ for d, i, k in self.ranking:
218
+ fi, r, ind, _ = self.checker.animals[i]
219
+ it = QtWidgets.QListWidgetItem(f"{d:6.1f} {ind} · {self.checker.bodyparts[k]}")
220
+ it.setToolTip(f"{self.files[fi].path.parent.name} / {self.files[fi].name(r)}")
221
+ self.todo.addItem(it)
222
+ self.todo.blockSignals(False)
223
+ self.stale = False
224
+ self.info.setText(f"{self.checker.n} labelled animals in {len(self.images)} images. A point's score: how far "
225
+ f"its distances to the animal's other points are from the usual ones, in their usual "
226
+ f"spread (median over the other points); right points score about 1.")
227
+ if self.images:
228
+ self.redraw()
229
+
230
+ def go_todo(self, row):
231
+ if 0 <= row < len(self.ranking):
232
+ self.go_animal(self.ranking[row][1], self.ranking[row][2])
233
+
234
+ def go_animal(self, i, k):
235
+ fi, r, ind, _ = self.checker.animals[i]
236
+ self.show_image(self.images.index((fi, r)), keep=True)
237
+ self.select(i, k)
238
+ pts = self._points(i)
239
+ pts = pts[np.isfinite(pts).all(1)]
240
+ if len(pts): # the whole animal, its stray point included
241
+ (x0, y0), (x1, y1) = pts.min(0), pts.max(0)
242
+ pad = max(x1 - x0, y1 - y0) * 0.25 + 20
243
+ self.view.fitInView(QtCore.QRectF(x0 - pad, y0 - pad, x1 - x0 + 2 * pad, y1 - y0 + 2 * pad),
244
+ QtCore.Qt.KeepAspectRatio)
245
+
246
+ def next_todo(self, step=1):
247
+ self.tabs.setCurrentIndex(0)
248
+ self.todo.setCurrentRow(min(max(self.todo.currentRow() + step, 0), self.todo.count() - 1))
249
+
250
+ # ------------------------------------------------------------------ showing
251
+ def show_image(self, i, keep=False):
252
+ if not self.images:
253
+ return
254
+ self.cur = min(max(i, 0), len(self.images) - 1)
255
+ fi, r = self.images[self.cur]
256
+ ok = self.view.show_image(self.files[fi].image(r))
257
+ if not keep:
258
+ self.sel = None
259
+ self.imglist.blockSignals(True)
260
+ self.imglist.setCurrentRow(self.cur)
261
+ self.imglist.blockSignals(False)
262
+ self.statusBar().showMessage(("" if ok else f"image not found: {self.files[fi].image(r)} · ") + HELP)
263
+ self.redraw()
264
+
265
+ def _here(self):
266
+ """[(animal index, points as they are now, colour)] of the animals of this image."""
267
+ fi, r = self.images[self.cur]
268
+ f = self.files[fi]
269
+ return [(self.checker.index[(fi, r, ind)], self._points(self.checker.index[(fi, r, ind)]),
270
+ COLOURS[f.individuals.index(ind) % len(COLOURS)])
271
+ for ind in f.individuals if (fi, r, ind) in self.checker.index]
272
+
273
+ def _points(self, ai):
274
+ """The animal's points as they are now (with edits), in the checker's keypoint order."""
275
+ f, fi, r, ind = self._where(ai)
276
+ p = f.points(r, ind)
277
+ return np.array([p[f.bodyparts.index(b)] if b in f.bodyparts else (np.nan, np.nan)
278
+ for b in self.checker.bodyparts])
279
+
280
+ def redraw(self):
281
+ here = self._here()
282
+ self.view.draw(here, self.edges, self.sel)
283
+ self._fill_animal()
284
+
285
+ def _fill_animal(self):
286
+ self.kps.clear()
287
+ if not self.sel:
288
+ self.animal.setText("Click a point")
289
+ return
290
+ ai, k0 = self.sel
291
+ fi, r, ind, _ = self.checker.animals[ai]
292
+ pts = self._points(ai)
293
+ self.animal.setText(f"<b>{ind}</b> · {self.files[fi].name(r)}" + (" · edited: F5 to check again" if self.stale else ""))
294
+ for k, b in enumerate(self.checker.bodyparts):
295
+ d = self.checker.S[ai, k] if not self.stale else np.nan
296
+ placed = np.isfinite(pts[k]).all()
297
+ it = QtWidgets.QListWidgetItem(f"{b} " + (f"{d:.1f}" if np.isfinite(d) else "" if placed else "(not placed)"))
298
+ self.kps.addItem(it)
299
+ self.kps.setCurrentRow(k0)
300
+
301
+ def hover(self, ai, k):
302
+ """Which point is under the mouse: animal, keypoint, score (bottom left)."""
303
+ if ai < 0:
304
+ return self.view.show_hint("")
305
+ fi, r, ind, _ = self.checker.animals[ai]
306
+ d = np.nan if self.stale else self.checker.S[ai, k]
307
+ self.view.show_hint(f"<b>{ind}</b> · {self.checker.bodyparts[k]}" + (f" · {d:.1f}" if np.isfinite(d) else ""))
308
+
309
+ def select(self, ai, k):
310
+ self.sel = (ai, k)
311
+ self.redraw()
312
+
313
+ # ------------------------------------------------------------------ editing
314
+ def _where(self, ai):
315
+ fi, r, ind, _ = self.checker.animals[ai]
316
+ return self.files[fi], fi, r, ind
317
+
318
+ def edit(self, ai, k, xy, record=True):
319
+ f, fi, r, ind = self._where(ai)
320
+ b = self.checker.bodyparts[k]
321
+ old = self._points(ai)[k]
322
+ f.set_point(r, ind, b, xy)
323
+ if record:
324
+ self.undo_stack.append((ai, k, None if not np.isfinite(old).all() else tuple(old), xy))
325
+ self.redo_stack.clear()
326
+ self.stale = True
327
+ self.sel = (ai, k)
328
+ self.setWindowTitle(f"TREKR labels · {self.root.name} *")
329
+ self.redraw()
330
+
331
+ def place(self, x, y):
332
+ """A click on the image places the selected keypoint, if it is not placed yet."""
333
+ if self.sel and not np.isfinite(self._points(self.sel[0])[self.sel[1]]).all():
334
+ self.edit(self.sel[0], self.sel[1], (x, y))
335
+
336
+ def remove_selected(self):
337
+ if self.sel:
338
+ self.edit(*self.sel, None)
339
+
340
+ def undo(self):
341
+ if self.undo_stack:
342
+ ai, k, old, new = self.undo_stack.pop()
343
+ self.redo_stack.append((ai, k, old, new))
344
+ self.edit(ai, k, old, record=False)
345
+
346
+ def redo(self):
347
+ if self.redo_stack:
348
+ ai, k, old, new = self.redo_stack.pop()
349
+ self.undo_stack.append((ai, k, old, new))
350
+ self.edit(ai, k, new, record=False)
351
+
352
+ def save(self):
353
+ changed = [f for f in self.files if f.dirty]
354
+ if not changed:
355
+ return self.statusBar().showMessage("nothing to save · " + HELP)
356
+ backups = [f.save() for f in changed]
357
+ stale = [dlc.h5_stale(f.path) for f in changed if dlc.h5_stale(f.path)]
358
+ self.setWindowTitle(f"TREKR labels · {self.root.name}")
359
+ msg = (f"Saved {len(changed)} label file(s); the originals are kept beside them "
360
+ f"({backups[0].name}{', …' if len(backups) > 1 else ''}).")
361
+ if stale:
362
+ msg += ("\n\nDeepLabCut trains from the .h5 files, which still hold the old labels. Make them again "
363
+ "from the saved CSV files, in Python with DeepLabCut:\n\n"
364
+ " import deeplabcut\n"
365
+ f" deeplabcut.convertcsv2h5(r\"{self.root / 'config.yaml'}\", userfeedback=False)")
366
+ QtWidgets.QMessageBox.information(self, "Saved", msg)
367
+ self.check_again()
368
+
369
+ def closeEvent(self, e):
370
+ if any(f.dirty for f in self.files):
371
+ b = QtWidgets.QMessageBox.question(self, "Unsaved changes", "Save the changed labels before closing?",
372
+ QtWidgets.QMessageBox.Save | QtWidgets.QMessageBox.Discard
373
+ | QtWidgets.QMessageBox.Cancel)
374
+ if b == QtWidgets.QMessageBox.Cancel:
375
+ return e.ignore()
376
+ if b == QtWidgets.QMessageBox.Save:
377
+ self.save()
378
+ e.accept()
379
+
380
+
381
+ def run(root):
382
+ app = QtWidgets.QApplication.instance() or QtWidgets.QApplication(sys.argv[:1])
383
+ w = Window(root)
384
+ w.show()
385
+ return app.exec()
trekr/labels/check.py ADDED
@@ -0,0 +1,95 @@
1
+ """
2
+ Which labels to look at first: points that do not fit the rest of their animal.
3
+
4
+ A wrong label is almost always one point -- on the neighbour, swapped with another,
5
+ dropped in the background -- while the animal's other points are right. So each
6
+ animal's points are compared through the distances between them, which do not change
7
+ when the animal turns, moves, or is seen mirrored (upside down):
8
+
9
+ - For every pair of keypoints, the typical distance is the median over all labelled
10
+ animals, each animal first scaled to the common size (the median ratio of its
11
+ distances to the typical ones).
12
+ - How far a distance is from the typical one is measured as a log ratio, in the labels'
13
+ own spread of that distance (median absolute deviation), never below the label noise
14
+ (LABEL_NOISE_PX pixels).
15
+ - A point's score is the median of that over the animal's other points. One wrong point
16
+ is off from almost every other point; a right point is off from that one only, so its
17
+ median stays low.
18
+
19
+ Animals are ranked by their highest-scoring point. No cut-off: a score is in units of
20
+ the labels' own spread, and where errors begin is read off the ranking (right points
21
+ score about 1; on a tested project, most animals scoring above about 10 had a wrong point).
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import warnings
27
+
28
+ import numpy as np
29
+
30
+ LABEL_NOISE_PX = 2.0 # how precisely a point can be placed by hand: the least spread of a distance
31
+ MIN_OTHERS = 3 # fewer other points placed: no score (a median of one or two says little)
32
+
33
+
34
+ class Checker:
35
+ def __init__(self, files):
36
+ """files: LabelFile list sharing their bodyparts (by name; the first file's order)."""
37
+ self.files = files
38
+ self.bodyparts = list(files[0].bodyparts) if files else []
39
+ self.refresh()
40
+
41
+ def _animals(self):
42
+ out = [] # (file index, row, individual, (K, 2))
43
+ for fi, f in enumerate(self.files):
44
+ take = [f.bodyparts.index(b) if b in f.bodyparts else None for b in self.bodyparts]
45
+ for r in range(len(f.rows)):
46
+ for ind, p in f.animals(r).items():
47
+ out.append((fi, r, ind, np.array([p[k] if k is not None else (np.nan, np.nan) for k in take])))
48
+ return out
49
+
50
+ def refresh(self):
51
+ """Score every labelled point as the labels are now."""
52
+ self.animals = self._animals()
53
+ self.index = {(fi, r, ind): i for i, (fi, r, ind, _) in enumerate(self.animals)}
54
+ K = len(self.bodyparts)
55
+ P = np.array([a[3] for a in self.animals]) if self.animals else np.zeros((0, K, 2))
56
+ self.S = scores(P)
57
+
58
+ @property
59
+ def n(self):
60
+ return len(self.animals)
61
+
62
+ def ranked(self):
63
+ """[(score, animal index, keypoint index)] of every animal's highest-scoring point, highest first."""
64
+ out = []
65
+ for i, s in enumerate(self.S):
66
+ if np.isfinite(s).any():
67
+ k = int(np.nanargmax(s))
68
+ out.append((float(s[k]), i, k))
69
+ return sorted(out, reverse=True)
70
+
71
+
72
+ def scores(P):
73
+ """Per point, how far it is from where the animal's other points put it: (n, K), NaN
74
+ where not placed or with fewer than MIN_OTHERS other points. P: (n, K, 2), NaN missing."""
75
+ n, K = P.shape[:2]
76
+ out = np.full((n, K), np.nan)
77
+ if n < 3 or K < MIN_OTHERS + 1:
78
+ return out
79
+ with np.errstate(all="ignore"), warnings.catch_warnings():
80
+ warnings.simplefilter("ignore", RuntimeWarning)
81
+ d = np.linalg.norm(P[:, :, None] - P[:, None, :], axis=-1) # (n, K, K)
82
+ d[:, np.arange(K), np.arange(K)] = np.nan
83
+ L = np.log(np.where(d > 0, d, np.nan))
84
+ typical = np.nanmedian(L, axis=0) # (K, K)
85
+ for _ in range(2): # each animal to the common size
86
+ size = np.nanmedian((L - typical[None]).reshape(n, -1), axis=1)
87
+ typical = np.nanmedian(L - size[:, None, None], axis=0)
88
+ R = L - size[:, None, None] - typical[None]
89
+ mad = 1.4826 * np.nanmedian(np.abs(R - np.nanmedian(R, axis=0)[None]), axis=0)
90
+ noise = LABEL_NOISE_PX / np.exp(typical) # the label noise, as a log ratio
91
+ Z = np.abs(R) / np.fmax(mad, noise)[None]
92
+ s = np.nanmedian(Z, axis=2) # over the animal's other points
93
+ enough = np.isfinite(Z).sum(axis=2) >= MIN_OTHERS
94
+ out[enough] = s[enough]
95
+ return out
trekr/labels/dlc.py ADDED
@@ -0,0 +1,163 @@
1
+ """
2
+ DeepLabCut labels, read and written in place.
3
+
4
+ A DeepLabCut project folder holds config.yaml and labeled-data/<video>/ with the
5
+ labelled frames as PNG images and the labels as CollectedData_<scorer>.csv (and .h5,
6
+ which DeepLabCut trains from; it is made from the CSV by deeplabcut.convertcsv2h5).
7
+
8
+ The CSV: a few header rows -- scorer, (individuals,) bodyparts, coords -- then one row
9
+ per image: the image's path in one or more leading columns, then x and y per
10
+ (individual, bodypart); an empty cell is a point not placed. Multi-animal projects have
11
+ the individuals row; an individual "single" holds unique bodyparts, which are kept as
12
+ they are and not checked.
13
+
14
+ Saving changes only the cells of points that were edited (written with Python's
15
+ shortest exact float notation); every other byte of the file stays as it was, line
16
+ endings included. The first save of a session keeps the original next to it.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import csv
22
+ import io
23
+ import os
24
+ import shutil
25
+ import time
26
+ from pathlib import Path
27
+
28
+ import numpy as np
29
+
30
+ SINGLE = "single" # DeepLabCut's individual for unique bodyparts
31
+
32
+
33
+ def find_files(root):
34
+ """The label files of a DeepLabCut project folder (or of its labeled-data folder, or
35
+ one video's folder), sorted."""
36
+ root = Path(root)
37
+ if root.is_file():
38
+ root = root.parent
39
+ base = root / "labeled-data" if (root / "labeled-data").is_dir() else root
40
+ return sorted(base.rglob("CollectedData_*.csv"))
41
+
42
+
43
+ def skeleton(root):
44
+ """The skeleton of config.yaml, as [(a, b)] (empty without a config)."""
45
+ import yaml
46
+ root = Path(root)
47
+ cfg = root if root.suffix in (".yaml", ".yml") else root / "config.yaml"
48
+ if not cfg.is_file():
49
+ return []
50
+ doc = yaml.safe_load(cfg.read_text()) or {}
51
+ return [tuple(e) for e in doc.get("skeleton") or [] if isinstance(e, (list, tuple)) and len(e) == 2]
52
+
53
+
54
+ class LabelFile:
55
+ """One CollectedData_*.csv: its animals per image, editable, saved in place."""
56
+
57
+ def __init__(self, path):
58
+ self.path = Path(path)
59
+ raw = self.path.read_bytes()
60
+ self.newline = "\r\n" if b"\r\n" in raw else "\n"
61
+ self.lines = raw.decode("utf-8").splitlines()
62
+ rows = list(csv.reader(self.lines))
63
+ names = [r[0].strip().lower() if r else "" for r in rows]
64
+ self.n_header = next((i for i, n in enumerate(names) if n not in ("scorer", "individuals", "bodyparts",
65
+ "coords")), len(rows))
66
+ head = {names[i]: rows[i] for i in range(self.n_header)}
67
+ if "bodyparts" not in head or "coords" not in head:
68
+ raise ValueError(f"{self.path}: not a DeepLabCut label file (no bodyparts/coords rows)")
69
+ scorer = head.get("scorer", rows[0])
70
+ self.n_index = next((i for i, c in enumerate(scorer) if i > 0 and c.strip()), 1)
71
+ n_cols = len(head["coords"])
72
+ inds = head.get("individuals", [""] * n_cols)
73
+ self.columns = {} # (individual, bodypart, "x"/"y") -> column
74
+ for c in range(self.n_index, n_cols):
75
+ self.columns[(inds[c], head["bodyparts"][c], head["coords"][c])] = c
76
+ order = [(i, b) for (i, b, k) in self.columns if k == "x"]
77
+ self.individuals = list(dict.fromkeys(i for i, _ in order if i != SINGLE))
78
+ self.bodyparts = list(dict.fromkeys(b for i, b in order if i != SINGLE))
79
+ self.rows = rows[self.n_header:]
80
+ self.edited = set() # data rows changed since the last save
81
+ self.backup = None
82
+
83
+ # ------------------------------------------------------------------ reading
84
+ def image(self, r):
85
+ """The image path of data row r (Windows separators allowed)."""
86
+ parts = [p for p in self.rows[r][:self.n_index] if p]
87
+ rel = Path(*[q for p in parts for q in p.replace("\\", "/").split("/") if q])
88
+ root = self.path.parent
89
+ while root != root.parent and not (root / rel).exists():
90
+ root = root.parent # the paths start at the project folder
91
+ found = root / rel
92
+ return found if found.exists() else self.path.parent / rel.name
93
+
94
+ def name(self, r):
95
+ return str(Path(*[p for p in self.rows[r][:self.n_index] if p]).name or r)
96
+
97
+ def _cell(self, r, c):
98
+ row = self.rows[r]
99
+ v = row[c].strip() if c < len(row) else ""
100
+ try:
101
+ return float(v) if v else np.nan
102
+ except ValueError:
103
+ return np.nan
104
+
105
+ def points(self, r, ind):
106
+ """(K, 2) float, NaN where not placed (K = bodyparts)."""
107
+ out = np.full((len(self.bodyparts), 2), np.nan)
108
+ for k, b in enumerate(self.bodyparts):
109
+ for j, xy in enumerate("xy"):
110
+ c = self.columns.get((ind, b, xy))
111
+ if c is not None:
112
+ out[k, j] = self._cell(r, c)
113
+ return out
114
+
115
+ def animals(self, r):
116
+ """{individual: (K, 2)} of the individuals with any point placed in data row r."""
117
+ out = {}
118
+ for ind in self.individuals:
119
+ p = self.points(r, ind)
120
+ if np.isfinite(p).any():
121
+ out[ind] = p
122
+ return out
123
+
124
+ # ------------------------------------------------------------------ editing
125
+ def set_point(self, r, ind, bodypart, xy):
126
+ """Place (xy = (x, y)) or remove (xy = None) one point."""
127
+ row = self.rows[r]
128
+ for j, k in enumerate("xy"):
129
+ c = self.columns[(ind, bodypart, k)]
130
+ while len(row) <= c:
131
+ row.append("")
132
+ row[c] = "" if xy is None else repr(float(xy[j]))
133
+ self.edited.add(r)
134
+
135
+ @property
136
+ def dirty(self):
137
+ return bool(self.edited)
138
+
139
+ def save(self):
140
+ """Write the edited rows into the file; other lines are left byte for byte. The
141
+ first save keeps the original as <name>.csv.<time>.orig. Returns the backup path."""
142
+ if not self.edited:
143
+ return self.backup
144
+ if self.backup is None:
145
+ self.backup = self.path.with_name(f"{self.path.name}.{time.strftime('%Y%m%d-%H%M%S')}.orig")
146
+ shutil.copy2(self.path, self.backup)
147
+ lines = list(self.lines)
148
+ for r in self.edited:
149
+ buf = io.StringIO()
150
+ csv.writer(buf, lineterminator="").writerow(self.rows[r])
151
+ lines[self.n_header + r] = buf.getvalue()
152
+ tmp = self.path.with_name(self.path.name + ".saving")
153
+ tmp.write_bytes((self.newline.join(lines) + self.newline).encode("utf-8"))
154
+ os.replace(tmp, self.path)
155
+ self.lines, self.edited = lines, set()
156
+ return self.backup
157
+
158
+
159
+ def h5_stale(path):
160
+ """The .h5 beside a saved CSV, which DeepLabCut trains from: it must be made again
161
+ from the CSV (deeplabcut.convertcsv2h5)."""
162
+ h5 = Path(path).with_suffix(".h5")
163
+ return h5 if h5.exists() else None
trekr/videocheck.py ADDED
@@ -0,0 +1,80 @@
1
+ """
2
+ `trekr videocheck`: is a video file still the one you recorded (or analysed)?
3
+
4
+ A fingerprint is the file's size and a SHA-256 of its first and last megabyte, so it is
5
+ fast for any file size and nothing is decoded; with it the stream facts (frames, frame
6
+ rate, width, height) as OpenCV reads them. Save it next to your data once; later, the
7
+ same command against that file says whether the video is the same, or what changed (a
8
+ re-encode, a cut, a different file under the same name).
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import hashlib
14
+ import json
15
+ from pathlib import Path
16
+
17
+ CHUNK = 1 << 20 # bytes hashed at each end of the file
18
+
19
+
20
+ def fingerprint(path):
21
+ """{"size", "ends_sha256"}: the file's size and a SHA-256 of its first and last CHUNK bytes."""
22
+ path = Path(path)
23
+ size = path.stat().st_size
24
+ h = hashlib.sha256()
25
+ with open(path, "rb") as f:
26
+ h.update(f.read(CHUNK))
27
+ if size > CHUNK:
28
+ f.seek(max(size - CHUNK, CHUNK))
29
+ h.update(f.read(CHUNK))
30
+ return {"size": size, "ends_sha256": h.hexdigest()}
31
+
32
+
33
+ def facts(path):
34
+ """{"frames", "fps", "width", "height"} as OpenCV reads them (None if it cannot open the file)."""
35
+ import cv2
36
+ cap = cv2.VideoCapture(str(path))
37
+ try:
38
+ if not cap.isOpened():
39
+ return None
40
+ return {"frames": int(cap.get(cv2.CAP_PROP_FRAME_COUNT)), "fps": round(float(cap.get(cv2.CAP_PROP_FPS)), 3),
41
+ "width": int(cap.get(cv2.CAP_PROP_FRAME_WIDTH)), "height": int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT))}
42
+ finally:
43
+ cap.release()
44
+
45
+
46
+ def describe(path):
47
+ path = Path(path)
48
+ return {"file": path.name, **fingerprint(path), "stream": facts(path)}
49
+
50
+
51
+ def compare(path, ref):
52
+ """("same" | "changed", [what differs])."""
53
+ now = describe(path)
54
+ if now["size"] == ref.get("size") and now["ends_sha256"] == ref.get("ends_sha256"):
55
+ return "same", []
56
+ diffs = [f"size {ref.get('size')} -> {now['size']} bytes"] if now["size"] != ref.get("size") else ["content"]
57
+ then_s, now_s = ref.get("stream") or {}, now["stream"] or {}
58
+ diffs += [f"{k} {then_s.get(k)} -> {now_s.get(k)}" for k in ("frames", "fps", "width", "height")
59
+ if then_s.get(k) != now_s.get(k)]
60
+ return "changed", diffs
61
+
62
+
63
+ def run(path, save=None, against=None, out=print):
64
+ """The command: print the fingerprint, save it, or compare with a saved one. Exit code
65
+ 0 (same, or printed / saved), 2 (changed), 1 (cannot read)."""
66
+ path = Path(path)
67
+ if not path.is_file():
68
+ out(f"no such file: {path}")
69
+ return 1
70
+ if against:
71
+ state, diffs = compare(path, json.loads(Path(against).read_text()))
72
+ out(f"{path.name}: the same file" if state == "same" else f"{path.name}: changed ({'; '.join(diffs)})")
73
+ return 0 if state == "same" else 2
74
+ d = describe(path)
75
+ if save:
76
+ Path(save).write_text(json.dumps(d, indent=1))
77
+ out(f"saved the fingerprint of {path.name} to {save}")
78
+ else:
79
+ out(json.dumps(d, indent=1))
80
+ return 0
@@ -0,0 +1,102 @@
1
+ Metadata-Version: 2.4
2
+ Name: trekr
3
+ Version: 0.1.0
4
+ Summary: Checks for pose tracking: a window to find and fix label errors in DeepLabCut projects, the GPU against the PyTorch build, whether OpenCV decodes camera video with the right colours, and whether a video file changed.
5
+ Author: Koen Freerks
6
+ License-Expression: MIT
7
+ Classifier: Development Status :: 2 - Pre-Alpha
8
+ Classifier: Environment :: Console
9
+ Classifier: Environment :: X11 Applications :: Qt
10
+ Classifier: Intended Audience :: Science/Research
11
+ Classifier: Operating System :: POSIX :: Linux
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: Topic :: Multimedia :: Video
14
+ Classifier: Topic :: Scientific/Engineering
15
+ Requires-Python: >=3.10
16
+ Description-Content-Type: text/markdown
17
+ License-File: LICENSE
18
+ Requires-Dist: numpy>=1.26
19
+ Requires-Dist: opencv-python-headless>=4.8
20
+ Requires-Dist: PySide6>=6.5
21
+ Requires-Dist: pyyaml>=6
22
+ Provides-Extra: test
23
+ Requires-Dist: pytest>=8; extra == "test"
24
+ Dynamic: license-file
25
+
26
+ # trekr
27
+
28
+ An early, small release of **TREKR**, a pose-tracking tool in development. This
29
+ release holds three checks that are useful on their own.
30
+
31
+ ```bash
32
+ pip install trekr
33
+ trekr labels PROJECT # check and fix DeepLabCut labels (a window)
34
+ trekr check
35
+ trekr videocheck VIDEO [--save REF.json | --against REF.json]
36
+ ```
37
+
38
+ ## `trekr labels`: find and fix label errors in a DeepLabCut project
39
+
40
+ ```bash
41
+ trekr labels path/to/my-dlc-project # the folder with config.yaml and labeled-data/
42
+ ```
43
+
44
+ A wrong label -- a leg point on the neighbour, two points swapped, a point dropped in
45
+ the background -- trains a network to be wrong, and among hundreds of labelled animals
46
+ it is hard to find by eye. Such an error is almost always one point while the rest of
47
+ the animal is right. `trekr labels` compares each point with the rest of its animal
48
+ through the distances between them (which do not change when the animal turns, or is
49
+ seen upside down), against the usual distances in your own labels, and lists the
50
+ animals with the point that fits worst first.
51
+
52
+ - **To check:** click an animal: the image opens on it with its worst point selected.
53
+ A score is in units of the labels' own spread: right points score about 1; errors
54
+ stand far above (on a tested project, most animals above about 10 had a wrong point).
55
+ - **Hover** a point to see which keypoint of which animal it is, and its score.
56
+ - **Editing:** drag a point to move it, right-click to remove it; pick a keypoint in
57
+ the list and click to place it; Ctrl+Z / Ctrl+Y. F5 checks again after edits.
58
+ - **Saving** (Ctrl+S) writes the edited points into `CollectedData_<scorer>.csv`;
59
+ every other byte of the file stays as it was, and the original is kept beside it
60
+ (`.orig`). DeepLabCut trains from the `.h5` file, which you make again from the CSV:
61
+
62
+ ```python
63
+ import deeplabcut
64
+ deeplabcut.convertcsv2h5("path/to/my-dlc-project/config.yaml", userfeedback=False)
65
+ ```
66
+
67
+ One kind of animal per project (DeepLabCut's unique bodyparts are kept as they are and
68
+ not checked); single- and multi-animal projects; the labelled frames are the PNG images
69
+ in `labeled-data/`, so no videos are needed. The skeleton is drawn from `config.yaml`.
70
+
71
+ ## `trekr check`: is this computer ready?
72
+
73
+ Every line says **OK**, **NOTE** (works, with a caveat) or **FAIL** (why, and what to
74
+ install); the exit status is 1 when anything fails.
75
+
76
+ - **The GPU against the PyTorch build** (if PyTorch is installed). A PyTorch build
77
+ carries compiled code for some GPU generations only, so a new card -- for example
78
+ the RTX 50 series -- can need a build for a newer CUDA. The check names the GPU's
79
+ architecture and the build's, and says which to install.
80
+ - **The NVIDIA driver against that CUDA version.**
81
+ - **Video decoding.** A tiny stored H.264 clip, encoded the way cameras write video
82
+ (BT.709 colour matrix, TV range), is decoded with the installed OpenCV and compared
83
+ with the frames a correct decoder gives. OpenCV 4.11 and 4.12 apply the wrong colour
84
+ matrix to such video: on the clip, red is off by 5.4 and blue by 2.5 grey levels on
85
+ average, up to 22. Every colour or brightness measured from video decoded that way
86
+ is shifted. OpenCV 4.13 and newer decode it exactly.
87
+
88
+ ## `trekr videocheck`: is a video file still the one you recorded?
89
+
90
+ ```bash
91
+ trekr videocheck run1.mp4 --save run1.fingerprint.json # once, next to your data
92
+ trekr videocheck run1.mp4 --against run1.fingerprint.json # later: the same file? (exit 2 if not)
93
+ ```
94
+
95
+ The fingerprint is the file's size and a SHA-256 of its first and last megabyte (fast
96
+ for any size; nothing is decoded), with the frame count, frame rate and size as OpenCV
97
+ reads them. A re-encode, a cut or a different file under the same name shows up, with
98
+ what changed.
99
+
100
+ ## Licence
101
+
102
+ MIT (see LICENSE).
@@ -0,0 +1,17 @@
1
+ trekr/__init__.py,sha256=yAHIyPH_bs-MEN-BMvVo1-OFOH6zAX_eVsbdm1TPxq4,108
2
+ trekr/__main__.py,sha256=8jseVsc_EkPppL12BvLuKmyarnVUV-oStItjURUCt1s,94
3
+ trekr/cli.py,sha256=1qPxyiBaXQzySU9c1jjCGXGxiGcQs_rqQJoE6kNUx38,2268
4
+ trekr/envcheck.py,sha256=b5Da6qhr3Z9atOFpzWBlfqp0cfnwkn99dV4HFZfIKqI,8071
5
+ trekr/videocheck.py,sha256=MvZnxSm8JZZnAHUcaITBcN_E6RzD2pTMbiBFsBWpZTI,3044
6
+ trekr/data/clip.expected.npz,sha256=QGZrWkszQct0ilT-3Yp42zumlPwNQhUk6fXkU64_uVU,8154
7
+ trekr/data/clip.mp4,sha256=EP78Jiv674_gx1lcS7KYSKhzKk18_mggRByh75QeoLY,2374
8
+ trekr/labels/__init__.py,sha256=VC7UL7qICr5-_fwUpKn_jAJBNMVOWPuq-IkEHyFXY_k,73
9
+ trekr/labels/app.py,sha256=ENsE_tCIejssXKl_ufRcJLau5Lv70OltPCr7J6DaEbc,17520
10
+ trekr/labels/check.py,sha256=fAq5frjfL0D-U3e4hVNJRXJ8kffxlkxonHkO1JXOY2g,4572
11
+ trekr/labels/dlc.py,sha256=qPAFamI2wtQ9NHTTBKa3-o27EJ_oei2vtw6zsFW8lPE,6922
12
+ trekr-0.1.0.dist-info/licenses/LICENSE,sha256=DaA65Q2z5RYoBCOybTYsbuTe2Kya4dB4r6_iXn-vg5c,1069
13
+ trekr-0.1.0.dist-info/METADATA,sha256=_VtPD_gqJZGwLxPB6HylWmbf2T7Alvu02ATao6WtVb0,4773
14
+ trekr-0.1.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
15
+ trekr-0.1.0.dist-info/entry_points.txt,sha256=2KWKypoUHCfVCuTIhrnnauiLltQz37On80Ge8j-vfHQ,41
16
+ trekr-0.1.0.dist-info/top_level.txt,sha256=s72VbKy4hCsnyNY9BY5YtTtaDdsQq6v6lN35WcYITq0,6
17
+ trekr-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ trekr = trekr.cli:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Koen Freerks
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1 @@
1
+ trekr