signlang-segmenter 0.1.0__tar.gz

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.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Mohamed Yehia
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,144 @@
1
+ Metadata-Version: 2.4
2
+ Name: signlang-segmenter
3
+ Version: 0.1.0
4
+ Summary: A Python library for sign language video and pose segmentation.
5
+ Home-page: https://github.com/24-mohamedyehia/signlang-segmenter
6
+ Author: Mohamed Yehia
7
+ Classifier: Programming Language :: Python :: 3
8
+ Classifier: Programming Language :: Python :: 3 :: Only
9
+ Classifier: License :: OSI Approved :: MIT License
10
+ Classifier: Operating System :: OS Independent
11
+ Requires-Python: >=3.10
12
+ Description-Content-Type: text/markdown
13
+ License-File: LICENSE
14
+ Requires-Dist: opencv-python==4.11.0.86
15
+ Requires-Dist: numpy==1.26.4
16
+ Requires-Dist: matplotlib==3.7.3
17
+ Requires-Dist: notebook==6.5.4
18
+ Dynamic: author
19
+ Dynamic: classifier
20
+ Dynamic: description
21
+ Dynamic: description-content-type
22
+ Dynamic: home-page
23
+ Dynamic: license-file
24
+ Dynamic: requires-dist
25
+ Dynamic: requires-python
26
+ Dynamic: summary
27
+
28
+ # signlang-segmenter
29
+
30
+ A Python library for sign language segmentation.
31
+
32
+ ## Optical Flow
33
+ ![motion curve with segments](./public/image.png)
34
+
35
+ ## Important Naming Note
36
+
37
+ - Distribution/package name: `signlang-segmenter`
38
+ - Python import name: `signlang_segmenter`
39
+
40
+ Python imports use underscores, not dashes.
41
+
42
+ ## Current Layout
43
+
44
+ ```text
45
+ signlang-segmenter/
46
+ ├── signlang_segmenter/
47
+ │ ├── __init__.py
48
+ │ ├── video/
49
+ │ │ ├── __init__.py
50
+ │ │ └── optical_flow/
51
+ │ │ ├── __init__.py
52
+ │ │ ├── segmenter.py
53
+ │ │ ├── motion_analyzer.py
54
+ │ │ ├── models.py
55
+ │ │ ├── utils.py
56
+ │ │ ├── exporter.py
57
+ │ │ └── visualization.py
58
+ │ └── pose/
59
+ │ └── __init__.py
60
+ ├── examples/
61
+ │ └── basic_pipeline.ipynb
62
+ ├── setup.py
63
+ └── README.md
64
+ ```
65
+
66
+ ## Installation
67
+
68
+ ### Option 1: Install the library to use it
69
+
70
+ If you only want to use the package in your own project, install it directly from GitHub:
71
+
72
+ ```bash
73
+ python -m pip install "git+https://github.com/24-mohamedyehia/signlang-segmenter.git"
74
+ ```
75
+
76
+ ### Option 2: Install the project for development
77
+
78
+ If you want to modify the code and contribute, use an isolated environment and editable install:
79
+
80
+ 1. Install Miniconda if needed.
81
+ 2. Run:
82
+
83
+ ```bash
84
+ git clone https://github.com/24-mohamedyehia/signlang-segmenter.git
85
+ cd signlang-segmenter
86
+ conda create -n signlang-segmenter python=3.11 -y
87
+ conda activate signlang-segmenter
88
+ python -m pip install --upgrade pip
89
+ python -m pip install -e .
90
+
91
+ ```
92
+
93
+ ## Quick Import Check
94
+
95
+ ```python
96
+ import signlang_segmenter
97
+ import signlang_segmenter.video
98
+ import signlang_segmenter.pose
99
+ ```
100
+
101
+ ## Segmentation API (MVP)
102
+
103
+ ```python
104
+ from signlang_segmenter.video import VideoSegmenter, SegmentExporter
105
+
106
+ segmenter = VideoSegmenter(
107
+ roi_mode="full",
108
+ smooth_window=11,
109
+ min_len_sec=0.30,
110
+ merge_gap_sec=0.45,
111
+ pad_before_frames=10,
112
+ pad_after_frames=12,
113
+ )
114
+
115
+ segments, info = segmenter.segment(VIDEO_PATH)
116
+ print(f"Found {len(segments)} segments: {segments}")
117
+
118
+ exporter = SegmentExporter(out_dir="../output/segments_out")
119
+ exporter.export(VIDEO_PATH, segments)
120
+ ```
121
+
122
+ ## Visualization
123
+
124
+ ```python
125
+ from signlang_segmenter.video import plot_motion_segments
126
+
127
+ plot_motion_segments(segments, info)
128
+ ```
129
+
130
+ The plot includes:
131
+
132
+ - motion raw curve
133
+ - motion smooth curve
134
+ - adaptive high/low thresholds
135
+ - shaded spans for detected segments
136
+
137
+ The `info` dict returned by `VideoSegmenter.segment` must include:
138
+
139
+ - `motion_raw`
140
+ - `motion_smooth`
141
+ - `fps`
142
+ - `frame_idx`
143
+ - `th_high_arr`
144
+ - `th_low_arr`
@@ -0,0 +1,117 @@
1
+ # signlang-segmenter
2
+
3
+ A Python library for sign language segmentation.
4
+
5
+ ## Optical Flow
6
+ ![motion curve with segments](./public/image.png)
7
+
8
+ ## Important Naming Note
9
+
10
+ - Distribution/package name: `signlang-segmenter`
11
+ - Python import name: `signlang_segmenter`
12
+
13
+ Python imports use underscores, not dashes.
14
+
15
+ ## Current Layout
16
+
17
+ ```text
18
+ signlang-segmenter/
19
+ ├── signlang_segmenter/
20
+ │ ├── __init__.py
21
+ │ ├── video/
22
+ │ │ ├── __init__.py
23
+ │ │ └── optical_flow/
24
+ │ │ ├── __init__.py
25
+ │ │ ├── segmenter.py
26
+ │ │ ├── motion_analyzer.py
27
+ │ │ ├── models.py
28
+ │ │ ├── utils.py
29
+ │ │ ├── exporter.py
30
+ │ │ └── visualization.py
31
+ │ └── pose/
32
+ │ └── __init__.py
33
+ ├── examples/
34
+ │ └── basic_pipeline.ipynb
35
+ ├── setup.py
36
+ └── README.md
37
+ ```
38
+
39
+ ## Installation
40
+
41
+ ### Option 1: Install the library to use it
42
+
43
+ If you only want to use the package in your own project, install it directly from GitHub:
44
+
45
+ ```bash
46
+ python -m pip install "git+https://github.com/24-mohamedyehia/signlang-segmenter.git"
47
+ ```
48
+
49
+ ### Option 2: Install the project for development
50
+
51
+ If you want to modify the code and contribute, use an isolated environment and editable install:
52
+
53
+ 1. Install Miniconda if needed.
54
+ 2. Run:
55
+
56
+ ```bash
57
+ git clone https://github.com/24-mohamedyehia/signlang-segmenter.git
58
+ cd signlang-segmenter
59
+ conda create -n signlang-segmenter python=3.11 -y
60
+ conda activate signlang-segmenter
61
+ python -m pip install --upgrade pip
62
+ python -m pip install -e .
63
+
64
+ ```
65
+
66
+ ## Quick Import Check
67
+
68
+ ```python
69
+ import signlang_segmenter
70
+ import signlang_segmenter.video
71
+ import signlang_segmenter.pose
72
+ ```
73
+
74
+ ## Segmentation API (MVP)
75
+
76
+ ```python
77
+ from signlang_segmenter.video import VideoSegmenter, SegmentExporter
78
+
79
+ segmenter = VideoSegmenter(
80
+ roi_mode="full",
81
+ smooth_window=11,
82
+ min_len_sec=0.30,
83
+ merge_gap_sec=0.45,
84
+ pad_before_frames=10,
85
+ pad_after_frames=12,
86
+ )
87
+
88
+ segments, info = segmenter.segment(VIDEO_PATH)
89
+ print(f"Found {len(segments)} segments: {segments}")
90
+
91
+ exporter = SegmentExporter(out_dir="../output/segments_out")
92
+ exporter.export(VIDEO_PATH, segments)
93
+ ```
94
+
95
+ ## Visualization
96
+
97
+ ```python
98
+ from signlang_segmenter.video import plot_motion_segments
99
+
100
+ plot_motion_segments(segments, info)
101
+ ```
102
+
103
+ The plot includes:
104
+
105
+ - motion raw curve
106
+ - motion smooth curve
107
+ - adaptive high/low thresholds
108
+ - shaded spans for detected segments
109
+
110
+ The `info` dict returned by `VideoSegmenter.segment` must include:
111
+
112
+ - `motion_raw`
113
+ - `motion_smooth`
114
+ - `fps`
115
+ - `frame_idx`
116
+ - `th_high_arr`
117
+ - `th_low_arr`
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,25 @@
1
+ from setuptools import find_packages, setup
2
+
3
+ setup(
4
+ name="signlang-segmenter",
5
+ version="0.1.0",
6
+ description="A Python library for sign language video and pose segmentation.",
7
+ long_description=open("README.md", encoding="utf-8").read(),
8
+ long_description_content_type="text/markdown",
9
+ author="Mohamed Yehia",
10
+ url="https://github.com/24-mohamedyehia/signlang-segmenter",
11
+ packages=find_packages(),
12
+ python_requires=">=3.10",
13
+ install_requires=[
14
+ "opencv-python==4.11.0.86",
15
+ "numpy==1.26.4",
16
+ "matplotlib==3.7.3",
17
+ "notebook==6.5.4"
18
+ ],
19
+ classifiers=[
20
+ "Programming Language :: Python :: 3",
21
+ "Programming Language :: Python :: 3 :: Only",
22
+ "License :: OSI Approved :: MIT License",
23
+ "Operating System :: OS Independent",
24
+ ],
25
+ )
@@ -0,0 +1,4 @@
1
+ """Top-level package for signlang_segmenter."""
2
+
3
+ __all__ = ["video", "pose"]
4
+ __version__ = "0.1.0"
@@ -0,0 +1 @@
1
+ """Pose segmentation subpackage placeholder."""
@@ -0,0 +1,17 @@
1
+ """Video segmentation package public API."""
2
+
3
+ from .optical_flow import (
4
+ MotionAnalyzer,
5
+ Segment,
6
+ SegmentExporter,
7
+ VideoSegmenter,
8
+ plot_motion_segments,
9
+ )
10
+
11
+ __all__ = [
12
+ "Segment",
13
+ "MotionAnalyzer",
14
+ "VideoSegmenter",
15
+ "SegmentExporter",
16
+ "plot_motion_segments",
17
+ ]
@@ -0,0 +1,15 @@
1
+ """Optical-flow segmentation algorithm package."""
2
+
3
+ from .exporter import SegmentExporter
4
+ from .models import Segment
5
+ from .motion_analyzer import MotionAnalyzer
6
+ from .segmenter import VideoSegmenter
7
+ from .visualization import plot_motion_segments
8
+
9
+ __all__ = [
10
+ "Segment",
11
+ "MotionAnalyzer",
12
+ "VideoSegmenter",
13
+ "SegmentExporter",
14
+ "plot_motion_segments",
15
+ ]
@@ -0,0 +1,111 @@
1
+ """SegmentExporter writes detected segments as individual video clips."""
2
+
3
+ import os
4
+ import shutil
5
+ import subprocess
6
+
7
+ import cv2
8
+
9
+ from .models import Segment
10
+
11
+
12
+ class SegmentExporter:
13
+ """Export Segment objects as per-segment MP4 clip files."""
14
+
15
+ _H264_CODECS = ("avc1", "H264", "X264")
16
+ _WRITER_ATTEMPT_ORDER = ("mp4v", "avc1", "H264", "X264")
17
+
18
+ def __init__(self, out_dir: str = "segments_out") -> None:
19
+ self.out_dir = out_dir
20
+
21
+ def export(self, video_path: str, segments: list[Segment]) -> str:
22
+ """Cut and export segments from a source video to out_dir."""
23
+ os.makedirs(self.out_dir, exist_ok=True)
24
+
25
+ cap = cv2.VideoCapture(video_path)
26
+ if not cap.isOpened():
27
+ raise FileNotFoundError(f"Cannot open video: {video_path}")
28
+
29
+ fps = cap.get(cv2.CAP_PROP_FPS)
30
+ if fps <= 0:
31
+ fps = 25.0
32
+ w = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH))
33
+ h = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT))
34
+
35
+ for i, seg in enumerate(segments, start=1):
36
+ self._write_segment(cap, seg, i, fps, (w, h))
37
+
38
+ cap.release()
39
+ return self.out_dir
40
+
41
+ def _write_segment(
42
+ self,
43
+ cap: cv2.VideoCapture,
44
+ seg: Segment,
45
+ index: int,
46
+ fps: float,
47
+ size: tuple[int, int],
48
+ ) -> None:
49
+ final_path = os.path.join(
50
+ self.out_dir,
51
+ f"seg_{index:03d}_{seg.start_frame}_{seg.end_frame}.mp4",
52
+ )
53
+ raw_path = final_path.replace(".mp4", "_raw.mp4")
54
+
55
+ writer, codec_used = self._open_writer(raw_path, fps, size)
56
+
57
+ cap.set(cv2.CAP_PROP_POS_FRAMES, seg.start_frame)
58
+ for _ in range(seg.start_frame, seg.end_frame + 1):
59
+ ret, frame = cap.read()
60
+ if not ret:
61
+ break
62
+ writer.write(frame)
63
+ writer.release()
64
+
65
+ if codec_used.lower() in {c.lower() for c in self._H264_CODECS}:
66
+ os.replace(raw_path, final_path)
67
+ return
68
+
69
+ if self._transcode_to_h264(raw_path, final_path):
70
+ if os.path.exists(raw_path):
71
+ os.remove(raw_path)
72
+ else:
73
+ os.replace(raw_path, final_path)
74
+
75
+ def _open_writer(
76
+ self,
77
+ path: str,
78
+ fps: float,
79
+ size: tuple[int, int],
80
+ ) -> tuple[cv2.VideoWriter, str]:
81
+ for codec in self._WRITER_ATTEMPT_ORDER:
82
+ writer = cv2.VideoWriter(path, cv2.VideoWriter_fourcc(*codec), fps, size)
83
+ if writer.isOpened():
84
+ return writer, codec
85
+ writer.release()
86
+ raise RuntimeError("Could not initialize VideoWriter with available codecs.")
87
+
88
+ @staticmethod
89
+ def _transcode_to_h264(src_path: str, dst_path: str) -> bool:
90
+ ffmpeg = shutil.which("ffmpeg")
91
+ if ffmpeg is None:
92
+ return False
93
+ cmd = [
94
+ ffmpeg,
95
+ "-y",
96
+ "-loglevel",
97
+ "error",
98
+ "-i",
99
+ src_path,
100
+ "-vf",
101
+ "scale=trunc(iw/2)*2:trunc(ih/2)*2",
102
+ "-c:v",
103
+ "libx264",
104
+ "-pix_fmt",
105
+ "yuv420p",
106
+ "-movflags",
107
+ "+faststart",
108
+ "-an",
109
+ dst_path,
110
+ ]
111
+ return subprocess.run(cmd).returncode == 0
@@ -0,0 +1,24 @@
1
+ from dataclasses import dataclass
2
+
3
+
4
+ @dataclass
5
+ class Segment:
6
+ """Represents a single motion segment identified in a video."""
7
+
8
+ start_frame: int
9
+ end_frame: int
10
+ video_path: str = ""
11
+
12
+ @property
13
+ def length_frames(self) -> int:
14
+ return self.end_frame - self.start_frame + 1
15
+
16
+ def to_seconds(self, fps: float) -> tuple[float, float]:
17
+ """Return (start_sec, end_sec) for this segment."""
18
+ return self.start_frame / fps, self.end_frame / fps
19
+
20
+ def __repr__(self) -> str:
21
+ return (
22
+ f"Segment(start={self.start_frame}, end={self.end_frame}, "
23
+ f"len={self.length_frames})"
24
+ )
@@ -0,0 +1,87 @@
1
+ """MotionAnalyzer computes a per-frame motion-energy curve via optical flow."""
2
+
3
+ import cv2
4
+ import numpy as np
5
+
6
+
7
+ class MotionAnalyzer:
8
+ """Extract a 1-D motion-energy signal from a video using Farneback flow."""
9
+
10
+ def __init__(
11
+ self,
12
+ roi_mode: str = "upper",
13
+ resize_width: int = 320,
14
+ step: int = 1,
15
+ ) -> None:
16
+ if roi_mode not in ("full", "upper"):
17
+ raise ValueError("roi_mode must be 'full' or 'upper'")
18
+ self.roi_mode = roi_mode
19
+ self.resize_width = resize_width
20
+ self.step = max(1, int(step))
21
+
22
+ def compute(self, video_path: str) -> tuple[np.ndarray, np.ndarray, float, int]:
23
+ """Compute motion-energy for a video path."""
24
+ cap = cv2.VideoCapture(video_path)
25
+ if not cap.isOpened():
26
+ raise FileNotFoundError(f"Cannot open video: {video_path}")
27
+
28
+ fps = cap.get(cv2.CAP_PROP_FPS)
29
+ if fps <= 0:
30
+ fps = 25.0
31
+ total_frames = int(cap.get(cv2.CAP_PROP_FRAME_COUNT))
32
+
33
+ ret, frame = cap.read()
34
+ if not ret:
35
+ cap.release()
36
+ raise RuntimeError("Failed to read the first frame.")
37
+
38
+ h0, w0 = frame.shape[:2]
39
+ scale = 1.0
40
+ if self.resize_width is not None and w0 > self.resize_width:
41
+ scale = self.resize_width / w0
42
+ frame = cv2.resize(frame, (int(w0 * scale), int(h0 * scale)))
43
+ h, w = frame.shape[:2]
44
+
45
+ prev_gray = cv2.cvtColor(self._crop_roi(frame, h), cv2.COLOR_BGR2GRAY)
46
+ motion: list[float] = []
47
+ frame_idx: list[int] = [0]
48
+ idx = 0
49
+
50
+ while True:
51
+ for _ in range(self.step):
52
+ ret, frame = cap.read()
53
+ idx += 1
54
+ if not ret:
55
+ cap.release()
56
+ return (
57
+ np.array(motion, dtype=np.float32),
58
+ np.array(frame_idx, dtype=int),
59
+ fps,
60
+ total_frames,
61
+ )
62
+
63
+ if scale != 1.0:
64
+ frame = cv2.resize(frame, (w, h))
65
+
66
+ gray = cv2.cvtColor(self._crop_roi(frame, h), cv2.COLOR_BGR2GRAY)
67
+ flow = cv2.calcOpticalFlowFarneback(
68
+ prev_gray,
69
+ gray,
70
+ None,
71
+ pyr_scale=0.5,
72
+ levels=3,
73
+ winsize=15,
74
+ iterations=3,
75
+ poly_n=5,
76
+ poly_sigma=1.2,
77
+ flags=0,
78
+ )
79
+ mag, _ = cv2.cartToPolar(flow[..., 0], flow[..., 1])
80
+ motion.append(float(np.mean(mag)))
81
+ frame_idx.append(idx)
82
+ prev_gray = gray
83
+
84
+ def _crop_roi(self, frame: np.ndarray, h: int) -> np.ndarray:
85
+ if self.roi_mode == "full":
86
+ return frame
87
+ return frame[: int(h * 0.7), :]
@@ -0,0 +1,122 @@
1
+ """VideoSegmenter detects and returns motion-based segments in a video."""
2
+
3
+ import numpy as np
4
+
5
+ from .models import Segment
6
+ from .motion_analyzer import MotionAnalyzer
7
+ from .utils import (
8
+ fill_short_gaps,
9
+ hysteresis_binarize_var,
10
+ mask_to_segments,
11
+ moving_average,
12
+ postprocess_segments,
13
+ )
14
+
15
+
16
+ class VideoSegmenter:
17
+ """High-level optical-flow segmentation pipeline."""
18
+
19
+ def __init__(
20
+ self,
21
+ roi_mode: str = "upper",
22
+ resize_width: int = 320,
23
+ step: int = 1,
24
+ smooth_window: int = 7,
25
+ min_len_sec: float = 0.25,
26
+ merge_gap_sec: float = 0.15,
27
+ pad_before_frames: int = 0,
28
+ pad_after_frames: int = 0,
29
+ ) -> None:
30
+ self.smooth_window = smooth_window
31
+ self.min_len_sec = min_len_sec
32
+ self.merge_gap_sec = merge_gap_sec
33
+ self.pad_before_frames = max(0, int(pad_before_frames))
34
+ self.pad_after_frames = max(0, int(pad_after_frames))
35
+ self._analyzer = MotionAnalyzer(
36
+ roi_mode=roi_mode,
37
+ resize_width=resize_width,
38
+ step=step,
39
+ )
40
+
41
+ def segment(self, video_path: str) -> tuple[list[Segment], dict]:
42
+ """Run segmentation on video_path and return segments with diagnostics."""
43
+ motion, frame_idx, fps, total_frames = self._analyzer.compute(video_path)
44
+ motion = motion.astype(np.float32)
45
+ motion_s = moving_average(motion, self.smooth_window)
46
+
47
+ th_high_arr, th_low_arr = self._adaptive_thresholds(motion_s, fps)
48
+ active = hysteresis_binarize_var(motion_s, th_high_arr, th_low_arr)
49
+
50
+ kernel = np.ones(max(1, int(fps * 0.25)))
51
+ active = np.convolve(active.astype(float), kernel, mode="same") > 0
52
+
53
+ active = fill_short_gaps(active, max_gap_frames=max(1, int(fps * 0.6)))
54
+ segs = mask_to_segments(active)
55
+
56
+ real_segments = self._remap_to_frames(segs, frame_idx)
57
+
58
+ min_len_frames = max(1, int(self.min_len_sec * fps))
59
+ merge_gap_frames = max(0, int(self.merge_gap_sec * fps))
60
+ real_segments = postprocess_segments(
61
+ real_segments,
62
+ min_len_frames=min_len_frames,
63
+ merge_gap_frames=merge_gap_frames,
64
+ )
65
+ real_segments = self._pad_segments(real_segments, total_frames)
66
+
67
+ for seg in real_segments:
68
+ seg.video_path = video_path
69
+
70
+ info = {
71
+ "fps": fps,
72
+ "total_frames": total_frames,
73
+ "th_high_arr": th_high_arr,
74
+ "th_low_arr": th_low_arr,
75
+ "motion_raw": motion,
76
+ "motion_smooth": motion_s,
77
+ "frame_idx": frame_idx,
78
+ "active_mask": active,
79
+ }
80
+ return real_segments, info
81
+
82
+ @staticmethod
83
+ def _adaptive_thresholds(
84
+ motion_s: np.ndarray,
85
+ fps: float,
86
+ ) -> tuple[np.ndarray, np.ndarray]:
87
+ """Compute local adaptive high and low thresholds."""
88
+ win = max(5, int(fps * 1.0))
89
+ local_mean = moving_average(motion_s, win)
90
+ local_var = moving_average((motion_s - local_mean) ** 2, win)
91
+ local_std = np.sqrt(np.maximum(local_var, 1e-8))
92
+ th_high = local_mean + 1.15 * local_std
93
+ th_low = local_mean + 0.65 * local_std
94
+ return th_high, th_low
95
+
96
+ @staticmethod
97
+ def _remap_to_frames(segs: list[Segment], frame_idx: np.ndarray) -> list[Segment]:
98
+ """Map motion-array segment indices back to original frame indices."""
99
+ real_segments: list[Segment] = []
100
+ for seg in segs:
101
+ start = int(frame_idx[seg.start_frame + 1])
102
+ end = int(frame_idx[seg.end_frame + 1])
103
+ real_segments.append(Segment(start, end))
104
+ return real_segments
105
+
106
+ def _pad_segments(self, segments: list[Segment], total_frames: int) -> list[Segment]:
107
+ """Expand segment boundaries by configured context then merge overlaps."""
108
+ if not segments:
109
+ return []
110
+
111
+ if self.pad_before_frames == 0 and self.pad_after_frames == 0:
112
+ return segments
113
+
114
+ max_end = max(0, int(total_frames) - 1)
115
+ expanded = [
116
+ Segment(
117
+ max(0, seg.start_frame - self.pad_before_frames),
118
+ min(max_end, seg.end_frame + self.pad_after_frames),
119
+ )
120
+ for seg in segments
121
+ ]
122
+ return postprocess_segments(expanded, min_len_frames=1, merge_gap_frames=0)
@@ -0,0 +1,99 @@
1
+ """Signal-processing utilities used by the optical-flow segmentation pipeline."""
2
+
3
+ import numpy as np
4
+
5
+ from .models import Segment
6
+
7
+
8
+ def moving_average(x: np.ndarray, window: int) -> np.ndarray:
9
+ """Smooth a 1-D array with a uniform moving average."""
10
+ if window <= 1:
11
+ return x.astype(np.float32)
12
+ kernel = np.ones(int(window), dtype=np.float32) / int(window)
13
+ return np.convolve(x, kernel, mode="same")
14
+
15
+
16
+ def hysteresis_binarize_var(
17
+ signal: np.ndarray,
18
+ th_high_arr: np.ndarray,
19
+ th_low_arr: np.ndarray,
20
+ ) -> np.ndarray:
21
+ """Hysteresis thresholding with per-sample adaptive thresholds."""
22
+ active = np.zeros_like(signal, dtype=bool)
23
+ on = False
24
+ for i, value in enumerate(signal):
25
+ if not on and value >= th_high_arr[i]:
26
+ on = True
27
+ elif on and value <= th_low_arr[i]:
28
+ on = False
29
+ active[i] = on
30
+ return active
31
+
32
+
33
+ def fill_short_gaps(active: np.ndarray, max_gap_frames: int) -> np.ndarray:
34
+ """Fill short OFF gaps between two ON regions in a boolean mask."""
35
+ mask = active.copy().astype(bool)
36
+ n = len(mask)
37
+ i = 0
38
+ while i < n:
39
+ if mask[i]:
40
+ i += 1
41
+ continue
42
+
43
+ j = i
44
+ while j < n and not mask[j]:
45
+ j += 1
46
+
47
+ gap_len = j - i
48
+ left_true = i - 1 >= 0 and mask[i - 1]
49
+ right_true = j < n and mask[j]
50
+ if left_true and right_true and gap_len <= max_gap_frames:
51
+ mask[i:j] = True
52
+ i = j
53
+
54
+ return mask
55
+
56
+
57
+ def mask_to_segments(active: np.ndarray) -> list[Segment]:
58
+ """Convert a boolean activity mask to a list of Segment objects."""
59
+ segments: list[Segment] = []
60
+ in_segment = False
61
+ start = 0
62
+
63
+ for i, is_active in enumerate(active):
64
+ if is_active and not in_segment:
65
+ in_segment = True
66
+ start = i
67
+ elif (not is_active) and in_segment:
68
+ in_segment = False
69
+ segments.append(Segment(start, i - 1))
70
+
71
+ if in_segment:
72
+ segments.append(Segment(start, len(active) - 1))
73
+
74
+ return segments
75
+
76
+
77
+ def postprocess_segments(
78
+ segments: list[Segment],
79
+ min_len_frames: int,
80
+ merge_gap_frames: int,
81
+ ) -> list[Segment]:
82
+ """Drop short segments and merge close neighboring segments."""
83
+ if not segments:
84
+ return []
85
+
86
+ filtered = [s for s in segments if s.length_frames >= min_len_frames]
87
+ if not filtered:
88
+ return []
89
+
90
+ merged = [filtered[0]]
91
+ for seg in filtered[1:]:
92
+ prev = merged[-1]
93
+ gap = seg.start_frame - prev.end_frame - 1
94
+ if gap <= merge_gap_frames:
95
+ merged[-1] = Segment(prev.start_frame, max(prev.end_frame, seg.end_frame))
96
+ else:
97
+ merged.append(seg)
98
+
99
+ return merged
@@ -0,0 +1,67 @@
1
+ """Visualization helpers for motion-based segmentation outputs."""
2
+
3
+ from collections.abc import Iterable
4
+
5
+ import matplotlib.pyplot as plt
6
+ import numpy as np
7
+
8
+ from .models import Segment
9
+
10
+
11
+ def plot_motion_segments(
12
+ segments: Iterable[Segment],
13
+ info: dict,
14
+ *,
15
+ figsize: tuple[float, float] = (14.0, 4.0),
16
+ segment_alpha: float = 0.2,
17
+ title: str = "Motion-based segmentation (shaded = detected segments)",
18
+ show: bool = True,
19
+ ):
20
+ """Plot motion curves, thresholds, and shaded detected segments.
21
+
22
+ Expected keys in info: motion_raw, motion_smooth, fps, frame_idx, th_high_arr, th_low_arr.
23
+ """
24
+ required = {
25
+ "motion_raw",
26
+ "motion_smooth",
27
+ "fps",
28
+ "frame_idx",
29
+ "th_high_arr",
30
+ "th_low_arr",
31
+ }
32
+ missing = [k for k in required if k not in info]
33
+ if missing:
34
+ raise KeyError(f"Missing required info keys: {missing}")
35
+
36
+ motion = np.asarray(info["motion_raw"])
37
+ motion_s = np.asarray(info["motion_smooth"])
38
+ th_high_arr = np.asarray(info["th_high_arr"])
39
+ th_low_arr = np.asarray(info["th_low_arr"])
40
+ fps = float(info["fps"])
41
+ frame_idx = np.asarray(info["frame_idx"])
42
+
43
+ if fps <= 0:
44
+ raise ValueError("fps must be > 0")
45
+ if frame_idx.size < 2:
46
+ raise ValueError("frame_idx must contain at least two indices")
47
+
48
+ t = frame_idx[1:] / fps
49
+
50
+ fig, ax = plt.subplots(figsize=figsize)
51
+ ax.plot(t, motion, label="motion raw")
52
+ ax.plot(t, motion_s, label="motion smooth")
53
+ ax.plot(t, th_high_arr, linestyle="--", label="th_high")
54
+ ax.plot(t, th_low_arr, linestyle="--", label="th_low")
55
+
56
+ for seg in segments:
57
+ ax.axvspan(seg.start_frame / fps, seg.end_frame / fps, alpha=segment_alpha)
58
+
59
+ ax.set_xlabel("Time (sec)")
60
+ ax.set_ylabel("Motion energy")
61
+ ax.legend()
62
+ ax.set_title(title)
63
+
64
+ if show:
65
+ plt.show()
66
+
67
+ return fig, ax
@@ -0,0 +1,144 @@
1
+ Metadata-Version: 2.4
2
+ Name: signlang-segmenter
3
+ Version: 0.1.0
4
+ Summary: A Python library for sign language video and pose segmentation.
5
+ Home-page: https://github.com/24-mohamedyehia/signlang-segmenter
6
+ Author: Mohamed Yehia
7
+ Classifier: Programming Language :: Python :: 3
8
+ Classifier: Programming Language :: Python :: 3 :: Only
9
+ Classifier: License :: OSI Approved :: MIT License
10
+ Classifier: Operating System :: OS Independent
11
+ Requires-Python: >=3.10
12
+ Description-Content-Type: text/markdown
13
+ License-File: LICENSE
14
+ Requires-Dist: opencv-python==4.11.0.86
15
+ Requires-Dist: numpy==1.26.4
16
+ Requires-Dist: matplotlib==3.7.3
17
+ Requires-Dist: notebook==6.5.4
18
+ Dynamic: author
19
+ Dynamic: classifier
20
+ Dynamic: description
21
+ Dynamic: description-content-type
22
+ Dynamic: home-page
23
+ Dynamic: license-file
24
+ Dynamic: requires-dist
25
+ Dynamic: requires-python
26
+ Dynamic: summary
27
+
28
+ # signlang-segmenter
29
+
30
+ A Python library for sign language segmentation.
31
+
32
+ ## Optical Flow
33
+ ![motion curve with segments](./public/image.png)
34
+
35
+ ## Important Naming Note
36
+
37
+ - Distribution/package name: `signlang-segmenter`
38
+ - Python import name: `signlang_segmenter`
39
+
40
+ Python imports use underscores, not dashes.
41
+
42
+ ## Current Layout
43
+
44
+ ```text
45
+ signlang-segmenter/
46
+ ├── signlang_segmenter/
47
+ │ ├── __init__.py
48
+ │ ├── video/
49
+ │ │ ├── __init__.py
50
+ │ │ └── optical_flow/
51
+ │ │ ├── __init__.py
52
+ │ │ ├── segmenter.py
53
+ │ │ ├── motion_analyzer.py
54
+ │ │ ├── models.py
55
+ │ │ ├── utils.py
56
+ │ │ ├── exporter.py
57
+ │ │ └── visualization.py
58
+ │ └── pose/
59
+ │ └── __init__.py
60
+ ├── examples/
61
+ │ └── basic_pipeline.ipynb
62
+ ├── setup.py
63
+ └── README.md
64
+ ```
65
+
66
+ ## Installation
67
+
68
+ ### Option 1: Install the library to use it
69
+
70
+ If you only want to use the package in your own project, install it directly from GitHub:
71
+
72
+ ```bash
73
+ python -m pip install "git+https://github.com/24-mohamedyehia/signlang-segmenter.git"
74
+ ```
75
+
76
+ ### Option 2: Install the project for development
77
+
78
+ If you want to modify the code and contribute, use an isolated environment and editable install:
79
+
80
+ 1. Install Miniconda if needed.
81
+ 2. Run:
82
+
83
+ ```bash
84
+ git clone https://github.com/24-mohamedyehia/signlang-segmenter.git
85
+ cd signlang-segmenter
86
+ conda create -n signlang-segmenter python=3.11 -y
87
+ conda activate signlang-segmenter
88
+ python -m pip install --upgrade pip
89
+ python -m pip install -e .
90
+
91
+ ```
92
+
93
+ ## Quick Import Check
94
+
95
+ ```python
96
+ import signlang_segmenter
97
+ import signlang_segmenter.video
98
+ import signlang_segmenter.pose
99
+ ```
100
+
101
+ ## Segmentation API (MVP)
102
+
103
+ ```python
104
+ from signlang_segmenter.video import VideoSegmenter, SegmentExporter
105
+
106
+ segmenter = VideoSegmenter(
107
+ roi_mode="full",
108
+ smooth_window=11,
109
+ min_len_sec=0.30,
110
+ merge_gap_sec=0.45,
111
+ pad_before_frames=10,
112
+ pad_after_frames=12,
113
+ )
114
+
115
+ segments, info = segmenter.segment(VIDEO_PATH)
116
+ print(f"Found {len(segments)} segments: {segments}")
117
+
118
+ exporter = SegmentExporter(out_dir="../output/segments_out")
119
+ exporter.export(VIDEO_PATH, segments)
120
+ ```
121
+
122
+ ## Visualization
123
+
124
+ ```python
125
+ from signlang_segmenter.video import plot_motion_segments
126
+
127
+ plot_motion_segments(segments, info)
128
+ ```
129
+
130
+ The plot includes:
131
+
132
+ - motion raw curve
133
+ - motion smooth curve
134
+ - adaptive high/low thresholds
135
+ - shaded spans for detected segments
136
+
137
+ The `info` dict returned by `VideoSegmenter.segment` must include:
138
+
139
+ - `motion_raw`
140
+ - `motion_smooth`
141
+ - `fps`
142
+ - `frame_idx`
143
+ - `th_high_arr`
144
+ - `th_low_arr`
@@ -0,0 +1,18 @@
1
+ LICENSE
2
+ README.md
3
+ setup.py
4
+ signlang_segmenter/__init__.py
5
+ signlang_segmenter.egg-info/PKG-INFO
6
+ signlang_segmenter.egg-info/SOURCES.txt
7
+ signlang_segmenter.egg-info/dependency_links.txt
8
+ signlang_segmenter.egg-info/requires.txt
9
+ signlang_segmenter.egg-info/top_level.txt
10
+ signlang_segmenter/pose/__init__.py
11
+ signlang_segmenter/video/__init__.py
12
+ signlang_segmenter/video/optical_flow/__init__.py
13
+ signlang_segmenter/video/optical_flow/exporter.py
14
+ signlang_segmenter/video/optical_flow/models.py
15
+ signlang_segmenter/video/optical_flow/motion_analyzer.py
16
+ signlang_segmenter/video/optical_flow/segmenter.py
17
+ signlang_segmenter/video/optical_flow/utils.py
18
+ signlang_segmenter/video/optical_flow/visualization.py
@@ -0,0 +1,4 @@
1
+ opencv-python==4.11.0.86
2
+ numpy==1.26.4
3
+ matplotlib==3.7.3
4
+ notebook==6.5.4
@@ -0,0 +1 @@
1
+ signlang_segmenter