From 8b43b4a0239137a6d8dfa16c03499bd89ffe91b0 Mon Sep 17 00:00:00 2001 From: Ziv Lazarov Date: Mon, 20 Apr 2026 07:06:22 -0400 Subject: [PATCH 1/2] feat: load models from safetensors, add baked-in + HF resolver - `bin.py`: new `resolve_model_path()` picks MODEL_PATH > HF_MODEL_REPO > baked-in `dist/2026/`. `load_model()` prefers `model.safetensors` + `config.json` and falls back to `.ckpt` for backward compatibility. - `load_model(..., revision="")` makes HF_MODEL_REVISION part of the lru_cache key so mid-process env changes invalidate the cached instance instead of silently returning a stale model. - `pyproject.toml`: add `[build-system]`, add `safetensors` to base deps, swap `.ckpt` for `.safetensors` in package-data, drop `slim_checkpoint` script (still runnable via `python -m`). - `.gitignore`: ignore `*.safetensors` artifacts. The `huggingface_hub` import stays behind a guarded `try/except ImportError`; no new runtime dep is advertised in this PR. --- .gitignore | 1 + pyproject.toml | 8 ++- sign_language_segmentation/bin.py | 95 +++++++++++++++++++++++++------ 3 files changed, 86 insertions(+), 18 deletions(-) diff --git a/.gitignore b/.gitignore index c3355f9..9f446e6 100644 --- a/.gitignore +++ b/.gitignore @@ -10,4 +10,5 @@ lightning_logs/ uv.lock run_hpo.sh *.pose +*.safetensors .cache/ diff --git a/pyproject.toml b/pyproject.toml index 544cc1a..e78bfe0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,3 +1,7 @@ +[build-system] +requires = ["setuptools>=64"] +build-backend = "setuptools.build_meta" + [project] name = "sign-language-segmentation" description = "Sign language pose segmentation model on both the sentence and sign level" @@ -16,6 +20,7 @@ dependencies = [ "pose-anonymization", "scikit-learn", "pytorch-lightning", + "safetensors", ] [project.optional-dependencies] @@ -61,7 +66,7 @@ where = ["."] include = ["sign_language_segmentation*"] [tool.setuptools.package-data] -sign_language_segmentation = ["**/*.json", "**/*.ckpt", "**/*.yaml"] +sign_language_segmentation = ["**/*.json", "**/*.safetensors", "**/*.yaml"] [tool.pytest.ini_options] addopts = "-v" @@ -69,4 +74,3 @@ testpaths = ["sign_language_segmentation"] [project.scripts] pose_to_segments = "sign_language_segmentation.bin:main" -slim_checkpoint = "sign_language_segmentation.slim_checkpoint:main" diff --git a/sign_language_segmentation/bin.py b/sign_language_segmentation/bin.py index 0662d6c..b8189a6 100644 --- a/sign_language_segmentation/bin.py +++ b/sign_language_segmentation/bin.py @@ -5,6 +5,7 @@ and writes an ELAN (.eaf) annotation file with SIGN and SENTENCE tiers. """ import argparse +import json import os from functools import lru_cache from pathlib import Path @@ -13,19 +14,80 @@ import pympi import torch from pose_format import Pose +from safetensors.torch import load_file as load_safetensors from sign_language_segmentation.utils.pose import preprocess_pose, compute_velocity from sign_language_segmentation.metrics import likeliest_probs_to_segments, filter_segments from sign_language_segmentation.model.model import PoseTaggingModel +_BAKED_IN_DIR = Path(__file__).resolve().parent / "dist" / "2026" -def _default_model_path() -> str: - return os.path.join(os.path.dirname(os.path.abspath(__file__)), "dist", "2026", "best.ckpt") + +def resolve_model_path() -> str: + """Resolve model directory path. + + Priority: MODEL_PATH env > HF_MODEL_REPO env > baked-in package default. + """ + # 1. explicit local path + explicit = os.environ.get("MODEL_PATH") + if explicit: + return explicit + + # 2. huggingface hub download + hf_repo = os.environ.get("HF_MODEL_REPO") + if hf_repo: + return _download_from_hf(hf_repo) + + # 3. baked-in default + return str(_BAKED_IN_DIR) + + +def _download_from_hf(repo_id: str) -> str: + """Download model from HuggingFace Hub. Returns local cache directory.""" + try: + from huggingface_hub import snapshot_download + except ImportError: + raise ImportError( + "huggingface_hub is required for HF_MODEL_REPO. " + "Install with: pip install sign-language-segmentation[hf]" + ) + revision = os.environ.get("HF_MODEL_REVISION") + if not revision: + raise ValueError("HF_MODEL_REVISION must be set when using HF_MODEL_REPO") + return snapshot_download( + repo_id=repo_id, + revision=revision, + allow_patterns=["model.safetensors", "config.json"], + ) + + +def _load_from_safetensors(model_dir: str, device: str) -> PoseTaggingModel: + """Load model from safetensors + config.json directory.""" + model_dir_path = Path(model_dir) + with open(model_dir_path / "config.json") as f: + config = json.load(f) + # config.json stores tuples as lists — convert pose_dims back + if "pose_dims" in config: + config["pose_dims"] = tuple(config["pose_dims"]) + model = PoseTaggingModel(**config) + state_dict = load_safetensors(filename=str(model_dir_path / "model.safetensors"), device=device) + model.load_state_dict(state_dict) + model = model.to(device) + model.eval() + return model @lru_cache(maxsize=1) -def load_model(model_path: str, device: str = "cpu") -> PoseTaggingModel: - model = PoseTaggingModel.load_from_checkpoint(model_path, map_location=device) +def load_model(model_dir: str, device: str = "cpu", revision: str = "") -> PoseTaggingModel: + # revision is part of the cache key only — callers pass HF_MODEL_REVISION so a mid-process + # env change invalidates the cache entry instead of silently returning a stale model. + model_dir_path = Path(model_dir) + # prefer safetensors if available, fall back to .ckpt + if (model_dir_path / "model.safetensors").exists(): + return _load_from_safetensors(model_dir=str(model_dir), device=device) + # backward compat: load .ckpt directly (model_dir might be a file path) + ckpt_path = model_dir_path if model_dir_path.suffix == ".ckpt" else model_dir_path / "best.ckpt" + model = PoseTaggingModel.load_from_checkpoint(checkpoint_path=str(ckpt_path), map_location=device) model = model.to(device) model.eval() return model @@ -51,7 +113,7 @@ def run_inference(model: PoseTaggingModel, pose: Pose, device: str) -> dict: return model(pose_tensor, timestamps=timestamps) -def segment_pose(pose: Pose, model_path: str = None, device: str = "cpu", +def segment_pose(pose: Pose, model_dir: str = None, device: str = "cpu", min_frames: int = 3, merge_gap: int = 0): """Segment a pose into signs and sentences. @@ -59,10 +121,11 @@ def segment_pose(pose: Pose, model_path: str = None, device: str = "cpu", eaf: pympi.Elan.Eaf with SIGN and SENTENCE tiers tiers: dict mapping tier name to list of {start, end} segment dicts """ - model_path = model_path or _default_model_path() - model = load_model(model_path, device) + model_dir = model_dir or resolve_model_path() + revision = os.environ.get("HF_MODEL_REVISION", "") + model = load_model(model_dir=model_dir, device=device, revision=revision) - log_probs = run_inference(model, pose, device) + log_probs = run_inference(model=model, pose=pose, device=device) fps = pose.body.fps seg_fn = likeliest_probs_to_segments @@ -103,7 +166,7 @@ def get_args(): parser.add_argument("--pose", required=True, type=Path, help="input .pose file") parser.add_argument("--elan", required=True, type=str, help="output .eaf file path") parser.add_argument("--model", default=None, type=str, - help="path to .ckpt checkpoint (default: dist/2026/best.ckpt)") + help="path to model directory (safetensors) or .ckpt file") parser.add_argument("--video", default=None, type=str, help="video file to link in ELAN") parser.add_argument("--subtitles", default=None, type=str, help="path to .srt subtitle file") parser.add_argument("--no-pose-link", action="store_true", help="do not link pose file in ELAN") @@ -120,21 +183,21 @@ def get_args(): def main(): args = get_args() - model_path = args.model or _default_model_path() - if not os.path.exists(model_path): + model_dir = args.model or resolve_model_path() + if not os.path.exists(model_dir): raise FileNotFoundError( - f"Model not found: {model_path}\n" - "Download a checkpoint and place it at dist/2026/best.ckpt, " - "or pass --model ." + f"Model not found: {model_dir}\n" + "Set HF_MODEL_REPO env var, pass --model , " + "or place model files at dist/2026/." ) print(f"Loading pose: {args.pose}") with open(args.pose, "rb") as f: pose = Pose.read(f) - print(f"Loading model: {model_path}") + print(f"Loading model: {model_dir}") print("Running inference...") - eaf, tiers = segment_pose(pose, model_path=model_path, device=args.device, + eaf, tiers = segment_pose(pose, model_dir=model_dir, device=args.device, min_frames=args.min_frames, merge_gap=args.merge_gap) sign_count = len(tiers["SIGN"]) From 77b2f9f8296b57a19e1daa48c418305a762366da Mon Sep 17 00:00:00 2001 From: Ziv Lazarov Date: Thu, 23 Apr 2026 11:58:47 -0400 Subject: [PATCH 2/2] chore(dist/2026): ship model as safetensors + config.json package-data now selects **/*.safetensors, so the bundled best.ckpt would not have been installed on pip install. convert it in-place (bf16 state dict + hparams json) so the baked-in default keeps working. safetensors/ckpt loads produce identical outputs (max abs diff = 0). --- .../dist/2026/config.json | 20 ++++++++++++++++++ .../2026/{best.ckpt => model.safetensors} | Bin 11509006 -> 11485662 bytes 2 files changed, 20 insertions(+) create mode 100644 sign_language_segmentation/dist/2026/config.json rename sign_language_segmentation/dist/2026/{best.ckpt => model.safetensors} (99%) diff --git a/sign_language_segmentation/dist/2026/config.json b/sign_language_segmentation/dist/2026/config.json new file mode 100644 index 0000000..d5b8e6d --- /dev/null +++ b/sign_language_segmentation/dist/2026/config.json @@ -0,0 +1,20 @@ +{ + "pose_dims": [ + 50, + 6 + ], + "hidden_dim": 384, + "encoder_depth": 4, + "num_classes": 4, + "learning_rate": 0.0005, + "steps_per_epoch": 69, + "max_epochs": 400, + "dice_loss_weight": 1.5, + "attn_nhead": 8, + "attn_ff_mult": 2, + "attn_dropout": 0.1, + "optimizer": "adamw-onecycle", + "fps_aug": true, + "frame_dropout": 0.15, + "num_frames": 1024 +} \ No newline at end of file diff --git a/sign_language_segmentation/dist/2026/best.ckpt b/sign_language_segmentation/dist/2026/model.safetensors similarity index 99% rename from sign_language_segmentation/dist/2026/best.ckpt rename to sign_language_segmentation/dist/2026/model.safetensors index 7d80417906c6417c798c26414c8fbfbeeaf37b77..d9e8feb2d7c5e8aa755fecbb0af6b44998036725 100644 GIT binary patch delta 12960 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 delta 36626 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