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 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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (83.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ gesto = gesto.cli:main
@@ -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