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 +3 -0
- trekr/__main__.py +5 -0
- trekr/cli.py +44 -0
- trekr/data/clip.expected.npz +0 -0
- trekr/data/clip.mp4 +0 -0
- trekr/envcheck.py +174 -0
- trekr/labels/__init__.py +1 -0
- trekr/labels/app.py +385 -0
- trekr/labels/check.py +95 -0
- trekr/labels/dlc.py +163 -0
- trekr/videocheck.py +80 -0
- trekr-0.1.0.dist-info/METADATA +102 -0
- trekr-0.1.0.dist-info/RECORD +17 -0
- trekr-0.1.0.dist-info/WHEEL +5 -0
- trekr-0.1.0.dist-info/entry_points.txt +2 -0
- trekr-0.1.0.dist-info/licenses/LICENSE +21 -0
- trekr-0.1.0.dist-info/top_level.txt +1 -0
trekr/__init__.py
ADDED
trekr/__main__.py
ADDED
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
|
trekr/labels/__init__.py
ADDED
|
@@ -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,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
|