gesto 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.
- gesto/__init__.py +71 -0
- gesto/artifacts.py +132 -0
- gesto/cli.py +195 -0
- gesto/data.py +113 -0
- gesto/detect.py +191 -0
- gesto/regions.py +216 -0
- gesto/train.py +171 -0
- gesto-0.1.0.dist-info/METADATA +160 -0
- gesto-0.1.0.dist-info/RECORD +13 -0
- gesto-0.1.0.dist-info/WHEEL +5 -0
- gesto-0.1.0.dist-info/entry_points.txt +2 -0
- gesto-0.1.0.dist-info/licenses/LICENSE +21 -0
- gesto-0.1.0.dist-info/top_level.txt +1 -0
gesto/__init__.py
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"""
|
|
2
|
+
gesto — train and run gesture recognition models from Gesto Labeller datasets.
|
|
3
|
+
|
|
4
|
+
Two model types:
|
|
5
|
+
static single frame; the gesture is a held shape or posture
|
|
6
|
+
sequence a window of frames; the gesture is a motion
|
|
7
|
+
|
|
8
|
+
Five regions: hands_one, hands_two, pose, legs, full.
|
|
9
|
+
|
|
10
|
+
Quick start (Python):
|
|
11
|
+
|
|
12
|
+
import gesto
|
|
13
|
+
|
|
14
|
+
run = gesto.train("./gesto_projects/signs", region="hands_one",
|
|
15
|
+
mode="static")
|
|
16
|
+
gesto.detect("static", "hands_one")
|
|
17
|
+
|
|
18
|
+
Quick start (command line):
|
|
19
|
+
|
|
20
|
+
gesto train static hands_one ./gesto_projects/signs
|
|
21
|
+
gesto detect static hands_one
|
|
22
|
+
|
|
23
|
+
Models are saved under artifacts/<mode>/<region>/, versioned so a new run never
|
|
24
|
+
overwrites an old one (pose, pose_2, pose_3, ...).
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
from __future__ import annotations
|
|
28
|
+
|
|
29
|
+
__version__ = "0.1.0"
|
|
30
|
+
|
|
31
|
+
from .regions import REGION_KEYS, REGION_INFO, feature_dim, extract, normalize
|
|
32
|
+
|
|
33
|
+
__all__ = [
|
|
34
|
+
"__version__",
|
|
35
|
+
"REGION_KEYS",
|
|
36
|
+
"REGION_INFO",
|
|
37
|
+
"feature_dim",
|
|
38
|
+
"extract",
|
|
39
|
+
"normalize",
|
|
40
|
+
"train",
|
|
41
|
+
"detect",
|
|
42
|
+
"Predictor",
|
|
43
|
+
"artifacts",
|
|
44
|
+
"data",
|
|
45
|
+
]
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def train(project_dir, region: str, mode: str = "static", **kwargs):
|
|
49
|
+
"""Train a model. See gesto.train.train for the full signature."""
|
|
50
|
+
from .train import train as _train
|
|
51
|
+
return _train(project_dir, region, mode, **kwargs)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def detect(mode: str, region: str, **kwargs):
|
|
55
|
+
"""Run live detection. See gesto.detect.run for the full signature."""
|
|
56
|
+
from .detect import run as _run
|
|
57
|
+
return _run(mode, region, **kwargs)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def Predictor(*args, **kwargs): # noqa: N802 - re-exported class-like helper
|
|
61
|
+
"""Load a trained model for programmatic use."""
|
|
62
|
+
from .detect import Predictor as _Predictor
|
|
63
|
+
return _Predictor(*args, **kwargs)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def __getattr__(name: str):
|
|
67
|
+
# lazy submodule access: gesto.artifacts / gesto.data without import cost
|
|
68
|
+
if name in ("artifacts", "data"):
|
|
69
|
+
import importlib
|
|
70
|
+
return importlib.import_module(f".{name}", __name__)
|
|
71
|
+
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
gesto/artifacts.py
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Artifact storage.
|
|
3
|
+
|
|
4
|
+
Everything a training run produces lives under one root folder, split by mode
|
|
5
|
+
and then region:
|
|
6
|
+
|
|
7
|
+
artifacts/
|
|
8
|
+
static/
|
|
9
|
+
pose/ <- first static pose model
|
|
10
|
+
model.keras
|
|
11
|
+
labels.json
|
|
12
|
+
pose_2/ <- next one doesn't overwrite; it's versioned
|
|
13
|
+
hands_one/
|
|
14
|
+
sequence/
|
|
15
|
+
pose/
|
|
16
|
+
legs/
|
|
17
|
+
|
|
18
|
+
`new_run()` never overwrites: if `artifacts/static/pose` exists it returns
|
|
19
|
+
`pose_2`, then `pose_3`, and so on. `resolve()` finds the newest version when
|
|
20
|
+
you don't name one explicitly, so `gesto detect static pose` picks up the model
|
|
21
|
+
you just trained.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
import json
|
|
27
|
+
import re
|
|
28
|
+
from pathlib import Path
|
|
29
|
+
|
|
30
|
+
MODES = ("static", "sequence")
|
|
31
|
+
DEFAULT_ROOT = "artifacts"
|
|
32
|
+
|
|
33
|
+
_VERSION_RE = re.compile(r"^(?P<base>.+?)(?:_(?P<n>\d+))?$")
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _check_mode(mode: str) -> None:
|
|
37
|
+
if mode not in MODES:
|
|
38
|
+
raise ValueError(f"mode must be one of {MODES}, got {mode!r}")
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def mode_dir(root: str | Path, mode: str) -> Path:
|
|
42
|
+
_check_mode(mode)
|
|
43
|
+
return Path(root) / mode
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _version_of(name: str, base: str) -> int:
|
|
47
|
+
"""Return the version number a folder name represents for `base`.
|
|
48
|
+
|
|
49
|
+
"pose" -> 1, "pose_2" -> 2, anything else -> 0 (not a version of base).
|
|
50
|
+
"""
|
|
51
|
+
m = _VERSION_RE.match(name)
|
|
52
|
+
if not m or m.group("base") != base:
|
|
53
|
+
return 0
|
|
54
|
+
n = m.group("n")
|
|
55
|
+
return int(n) if n else 1
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def list_runs(root: str | Path, mode: str, region: str) -> list[Path]:
|
|
59
|
+
"""Every run folder for this mode+region, oldest version first."""
|
|
60
|
+
d = mode_dir(root, mode)
|
|
61
|
+
if not d.exists():
|
|
62
|
+
return []
|
|
63
|
+
runs = [(p, _version_of(p.name, region)) for p in d.iterdir() if p.is_dir()]
|
|
64
|
+
runs = [(p, v) for p, v in runs if v > 0]
|
|
65
|
+
runs.sort(key=lambda t: t[1])
|
|
66
|
+
return [p for p, _ in runs]
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def new_run(root: str | Path, mode: str, region: str) -> Path:
|
|
70
|
+
"""Create and return a fresh run folder, versioning instead of overwriting.
|
|
71
|
+
|
|
72
|
+
First call -> artifacts/<mode>/<region>
|
|
73
|
+
Later calls -> artifacts/<mode>/<region>_2, _3, ...
|
|
74
|
+
"""
|
|
75
|
+
d = mode_dir(root, mode)
|
|
76
|
+
d.mkdir(parents=True, exist_ok=True)
|
|
77
|
+
existing = list_runs(root, mode, region)
|
|
78
|
+
if not existing:
|
|
79
|
+
run = d / region
|
|
80
|
+
else:
|
|
81
|
+
highest = max(_version_of(p.name, region) for p in existing)
|
|
82
|
+
run = d / f"{region}_{highest + 1}"
|
|
83
|
+
run.mkdir(parents=True, exist_ok=False)
|
|
84
|
+
return run
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def resolve(root: str | Path, mode: str, region: str,
|
|
88
|
+
version: int | str | None = None) -> Path:
|
|
89
|
+
"""Locate an existing run folder.
|
|
90
|
+
|
|
91
|
+
version=None -> newest version
|
|
92
|
+
version=2 -> artifacts/<mode>/<region>_2
|
|
93
|
+
version="pose_2" (a folder name) -> that exact folder
|
|
94
|
+
"""
|
|
95
|
+
d = mode_dir(root, mode)
|
|
96
|
+
if isinstance(version, str) and not version.isdigit():
|
|
97
|
+
run = d / version
|
|
98
|
+
if not run.exists():
|
|
99
|
+
raise FileNotFoundError(f"No such run: {run}")
|
|
100
|
+
return run
|
|
101
|
+
|
|
102
|
+
runs = list_runs(root, mode, region)
|
|
103
|
+
if not runs:
|
|
104
|
+
raise FileNotFoundError(
|
|
105
|
+
f"No {mode} model for region {region!r} under {d}. "
|
|
106
|
+
f"Train one first: gesto train {mode} {region} <project_dir>")
|
|
107
|
+
if version is None:
|
|
108
|
+
return runs[-1] # newest
|
|
109
|
+
want = int(version)
|
|
110
|
+
for p in runs:
|
|
111
|
+
if _version_of(p.name, region) == want:
|
|
112
|
+
return p
|
|
113
|
+
have = ", ".join(p.name for p in runs)
|
|
114
|
+
raise FileNotFoundError(
|
|
115
|
+
f"No version {want} for {mode}/{region}. Available: {have}")
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def save_meta(run: Path, meta: dict) -> Path:
|
|
119
|
+
path = Path(run) / "labels.json"
|
|
120
|
+
path.write_text(json.dumps(meta, indent=2), encoding="utf-8")
|
|
121
|
+
return path
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def load_meta(run: Path) -> dict:
|
|
125
|
+
path = Path(run) / "labels.json"
|
|
126
|
+
if not path.exists():
|
|
127
|
+
raise FileNotFoundError(f"No labels.json in {run}")
|
|
128
|
+
return json.loads(path.read_text(encoding="utf-8"))
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def model_path(run: Path) -> Path:
|
|
132
|
+
return Path(run) / "model.keras"
|
gesto/cli.py
ADDED
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Command line interface.
|
|
3
|
+
|
|
4
|
+
gesto train static pose ./gesto_projects/postures
|
|
5
|
+
gesto train sequence hands_one ./gesto_projects/signs --seq-len 30
|
|
6
|
+
gesto detect static pose
|
|
7
|
+
gesto detect sequence hands_one --source clip.mp4 --version 2
|
|
8
|
+
gesto list
|
|
9
|
+
gesto inspect ./gesto_projects/signs
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import argparse
|
|
15
|
+
import sys
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
|
|
18
|
+
from . import __version__, artifacts
|
|
19
|
+
from .regions import REGION_KEYS
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _add_common(p: argparse.ArgumentParser) -> None:
|
|
23
|
+
p.add_argument("--root", default=artifacts.DEFAULT_ROOT,
|
|
24
|
+
help="artifact root folder (default: artifacts)")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
28
|
+
parser = argparse.ArgumentParser(
|
|
29
|
+
prog="gesto",
|
|
30
|
+
description="Train and run gesture models from Gesto Labeller datasets.")
|
|
31
|
+
parser.add_argument("--version", action="version",
|
|
32
|
+
version=f"gesto {__version__}")
|
|
33
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
34
|
+
|
|
35
|
+
# ---- train ----
|
|
36
|
+
t = sub.add_parser("train", help="train a model")
|
|
37
|
+
t.add_argument("mode", choices=artifacts.MODES)
|
|
38
|
+
t.add_argument("region", choices=REGION_KEYS)
|
|
39
|
+
t.add_argument("project_dir", help="Gesto project folder (Copy path button)")
|
|
40
|
+
t.add_argument("--seq-len", type=int, default=30,
|
|
41
|
+
help="frames per sequence (sequence mode, default 30)")
|
|
42
|
+
t.add_argument("--epochs", type=int, default=None)
|
|
43
|
+
t.add_argument("--batch-size", type=int, default=16)
|
|
44
|
+
t.add_argument("--val-split", type=float, default=0.2)
|
|
45
|
+
t.add_argument("--small", dest="small", action="store_true", default=None,
|
|
46
|
+
help="force the lighter model")
|
|
47
|
+
t.add_argument("--large", dest="small", action="store_false",
|
|
48
|
+
help="force the full-size model")
|
|
49
|
+
t.add_argument("--raw", action="store_true",
|
|
50
|
+
help="data was captured with Gesto 'Normalise' unchecked")
|
|
51
|
+
_add_common(t)
|
|
52
|
+
|
|
53
|
+
# ---- detect ----
|
|
54
|
+
d = sub.add_parser("detect", help="run live detection")
|
|
55
|
+
d.add_argument("mode", choices=artifacts.MODES)
|
|
56
|
+
d.add_argument("region", choices=REGION_KEYS)
|
|
57
|
+
d.add_argument("--source", default="0", help="webcam index or video path")
|
|
58
|
+
d.add_argument("--version", dest="run_version", default=None,
|
|
59
|
+
help="model version (default: newest)")
|
|
60
|
+
d.add_argument("--threshold", type=float, default=0.5)
|
|
61
|
+
d.add_argument("--smooth", type=int, default=5,
|
|
62
|
+
help="frames of agreement before committing a label")
|
|
63
|
+
d.add_argument("--width", type=int, default=960, help="display width")
|
|
64
|
+
d.add_argument("--no-mirror", dest="mirror", action="store_false",
|
|
65
|
+
default=None, help="don't mirror the webcam")
|
|
66
|
+
_add_common(d)
|
|
67
|
+
|
|
68
|
+
# ---- list ----
|
|
69
|
+
ls = sub.add_parser("list", help="show trained models")
|
|
70
|
+
_add_common(ls)
|
|
71
|
+
|
|
72
|
+
# ---- inspect ----
|
|
73
|
+
i = sub.add_parser("inspect", help="summarise a Gesto project's data")
|
|
74
|
+
i.add_argument("project_dir")
|
|
75
|
+
i.add_argument("--seq-len", type=int, default=30)
|
|
76
|
+
|
|
77
|
+
return parser
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _cmd_train(args) -> int:
|
|
81
|
+
from .train import train
|
|
82
|
+
run = train(args.project_dir, args.region, args.mode, root=args.root,
|
|
83
|
+
seq_len=args.seq_len, epochs=args.epochs,
|
|
84
|
+
batch_size=args.batch_size, val_split=args.val_split,
|
|
85
|
+
small=args.small, normalized=not args.raw)
|
|
86
|
+
print(f"\nDetect with: gesto detect {args.mode} {args.region}"
|
|
87
|
+
f"{'' if args.root == artifacts.DEFAULT_ROOT else f' --root {args.root}'}")
|
|
88
|
+
return 0
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _cmd_detect(args) -> int:
|
|
92
|
+
from .detect import run as run_detect
|
|
93
|
+
version = args.run_version
|
|
94
|
+
if version is not None and str(version).isdigit():
|
|
95
|
+
version = int(version)
|
|
96
|
+
run_detect(args.mode, args.region, root=args.root, version=version,
|
|
97
|
+
source=args.source, threshold=args.threshold,
|
|
98
|
+
smooth=args.smooth, width=args.width, mirror=args.mirror)
|
|
99
|
+
return 0
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _cmd_list(args) -> int:
|
|
103
|
+
root = Path(args.root)
|
|
104
|
+
if not root.exists():
|
|
105
|
+
print(f"No artifacts yet under {root}/")
|
|
106
|
+
return 0
|
|
107
|
+
found = False
|
|
108
|
+
for mode in artifacts.MODES:
|
|
109
|
+
d = root / mode
|
|
110
|
+
if not d.exists():
|
|
111
|
+
continue
|
|
112
|
+
runs = sorted(p for p in d.iterdir() if p.is_dir())
|
|
113
|
+
if not runs:
|
|
114
|
+
continue
|
|
115
|
+
found = True
|
|
116
|
+
print(f"{mode}/")
|
|
117
|
+
for run in runs:
|
|
118
|
+
try:
|
|
119
|
+
meta = artifacts.load_meta(run)
|
|
120
|
+
classes = ", ".join(meta.get("labels", []))
|
|
121
|
+
extra = (f", seq_len={meta['seq_len']}"
|
|
122
|
+
if "seq_len" in meta else "")
|
|
123
|
+
print(f" {run.name:20} {meta.get('region','?'):10} "
|
|
124
|
+
f"{meta.get('samples','?')} samples{extra} [{classes}]")
|
|
125
|
+
except FileNotFoundError:
|
|
126
|
+
print(f" {run.name:20} (no labels.json)")
|
|
127
|
+
if not found:
|
|
128
|
+
print(f"No trained models under {root}/")
|
|
129
|
+
return 0
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _cmd_inspect(args) -> int:
|
|
133
|
+
import numpy as np
|
|
134
|
+
from .data import project_meta
|
|
135
|
+
|
|
136
|
+
project = Path(args.project_dir)
|
|
137
|
+
meta = project_meta(project)
|
|
138
|
+
if meta:
|
|
139
|
+
print(f"Project: {meta.get('name', project.name)} "
|
|
140
|
+
f"region={meta.get('region')} hands={meta.get('hands')}")
|
|
141
|
+
print(f"Classes: {meta.get('classes', [])}")
|
|
142
|
+
else:
|
|
143
|
+
print(f"Project: {project.name} (no project.json)")
|
|
144
|
+
|
|
145
|
+
for mode in artifacts.MODES:
|
|
146
|
+
root = project / "data" / mode
|
|
147
|
+
if not root.exists():
|
|
148
|
+
print(f"\n{mode}: none")
|
|
149
|
+
continue
|
|
150
|
+
labels = sorted(d.name for d in root.iterdir() if d.is_dir())
|
|
151
|
+
print(f"\n{mode}:")
|
|
152
|
+
dims = set()
|
|
153
|
+
for name in labels:
|
|
154
|
+
files = sorted((root / name).glob("*.npy"))
|
|
155
|
+
if not files:
|
|
156
|
+
print(f" {name:16} 0")
|
|
157
|
+
continue
|
|
158
|
+
lengths = []
|
|
159
|
+
for f in files:
|
|
160
|
+
a = np.load(f)
|
|
161
|
+
if a.ndim == 1:
|
|
162
|
+
a = a[None, :]
|
|
163
|
+
lengths.append(a.shape[0])
|
|
164
|
+
dims.add(a.shape[1])
|
|
165
|
+
if mode == "sequence":
|
|
166
|
+
usable = sum(1 for n in lengths if n >= args.seq_len)
|
|
167
|
+
print(f" {name:16} {len(files):4} clips, "
|
|
168
|
+
f"{usable} usable at seq_len={args.seq_len}, "
|
|
169
|
+
f"frames {min(lengths)}-{max(lengths)}")
|
|
170
|
+
else:
|
|
171
|
+
print(f" {name:16} {len(files):4} samples")
|
|
172
|
+
if dims:
|
|
173
|
+
print(f" feature dim: {sorted(dims)}"
|
|
174
|
+
+ (" MIXED — recapture consistently!" if len(dims) > 1 else ""))
|
|
175
|
+
return 0
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def main(argv: list[str] | None = None) -> int:
|
|
179
|
+
parser = build_parser()
|
|
180
|
+
args = parser.parse_args(argv)
|
|
181
|
+
handlers = {
|
|
182
|
+
"train": _cmd_train,
|
|
183
|
+
"detect": _cmd_detect,
|
|
184
|
+
"list": _cmd_list,
|
|
185
|
+
"inspect": _cmd_inspect,
|
|
186
|
+
}
|
|
187
|
+
try:
|
|
188
|
+
return handlers[args.command](args)
|
|
189
|
+
except (FileNotFoundError, ValueError) as exc:
|
|
190
|
+
print(f"error: {exc}", file=sys.stderr)
|
|
191
|
+
return 1
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
if __name__ == "__main__":
|
|
195
|
+
raise SystemExit(main())
|
gesto/data.py
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Loading Gesto Labeller datasets.
|
|
3
|
+
|
|
4
|
+
A Gesto project folder looks like:
|
|
5
|
+
|
|
6
|
+
<project>/
|
|
7
|
+
project.json
|
|
8
|
+
data/
|
|
9
|
+
static/<label>/<uid>.npy each (D,)
|
|
10
|
+
sequence/<label>/<uid>.npy each (T, D)
|
|
11
|
+
|
|
12
|
+
Use the "Copy path" button on a project card in Gesto Labeller to get the path.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import json
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
|
|
20
|
+
import numpy as np
|
|
21
|
+
|
|
22
|
+
from .regions import feature_dim
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def project_meta(project_dir: str | Path) -> dict:
|
|
26
|
+
"""Contents of project.json, or {} when it isn't there."""
|
|
27
|
+
p = Path(project_dir) / "project.json"
|
|
28
|
+
if not p.exists():
|
|
29
|
+
return {}
|
|
30
|
+
try:
|
|
31
|
+
return json.loads(p.read_text(encoding="utf-8"))
|
|
32
|
+
except (OSError, ValueError):
|
|
33
|
+
return {}
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _class_dirs(project_dir: str | Path, mode: str) -> tuple[Path, list[str]]:
|
|
37
|
+
root = Path(project_dir) / "data" / mode
|
|
38
|
+
if not root.exists():
|
|
39
|
+
other = "sequence" if mode == "static" else "static"
|
|
40
|
+
hint = ""
|
|
41
|
+
if (Path(project_dir) / "data" / other).exists():
|
|
42
|
+
hint = (f"\nThis project has {other} data instead — either capture "
|
|
43
|
+
f"in {mode.capitalize()} mode in Gesto Labeller, or train "
|
|
44
|
+
f"the {other} model.")
|
|
45
|
+
raise FileNotFoundError(f"No {mode} data at {root}{hint}")
|
|
46
|
+
labels = sorted(d.name for d in root.iterdir() if d.is_dir())
|
|
47
|
+
if not labels:
|
|
48
|
+
raise ValueError(f"No class folders under {root}")
|
|
49
|
+
return root, labels
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def load_static(project_dir: str | Path, region: str):
|
|
53
|
+
"""-> X (N, D) float32, y (N,) int, labels list[str]."""
|
|
54
|
+
dim = feature_dim(region)
|
|
55
|
+
root, labels = _class_dirs(project_dir, "static")
|
|
56
|
+
index = {name: i for i, name in enumerate(labels)}
|
|
57
|
+
|
|
58
|
+
X: list[np.ndarray] = []
|
|
59
|
+
y: list[int] = []
|
|
60
|
+
for name in labels:
|
|
61
|
+
for f in sorted((root / name).glob("*.npy")):
|
|
62
|
+
arr = np.load(f).astype(np.float32).reshape(-1)
|
|
63
|
+
if arr.shape[0] != dim:
|
|
64
|
+
raise ValueError(
|
|
65
|
+
f"{f} has dim {arr.shape[0]} but region {region!r} expects "
|
|
66
|
+
f"{dim}. Wrong region for this project?")
|
|
67
|
+
X.append(arr)
|
|
68
|
+
y.append(index[name])
|
|
69
|
+
if not X:
|
|
70
|
+
raise ValueError(f"No .npy samples found under {root}")
|
|
71
|
+
return np.asarray(X, np.float32), np.asarray(y), labels
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def load_sequence(project_dir: str | Path, region: str, seq_len: int = 30):
|
|
75
|
+
"""-> X (N, seq_len, D) float32, y (N,) int, labels list[str].
|
|
76
|
+
|
|
77
|
+
Sequences shorter than seq_len are skipped (they can't fill the window);
|
|
78
|
+
longer ones are trimmed to the first seq_len frames. Capture at a consistent
|
|
79
|
+
length in Gesto Labeller ("Max frames") for best results.
|
|
80
|
+
"""
|
|
81
|
+
dim = feature_dim(region)
|
|
82
|
+
root, labels = _class_dirs(project_dir, "sequence")
|
|
83
|
+
index = {name: i for i, name in enumerate(labels)}
|
|
84
|
+
|
|
85
|
+
X: list[np.ndarray] = []
|
|
86
|
+
y: list[int] = []
|
|
87
|
+
skipped = 0
|
|
88
|
+
for name in labels:
|
|
89
|
+
for f in sorted((root / name).glob("*.npy")):
|
|
90
|
+
arr = np.load(f).astype(np.float32)
|
|
91
|
+
if arr.ndim == 1:
|
|
92
|
+
arr = arr[None, :]
|
|
93
|
+
if arr.shape[1] != dim:
|
|
94
|
+
raise ValueError(
|
|
95
|
+
f"{f} has dim {arr.shape[1]} but region {region!r} expects "
|
|
96
|
+
f"{dim}. Wrong region for this project?")
|
|
97
|
+
if arr.shape[0] < seq_len:
|
|
98
|
+
skipped += 1
|
|
99
|
+
continue
|
|
100
|
+
X.append(arr[:seq_len])
|
|
101
|
+
y.append(index[name])
|
|
102
|
+
if not X:
|
|
103
|
+
raise ValueError(
|
|
104
|
+
f"No sequences with at least {seq_len} frames under {root}. "
|
|
105
|
+
f"Lower seq_len or capture longer clips.")
|
|
106
|
+
if skipped:
|
|
107
|
+
print(f"Note: skipped {skipped} sequence(s) shorter than {seq_len} frames.")
|
|
108
|
+
return np.asarray(X, np.float32), np.asarray(y), labels
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def class_summary(y, labels) -> dict[str, int]:
|
|
112
|
+
counts = np.bincount(np.asarray(y), minlength=len(labels))
|
|
113
|
+
return {name: int(c) for name, c in zip(labels, counts)}
|
gesto/detect.py
ADDED
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Live detection from a webcam or video file.
|
|
3
|
+
|
|
4
|
+
Matches Gesto Labeller's capture exactly, which is what keeps predictions
|
|
5
|
+
correct: the same MediaPipe Holistic engine, the same landmark ordering, the
|
|
6
|
+
same normalization, and the same webcam mirroring.
|
|
7
|
+
|
|
8
|
+
static -> classifies every frame on its own; instant, no warm-up.
|
|
9
|
+
sequence -> keeps a rolling window of the last seq_len frames.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from collections import deque
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
import numpy as np
|
|
18
|
+
|
|
19
|
+
from . import artifacts
|
|
20
|
+
from .regions import draw, extract, make_holistic
|
|
21
|
+
|
|
22
|
+
HEADER_BG = (245, 117, 16)
|
|
23
|
+
WHITE = (255, 255, 255)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class Predictor:
|
|
27
|
+
"""A trained model plus everything needed to feed it correctly."""
|
|
28
|
+
|
|
29
|
+
def __init__(self, run: str | Path):
|
|
30
|
+
from tensorflow import keras
|
|
31
|
+
self.run = Path(run)
|
|
32
|
+
self.meta = artifacts.load_meta(self.run)
|
|
33
|
+
self.model = keras.models.load_model(artifacts.model_path(self.run))
|
|
34
|
+
self.labels: list[str] = self.meta["labels"]
|
|
35
|
+
self.region: str = self.meta["region"]
|
|
36
|
+
self.mode: str = self.meta["mode"]
|
|
37
|
+
self.dim: int = self.meta["input_dim"]
|
|
38
|
+
self.normalized: bool = self.meta.get("normalized", True)
|
|
39
|
+
self.seq_len: int = int(self.meta.get("seq_len", 1))
|
|
40
|
+
self._window: deque = deque(maxlen=self.seq_len)
|
|
41
|
+
|
|
42
|
+
@classmethod
|
|
43
|
+
def load(cls, mode: str, region: str, *,
|
|
44
|
+
root: str | Path = artifacts.DEFAULT_ROOT,
|
|
45
|
+
version: int | str | None = None) -> "Predictor":
|
|
46
|
+
return cls(artifacts.resolve(root, mode, region, version))
|
|
47
|
+
|
|
48
|
+
def features(self, res) -> np.ndarray | None:
|
|
49
|
+
"""Landmark vector for a frame, normalized the way training expected."""
|
|
50
|
+
vec = extract(res, self.region)
|
|
51
|
+
if vec is None:
|
|
52
|
+
return None
|
|
53
|
+
if self.normalized:
|
|
54
|
+
from .regions import normalize
|
|
55
|
+
vec = normalize(vec, self.region)
|
|
56
|
+
return vec
|
|
57
|
+
|
|
58
|
+
def reset(self) -> None:
|
|
59
|
+
self._window.clear()
|
|
60
|
+
|
|
61
|
+
def predict(self, vec: np.ndarray | None) -> np.ndarray | None:
|
|
62
|
+
"""Probabilities for one frame, or None when there's nothing to say yet.
|
|
63
|
+
|
|
64
|
+
For sequence models this returns None until the window is full.
|
|
65
|
+
"""
|
|
66
|
+
if self.mode == "static":
|
|
67
|
+
if vec is None:
|
|
68
|
+
return None
|
|
69
|
+
return self.model.predict(vec[None, :], verbose=0)[0]
|
|
70
|
+
|
|
71
|
+
if vec is not None:
|
|
72
|
+
self._window.append(vec)
|
|
73
|
+
if len(self._window) < self.seq_len:
|
|
74
|
+
return None
|
|
75
|
+
batch = np.stack(self._window)[None, :, :]
|
|
76
|
+
return self.model.predict(batch, verbose=0)[0]
|
|
77
|
+
|
|
78
|
+
@property
|
|
79
|
+
def progress(self) -> tuple[int, int]:
|
|
80
|
+
return len(self._window), self.seq_len
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _palette(n: int) -> list[tuple[int, int, int]]:
|
|
84
|
+
rng = np.random.RandomState(7)
|
|
85
|
+
return [(int(rng.randint(60, 256)), int(rng.randint(60, 256)),
|
|
86
|
+
int(rng.randint(60, 256))) for _ in range(n)]
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _overlay(frame, labels, probs, current, colors, progress=None) -> None:
|
|
90
|
+
import cv2
|
|
91
|
+
cv2.rectangle(frame, (0, 0), (frame.shape[1], 46), HEADER_BG, -1)
|
|
92
|
+
text = current
|
|
93
|
+
if progress and progress[1] > 1:
|
|
94
|
+
text += f" [{progress[0]}/{progress[1]}]"
|
|
95
|
+
cv2.putText(frame, text, (10, 32), cv2.FONT_HERSHEY_SIMPLEX, 0.9, WHITE, 2,
|
|
96
|
+
cv2.LINE_AA)
|
|
97
|
+
for i, p in enumerate(probs):
|
|
98
|
+
y = 60 + i * 34
|
|
99
|
+
cv2.rectangle(frame, (10, y), (270, y + 26), (50, 50, 50), -1)
|
|
100
|
+
cv2.rectangle(frame, (10, y), (10 + int(260 * float(p)), y + 26),
|
|
101
|
+
colors[i], -1)
|
|
102
|
+
cv2.putText(frame, f"{labels[i]} {float(p) * 100:4.1f}%", (16, y + 19),
|
|
103
|
+
cv2.FONT_HERSHEY_SIMPLEX, 0.55, WHITE, 1, cv2.LINE_AA)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def run(mode: str, region: str, *, root: str | Path = artifacts.DEFAULT_ROOT,
|
|
107
|
+
version: int | str | None = None, source: str = "0",
|
|
108
|
+
threshold: float = 0.5, smooth: int = 5, width: int = 960,
|
|
109
|
+
mirror: bool | None = None) -> None:
|
|
110
|
+
"""Open a camera or video and show live predictions."""
|
|
111
|
+
import cv2
|
|
112
|
+
|
|
113
|
+
predictor = Predictor.load(mode, region, root=root, version=version)
|
|
114
|
+
labels = predictor.labels
|
|
115
|
+
print(f"{predictor.mode} / {predictor.region}: {labels}")
|
|
116
|
+
print(f"model: {predictor.run}")
|
|
117
|
+
|
|
118
|
+
src: int | str = int(source) if str(source).isdigit() else source
|
|
119
|
+
is_webcam = isinstance(src, int)
|
|
120
|
+
if mirror is None:
|
|
121
|
+
mirror = is_webcam # Gesto mirrors the webcam, not video files
|
|
122
|
+
|
|
123
|
+
cap = cv2.VideoCapture(src)
|
|
124
|
+
if not cap.isOpened():
|
|
125
|
+
raise SystemExit(f"Could not open source: {src}")
|
|
126
|
+
|
|
127
|
+
if is_webcam:
|
|
128
|
+
wait_ms = 1
|
|
129
|
+
else:
|
|
130
|
+
fps = cap.get(cv2.CAP_PROP_FPS)
|
|
131
|
+
wait_ms = int(1000 / fps) if fps and fps > 1 else 33
|
|
132
|
+
|
|
133
|
+
window_name = f"gesto {mode}/{region} (q to quit)"
|
|
134
|
+
cv2.namedWindow(window_name, cv2.WINDOW_AUTOSIZE)
|
|
135
|
+
|
|
136
|
+
holistic = make_holistic()
|
|
137
|
+
colors = _palette(len(labels))
|
|
138
|
+
recent: deque = deque(maxlen=max(1, smooth))
|
|
139
|
+
current = "-"
|
|
140
|
+
probs = np.zeros(len(labels), np.float32)
|
|
141
|
+
|
|
142
|
+
quit_pressed = False
|
|
143
|
+
frame = None
|
|
144
|
+
try:
|
|
145
|
+
while cap.isOpened():
|
|
146
|
+
ok, frame = cap.read()
|
|
147
|
+
if not ok:
|
|
148
|
+
break
|
|
149
|
+
if mirror:
|
|
150
|
+
frame = cv2.flip(frame, 1)
|
|
151
|
+
|
|
152
|
+
rgb = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
|
|
153
|
+
rgb.flags.writeable = False
|
|
154
|
+
res = holistic.process(rgb)
|
|
155
|
+
|
|
156
|
+
h0, w0 = frame.shape[:2]
|
|
157
|
+
if w0 != width:
|
|
158
|
+
frame = cv2.resize(frame, (width, int(h0 * width / w0)))
|
|
159
|
+
draw(frame, res, predictor.region)
|
|
160
|
+
|
|
161
|
+
vec = predictor.features(res)
|
|
162
|
+
out = predictor.predict(vec)
|
|
163
|
+
if out is not None:
|
|
164
|
+
probs = out
|
|
165
|
+
top = int(np.argmax(probs))
|
|
166
|
+
recent.append(top)
|
|
167
|
+
if (len(recent) == recent.maxlen and len(set(recent)) == 1
|
|
168
|
+
and float(probs[top]) >= threshold):
|
|
169
|
+
current = labels[top]
|
|
170
|
+
elif vec is None and predictor.mode == "static":
|
|
171
|
+
recent.clear()
|
|
172
|
+
current = "-"
|
|
173
|
+
probs = np.zeros(len(labels), np.float32)
|
|
174
|
+
|
|
175
|
+
_overlay(frame, labels, probs, current, colors,
|
|
176
|
+
predictor.progress if predictor.mode == "sequence" else None)
|
|
177
|
+
cv2.imshow(window_name, frame)
|
|
178
|
+
if cv2.waitKey(wait_ms) & 0xFF == ord("q"):
|
|
179
|
+
quit_pressed = True
|
|
180
|
+
break
|
|
181
|
+
|
|
182
|
+
if not is_webcam and not quit_pressed and frame is not None:
|
|
183
|
+
cv2.putText(frame, "video ended - press any key",
|
|
184
|
+
(10, frame.shape[0] - 15), cv2.FONT_HERSHEY_SIMPLEX,
|
|
185
|
+
0.6, WHITE, 1, cv2.LINE_AA)
|
|
186
|
+
cv2.imshow(window_name, frame)
|
|
187
|
+
cv2.waitKey(0)
|
|
188
|
+
finally:
|
|
189
|
+
cap.release()
|
|
190
|
+
cv2.destroyAllWindows()
|
|
191
|
+
holistic.close()
|
gesto/regions.py
ADDED
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Region definitions — extraction, normalization and drawing for each region.
|
|
3
|
+
|
|
4
|
+
These mirror Gesto Labeller's capture exactly, so a model trained on Gesto data
|
|
5
|
+
receives identically-shaped, identically-ordered, identically-normalized vectors
|
|
6
|
+
at inference time.
|
|
7
|
+
|
|
8
|
+
hands_one : one hand (prefer right, else left) -> 63 (21 x 3)
|
|
9
|
+
hands_two : left hand + right hand -> 126 (2 x 21 x 3)
|
|
10
|
+
pose : full body -> 132 (33 x 4: x,y,z,vis)
|
|
11
|
+
legs : lower body subset -> 32 (8 x 4)
|
|
12
|
+
full : pose + both hands -> 258 (132 + 126)
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import numpy as np
|
|
18
|
+
|
|
19
|
+
LEG_POSE_IDX = [23, 24, 25, 26, 27, 28, 31, 32]
|
|
20
|
+
|
|
21
|
+
REGION_KEYS = ("hands_one", "hands_two", "pose", "legs", "full")
|
|
22
|
+
|
|
23
|
+
# region -> (feature dim, Gesto project "region", Gesto project "hands")
|
|
24
|
+
REGION_INFO = {
|
|
25
|
+
"hands_one": (63, "Hands", "one"),
|
|
26
|
+
"hands_two": (126, "Hands", "two"),
|
|
27
|
+
"pose": (132, "Pose", "two"),
|
|
28
|
+
"legs": (32, "Legs", "two"),
|
|
29
|
+
"full": (258, "Full", "two"),
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def feature_dim(region: str) -> int:
|
|
34
|
+
_check(region)
|
|
35
|
+
return REGION_INFO[region][0]
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _check(region: str) -> None:
|
|
39
|
+
if region not in REGION_INFO:
|
|
40
|
+
raise ValueError(
|
|
41
|
+
f"Unknown region {region!r}. Choose one of: {', '.join(REGION_KEYS)}"
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
# --------------------------------------------------------------------------
|
|
46
|
+
# extraction
|
|
47
|
+
# --------------------------------------------------------------------------
|
|
48
|
+
|
|
49
|
+
def _hand(landmarks) -> np.ndarray:
|
|
50
|
+
if landmarks is None:
|
|
51
|
+
return np.zeros(63, np.float32)
|
|
52
|
+
return np.array([[p.x, p.y, p.z] for p in landmarks.landmark],
|
|
53
|
+
dtype=np.float32).reshape(-1)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _pose_all(res) -> np.ndarray:
|
|
57
|
+
if not res.pose_landmarks:
|
|
58
|
+
return np.zeros(132, np.float32)
|
|
59
|
+
return np.array([[p.x, p.y, p.z, p.visibility]
|
|
60
|
+
for p in res.pose_landmarks.landmark],
|
|
61
|
+
dtype=np.float32).reshape(-1)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _pose_legs(res) -> np.ndarray:
|
|
65
|
+
if not res.pose_landmarks:
|
|
66
|
+
return np.zeros(len(LEG_POSE_IDX) * 4, np.float32)
|
|
67
|
+
lm = res.pose_landmarks.landmark
|
|
68
|
+
return np.array([[lm[i].x, lm[i].y, lm[i].z, lm[i].visibility]
|
|
69
|
+
for i in LEG_POSE_IDX], dtype=np.float32).reshape(-1)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def extract(res, region: str) -> np.ndarray | None:
|
|
73
|
+
"""Feature vector for `region`, or None when nothing was detected.
|
|
74
|
+
|
|
75
|
+
Returning None (rather than zeros) lets callers skip empty frames instead of
|
|
76
|
+
feeding the model meaningless all-zero input.
|
|
77
|
+
"""
|
|
78
|
+
_check(region)
|
|
79
|
+
if region == "hands_one":
|
|
80
|
+
hand = res.right_hand_landmarks or res.left_hand_landmarks
|
|
81
|
+
if hand is None:
|
|
82
|
+
return None
|
|
83
|
+
return _hand(hand)
|
|
84
|
+
if region == "hands_two":
|
|
85
|
+
if not (res.left_hand_landmarks or res.right_hand_landmarks):
|
|
86
|
+
return None
|
|
87
|
+
return np.concatenate([_hand(res.left_hand_landmarks),
|
|
88
|
+
_hand(res.right_hand_landmarks)])
|
|
89
|
+
if region == "pose":
|
|
90
|
+
return _pose_all(res) if res.pose_landmarks else None
|
|
91
|
+
if region == "legs":
|
|
92
|
+
return _pose_legs(res) if res.pose_landmarks else None
|
|
93
|
+
# full
|
|
94
|
+
if not (res.pose_landmarks or res.left_hand_landmarks
|
|
95
|
+
or res.right_hand_landmarks):
|
|
96
|
+
return None
|
|
97
|
+
return np.concatenate([_pose_all(res),
|
|
98
|
+
_hand(res.left_hand_landmarks),
|
|
99
|
+
_hand(res.right_hand_landmarks)])
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
# --------------------------------------------------------------------------
|
|
103
|
+
# normalization — byte-for-byte identical to Gesto Labeller's normalize_vector
|
|
104
|
+
# --------------------------------------------------------------------------
|
|
105
|
+
|
|
106
|
+
def _norm_hand(pts: np.ndarray) -> np.ndarray:
|
|
107
|
+
"""(21,3) -> wrist-relative, scale-normalised."""
|
|
108
|
+
if np.any(pts):
|
|
109
|
+
pts = pts - pts[0]
|
|
110
|
+
scale = np.linalg.norm(pts, axis=1).max()
|
|
111
|
+
if scale > 1e-6:
|
|
112
|
+
pts = pts / scale
|
|
113
|
+
return pts
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _norm_body(pts: np.ndarray) -> np.ndarray:
|
|
117
|
+
"""(N,4) x,y,z,visibility -> centred and scale-normalised; visibility kept."""
|
|
118
|
+
xyz = pts[:, :3]
|
|
119
|
+
mask = np.any(xyz != 0, axis=1)
|
|
120
|
+
if np.any(mask):
|
|
121
|
+
xyz -= xyz[mask].mean(axis=0)
|
|
122
|
+
scale = np.linalg.norm(xyz, axis=1).max()
|
|
123
|
+
if scale > 1e-6:
|
|
124
|
+
xyz /= scale
|
|
125
|
+
pts[:, :3] = xyz
|
|
126
|
+
return pts
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def normalize(vec, region: str) -> np.ndarray:
|
|
130
|
+
"""Translation/scale-invariant normalization, matching Gesto exactly."""
|
|
131
|
+
_check(region)
|
|
132
|
+
vec = np.asarray(vec, np.float32)
|
|
133
|
+
if region == "hands_one":
|
|
134
|
+
return _norm_hand(vec.reshape(21, 3).copy()).reshape(-1)
|
|
135
|
+
if region == "hands_two":
|
|
136
|
+
pts = vec.reshape(2, 21, 3).copy()
|
|
137
|
+
for h in range(2):
|
|
138
|
+
pts[h] = _norm_hand(pts[h])
|
|
139
|
+
return pts.reshape(-1)
|
|
140
|
+
if region in ("pose", "legs"):
|
|
141
|
+
n = 33 if region == "pose" else len(LEG_POSE_IDX)
|
|
142
|
+
return _norm_body(vec.reshape(n, 4).copy()).reshape(-1)
|
|
143
|
+
# full: pose part as body, hands part per-hand
|
|
144
|
+
pose = _norm_body(vec[:132].reshape(33, 4).copy()).reshape(-1)
|
|
145
|
+
hands = vec[132:].reshape(2, 21, 3).copy()
|
|
146
|
+
for h in range(2):
|
|
147
|
+
hands[h] = _norm_hand(hands[h])
|
|
148
|
+
return np.concatenate([pose, hands.reshape(-1)])
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
# --------------------------------------------------------------------------
|
|
152
|
+
# drawing
|
|
153
|
+
# --------------------------------------------------------------------------
|
|
154
|
+
|
|
155
|
+
HAND_BONES = [(0, 1), (1, 2), (2, 3), (3, 4), (0, 5), (5, 6), (6, 7), (7, 8),
|
|
156
|
+
(5, 9), (9, 10), (10, 11), (11, 12), (9, 13), (13, 14), (14, 15),
|
|
157
|
+
(15, 16), (13, 17), (17, 18), (18, 19), (19, 20), (0, 17)]
|
|
158
|
+
POSE_BONES = [(11, 12), (11, 13), (13, 15), (12, 14), (14, 16), (11, 23),
|
|
159
|
+
(12, 24), (23, 24), (23, 25), (25, 27), (27, 29), (27, 31),
|
|
160
|
+
(24, 26), (26, 28), (28, 30), (28, 32)]
|
|
161
|
+
LEG_BONES = [(0, 2), (2, 4), (4, 6), (1, 3), (3, 5), (5, 7), (0, 1)]
|
|
162
|
+
|
|
163
|
+
_BONE_COLOR = (60, 180, 75)
|
|
164
|
+
_JOINT_COLOR = (40, 160, 230)
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _plot(frame, pts, bones) -> None:
|
|
168
|
+
import cv2
|
|
169
|
+
h, w = frame.shape[:2]
|
|
170
|
+
for a, b in bones:
|
|
171
|
+
if a < len(pts) and b < len(pts):
|
|
172
|
+
ax, ay = pts[a]
|
|
173
|
+
bx, by = pts[b]
|
|
174
|
+
if (ax or ay) and (bx or by):
|
|
175
|
+
cv2.line(frame, (int(ax * w), int(ay * h)),
|
|
176
|
+
(int(bx * w), int(by * h)), _BONE_COLOR, 2, cv2.LINE_AA)
|
|
177
|
+
for x, y in pts:
|
|
178
|
+
if x or y:
|
|
179
|
+
cv2.circle(frame, (int(x * w), int(y * h)), 3, _JOINT_COLOR, -1,
|
|
180
|
+
cv2.LINE_AA)
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def draw(frame, res, region: str) -> None:
|
|
184
|
+
"""Draw the region's skeleton onto a BGR frame, in place."""
|
|
185
|
+
_check(region)
|
|
186
|
+
xy = lambda h: [(p.x, p.y) for p in h.landmark] if h else []
|
|
187
|
+
|
|
188
|
+
if region == "hands_one":
|
|
189
|
+
hand = res.right_hand_landmarks or res.left_hand_landmarks
|
|
190
|
+
if hand:
|
|
191
|
+
_plot(frame, xy(hand), HAND_BONES)
|
|
192
|
+
elif region == "hands_two":
|
|
193
|
+
for hand in (res.left_hand_landmarks, res.right_hand_landmarks):
|
|
194
|
+
if hand:
|
|
195
|
+
_plot(frame, xy(hand), HAND_BONES)
|
|
196
|
+
elif region == "pose":
|
|
197
|
+
if res.pose_landmarks:
|
|
198
|
+
_plot(frame, xy(res.pose_landmarks), POSE_BONES)
|
|
199
|
+
elif region == "legs":
|
|
200
|
+
if res.pose_landmarks:
|
|
201
|
+
lm = res.pose_landmarks.landmark
|
|
202
|
+
_plot(frame, [(lm[i].x, lm[i].y) for i in LEG_POSE_IDX], LEG_BONES)
|
|
203
|
+
else: # full
|
|
204
|
+
if res.pose_landmarks:
|
|
205
|
+
_plot(frame, xy(res.pose_landmarks), POSE_BONES)
|
|
206
|
+
for hand in (res.left_hand_landmarks, res.right_hand_landmarks):
|
|
207
|
+
if hand:
|
|
208
|
+
_plot(frame, xy(hand), HAND_BONES)
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def make_holistic():
|
|
212
|
+
"""MediaPipe Holistic with the same settings Gesto Labeller captures with."""
|
|
213
|
+
import mediapipe as mp
|
|
214
|
+
return mp.solutions.holistic.Holistic(
|
|
215
|
+
static_image_mode=False, model_complexity=1,
|
|
216
|
+
min_detection_confidence=0.6, min_tracking_confidence=0.5)
|
gesto/train.py
ADDED
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Training.
|
|
3
|
+
|
|
4
|
+
Two model types, matched to what the gesture actually is:
|
|
5
|
+
|
|
6
|
+
- static : one frame per sample. The gesture IS a held shape or posture
|
|
7
|
+
(hand signs, letters, stances). A small Dense network.
|
|
8
|
+
- sequence : a window of frames. The gesture is a MOTION (waving, clapping,
|
|
9
|
+
jogging). A stacked LSTM.
|
|
10
|
+
|
|
11
|
+
Both auto-select a lighter architecture on small datasets — an oversized model
|
|
12
|
+
on a few dozen samples overfits and collapses to predicting one class.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
|
|
19
|
+
import numpy as np
|
|
20
|
+
|
|
21
|
+
from . import artifacts, data
|
|
22
|
+
from .regions import REGION_INFO, feature_dim
|
|
23
|
+
|
|
24
|
+
# datasets smaller than this get the lighter model
|
|
25
|
+
SMALL_STATIC = 300
|
|
26
|
+
SMALL_SEQUENCE = 100
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _keras():
|
|
30
|
+
"""Imported lazily so `import gesto` stays cheap and TF is optional."""
|
|
31
|
+
from tensorflow import keras
|
|
32
|
+
return keras
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _class_weights(y, n_classes) -> dict[int, float]:
|
|
36
|
+
counts = np.bincount(np.asarray(y), minlength=n_classes).astype(float)
|
|
37
|
+
total = counts.sum()
|
|
38
|
+
return {i: float(total / (n_classes * c)) if c else 1.0
|
|
39
|
+
for i, c in enumerate(counts)}
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def build_static(dim: int, n_classes: int, small: bool):
|
|
43
|
+
keras = _keras()
|
|
44
|
+
L = keras.layers
|
|
45
|
+
if small:
|
|
46
|
+
layers = [L.Dense(64, activation="relu"), L.Dropout(0.3),
|
|
47
|
+
L.Dense(32, activation="relu"), L.Dropout(0.2)]
|
|
48
|
+
else:
|
|
49
|
+
layers = [L.Dense(256, activation="relu"), L.Dropout(0.4),
|
|
50
|
+
L.Dense(128, activation="relu"), L.Dropout(0.3),
|
|
51
|
+
L.Dense(64, activation="relu"), L.Dropout(0.2)]
|
|
52
|
+
model = keras.Sequential([keras.Input((dim,)), *layers,
|
|
53
|
+
L.Dense(n_classes, activation="softmax")])
|
|
54
|
+
model.compile(optimizer="adam", loss="sparse_categorical_crossentropy",
|
|
55
|
+
metrics=["accuracy"])
|
|
56
|
+
return model
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def build_sequence(seq_len: int, dim: int, n_classes: int, small: bool):
|
|
60
|
+
keras = _keras()
|
|
61
|
+
L = keras.layers
|
|
62
|
+
if small:
|
|
63
|
+
layers = [L.LSTM(32, activation="tanh"), L.Dropout(0.4),
|
|
64
|
+
L.Dense(32, activation="relu"), L.Dropout(0.3)]
|
|
65
|
+
else:
|
|
66
|
+
layers = [L.LSTM(64, return_sequences=True, activation="tanh"),
|
|
67
|
+
L.LSTM(128, return_sequences=True, activation="tanh"),
|
|
68
|
+
L.LSTM(64, activation="tanh"),
|
|
69
|
+
L.Dense(64, activation="relu"),
|
|
70
|
+
L.Dense(32, activation="relu")]
|
|
71
|
+
model = keras.Sequential([keras.Input((seq_len, dim)), *layers,
|
|
72
|
+
L.Dense(n_classes, activation="softmax")])
|
|
73
|
+
model.compile(optimizer="adam", loss="sparse_categorical_crossentropy",
|
|
74
|
+
metrics=["accuracy"])
|
|
75
|
+
return model
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _fit(model, X, y, labels, *, epochs, batch_size, val_split, quiet):
|
|
79
|
+
keras = _keras()
|
|
80
|
+
stop = keras.callbacks.EarlyStopping(
|
|
81
|
+
monitor="val_loss", patience=25, restore_best_weights=True)
|
|
82
|
+
model.fit(X, y, validation_split=val_split, epochs=epochs,
|
|
83
|
+
batch_size=batch_size, callbacks=[stop],
|
|
84
|
+
class_weight=_class_weights(y, len(labels)),
|
|
85
|
+
verbose=0 if quiet else 2)
|
|
86
|
+
return model
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _report(counts: dict[str, int], quiet: bool) -> None:
|
|
90
|
+
if quiet:
|
|
91
|
+
return
|
|
92
|
+
for name, c in counts.items():
|
|
93
|
+
print(f" {name:16} {c}")
|
|
94
|
+
values = list(counts.values())
|
|
95
|
+
if values and min(values) and max(values) >= 3 * min(values):
|
|
96
|
+
print("Note: classes are imbalanced — applying class weights.")
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def train(project_dir: str | Path, region: str, mode: str, *,
|
|
100
|
+
root: str | Path = artifacts.DEFAULT_ROOT,
|
|
101
|
+
seq_len: int = 30, epochs: int | None = None, batch_size: int = 16,
|
|
102
|
+
val_split: float = 0.2, small: bool | None = None,
|
|
103
|
+
normalized: bool = True, quiet: bool = False) -> Path:
|
|
104
|
+
"""Train a model and save it to a fresh, versioned artifact folder.
|
|
105
|
+
|
|
106
|
+
Returns the run folder, e.g. artifacts/static/pose_2.
|
|
107
|
+
"""
|
|
108
|
+
if mode not in artifacts.MODES:
|
|
109
|
+
raise ValueError(f"mode must be one of {artifacts.MODES}")
|
|
110
|
+
dim = feature_dim(region)
|
|
111
|
+
|
|
112
|
+
if mode == "static":
|
|
113
|
+
X, y, labels = data.load_static(project_dir, region)
|
|
114
|
+
auto_small = len(X) < SMALL_STATIC
|
|
115
|
+
epochs = 200 if epochs is None else epochs
|
|
116
|
+
else:
|
|
117
|
+
X, y, labels = data.load_sequence(project_dir, region, seq_len)
|
|
118
|
+
auto_small = len(X) < SMALL_SEQUENCE
|
|
119
|
+
epochs = 300 if epochs is None else epochs
|
|
120
|
+
|
|
121
|
+
use_small = auto_small if small is None else small
|
|
122
|
+
counts = data.class_summary(y, labels)
|
|
123
|
+
|
|
124
|
+
if not quiet:
|
|
125
|
+
shape = f"{len(X)} samples" if mode == "static" else f"{len(X)} sequences"
|
|
126
|
+
print(f"{mode} / {region} (dim {dim}): {shape}, classes={labels}")
|
|
127
|
+
_report(counts, quiet)
|
|
128
|
+
if min(counts.values()) == 0:
|
|
129
|
+
raise ValueError("A class has no usable samples — capture some for "
|
|
130
|
+
"every class, or lower seq_len.")
|
|
131
|
+
if use_small and small is None and not quiet:
|
|
132
|
+
print(f"Note: small dataset — using the lighter model (a large one "
|
|
133
|
+
f"overfits and collapses on this much data).")
|
|
134
|
+
|
|
135
|
+
rng = np.random.default_rng(42)
|
|
136
|
+
order = rng.permutation(len(X))
|
|
137
|
+
X, y = X[order], np.asarray(y)[order]
|
|
138
|
+
|
|
139
|
+
if mode == "static":
|
|
140
|
+
model = build_static(dim, len(labels), use_small)
|
|
141
|
+
else:
|
|
142
|
+
model = build_sequence(seq_len, dim, len(labels), use_small)
|
|
143
|
+
if not quiet:
|
|
144
|
+
model.summary()
|
|
145
|
+
|
|
146
|
+
_fit(model, X, y, labels, epochs=epochs, batch_size=batch_size,
|
|
147
|
+
val_split=val_split, quiet=quiet)
|
|
148
|
+
|
|
149
|
+
run = artifacts.new_run(root, mode, region)
|
|
150
|
+
model.save(artifacts.model_path(run))
|
|
151
|
+
gesto_region, hands = REGION_INFO[region][1], REGION_INFO[region][2]
|
|
152
|
+
meta = {
|
|
153
|
+
"labels": labels,
|
|
154
|
+
"region": region,
|
|
155
|
+
"mode": mode,
|
|
156
|
+
"input_dim": dim,
|
|
157
|
+
"gesto_region": gesto_region,
|
|
158
|
+
"hands": hands,
|
|
159
|
+
"normalized": normalized,
|
|
160
|
+
"samples": int(len(X)),
|
|
161
|
+
"counts": counts,
|
|
162
|
+
}
|
|
163
|
+
if mode == "sequence":
|
|
164
|
+
meta["seq_len"] = int(seq_len)
|
|
165
|
+
artifacts.save_meta(run, meta)
|
|
166
|
+
|
|
167
|
+
if not quiet:
|
|
168
|
+
loss, acc = model.evaluate(X, y, verbose=0)
|
|
169
|
+
print(f"\nTraining-set accuracy: {acc:.3f}")
|
|
170
|
+
print(f"Saved -> {run}")
|
|
171
|
+
return run
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: gesto
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Train and run gesture recognition models from Gesto Labeller datasets
|
|
5
|
+
Author: Sundar Balamurugan
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/TheMadrasTechie/gesto
|
|
8
|
+
Project-URL: Issues, https://github.com/TheMadrasTechie/gesto/issues
|
|
9
|
+
Keywords: gesture-recognition,mediapipe,pose-estimation,computer-vision,lstm,sign-language
|
|
10
|
+
Classifier: Development Status :: 4 - Beta
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: Intended Audience :: Science/Research
|
|
13
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
19
|
+
Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
|
|
20
|
+
Classifier: Topic :: Scientific/Engineering :: Image Recognition
|
|
21
|
+
Requires-Python: >=3.9
|
|
22
|
+
Description-Content-Type: text/markdown
|
|
23
|
+
License-File: LICENSE
|
|
24
|
+
Requires-Dist: numpy>=1.23
|
|
25
|
+
Requires-Dist: tensorflow>=2.15
|
|
26
|
+
Requires-Dist: opencv-python>=4.8
|
|
27
|
+
Requires-Dist: mediapipe>=0.10
|
|
28
|
+
Provides-Extra: dev
|
|
29
|
+
Requires-Dist: pytest>=7; extra == "dev"
|
|
30
|
+
Requires-Dist: build; extra == "dev"
|
|
31
|
+
Requires-Dist: twine; extra == "dev"
|
|
32
|
+
Dynamic: license-file
|
|
33
|
+
|
|
34
|
+
# gesto
|
|
35
|
+
|
|
36
|
+
Train and run gesture recognition models from [Gesto Labeller](https://github.com/TheMadrasTechie) datasets.
|
|
37
|
+
|
|
38
|
+
Point it at a project folder, pick a mode and a region, and it handles the rest —
|
|
39
|
+
loading, training, versioned model storage, and live detection that matches how
|
|
40
|
+
the data was captured.
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
pip install gesto
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
## Two modes
|
|
47
|
+
|
|
48
|
+
| mode | the gesture is… | model | example |
|
|
49
|
+
|---|---|---|---|
|
|
50
|
+
| `static` | a held shape or posture | Dense network | thumbs up, alphabet letters, a stance |
|
|
51
|
+
| `sequence` | a motion over time | stacked LSTM | waving, clapping, jogging |
|
|
52
|
+
|
|
53
|
+
Static needs far less data (every captured frame is a training sample) and
|
|
54
|
+
predicts instantly with no warm-up. Reach for `sequence` only when two gestures
|
|
55
|
+
share the same shape and differ by movement.
|
|
56
|
+
|
|
57
|
+
## Five regions
|
|
58
|
+
|
|
59
|
+
| region | dim | tracks |
|
|
60
|
+
|---|---|---|
|
|
61
|
+
| `hands_one` | 63 | one hand, 21 joints |
|
|
62
|
+
| `hands_two` | 126 | both hands |
|
|
63
|
+
| `pose` | 132 | full body, 33 points |
|
|
64
|
+
| `legs` | 32 | lower body, 8 points |
|
|
65
|
+
| `full` | 258 | body + both hands |
|
|
66
|
+
|
|
67
|
+
The region must match how the project was captured — `gesto` checks the feature
|
|
68
|
+
dimension and tells you if it doesn't.
|
|
69
|
+
|
|
70
|
+
## Command line
|
|
71
|
+
|
|
72
|
+
```bash
|
|
73
|
+
# what's in this dataset?
|
|
74
|
+
gesto inspect ./gesto_projects/signs
|
|
75
|
+
|
|
76
|
+
# train
|
|
77
|
+
gesto train static hands_one ./gesto_projects/signs
|
|
78
|
+
gesto train sequence pose ./gesto_projects/jogging --seq-len 30
|
|
79
|
+
|
|
80
|
+
# detect (newest model by default)
|
|
81
|
+
gesto detect static hands_one
|
|
82
|
+
gesto detect sequence pose --source clip.mp4
|
|
83
|
+
gesto detect static hands_one --version 2
|
|
84
|
+
|
|
85
|
+
# what have I trained?
|
|
86
|
+
gesto list
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
## Python
|
|
90
|
+
|
|
91
|
+
```python
|
|
92
|
+
import gesto
|
|
93
|
+
|
|
94
|
+
run = gesto.train("./gesto_projects/signs", region="hands_one", mode="static")
|
|
95
|
+
gesto.detect("static", "hands_one")
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
Or drive a model yourself:
|
|
99
|
+
|
|
100
|
+
```python
|
|
101
|
+
from gesto.detect import Predictor
|
|
102
|
+
|
|
103
|
+
predictor = Predictor.load("static", "hands_one")
|
|
104
|
+
vector = predictor.features(holistic_result) # extract + normalize
|
|
105
|
+
probs = predictor.predict(vector)
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
## Where models go
|
|
109
|
+
|
|
110
|
+
Everything lands under one `artifacts/` folder, split by mode then region.
|
|
111
|
+
Training never overwrites an earlier run — it versions:
|
|
112
|
+
|
|
113
|
+
```
|
|
114
|
+
artifacts/
|
|
115
|
+
static/
|
|
116
|
+
hands_one/ model.keras, labels.json
|
|
117
|
+
hands_one_2/ the next run
|
|
118
|
+
pose/
|
|
119
|
+
sequence/
|
|
120
|
+
pose/
|
|
121
|
+
pose_2/
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
`gesto detect static pose` picks the newest version; `--version 1` picks a
|
|
125
|
+
specific one.
|
|
126
|
+
|
|
127
|
+
## Matching your capture
|
|
128
|
+
|
|
129
|
+
Predictions are only correct when detection feeds the model the same kind of
|
|
130
|
+
vector it trained on. `gesto` mirrors Gesto Labeller exactly:
|
|
131
|
+
|
|
132
|
+
- **the same engine** — MediaPipe Holistic, same confidence settings
|
|
133
|
+
- **the same landmark order** — including one-hand mode preferring the right
|
|
134
|
+
hand and falling back to the left
|
|
135
|
+
- **the same normalization** — translation/scale-invariant, verified identical
|
|
136
|
+
- **the same mirroring** — webcam frames are flipped, video files are not
|
|
137
|
+
|
|
138
|
+
If you captured with Gesto's **Normalise** unchecked, pass `--raw` when training
|
|
139
|
+
so detection knows to skip it.
|
|
140
|
+
|
|
141
|
+
## Getting good results
|
|
142
|
+
|
|
143
|
+
- **Balance your classes.** Similar sample counts per class; `gesto` applies
|
|
144
|
+
class weights but balanced data is better.
|
|
145
|
+
- **Enough samples.** ~20–30 static frames per class, or ~15–30 sequences.
|
|
146
|
+
Small datasets automatically get a lighter model, since an oversized network
|
|
147
|
+
on little data overfits and collapses to predicting one class.
|
|
148
|
+
- **Consistent clip length** for sequence mode — set "Max frames" in Gesto
|
|
149
|
+
Labeller so every capture is the same length.
|
|
150
|
+
|
|
151
|
+
Run `gesto inspect <project>` to check all of this before training.
|
|
152
|
+
|
|
153
|
+
## Requirements
|
|
154
|
+
|
|
155
|
+
Python 3.9+, TensorFlow, OpenCV, MediaPipe, NumPy — all installed with the
|
|
156
|
+
package.
|
|
157
|
+
|
|
158
|
+
## License
|
|
159
|
+
|
|
160
|
+
MIT
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
gesto/__init__.py,sha256=r4CH5XvWh967F4snt0DX34-p2asr2DE1GdvRxwZwYBs,2023
|
|
2
|
+
gesto/artifacts.py,sha256=DwM6z9gjqUUeMgjehK0QN27mPFvaMVltZj4lyQe4QRc,4003
|
|
3
|
+
gesto/cli.py,sha256=D_j2UdfiWpUK5JqoGFDcxc1aqMozq3xplcLPIS6VFWU,7142
|
|
4
|
+
gesto/data.py,sha256=RKHstVwBz4dFlG4PZsZajPPKFwRBrpzgEtZzc3LYqBw,4003
|
|
5
|
+
gesto/detect.py,sha256=xyCbXof6K2IUBf4f85tsOtQuv3kkhcGSJ-f9mj_E_sk,6874
|
|
6
|
+
gesto/regions.py,sha256=meMb4oaaamv9b5vKYj_Xtf6bVZQQx9butTDyN1Snk7I,7929
|
|
7
|
+
gesto/train.py,sha256=KUiRGsNWXyna8jC6ial4cuUdYKCEiqYhtjjxI6U8_vY,6236
|
|
8
|
+
gesto-0.1.0.dist-info/licenses/LICENSE,sha256=XFJjfKTnDwOnOxZ1rFh96YppdNU9sFw3Kb455hK3-q8,1075
|
|
9
|
+
gesto-0.1.0.dist-info/METADATA,sha256=XKTL5b89P5T8GjENIR5tGQxYsHlxTtjMxIjcYKNdli0,5190
|
|
10
|
+
gesto-0.1.0.dist-info/WHEEL,sha256=K260EYznzXsJYBQGqmI8VTxEdiZYNvDZwW9cBh9-_MA,91
|
|
11
|
+
gesto-0.1.0.dist-info/entry_points.txt,sha256=6viZErcidQAscKN9S-ks6aaEH37CIRujaAeht0_qyig,41
|
|
12
|
+
gesto-0.1.0.dist-info/top_level.txt,sha256=PmyDmZ9EQmvbTSlj9YAL5nljnkb1dBwJtK8eOvzajog,6
|
|
13
|
+
gesto-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Sundar Balamurugan
|
|
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
|
+
gesto
|