Commit ·
1122f32
1
Parent(s): b34cf5c
Integrate ASL TFLite model assets
Browse files- .gitignore +1 -0
- README.md +8 -1
- data/models/asl/model.tflite +3 -0
- data/models/asl/sign_to_prediction_index_map.json +1 -0
- data/models/asl/train.csv +0 -0
- requirements-asl-full.txt +0 -6
- requirements.txt +25 -3
- signspeak/asl/asl_detector.py +41 -8
- tests/test_asl_detector.py +42 -0
.gitignore
CHANGED
|
@@ -2,3 +2,4 @@ __pycache__/
|
|
| 2 |
*.py[cod]
|
| 3 |
.pytest_cache/
|
| 4 |
data/examples/*.mp4
|
|
|
|
|
|
| 2 |
*.py[cod]
|
| 3 |
.pytest_cache/
|
| 4 |
data/examples/*.mp4
|
| 5 |
+
external/
|
README.md
CHANGED
|
@@ -66,11 +66,18 @@ python3 scripts/test_asl_brick.py --gloss-override "I LOVE YOU"
|
|
| 66 |
|
| 67 |
## ASL model files
|
| 68 |
|
| 69 |
-
|
| 70 |
|
| 71 |
```text
|
| 72 |
data/models/asl/model.tflite
|
| 73 |
data/models/asl/train.csv
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 74 |
```
|
| 75 |
|
| 76 |
Without these files, the ASL brick still samples frames and emits `model_missing` diagnostics.
|
|
|
|
| 66 |
|
| 67 |
## ASL model files
|
| 68 |
|
| 69 |
+
The ASL classifier assets are stored under:
|
| 70 |
|
| 71 |
```text
|
| 72 |
data/models/asl/model.tflite
|
| 73 |
data/models/asl/train.csv
|
| 74 |
+
data/models/asl/sign_to_prediction_index_map.json
|
| 75 |
+
```
|
| 76 |
+
|
| 77 |
+
They come from:
|
| 78 |
+
|
| 79 |
+
```text
|
| 80 |
+
https://github.com/jamesjbustos/sign-language-recognition
|
| 81 |
```
|
| 82 |
|
| 83 |
Without these files, the ASL brick still samples frames and emits `model_missing` diagnostics.
|
data/models/asl/model.tflite
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:af631244b8a7595a1925c8c7cd2b6b20b3086f6783061041d85f5bddbcb94da1
|
| 3 |
+
size 3383908
|
data/models/asl/sign_to_prediction_index_map.json
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
{"TV": 0, "after": 1, "airplane": 2, "all": 3, "alligator": 4, "animal": 5, "another": 6, "any": 7, "apple": 8, "arm": 9, "aunt": 10, "awake": 11, "backyard": 12, "bad": 13, "balloon": 14, "bath": 15, "because": 16, "bed": 17, "bedroom": 18, "bee": 19, "before": 20, "beside": 21, "better": 22, "bird": 23, "black": 24, "blow": 25, "blue": 26, "boat": 27, "book": 28, "boy": 29, "brother": 30, "brown": 31, "bug": 32, "bye": 33, "callonphone": 34, "can": 35, "car": 36, "carrot": 37, "cat": 38, "cereal": 39, "chair": 40, "cheek": 41, "child": 42, "chin": 43, "chocolate": 44, "clean": 45, "close": 46, "closet": 47, "cloud": 48, "clown": 49, "cow": 50, "cowboy": 51, "cry": 52, "cut": 53, "cute": 54, "dad": 55, "dance": 56, "dirty": 57, "dog": 58, "doll": 59, "donkey": 60, "down": 61, "drawer": 62, "drink": 63, "drop": 64, "dry": 65, "dryer": 66, "duck": 67, "ear": 68, "elephant": 69, "empty": 70, "every": 71, "eye": 72, "face": 73, "fall": 74, "farm": 75, "fast": 76, "feet": 77, "find": 78, "fine": 79, "finger": 80, "finish": 81, "fireman": 82, "first": 83, "fish": 84, "flag": 85, "flower": 86, "food": 87, "for": 88, "frenchfries": 89, "frog": 90, "garbage": 91, "gift": 92, "giraffe": 93, "girl": 94, "give": 95, "glasswindow": 96, "go": 97, "goose": 98, "grandma": 99, "grandpa": 100, "grass": 101, "green": 102, "gum": 103, "hair": 104, "happy": 105, "hat": 106, "hate": 107, "have": 108, "haveto": 109, "head": 110, "hear": 111, "helicopter": 112, "hello": 113, "hen": 114, "hesheit": 115, "hide": 116, "high": 117, "home": 118, "horse": 119, "hot": 120, "hungry": 121, "icecream": 122, "if": 123, "into": 124, "jacket": 125, "jeans": 126, "jump": 127, "kiss": 128, "kitty": 129, "lamp": 130, "later": 131, "like": 132, "lion": 133, "lips": 134, "listen": 135, "look": 136, "loud": 137, "mad": 138, "make": 139, "man": 140, "many": 141, "milk": 142, "minemy": 143, "mitten": 144, "mom": 145, "moon": 146, "morning": 147, "mouse": 148, "mouth": 149, "nap": 150, "napkin": 151, "night": 152, "no": 153, "noisy": 154, "nose": 155, "not": 156, "now": 157, "nuts": 158, "old": 159, "on": 160, "open": 161, "orange": 162, "outside": 163, "owie": 164, "owl": 165, "pajamas": 166, "pen": 167, "pencil": 168, "penny": 169, "person": 170, "pig": 171, "pizza": 172, "please": 173, "police": 174, "pool": 175, "potty": 176, "pretend": 177, "pretty": 178, "puppy": 179, "puzzle": 180, "quiet": 181, "radio": 182, "rain": 183, "read": 184, "red": 185, "refrigerator": 186, "ride": 187, "room": 188, "sad": 189, "same": 190, "say": 191, "scissors": 192, "see": 193, "shhh": 194, "shirt": 195, "shoe": 196, "shower": 197, "sick": 198, "sleep": 199, "sleepy": 200, "smile": 201, "snack": 202, "snow": 203, "stairs": 204, "stay": 205, "sticky": 206, "store": 207, "story": 208, "stuck": 209, "sun": 210, "table": 211, "talk": 212, "taste": 213, "thankyou": 214, "that": 215, "there": 216, "think": 217, "thirsty": 218, "tiger": 219, "time": 220, "tomorrow": 221, "tongue": 222, "tooth": 223, "toothbrush": 224, "touch": 225, "toy": 226, "tree": 227, "uncle": 228, "underwear": 229, "up": 230, "vacuum": 231, "wait": 232, "wake": 233, "water": 234, "wet": 235, "weus": 236, "where": 237, "white": 238, "who": 239, "why": 240, "will": 241, "wolf": 242, "yellow": 243, "yes": 244, "yesterday": 245, "yourself": 246, "yucky": 247, "zebra": 248, "zipper": 249}
|
data/models/asl/train.csv
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
requirements-asl-full.txt
CHANGED
|
@@ -1,7 +1 @@
|
|
| 1 |
-r requirements.txt
|
| 2 |
-
|
| 3 |
-
protobuf>=4.25,<5.0
|
| 4 |
-
mediapipe==0.10.14
|
| 5 |
-
tensorflow-cpu==2.17.1
|
| 6 |
-
deepface==0.0.100
|
| 7 |
-
tf-keras==2.17.0
|
|
|
|
| 1 |
-r requirements.txt
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
requirements.txt
CHANGED
|
@@ -1,13 +1,35 @@
|
|
| 1 |
--extra-index-url https://abetlen.github.io/llama-cpp-python/whl/cpu
|
| 2 |
--prefer-binary
|
| 3 |
|
| 4 |
-
gradio
|
| 5 |
qwen-tts
|
| 6 |
soundfile
|
| 7 |
torch
|
| 8 |
huggingface-hub
|
| 9 |
llama-cpp-python
|
| 10 |
-
numpy
|
| 11 |
pandas>=2.1,<3.0
|
| 12 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 13 |
pytest>=8.0,<9.0
|
|
|
|
| 1 |
--extra-index-url https://abetlen.github.io/llama-cpp-python/whl/cpu
|
| 2 |
--prefer-binary
|
| 3 |
|
| 4 |
+
gradio>=5.50,<6.0
|
| 5 |
qwen-tts
|
| 6 |
soundfile
|
| 7 |
torch
|
| 8 |
huggingface-hub
|
| 9 |
llama-cpp-python
|
| 10 |
+
numpy==1.26.4
|
| 11 |
pandas>=2.1,<3.0
|
| 12 |
+
protobuf==4.25.9
|
| 13 |
+
opencv-contrib-python==4.11.0.86
|
| 14 |
+
attrs>=26.1,<27
|
| 15 |
+
mediapipe==0.10.14
|
| 16 |
+
tensorflow-cpu==2.17.1
|
| 17 |
+
tf-keras==2.17.0
|
| 18 |
+
Flask
|
| 19 |
+
flask-cors
|
| 20 |
+
gdown
|
| 21 |
+
mtcnn
|
| 22 |
+
fire
|
| 23 |
+
gunicorn
|
| 24 |
+
lightphe
|
| 25 |
+
lightdsa
|
| 26 |
+
python-dotenv
|
| 27 |
+
beautifulsoup4
|
| 28 |
+
PySocks
|
| 29 |
+
joblib
|
| 30 |
+
lz4
|
| 31 |
+
sympy
|
| 32 |
+
deepface==0.0.100
|
| 33 |
+
retina-face==0.0.18
|
| 34 |
+
pillow==11.3.0
|
| 35 |
pytest>=8.0,<9.0
|
signspeak/asl/asl_detector.py
CHANGED
|
@@ -1,5 +1,6 @@
|
|
| 1 |
from __future__ import annotations
|
| 2 |
|
|
|
|
| 3 |
from pathlib import Path
|
| 4 |
from typing import Any
|
| 5 |
|
|
@@ -16,6 +17,7 @@ class ASLDetector:
|
|
| 16 |
self.model_dir = Path(configured_model_dir) if configured_model_dir else default_model_dir
|
| 17 |
self.model_path = self.model_dir / "model.tflite"
|
| 18 |
self.train_csv_path = self.model_dir / "train.csv"
|
|
|
|
| 19 |
self.labels = self._load_labels()
|
| 20 |
|
| 21 |
def predict_from_frames(self, frames: list[np.ndarray]) -> dict[str, Any]:
|
|
@@ -44,14 +46,7 @@ class ASLDetector:
|
|
| 44 |
|
| 45 |
try:
|
| 46 |
interpreter = self._load_interpreter()
|
| 47 |
-
|
| 48 |
-
input_details = interpreter.get_input_details()
|
| 49 |
-
output_details = interpreter.get_output_details()
|
| 50 |
-
|
| 51 |
-
input_data = self._prepare_input(keypoints, input_details[0])
|
| 52 |
-
interpreter.set_tensor(input_details[0]["index"], input_data)
|
| 53 |
-
interpreter.invoke()
|
| 54 |
-
output = interpreter.get_tensor(output_details[0]["index"])
|
| 55 |
|
| 56 |
probs = self._softmax_if_needed(np.asarray(output).reshape(-1))
|
| 57 |
top_idx = int(np.argmax(probs))
|
|
@@ -86,6 +81,34 @@ class ASLDetector:
|
|
| 86 |
|
| 87 |
return Interpreter(model_path=str(self.model_path))
|
| 88 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 89 |
def _prepare_input(self, keypoints: np.ndarray, input_detail: dict[str, Any]) -> np.ndarray:
|
| 90 |
shape = input_detail.get("shape")
|
| 91 |
dtype = input_detail.get("dtype", np.float32)
|
|
@@ -117,6 +140,16 @@ class ASLDetector:
|
|
| 117 |
return fitted.reshape(target_shape)
|
| 118 |
|
| 119 |
def _load_labels(self) -> list[str]:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 120 |
if not self.train_csv_path.exists():
|
| 121 |
return []
|
| 122 |
try:
|
|
|
|
| 1 |
from __future__ import annotations
|
| 2 |
|
| 3 |
+
import json
|
| 4 |
from pathlib import Path
|
| 5 |
from typing import Any
|
| 6 |
|
|
|
|
| 17 |
self.model_dir = Path(configured_model_dir) if configured_model_dir else default_model_dir
|
| 18 |
self.model_path = self.model_dir / "model.tflite"
|
| 19 |
self.train_csv_path = self.model_dir / "train.csv"
|
| 20 |
+
self.sign_map_path = self.model_dir / "sign_to_prediction_index_map.json"
|
| 21 |
self.labels = self._load_labels()
|
| 22 |
|
| 23 |
def predict_from_frames(self, frames: list[np.ndarray]) -> dict[str, Any]:
|
|
|
|
| 46 |
|
| 47 |
try:
|
| 48 |
interpreter = self._load_interpreter()
|
| 49 |
+
output = self._predict(interpreter, keypoints)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 50 |
|
| 51 |
probs = self._softmax_if_needed(np.asarray(output).reshape(-1))
|
| 52 |
top_idx = int(np.argmax(probs))
|
|
|
|
| 81 |
|
| 82 |
return Interpreter(model_path=str(self.model_path))
|
| 83 |
|
| 84 |
+
def _predict(self, interpreter: Any, keypoints: np.ndarray) -> np.ndarray:
|
| 85 |
+
signatures = interpreter.get_signature_list() if hasattr(interpreter, "get_signature_list") else {}
|
| 86 |
+
if "serving_default" in signatures:
|
| 87 |
+
prediction_fn = interpreter.get_signature_runner("serving_default")
|
| 88 |
+
input_name = self._signature_input_name(signatures["serving_default"])
|
| 89 |
+
input_data = np.nan_to_num(keypoints, nan=0.0, posinf=0.0, neginf=0.0).astype(np.float32)
|
| 90 |
+
prediction = prediction_fn(**{input_name: input_data})
|
| 91 |
+
output_name = self._signature_output_name(prediction)
|
| 92 |
+
return np.asarray(prediction[output_name])
|
| 93 |
+
|
| 94 |
+
interpreter.allocate_tensors()
|
| 95 |
+
input_details = interpreter.get_input_details()
|
| 96 |
+
output_details = interpreter.get_output_details()
|
| 97 |
+
|
| 98 |
+
input_data = self._prepare_input(keypoints, input_details[0])
|
| 99 |
+
interpreter.set_tensor(input_details[0]["index"], input_data)
|
| 100 |
+
interpreter.invoke()
|
| 101 |
+
return np.asarray(interpreter.get_tensor(output_details[0]["index"]))
|
| 102 |
+
|
| 103 |
+
def _signature_input_name(self, signature: dict[str, Any]) -> str:
|
| 104 |
+
inputs = signature.get("inputs") or ["inputs"]
|
| 105 |
+
return inputs[0]
|
| 106 |
+
|
| 107 |
+
def _signature_output_name(self, prediction: dict[str, Any]) -> str:
|
| 108 |
+
if "outputs" in prediction:
|
| 109 |
+
return "outputs"
|
| 110 |
+
return next(iter(prediction))
|
| 111 |
+
|
| 112 |
def _prepare_input(self, keypoints: np.ndarray, input_detail: dict[str, Any]) -> np.ndarray:
|
| 113 |
shape = input_detail.get("shape")
|
| 114 |
dtype = input_detail.get("dtype", np.float32)
|
|
|
|
| 140 |
return fitted.reshape(target_shape)
|
| 141 |
|
| 142 |
def _load_labels(self) -> list[str]:
|
| 143 |
+
if self.sign_map_path.exists():
|
| 144 |
+
try:
|
| 145 |
+
sign_to_index = json.loads(self.sign_map_path.read_text(encoding="utf-8"))
|
| 146 |
+
labels = [""] * len(sign_to_index)
|
| 147 |
+
for sign, index in sign_to_index.items():
|
| 148 |
+
labels[int(index)] = str(sign)
|
| 149 |
+
return labels
|
| 150 |
+
except Exception:
|
| 151 |
+
pass
|
| 152 |
+
|
| 153 |
if not self.train_csv_path.exists():
|
| 154 |
return []
|
| 155 |
try:
|
tests/test_asl_detector.py
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import json
|
| 2 |
+
|
| 3 |
+
import numpy as np
|
| 4 |
+
|
| 5 |
+
from signspeak.asl.asl_detector import ASLDetector
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
def test_load_labels_prefers_prediction_index_map(tmp_path):
|
| 9 |
+
model_dir = tmp_path / "asl"
|
| 10 |
+
model_dir.mkdir()
|
| 11 |
+
(model_dir / "sign_to_prediction_index_map.json").write_text(
|
| 12 |
+
json.dumps({"love": 2, "hello": 0, "thanks": 1}),
|
| 13 |
+
encoding="utf-8",
|
| 14 |
+
)
|
| 15 |
+
|
| 16 |
+
detector = ASLDetector(model_dir=model_dir)
|
| 17 |
+
|
| 18 |
+
assert detector.labels == ["hello", "thanks", "love"]
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
class FakeSignatureInterpreter:
|
| 22 |
+
def get_signature_list(self):
|
| 23 |
+
return {"serving_default": {"inputs": ["inputs"], "outputs": ["outputs"]}}
|
| 24 |
+
|
| 25 |
+
def get_signature_runner(self, signature_name):
|
| 26 |
+
assert signature_name == "serving_default"
|
| 27 |
+
|
| 28 |
+
def predict(**kwargs):
|
| 29 |
+
assert kwargs["inputs"].shape == (543, 3)
|
| 30 |
+
return {"outputs": np.asarray([[0.1, 0.8, 0.1]], dtype=np.float32)}
|
| 31 |
+
|
| 32 |
+
return predict
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
def test_predict_uses_tflite_signature_runner(tmp_path):
|
| 36 |
+
detector = ASLDetector(model_dir=tmp_path)
|
| 37 |
+
keypoints = np.zeros((543, 3), dtype=np.float32)
|
| 38 |
+
|
| 39 |
+
output = detector._predict(FakeSignatureInterpreter(), keypoints)
|
| 40 |
+
|
| 41 |
+
assert output.shape == (1, 3)
|
| 42 |
+
assert float(output[0][1]) == np.float32(0.8)
|