Skip to content

API Reference

This page provides the technical documentation for all ErgoMoCap modules, automatically extracted from the source code docstrings.

Check the Source Code on GitHub: Official Repo | Structural details are accessible at the GUI Architecture Documentation.


Core Calculators

These modules contain the mathematical logic for the ergonomic assessment standards.

REBA (Rapid Entire Body Assessment)

The REBA engine is divided into specific body part modules to calculate localized scores.

Main Calculator Engine

calculators.reba_calculator.REBA_calculator

ErgoMoCap: Biomechanical Scoring Engine (REBA)

Master orchestrator for the Rapid Entire Body Assessment (REBA) pipeline.

This module implements the full REBA calculation logic, transforming 3D skeletal angles into ergonomic risk scores. It organizes the assessment into: - Group A: Trunk, Neck, and Legs. - Group B: Upper Arm, Lower Arm, and Wrist.

The engine utilizes optimized lookup tables and support for Numba-accelerated numerical operations to ensure high-performance processing of MoCap data.

Classes

Functions

calculate_frame_reba_from_degs(degs)

Modular REBA Scoring Entry Point (Vectorized Input).

This function processes pre-calculated biomechanical angles directly, bypassing the kinematic transformation stage. It maps a flat 1D array of degrees to specific body districts, calculates individual penalty scores, and synthesizes the final REBA Risk Index using Numba-accelerated lookup tables (A, B, and C).

Parameters:

Name Type Description Default
degs NDArray[float64]

A 1D array containing exactly 22 kinematic values in the following sequence: - [0:2] Legs: [RIGHT_KNEE_EXTENSION_FLEXION, LEFT_KNEE_EXTENSION_FLEXION] - [2:5] Trunk: [SPINE_EXTENSION_FLEXION, SPINE_LATERAL_FLEXION, SPINE_ROTATION_TORSION] - [5:8] Neck: [NECK_EXTENSION_FLEXION, NECK_LATERAL_FLEXION, NECK_ROTATION] - [8:14] Upper Arm: [RIGHT_SHOULDER_EXTENSION_FLEXION, LEFT_SHOULDER_EXTENSION_FLEXION, RIGHT_SHOULDER_ABDUCTION_ADDUCTION, LEFT_SHOULDER_ABDUCTION_ADDUCTION, RIGHT_SHOULDER_RISE, LEFT_SHOULDER_RISE] - [14:16] Lower Arm: [RIGHT_ELBOW_EXTENSION_FLEXION, LEFT_ELBOW_EXTENSION_FLEXION] - [16:22] Wrist: [RIGHT_HAND_EXTENSION_FLEXION, LEFT_HAND_EXTENSION_FLEXION, RIGHT_HAND_LATERAL_SIDE, LEFT_HAND_LATERAL_SIDE, RIGHT_HAND_TWIST, LEFT_HAND_TWIST]

required

Returns:

Type Description
tuple[dict[str, int], dict[str, Any]]

tuple[dict[str, int], dict[str, Any]]: - final_scores: dictionary containing integer penalty scores for each district plus the "Final_REBA_Score". - degrees_map: Empty dictionary (reserved for API consistency).

Note

This method is preferred for processing high-frequency offline data where joint angles have already been solved (e.g., FreeMoCap post-processing).

Source code in calculators\reba_calculator\REBA_calculator.py
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
def calculate_frame_reba_from_degs(
    degs: NDArray[np.float64],
) -> tuple[dict[str, int], dict[str, Any]]:
    """
    Modular REBA Scoring Entry Point (Vectorized Input).

    This function processes pre-calculated biomechanical angles directly, bypassing
    the kinematic transformation stage. It maps a flat 1D array of degrees to
    specific body districts, calculates individual penalty scores, and synthesizes
    the final REBA Risk Index using Numba-accelerated lookup tables (A, B, and C).

    Args:
        degs (NDArray[np.float64]): A 1D array containing exactly 22 kinematic
            values in the following sequence:
            - [0:2]   Legs: [RIGHT_KNEE_EXTENSION_FLEXION, LEFT_KNEE_EXTENSION_FLEXION]
            - [2:5]   Trunk: [SPINE_EXTENSION_FLEXION, SPINE_LATERAL_FLEXION, SPINE_ROTATION_TORSION]
            - [5:8]   Neck: [NECK_EXTENSION_FLEXION, NECK_LATERAL_FLEXION, NECK_ROTATION]
            - [8:14]  Upper Arm: [RIGHT_SHOULDER_EXTENSION_FLEXION, LEFT_SHOULDER_EXTENSION_FLEXION,
                                  RIGHT_SHOULDER_ABDUCTION_ADDUCTION, LEFT_SHOULDER_ABDUCTION_ADDUCTION,
                                  RIGHT_SHOULDER_RISE, LEFT_SHOULDER_RISE]
            - [14:16] Lower Arm: [RIGHT_ELBOW_EXTENSION_FLEXION, LEFT_ELBOW_EXTENSION_FLEXION]
            - [16:22] Wrist: [RIGHT_HAND_EXTENSION_FLEXION, LEFT_HAND_EXTENSION_FLEXION,
                              RIGHT_HAND_LATERAL_SIDE, LEFT_HAND_LATERAL_SIDE,
                              RIGHT_HAND_TWIST, LEFT_HAND_TWIST]

    Returns:
        tuple[dict[str, int], dict[str, Any]]:
            - final_scores: dictionary containing integer penalty scores for
              each district plus the "Final_REBA_Score".
            - degrees_map: Empty dictionary (reserved for API consistency).

    Note:
        This method is preferred for processing high-frequency offline data
        where joint angles have already been solved (e.g., FreeMoCap post-processing).
    """
    if len(degs) != 22:
        raise IndexError(f"Expected exactly 22 degree values, got {len(degs)}")

    # 2. Calculate District Scores using Verbose Constant Slices
    # Slicing logic: [START : END + 1] to ensure the last index is included
    # IMPORTANT! Single body parts return an array so you must pop the first value [0] to get the score

    legs_score = leg_reba_score(
        degs[DI.RIGHT_KNEE_EXTENSION_FLEXION : DI.LEFT_KNEE_EXTENSION_FLEXION + 1]
    )[0]

    trunk_score = trunk_reba_score(
        degs[DI.SPINE_EXTENSION_FLEXION : DI.SPINE_ROTATION_TORSION + 1]
    )[0]

    neck_score = neck_reba_score(
        degs[DI.NECK_EXTENSION_FLEXION : DI.NECK_ROTATION + 1]
    )[0]

    upper_arm_score = upper_arm_reba_score(
        degs[DI.RIGHT_SHOULDER_EXTENSION_FLEXION : DI.LEFT_SHOULDER_RISE + 1]
    )[0]

    lower_arm_score = lower_arm_reba_score(
        degs[DI.RIGHT_ELBOW_EXTENSION_FLEXION : DI.LEFT_ELBOW_EXTENSION_FLEXION + 1]
    )[0]

    wrist_score = wrist_reba_score(
        degs[DI.RIGHT_HAND_EXTENSION_FLEXION : DI.LEFT_HAND_TWIST + 1]
    )[0]

    # Calculate Score A, B, and Final
    score_a = get_score_a(trunk_score, neck_score, legs_score)

    # TODO adjust for load score
    # If load < 11 lbs. : +0
    # If load 11 to 22 lbs. : +1
    # If load > 22 lbs.: +2
    # Adjust: If shock or rapid build up of force: add +1
    load_score = (
        0  # Placeholder for load score (to be integrated with actual load data)
    )
    adjusted_score_a = score_a + load_score

    score_b = get_score_b(
        upper_arm_score,
        lower_arm_score,
        wrist_score,
    )

    # TODO adjust for coupling/activity score
    # Coupling/Activity Score Adjustments (to be added to Score B):
    # Well fitting Handle and mid rang power grip, good: +0
    # Acceptable but not ideal hand hold or coupling
    # acceptable with another body part, fair: +1
    # Hand hold not acceptable but possible, poor: +2
    # No handles, awkward, unsafe with any body part,
    # Unacceptable: +3
    coupling_score = 0  # Placeholder for coupling/activity score (to be integrated with actual task data)
    adjusted_score_b = score_b + coupling_score

    final_reba_val = get_final_reba(adjusted_score_a, adjusted_score_b)

    final_scores = {  # TODO this dict is also hardcoded, should be a class if numba is unused
        "Legs_Score_REBA": int(legs_score),
        "Trunk_Score_REBA": int(trunk_score),
        "Neck_Score_REBA": int(neck_score),
        "Upper_Arm_Score_REBA": int(upper_arm_score),
        "Lower_Arm_Score_REBA": int(lower_arm_score),
        "Wrist_Score_REBA": int(wrist_score),
        "Final_Score_REBA": int(final_reba_val),
        "Score_A_REBA": int(adjusted_score_a),
        "Score_B_REBA": int(adjusted_score_b),
        "Score_C_REBA": int(final_reba_val),
    }

    return final_scores, {}

get_final_reba(score_a, score_b, load=0, activity=0)

Calculates the final REBA score by combining Score A and Score B via Table C.

Parameters:

Name Type Description Default
score_a int

The total score from Group A (including load/force).

required
score_b int

The total score from Group B (including coupling).

required
load int

Penalty score for load/force (default: 0).

0
activity int

Penalty score for activity/postural instability (default: 0).

0

Returns:

Name Type Description
int int

The final REBA Risk Index.

Source code in calculators\reba_calculator\REBA_calculator.py
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
def get_final_reba(score_a: int, score_b: int, load: int = 0, activity: int = 0) -> int:
    """
    Calculates the final REBA score by combining Score A and Score B via Table C.

    Args:
        score_a (int): The total score from Group A (including load/force).
        score_b (int): The total score from Group B (including coupling).
        load (int): Penalty score for load/force (default: 0).
        activity (int): Penalty score for activity/postural instability (default: 0).

    Returns:
        int (int): The final REBA Risk Index.
    """
    # Add external load to Score A
    a_total = max(1, min(score_a + load, 12)) - 1
    # Add coupling/activity to Score B
    b_total = max(1, min(score_b, 12)) - 1

    score_c = _TABLE_C_DATA[a_total, b_total]

    # Final Result = Score C + Activity Score
    return score_c + activity

get_score_a(trunk, neck, legs)

Performs Table A lookup for Group A (Trunk, Neck, Legs).

Parameters:

Name Type Description Default
trunk int

The calculated score for the trunk district.

required
neck int

The calculated score for the neck district.

required
legs int

The calculated score for the legs district.

required

Returns:

Name Type Description
int int

The composite Score A from the REBA matrix.

Source code in calculators\reba_calculator\REBA_calculator.py
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
def get_score_a(trunk: int, neck: int, legs: int) -> int:
    """
    Performs Table A lookup for Group A (Trunk, Neck, Legs).

    Args:
        trunk (int): The calculated score for the trunk district.
        neck (int): The calculated score for the neck district.
        legs (int): The calculated score for the legs district.

    Returns:
        int (int): The composite Score A from the REBA matrix.
    """
    # Use floor/int conversion because MoCap data often comes in as floats
    tr = int(max(1, min(trunk, 5))) - 1
    ne = int(max(1, min(neck, 3))) - 1
    le = int(max(1, min(legs, 4))) - 1
    return _TABLE_A_DATA[tr, ne, le]

get_score_b(upper_arm, lower_arm, wrist)

Performs Table B lookup for Group B (Arms, Wrists).

Parameters:

Name Type Description Default
upper_arm int

The calculated score for the upper arm.

required
lower_arm int

The calculated score for the lower arm.

required
wrist int

The calculated score for the wrist.

required

Returns:

Name Type Description
int int

The composite Score B from the REBA matrix.

Source code in calculators\reba_calculator\REBA_calculator.py
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
def get_score_b(upper_arm: int, lower_arm: int, wrist: int) -> int:
    """
    Performs Table B lookup for Group B (Arms, Wrists).

    Args:
        upper_arm (int): The calculated score for the upper arm.
        lower_arm (int): The calculated score for the lower arm.
        wrist (int): The calculated score for the wrist.

    Returns:
        int (int): The composite Score B from the REBA matrix.
    """
    ua = int(max(1, min(upper_arm, 6))) - 1
    la = int(max(1, min(lower_arm, 2))) - 1
    wr = int(max(1, min(wrist, 3))) - 1
    return _TABLE_B_DATA[ua, la, wr]

options: heading_level: 4

Body Part Sub-modules

calculators.reba_calculator.body_parts.leg_reba

ErgoMoCap: REBA Leg Calculator

Lower limb stability and postural assessment for REBA.

This module provides the logic to evaluate leg support and knee flexion as part of the REBA scoring system. It assesses whether the weight is distributed evenly between both legs and if the degree of knee flexion indicates an unstable or high-strain posture.

The scores generated here contribute to the Group A postural score within the overall REBA methodology.

Functions

leg_reba_score(leg_degrees)

Calculates the REBA (Rapid Entire Body Assessment) score for legs.

The function selects the leg with the highest absolute flexion/degree and applies a score based on specific threshold ranges (30° and 60°).

Parameters:

Name Type Description Default
leg_degrees NDArray[float64]

A 1D NumPy array containing [RIGHT_KNEE_EXTENSION_FLEXION, LEFT_KNEE_EXTENSION_FLEXION]. Expected unit: Degrees as float64.

required

Returns:

Type Description
NDArray[int16]

NDArray[np.int16]: A 1D NumPy array containing the final REBA leg score [score]. Values are typically 1 or 2.

Source code in calculators\reba_calculator\body_parts\leg_reba.py
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
def leg_reba_score(leg_degrees: NDArray[np.float64]) -> NDArray[np.int16]:
    """
    Calculates the REBA (Rapid Entire Body Assessment) score for legs.

    The function selects the leg with the highest absolute flexion/degree
    and applies a score based on specific threshold ranges (30° and 60°).

    Args:
        leg_degrees (NDArray[np.float64]): A 1D NumPy array containing
            [RIGHT_KNEE_EXTENSION_FLEXION, LEFT_KNEE_EXTENSION_FLEXION].
            Expected unit: Degrees as float64.

    Returns:
        NDArray[np.int16]: A 1D NumPy array containing the final
            REBA leg score [score]. Values are typically 1 or 2.
    """

    # freemocap_adapter.py mapping
    # 1. Legs [0:2] -> [right_knee, left_knee]
    # degs[0] = row["right_knee_extension_flexion"]
    # degs[1] = row["left_knee_extension_flexion"]

    right_leg_degree = leg_degrees[0]
    left_leg_degree = leg_degrees[1]
    leg_reba_score = 1
    leg_flexion_score = 0
    balance_score = 1  # Default to balanced TODO remove with REBA FIX #1

    unbalanced = False  # TODO implement a way to chek legs balance

    if right_leg_degree < 30.0 or left_leg_degree < 30.0:
        leg_flexion_score = 0
    elif 30.0 <= right_leg_degree < 60.0 or 30.0 <= left_leg_degree < 60.0:
        leg_flexion_score = 1
    elif 60.0 <= right_leg_degree or 60.0 <= right_leg_degree:
        leg_flexion_score = 2

    if unbalanced:
        balance_score = 2

    total: float = leg_flexion_score + balance_score
    leg_reba_score = min(total, 4)  # Capped at 4 for legs (as per REBA guidelines)

    return np.array([int16(leg_reba_score)], dtype=np.int16)

options: heading_level: 4

calculators.reba_calculator.body_parts.lower_arm_reba

ErgoMoCap: REBA Lower Arm Calculator

Lower arm postural assessment for the Rapid Entire Body Assessment (REBA).

This module calculates the scoring for the lower arm component of the REBA method. It evaluates elbow flexion and extension angles to determine the postural risk score for the upper limbs, specifically focusing on identifying the most strained arm to ensure a conservative (worst-case) ergonomic assessment.

The calculator utilizes numpy.ndarray for input/output to maintain compatibility with the project's high-performance data processing pipelines and is designed for future numba optimization.

Functions

lower_arm_reba_score(lower_arm_degrees)

Calculates the REBA (Rapid Entire Body Assessment) score for the lower arms.

The function evaluates both arms and selects the score based on the arm with the higher degree of flexion, following standard REBA threshold intervals.

Parameters:

Name Type Description Default
lower_arm_degrees NDArray[float64]

A 1D NumPy array containing [RIGHT_ELBOW_EXTENSION_FLEXION, LEFT_ELBOW_EXTENSION_FLEXION]. Expected unit: Degrees.

required

Returns:

Type Description
NDArray[int16]

NDArray[np.int16]: A 1D NumPy array containing the final REBA lower arm score [score]. Values are typically 1 or 2.

Source code in calculators\reba_calculator\body_parts\lower_arm_reba.py
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
def lower_arm_reba_score(lower_arm_degrees: NDArray[np.float64]) -> NDArray[np.int16]:
    """
    Calculates the REBA (Rapid Entire Body Assessment) score for the lower arms.

    The function evaluates both arms and selects the score based on the arm with
    the higher degree of flexion, following standard REBA threshold intervals.

    Args:
        lower_arm_degrees (NDArray[np.float64]): A 1D NumPy array containing
            [RIGHT_ELBOW_EXTENSION_FLEXION, LEFT_ELBOW_EXTENSION_FLEXION].
            Expected unit: Degrees.

    Returns:
        NDArray[np.int16]: A 1D NumPy array containing the final
            REBA lower arm score [score]. Values are typically 1 or 2.
    """

    # freemocap_adapter.py mapping TODO remove or implement this chekc if works with numba
    # 5. Lower Arm [14:16] -> [right_elbow, left_elbow]
    # degs[14] = row["right_elbow_extension_flexion"]
    # degs[15] = row["left_elbow_extension_flexion"]

    right_degree = lower_arm_degrees[0]
    left_degree = lower_arm_degrees[1]
    lower_arm_reba_score = 1  # Initialized as int

    if (
        right_degree >= left_degree
    ):  # TODO important test for the degrees as this are calculated from a saggital perspective and the sign can be inconsistent based on the direction of movement or sensor placement.
        # We want to ensure we are correctly identifying the arm with the greater degree of flexion for accurate scoring.
        if 0.0 <= right_degree < 60.0:
            lower_arm_reba_score = 2
        if 60.0 <= right_degree < 100.0:
            lower_arm_reba_score = 1
        if 100.0 <= right_degree:
            lower_arm_reba_score = 2
    else:
        if 0.0 <= left_degree < 60.0:
            lower_arm_reba_score = 2
        if 60.0 <= left_degree < 100.0:
            lower_arm_reba_score = 1
        if 100.0 <= left_degree:
            lower_arm_reba_score = 2

    return np.array([int16(lower_arm_reba_score)], dtype=np.int16)

options: heading_level: 4

calculators.reba_calculator.body_parts.neck_reba

ErgoMoCap: REBA Neck Calculator

Cervical spine postural assessment for the Rapid Entire Body Assessment (REBA).

This module implements the scoring logic for the neck region. It processes three-dimensional angular data including flexion, extension, lateral side-bending, and axial rotation. The final score is a composite value that penalizes non-neutral postures and excessive torsion or lateral deviation.

Key calculations: - Flexion/Extension: Base score determined by the sagittal angle. - Lateral Bending: Penalty score for side-leaning exceeding threshold. - Torsion: Penalty score for axial rotation exceeding threshold.

Functions

neck_reba_score(neck_degrees)

Calculates the REBA (Rapid Entire Body Assessment) score for the neck.

Evaluates flexion/extension, side bending, and twisting to provide a composite score and individual component scores.

Parameters:

Name Type Description Default
neck_degrees NDArray[float64]

A 1D NumPy array containing [NECK_EXTENSION_FLEXION, NECK_LATERAL_FLEXION, NECK_ROTATION]. Note: Negative flexion values are treated as neck extension.

required

Returns:

Type Description
NDArray[int16]

NDArray[np.int16]: A 1D NumPy array containing: [total_neck_score, flex_score, side_score, torsion_score].

Source code in calculators\reba_calculator\body_parts\neck_reba.py
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
def neck_reba_score(neck_degrees: NDArray[np.float64]) -> NDArray[np.int16]:
    """
    Calculates the REBA (Rapid Entire Body Assessment) score for the neck.

    Evaluates flexion/extension, side bending, and twisting to provide a
    composite score and individual component scores.

    Args:
        neck_degrees (NDArray[np.float64]): A 1D NumPy array containing
            [NECK_EXTENSION_FLEXION, NECK_LATERAL_FLEXION, NECK_ROTATION].
            Note: Negative flexion values are treated as neck extension.

    Returns:
        NDArray[np.int16]: A 1D NumPy array containing:
            [total_neck_score, flex_score, side_score, torsion_score].
    """

    # freemocap_adapter.py mapping
    # 1. Legs [0:2] -> [right_knee, left_knee]
    # degs[0] = row["right_knee_extension_flexion"]
    # degs[1] = row["left_knee_extension_flexion"]

    neck_flex_degree = neck_degrees[0]
    neck_side_bending_degree = neck_degrees[1]
    neck_twist_degree = neck_degrees[2]

    neck_reba_score = 1  # Updated to int
    neck_flex_score = 0
    neck_side_score = 0
    neck_torsion_score = 0

    # Flexion / Extension Logic (determines base score)
    if neck_flex_degree >= 0.0:
        if 0.0 <= neck_flex_degree < 20.0:
            neck_flex_score = 1
        elif 20.0 <= neck_flex_degree:
            neck_flex_score = 2
        else:
            neck_flex_score = 1
    else:  # Extension Logic (negative values treated as extension)
        neck_flex_score = 2

    # Side Bending Logic
    if abs(neck_side_bending_degree) >= 10.0:
        neck_side_score = 1

    # Twisting Logic
    if abs(neck_twist_degree) >= 10.0:
        neck_torsion_score = 1

    total: int = neck_flex_score + neck_side_score + neck_torsion_score
    neck_reba_score = min(total, 3)  # Cap the neck score at 3 (as per REBA guidelines)

    return np.array(
        [
            int16(neck_reba_score),
            int16(neck_flex_score),
            int16(neck_side_score),
            int16(neck_torsion_score),
        ],
        dtype=np.int16,
    )

options: heading_level: 4

calculators.reba_calculator.body_parts.trunk_reba

ErgoMoCap: REBA Trunk Calculator

Thoracic and lumbar postural assessment for the Rapid Entire Body Assessment (REBA).

This module implements the scoring logic for the trunk/spine region. It processes multi-axial angular data including flexion, extension, lateral side-bending, and axial torsion. The final score is a composite value that identifies postural strain in the spine, which serves as a foundational component for the REBA Group A score.

Key calculations: - Flexion/Extension: Categorical scoring based on forward or backward deviation. - Lateral Bending: Binary penalty for any significant lateral deviation. - Torsion: Binary penalty for spinal twisting.

Functions

trunk_reba_score(trunk_degrees)

Calculates the REBA (Rapid Entire Body Assessment) score for the trunk.

Evaluates trunk flexion, extension, side bending, and torsion to provide a total trunk score and its individual components.

NOTE: trunk_degrees input MUST comply with degrees API from freemocap_adapter.py

Parameters:

Name Type Description Default
trunk_degrees NDArray[float64]

A 1D NumPy array containing [SPINE_EXTENSION_FLEXION, SPINE_LATERAL_FLEXION, SPINE_ROTATION_TORSION]. Note: Positive flexion is forward bending; negative is extension.

required

Returns:

Type Description
NDArray[int16]

NDArray[np.int16]: A 1D NumPy array containing: [total_trunk_score, flex_score, side_score, torsion_score].

Source code in calculators\reba_calculator\body_parts\trunk_reba.py
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
def trunk_reba_score(trunk_degrees: NDArray[np.float64]) -> NDArray[np.int16]:
    """
    Calculates the REBA (Rapid Entire Body Assessment) score for the trunk.

    Evaluates trunk flexion, extension, side bending, and torsion to provide
    a total trunk score and its individual components.

    NOTE: trunk_degrees input MUST comply with degrees API from freemocap_adapter.py

    Args:
        trunk_degrees (NDArray[np.float64]): A 1D NumPy array containing
            [SPINE_EXTENSION_FLEXION, SPINE_LATERAL_FLEXION, SPINE_ROTATION_TORSION].
            Note: Positive flexion is forward bending; negative is extension.

    Returns:
        NDArray[np.int16]: A 1D NumPy array containing:
            [total_trunk_score, flex_score, side_score, torsion_score].
    """

    trunk_flex_degree = trunk_degrees[0]
    trunk_side_bending_degree = trunk_degrees[1]
    trunk_torsion_degree = trunk_degrees[
        2
    ]  # TODO EFFECTIVELY ALWAYS 0 !! MUST IMPLEMENT IN FMC ADAPTER TO GET REAL VALUES

    trunk_reba_score = 1
    trunk_flex_score = 0
    trunk_side_score = 0
    trunk_torsion_score = 0  # TODO EFFECTIVELY ALWAYS 0 !! MUST IMPLEMENT IN FMC ADAPTER TO GET REAL VALUES

    # Trunk Flexion / Extension Logic
    if trunk_flex_degree >= 0.0:
        # Forward Flexion
        if 0.0 <= trunk_flex_degree < 5.0:
            trunk_flex_score = 1
        elif 5.0 <= trunk_flex_degree < 20.0:
            trunk_flex_score = 2
        elif 20.0 <= trunk_flex_degree < 60.0:
            trunk_flex_score = 3
        elif 60.0 <= trunk_flex_degree:
            trunk_flex_score = 4
    else:
        # Trunk Extension
        trunk_flex_score = 2

    # Side Bending Logic
    if abs(trunk_side_bending_degree) >= 1.0:
        trunk_side_score = 1

    # Torsion (Twisting) Logic
    if abs(trunk_torsion_degree) >= 1.0:
        # TODO EFFECTIVELY ALWAYS 0 !! MUST IMPLEMENT IN FMC ADAPTER TO GET REAL VALUES
        trunk_torsion_score = 1

    total: int = trunk_flex_score + trunk_side_score + trunk_torsion_score
    trunk_reba_score = min(total, 5)  # Capped at 5 for trunk (as per REBA guidelines)

    return np.array(
        [
            int16(trunk_reba_score),
            int16(trunk_flex_score),
            int16(trunk_side_score),
            int16(trunk_torsion_score),
        ],
        dtype=np.int16,
    )

options: heading_level: 4

calculators.reba_calculator.body_parts.upper_arm_reba

ErgoMoCap: REBA Upper Arm Calculator

Shoulder and upper arm postural assessment for the Rapid Entire Body Assessment (REBA).

This module evaluates the ergonomic risk of the upper limbs by analyzing shoulder flexion/extension, abduction/adduction, and shoulder girdle elevation (rise). It employs a "worst-case" selection logic, prioritizing the arm with the greatest deviation from the neutral position to ensure conservative risk estimation.

Calculations include: - Flexion/Extension: Range-based scoring for the humerus. - Abduction: Penalty for arms moving away from the midline of the body. - Shoulder Rise: Penalty for elevated shoulder postures. - Static Support: Adjustments for supported arm postures or leaning.

Functions

upper_arm_reba_score(upper_arm_degrees)

Calculates the REBA (Rapid Entire Body Assessment) score for the upper arms.

Evaluates flexion/extension for both arms (choosing the maximum), and checks for side abduction and shoulder elevation penalties.

NOTE: upper_arm_degrees input MUST comply with degrees API from freemocap_adapter.py

Parameters:

Name Type Description Default
upper_arm_degrees NDArray[float64]

A 1D NumPy array containing [RIGHT_SHOULDER_EXTENSION_FLEXION, LEFT_SHOULDER_EXTENSION_FLEXION, RIGHT_SHOULDER_ABDUCTION_ADDUCTION, LEFT_SHOULDER_ABDUCTION_ADDUCTION, RIGHT_SHOULDER_RISE, LEFT_SHOULDER_RISE].

required

Returns:

Type Description
NDArray[int16]

NDArray[np.int16]: A 1D NumPy array containing: [total_score, flex_score, side_score, shoulder_rise_score].

Source code in calculators\reba_calculator\body_parts\upper_arm_reba.py
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
def upper_arm_reba_score(upper_arm_degrees: NDArray[np.float64]) -> NDArray[np.int16]:
    """
    Calculates the REBA (Rapid Entire Body Assessment) score for the upper arms.

    Evaluates flexion/extension for both arms (choosing the maximum),
    and checks for side abduction and shoulder elevation penalties.

    NOTE: upper_arm_degrees input MUST comply with degrees API from freemocap_adapter.py

    Args:
        upper_arm_degrees (NDArray[np.float64]): A 1D NumPy array containing
            [RIGHT_SHOULDER_EXTENSION_FLEXION, LEFT_SHOULDER_EXTENSION_FLEXION,
             RIGHT_SHOULDER_ABDUCTION_ADDUCTION, LEFT_SHOULDER_ABDUCTION_ADDUCTION,
             RIGHT_SHOULDER_RISE, LEFT_SHOULDER_RISE].

    Returns:
        NDArray[np.int16]: A 1D NumPy array containing:
            [total_score, flex_score, side_score, shoulder_rise_score].
    """
    # freemocap_adapter.py mapping TODO remove or implement!
    # 4. Upper Arm [8:14] -> [R_flex, L_flex, R_side, L_side, R_rise, L_rise]
    # degs[8] = row["right_shoulder_extension_flexion"]
    # degs[9] = row["left_shoulder_extension_flexion"]
    # degs[10] = row["right_shoulder_abduction_adduction"]
    # degs[11] = row["left_shoulder_abduction_adduction"]
    # R/L Shoulder rise usually mapped from abduction or separate landmarks
    # degs[12] = 0
    # degs[13] = 0

    right_flexion = upper_arm_degrees[0]
    left_flexion = upper_arm_degrees[1]
    right_side = upper_arm_degrees[2]
    left_side = upper_arm_degrees[3]
    right_shoulder_rise = upper_arm_degrees[
        4
    ]  # TODO EFFECTIVELY ALWAYS 0 !! MUST IMPLEMENT IN FMC ADAPTER TO GET REAL VALUES
    left_shoulder_rise = upper_arm_degrees[
        5
    ]  # TODO EFFECTIVELY ALWAYS 0 !! MUST IMPLEMENT IN FMC ADAPTER TO GET REAL VALUES

    upper_arm_reba_score = 1
    upper_arm_flex_score = 0
    upper_arm_side_score = 0
    upper_arm_shoulder_rise = 0
    support_leaning_score = 0

    # Flexion Logic (Choosing the arm in worse ergonomic position - furthest from neutral)
    # TODO this is almost all WRONG! should check the degrees, now it consider normal position at 0
    if abs(right_flexion) >= abs(left_flexion):
        if -20.0 <= right_flexion < 20.0:
            upper_arm_flex_score = 1
        if 20.0 <= right_flexion < 45.0:
            upper_arm_flex_score = 2
        if right_flexion < -20.0:
            upper_arm_flex_score = 2
        if 45.0 <= right_flexion < 90.0:
            upper_arm_flex_score = 3
        if 90.0 <= right_flexion:
            upper_arm_flex_score = 4
    else:
        if -20.0 <= left_flexion < 20.0:
            upper_arm_flex_score = 1
        if left_flexion < -20.0:
            upper_arm_flex_score = 2
        if 20.0 <= left_flexion < 45.0:
            upper_arm_flex_score = 2
        if 45.0 <= left_flexion < 90.0:
            upper_arm_flex_score = 3
        if 90.0 <= left_flexion:
            upper_arm_flex_score = 4

    # Side Bending / Abduction Penalty
    if (
        abs(right_side) > 20.0 or abs(left_side) > 20.0
    ):  # put at 20° to avoid penalizing small deviations
        upper_arm_side_score = 1

    # Shoulder Rise Penalty
    if right_shoulder_rise > 90.0 or left_shoulder_rise > 90.0:
        # TODO EFFECTIVELY ALWAYS 0 !! MUST IMPLEMENT IN FMC ADAPTER TO GET REAL VALUES
        upper_arm_shoulder_rise = 1

    arm_supported: bool = False  # TODO EFFECTIVELY ALWAYS FALSE !! MUST IMPLEMENT IN FMC ADAPTER TO GET REAL VALUES
    person_leaning: bool = False  # TODO EFFECTIVELY ALWAYS FALSE !! MUST IMPLEMENT IN FMC ADAPTER TO GET REAL VALUES

    # Arm Supported/ Leaning Penalty
    if arm_supported or person_leaning:
        # TODO EFFECTIVELY ALWAYS 0 !! MUST IMPLEMENT IN FMC ADAPTER TO GET REAL VALUES
        support_leaning_score = -1

    total: int = (
        upper_arm_flex_score
        + upper_arm_side_score
        + upper_arm_shoulder_rise
        + support_leaning_score
    )

    # Use max(1, ...) to ensure the negative penalty doesn't drop score below the REBA minimum
    upper_arm_reba_score = int16(max(1, min(total, 6)))

    return np.array(
        [
            upper_arm_reba_score,
            int16(upper_arm_flex_score),
            int16(upper_arm_side_score),
            int16(upper_arm_shoulder_rise),
        ],
        dtype=np.int16,
    )

options: heading_level: 4

calculators.reba_calculator.body_parts.wrist_reba

ErgoMoCap: REBA Wrist Calculator

Distal upper limb postural assessment for the Rapid Entire Body Assessment (REBA).

This module calculates the scoring for the wrist joint, focusing on deviations from the neutral plane. It evaluates flexion, extension, radial/ulnar deviation (side bending), and forearm pronation/supination (torsion). This score is a critical component of the REBA Group B assessment for fine-motor or manual handling tasks.

Key features: - Flexion/Extension: Binary scoring threshold at 15 degrees. - Deviation/Torsion: Individual penalty increments for non-neutral alignments. - Input Compatibility: Processes data typically derived from high-fidelity motion capture or specialized hand-tracking sensors.

Functions

wrist_reba_score(wrist_degrees)

Calculates the REBA (Rapid Entire Body Assessment) score for the wrists.

Evaluates flexion/extension by selecting the wrist with the maximum deviation, and applies penalties for side bending (radial/ulnar deviation) or torsion.

NOTE: wrist_degrees input MUST comply with degrees API from freemocap_adapter.py

Parameters:

Name Type Description Default
wrist_degrees NDArray[float64]

A 1D NumPy array containing [RIGHT_HAND_EXTENSION_FLEXION, LEFT_HAND_EXTENSION_FLEXION, RIGHT_HAND_LATERAL_SIDE, LEFT_HAND_LATERAL_SIDE, RIGHT_HAND_TWIST, LEFT_HAND_TWIST].

required

Returns:

Type Description
NDArray[int16]

NDArray[np.int16]: A 1D NumPy array containing: [total_wrist_score, flex_score, side_bend_score, torsion_score].

Source code in calculators\reba_calculator\body_parts\wrist_reba.py
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
def wrist_reba_score(wrist_degrees: NDArray[np.float64]) -> NDArray[np.int16]:
    """
    Calculates the REBA (Rapid Entire Body Assessment) score for the wrists.

    Evaluates flexion/extension by selecting the wrist with the maximum deviation,
    and applies penalties for side bending (radial/ulnar deviation) or torsion.

    NOTE: wrist_degrees input MUST comply with degrees API from freemocap_adapter.py

    Args:
        wrist_degrees (NDArray[np.float64]): A 1D NumPy array containing
            [RIGHT_HAND_EXTENSION_FLEXION, LEFT_HAND_EXTENSION_FLEXION,
             RIGHT_HAND_LATERAL_SIDE, LEFT_HAND_LATERAL_SIDE,
             RIGHT_HAND_TWIST, LEFT_HAND_TWIST].

    Returns:
        NDArray[np.int16]: A 1D NumPy array containing:
            [total_wrist_score, flex_score, side_bend_score, torsion_score].
    """
    # freemocap_adapter.py mapping TODO REMOVE OR IMPLEMENT!!
    # 6. Wrist [16:22] -> [R_flex, L_flex, R_side, L_side, R_twist, L_twist]
    # degs[16] = row["right_hand_extension_flexion"]
    # degs[17] = row["left_hand_extension_flexion"]
    # # Side/Twist for wrist often 0 unless using high-fidelity FMC gloves/configs
    # degs[18:22] = 0

    right_flex = wrist_degrees[0]
    left_flex = wrist_degrees[1]
    right_side = wrist_degrees[
        2
    ]  # TODO EFFECTIVELY ALWAYS 0 !! MUST IMPLEMENT IN FMC ADAPTER TO GET REAL VALUES
    left_side = wrist_degrees[
        3
    ]  # TODO EFFECTIVELY ALWAYS 0 !! MUST IMPLEMENT IN FMC ADAPTER TO GET REAL VALUES
    right_twist = wrist_degrees[
        4
    ]  # TODO EFFECTIVELY ALWAYS 0 !! MUST IMPLEMENT IN FMC ADAPTER TO GET REAL VALUES
    left_twist = wrist_degrees[
        5
    ]  # TODO EFFECTIVELY ALWAYS 0 !! MUST IMPLEMENT IN FMC ADAPTER TO GET REAL VALUES

    wrist_reba_score = 1
    wrist_flex_score = 0
    wrist_side_bend_score = 0
    wrist_torsion_score = 0

    # Flexion / Extension Logic (Choosing the wrist with higher deviation)
    if right_flex > left_flex:
        if -15.0 <= right_flex < 15.0:
            wrist_flex_score = 1
        if 15.0 <= right_flex or right_flex < -15.0:
            wrist_flex_score = 2
    else:
        if -15.0 <= left_flex < 15.0:
            wrist_flex_score = 1
        if 15.0 <= left_flex or left_flex < -15.0:
            wrist_flex_score = 2

    # Side Bending (Deviation) Penalty
    if (
        right_side != 0.0 or left_side != 0.0
    ):  # TODO EFFECTIVELY ALWAYS 0 !! MUST IMPLEMENT IN FMC ADAPTER TO GET REAL VALUES
        wrist_side_bend_score = 1

    # Torsion (Twist) Penalty
    if (
        right_twist != 0.0 or left_twist != 0.0
    ):  # TODO EFFECTIVELY ALWAYS 0 !! MUST IMPLEMENT IN FMC ADAPTER TO GET REAL VALUES
        wrist_torsion_score = 1

    total: int = wrist_flex_score + wrist_side_bend_score + wrist_torsion_score
    wrist_reba_score = min(total, 3)  # Capped at 3 for wrist (as per REBA guidelines)

    return np.array(
        [
            int16(wrist_reba_score),
            int16(wrist_flex_score),
            int16(wrist_side_bend_score),
            int16(wrist_torsion_score),
        ],
        dtype=np.int16,
    )

options: heading_level: 4

Internal Tables

calculators.reba_calculator.reba_score_tables

ErgoMoCap - Biomechanical Scoring Tables

This module contains the matrix representations of the REBA (Rapid Entire Body Assessment) lookup tables. These tables are used to consolidate partial joint scores into composite risk indices (Score A and Score B), which finally determine the Grand Score (Score C).

The implementation uses NumPy arrays for O(1) lookups. Because the REBA standard uses 1-based indexing for scores, all retrieval functions in this project adjust these values to 0-based indexing for internal array access.

options: heading_level: 4


RULA (Rapid Upper Limb Assessment)

The RULA engine extracts scoring bounds from dedicated body layouts and internal mapping grids.

calculators.rula_calculator.RULA_calculator

ErgoMoCap: RULA Assessment Calculator

Implementation of the Rapid Upper Limb Assessment (RULA) ergonomic method.

This module provides the computational logic to determine postural risk scores based on the RULA standard. It uses a series of lookup tables (Table A, B, and C) to aggregate individual joint scores into a final ergonomic risk value.

The calculator processes 3D skeletal data (angles) to evaluate: - Group A: Upper arms, lower arms, wrists, and wrist twist. - Group B: Neck, trunk, and legs.

Final scores indicate the level of intervention required, ranging from 1 (negligible risk) to 7+ (immediate change required).

Classes

Functions

calculate_frame_rula_from_degs(degs, muscle_score=0, force_score=0, is_arm_supported=False, are_legs_unsupported=False)

Standardized RULA Entry Point using DegsIndexes Enum.

Calculates the complete RULA assessment for a single frame of data by performing lookups in Table A (Upper Limb), Table B (Neck/Trunk/Legs), and Table C (Grand Score).

Parameters:

Name Type Description Default
degs ndarray

A 1D numpy.ndarray containing 22 joint angle values.

required
muscle_score int

Static posture or repetition penalty (typically 0 or 1).

0
force_score int

Load or force penalty (0, 1, 2, or 3).

0
is_arm_supported bool

Whether the upper arm is supported.

False
are_legs_unsupported bool

Whether the legs and feet are poorly supported.

False

Raises:

Type Description
IndexError

If the degs array does not contain exactly 22 values.

Returns:

Type Description
tuple[dict[str, int], dict[str, Any]]

tuple[dict[str, int], dict[str, Any]]: A tuple containing: - final_scores: A dict with specific RULA keys (Upper_Arm_Score_RULA, etc.). - metadata: An empty dict for future compatibility.

Source code in calculators\rula_calculator\RULA_calculator.py
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
def calculate_frame_rula_from_degs(
    degs: NDArray[np.float64],
    muscle_score: int = 0,
    force_score: int = 0,
    is_arm_supported: bool = False,
    are_legs_unsupported: bool = False,
) -> tuple[dict[str, int], dict[str, Any]]:
    """
    Standardized RULA Entry Point using DegsIndexes Enum.

    Calculates the complete RULA assessment for a single frame of data by
    performing lookups in Table A (Upper Limb), Table B (Neck/Trunk/Legs),
    and Table C (Grand Score).

    Args:
        degs (numpy.ndarray): A 1D `numpy.ndarray` containing 22 joint angle values.
        muscle_score (int): Static posture or repetition penalty (typically 0 or 1).
        force_score (int): Load or force penalty (0, 1, 2, or 3).
        is_arm_supported (bool): Whether the upper arm is supported.
        are_legs_unsupported (bool): Whether the legs and feet are poorly supported.

    Raises:
        IndexError: If the `degs` array does not contain exactly 22 values.

    Returns:
        tuple[dict[str, int], dict[str, Any]]: A tuple containing:
            - `final_scores`: A `dict` with specific RULA keys (Upper_Arm_Score_RULA, etc.).
            - `metadata`: An empty `dict` for future compatibility.
    """

    if len(degs) != 22:
        raise IndexError(f"Expected 22 degree values, got {len(degs)}")

    # --- Group A: Arms & Wrist ---
    # Slicing from DI for clarity, but passing specific indices to RULA sub-functions
    upper_arm_score = upper_arm_rula_score(
        degs[DI.RIGHT_SHOULDER_EXTENSION_FLEXION],
        degs[DI.RIGHT_SHOULDER_ABDUCTION_ADDUCTION],
        degs[DI.RIGHT_SHOULDER_RISE],
        is_arm_supported,
    )

    lower_arm_score = lower_arm_rula_score(degs[DI.RIGHT_ELBOW_EXTENSION_FLEXION])

    wrist_score = wrist_rula_score(
        degs[DI.RIGHT_HAND_EXTENSION_FLEXION], degs[DI.RIGHT_HAND_LATERAL_SIDE]
    )

    # Wrist Twist: Penalty if rotation exceeds 40 degrees
    wrist_twist_score = wrist_twist_rula_score(
        degs[DI.RIGHT_HAND_TWIST],
    )  # TODO here only uses RIGHT Side and even before, address this

    # Score A Table Lookup
    score_a_raw = _TABLE_A_DATA[
        int(upper_arm_score) - 1,
        int(lower_arm_score) - 1,
        int(wrist_score) - 1,
        int(wrist_twist_score) - 1,
    ]
    grand_score_a = int(max(1, min(score_a_raw + muscle_score + force_score, 8)))

    # --- Group B: Neck, Trunk, Legs ---
    neck_score = neck_rula_score(
        degs[DI.NECK_EXTENSION_FLEXION],
        degs[DI.NECK_LATERAL_FLEXION],
        degs[DI.NECK_ROTATION],
    )

    trunk_score = trunk_rula_score(
        degs[DI.SPINE_EXTENSION_FLEXION],
        degs[DI.SPINE_LATERAL_FLEXION],
        degs[DI.SPINE_ROTATION_TORSION],
    )

    legs_score = 2 if are_legs_unsupported else 1

    # Score B Table Lookup
    score_b_raw = _TABLE_B_DATA[
        int(neck_score) - 1, int(trunk_score) - 1, int(legs_score) - 1
    ]
    grand_score_b = int(max(1, min(score_b_raw + muscle_score + force_score, 7)))

    # Final Synthesis (Table C)
    final_rula = _TABLE_C_DATA[grand_score_a - 1, grand_score_b - 1]

    # DO NOT CHANGE THESE NAMES
    final_scores = {
        "Upper_Arm_Score_RULA": upper_arm_score,
        "Lower_Arm_Score_RULA": lower_arm_score,
        "Trunk_Score_RULA": trunk_score,
        "Neck_Score_RULA": neck_score,
        "Wrist_Score_RULA": wrist_score,
        "Legs_Score_RULA": legs_score,
        "Score_A_RULA": grand_score_a,
        "Score_B_RULA": grand_score_b,
        "Final_Score_RULA": int(final_rula),
    }

    return final_scores, {}

options: heading_level: 4

calculators.rula_calculator.rula_body_parts

Functions

lower_arm_rula_score(flexion)

Calculates the RULA score for the lower arm based on flexion.

Parameters:

Name Type Description Default
flexion float

The elbow flexion/extension angle in degrees.

required

Returns:

Name Type Description
int int

A score of 1 (60-100°) or 2 (outside range).

Source code in calculators\rula_calculator\rula_body_parts.py
57
58
59
60
61
62
63
64
65
66
67
68
69
70
def lower_arm_rula_score(flexion: float) -> int:
    """
    Calculates the RULA score for the lower arm based on flexion.

    Args:
        flexion (float): The elbow flexion/extension angle in degrees.

    Returns:
        int (int): A score of 1 (60-100°) or 2 (outside range).
    """
    # Logic for 'working across midline' is usually a manual observation (+1).
    if 60.0 <= flexion <= 100.0:
        return 1
    return 2

neck_rula_score(flexion, side_bend, twist)

Calculates the RULA score for the neck.

Parameters:

Name Type Description Default
flexion float

The neck flexion/extension angle in degrees.

required
side_bend float

The neck lateral flexion angle in degrees.

required
twist float

The neck rotation angle in degrees.

required

Returns:

Name Type Description
int int

A score between 1 and 6.

Source code in calculators\rula_calculator\rula_body_parts.py
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
def neck_rula_score(flexion: float, side_bend: float, twist: float) -> int:
    """
    Calculates the RULA score for the neck.

    Args:
        flexion (float): The neck flexion/extension angle in degrees.
        side_bend (float): The neck lateral flexion angle in degrees.
        twist (float): The neck rotation angle in degrees.

    Returns:
        int (int): A score between 1 and 6.
    """

    if flexion < 0:
        score = 4
    elif flexion > 20.0:
        score = 3
    elif 10.0 < flexion <= 20.0:
        score = 2
    else:
        score = 1
    if abs(twist) > 10.0:
        score += 1
    if abs(side_bend) > 10.0:
        score += 1
    return int(max(1, min(score, 6)))

trunk_rula_score(flexion, side_bending, torsion)

Calculates the RULA score for the trunk.

Parameters:

Name Type Description Default
flexion float

The trunk flexion/extension angle in degrees.

required
side_bending float

The trunk lateral flexion angle in degrees.

required
torsion float

The trunk rotation/torsion angle in degrees.

required

Returns:

Name Type Description
int int

A score between 1 and 6.

Source code in calculators\rula_calculator\rula_body_parts.py
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
def trunk_rula_score(flexion: float, side_bending: float, torsion: float) -> int:
    """
    Calculates the RULA score for the trunk.

    Args:
        flexion (float): The trunk flexion/extension angle in degrees.
        side_bending (float): The trunk lateral flexion angle in degrees.
        torsion (float): The trunk rotation/torsion angle in degrees.

    Returns:
        int (int): A score between 1 and 6.
    """

    if flexion > 60.0:
        score = 4
    elif 20.0 < flexion <= 60.0:
        score = 3
    elif 0.0 < flexion <= 20.0:
        score = 2
    else:
        score = 1
    if abs(torsion) > 10.0:
        score += 1
    if abs(side_bending) > 10.0:
        score += 1
    return int(max(1, min(score, 6)))

upper_arm_rula_score(flexion, abduction, shoulder_rise, is_supported)

Calculates the RULA score for the upper arm based on flexion and posture.

Parameters:

Name Type Description Default
flexion float

The flexion/extension angle in degrees.

required
abduction float

The abduction/adduction angle in degrees.

required
shoulder_rise float

The vertical shoulder elevation in degrees.

required
is_supported bool

Whether the arm is supported or the person is leaning.

required

Returns:

Name Type Description
int int

A score between 1 and 6.

Source code in calculators\rula_calculator\rula_body_parts.py
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
def upper_arm_rula_score(
    flexion: float, abduction: float, shoulder_rise: float, is_supported: bool
) -> int:
    """
    Calculates the RULA score for the upper arm based on flexion and posture.

    Args:
        flexion (float): The flexion/extension angle in degrees.
        abduction (float): The abduction/adduction angle in degrees.
        shoulder_rise (float): The vertical shoulder elevation in degrees.
        is_supported (bool): Whether the arm is supported or the person is leaning.

    Returns:
        int (int): A score between 1 and 6.
    """
    # Base Score
    if flexion > 90.0:
        score = 4
    elif 45.0 < flexion <= 90.0:
        score = 3
    elif 20.0 < flexion <= 45.0 or flexion < -20.0:
        score = 2
    else:
        score = 1
    # Adjustments
    if shoulder_rise > 10.0:
        score += 1
    if abduction > 20.0:
        score += 1
    if is_supported:
        score -= 1
    return int(max(1, min(score, 6)))

wrist_rula_score(flexion_extension, side_deviation)

Calculates the RULA score for the wrist based on flexion and deviation.

Parameters:

Name Type Description Default
flexion_extension float

The wrist flexion/extension angle in degrees.

required
side_deviation float

The wrist radial/ulnar deviation angle in degrees.

required

Returns:

Name Type Description
int int

A score between 1 and 4.

Source code in calculators\rula_calculator\rula_body_parts.py
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
def wrist_rula_score(flexion_extension: float, side_deviation: float) -> int:
    """
    Calculates the RULA score for the wrist based on flexion and deviation.

    Args:
        flexion_extension (float): The wrist flexion/extension angle in degrees.
        side_deviation (float): The wrist radial/ulnar deviation angle in degrees.

    Returns:
        int (int): A score between 1 and 4.
    """

    if flexion_extension == 0:
        score = 1
    elif -15.0 <= flexion_extension <= 15.0:
        score = 2
    else:
        score = 3
    if abs(side_deviation) > 10.0:
        score += 1
    return int(max(1, min(score, 4)))

wrist_twist_rula_score(twist_degree)

Calculates the RULA score for the wrist based on flexion and deviation.

Parameters:

Name Type Description Default
twist_degree float

The wrist twist angle in degrees.

required

Returns:

Name Type Description
int int

A score of either 1 or 2.

Source code in calculators\rula_calculator\rula_body_parts.py
 96
 97
 98
 99
100
101
102
103
104
105
106
def wrist_twist_rula_score(twist_degree: float) -> int:
    """
    Calculates the RULA score for the wrist based on flexion and deviation.

    Args:
        twist_degree (float): The wrist twist angle in degrees.

    Returns:
        int (int): A score of either 1 or 2.
    """
    return 2 if abs(twist_degree) > 40.0 else 1

options: heading_level: 4

calculators.rula_calculator.rula_score_tables

options: heading_level: 4


NIOSH, OCRA, & Specialized Engines

calculators.niosh_calculator.NIOSH_calculator

Functions

calculate_frame_niosh_li(niosh_vars)

TODO: Calculate NIOSh Lifting Index.

Source code in calculators\niosh_calculator\NIOSH_calculator.py
25
26
27
28
29
def calculate_frame_niosh_li(
    niosh_vars: dict[str, float],
) -> Any:  # dict[str, float]: # type: ignore
    """TODO: Calculate NIOSh Lifting Index."""
    pass

options: heading_level: 3

calculators.ocra_calculator.OCRA_calculator

Functions

calculate_frame_ocra_index(ocra_vars)

TODO: Calculate OCRA Index.

Source code in calculators\ocra_calculator\OCRA_calculator.py
25
26
27
def calculate_frame_ocra_index(ocra_vars: Any) -> Any:
    """TODO: Calculate OCRA Index."""
    pass

options: heading_level: 3

calculators.ewas_calculator.EWAS_calculator

Functions

calculate_frame_ewas_score(ewas_vars)

TODO: Calculate EWAS Score.

Source code in calculators\ewas_calculator\EWAS_calculator.py
28
29
30
def calculate_frame_ewas_score(ewas_vars: Any) -> Any:
    """TODO: Calculate EWAS Score."""
    pass

options: heading_level: 3

calculators.snook_calculator.SNOOK_calculator

Functions

calculate_frame_snook_index(snook_vars)

TODO: Calculate SNOOK Index.

Source code in calculators\snook_calculator\SNOOK_calculator.py
25
26
27
def calculate_frame_snook_index(snook_vars: Any) -> Any:
    """TODO: Calculate SNOOK Index."""
    pass

options: heading_level: 3


Calculator Global Utilities

calculators.calculators

Functions

calculate_frame_ewas_score(ewas_vars)

TODO: Calculate EWAS Score.

Source code in calculators\ewas_calculator\EWAS_calculator.py
28
29
30
def calculate_frame_ewas_score(ewas_vars: Any) -> Any:
    """TODO: Calculate EWAS Score."""
    pass

calculate_frame_niosh_li(niosh_vars)

TODO: Calculate NIOSh Lifting Index.

Source code in calculators\niosh_calculator\NIOSH_calculator.py
25
26
27
28
29
def calculate_frame_niosh_li(
    niosh_vars: dict[str, float],
) -> Any:  # dict[str, float]: # type: ignore
    """TODO: Calculate NIOSh Lifting Index."""
    pass

calculate_frame_ocra_index(ocra_vars)

TODO: Calculate OCRA Index.

Source code in calculators\ocra_calculator\OCRA_calculator.py
25
26
27
def calculate_frame_ocra_index(ocra_vars: Any) -> Any:
    """TODO: Calculate OCRA Index."""
    pass

calculate_frame_reba_from_degs(degs)

Modular REBA Scoring Entry Point (Vectorized Input).

This function processes pre-calculated biomechanical angles directly, bypassing the kinematic transformation stage. It maps a flat 1D array of degrees to specific body districts, calculates individual penalty scores, and synthesizes the final REBA Risk Index using Numba-accelerated lookup tables (A, B, and C).

Parameters:

Name Type Description Default
degs NDArray[float64]

A 1D array containing exactly 22 kinematic values in the following sequence: - [0:2] Legs: [RIGHT_KNEE_EXTENSION_FLEXION, LEFT_KNEE_EXTENSION_FLEXION] - [2:5] Trunk: [SPINE_EXTENSION_FLEXION, SPINE_LATERAL_FLEXION, SPINE_ROTATION_TORSION] - [5:8] Neck: [NECK_EXTENSION_FLEXION, NECK_LATERAL_FLEXION, NECK_ROTATION] - [8:14] Upper Arm: [RIGHT_SHOULDER_EXTENSION_FLEXION, LEFT_SHOULDER_EXTENSION_FLEXION, RIGHT_SHOULDER_ABDUCTION_ADDUCTION, LEFT_SHOULDER_ABDUCTION_ADDUCTION, RIGHT_SHOULDER_RISE, LEFT_SHOULDER_RISE] - [14:16] Lower Arm: [RIGHT_ELBOW_EXTENSION_FLEXION, LEFT_ELBOW_EXTENSION_FLEXION] - [16:22] Wrist: [RIGHT_HAND_EXTENSION_FLEXION, LEFT_HAND_EXTENSION_FLEXION, RIGHT_HAND_LATERAL_SIDE, LEFT_HAND_LATERAL_SIDE, RIGHT_HAND_TWIST, LEFT_HAND_TWIST]

required

Returns:

Type Description
tuple[dict[str, int], dict[str, Any]]

tuple[dict[str, int], dict[str, Any]]: - final_scores: dictionary containing integer penalty scores for each district plus the "Final_REBA_Score". - degrees_map: Empty dictionary (reserved for API consistency).

Note

This method is preferred for processing high-frequency offline data where joint angles have already been solved (e.g., FreeMoCap post-processing).

Source code in calculators\reba_calculator\REBA_calculator.py
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
def calculate_frame_reba_from_degs(
    degs: NDArray[np.float64],
) -> tuple[dict[str, int], dict[str, Any]]:
    """
    Modular REBA Scoring Entry Point (Vectorized Input).

    This function processes pre-calculated biomechanical angles directly, bypassing
    the kinematic transformation stage. It maps a flat 1D array of degrees to
    specific body districts, calculates individual penalty scores, and synthesizes
    the final REBA Risk Index using Numba-accelerated lookup tables (A, B, and C).

    Args:
        degs (NDArray[np.float64]): A 1D array containing exactly 22 kinematic
            values in the following sequence:
            - [0:2]   Legs: [RIGHT_KNEE_EXTENSION_FLEXION, LEFT_KNEE_EXTENSION_FLEXION]
            - [2:5]   Trunk: [SPINE_EXTENSION_FLEXION, SPINE_LATERAL_FLEXION, SPINE_ROTATION_TORSION]
            - [5:8]   Neck: [NECK_EXTENSION_FLEXION, NECK_LATERAL_FLEXION, NECK_ROTATION]
            - [8:14]  Upper Arm: [RIGHT_SHOULDER_EXTENSION_FLEXION, LEFT_SHOULDER_EXTENSION_FLEXION,
                                  RIGHT_SHOULDER_ABDUCTION_ADDUCTION, LEFT_SHOULDER_ABDUCTION_ADDUCTION,
                                  RIGHT_SHOULDER_RISE, LEFT_SHOULDER_RISE]
            - [14:16] Lower Arm: [RIGHT_ELBOW_EXTENSION_FLEXION, LEFT_ELBOW_EXTENSION_FLEXION]
            - [16:22] Wrist: [RIGHT_HAND_EXTENSION_FLEXION, LEFT_HAND_EXTENSION_FLEXION,
                              RIGHT_HAND_LATERAL_SIDE, LEFT_HAND_LATERAL_SIDE,
                              RIGHT_HAND_TWIST, LEFT_HAND_TWIST]

    Returns:
        tuple[dict[str, int], dict[str, Any]]:
            - final_scores: dictionary containing integer penalty scores for
              each district plus the "Final_REBA_Score".
            - degrees_map: Empty dictionary (reserved for API consistency).

    Note:
        This method is preferred for processing high-frequency offline data
        where joint angles have already been solved (e.g., FreeMoCap post-processing).
    """
    if len(degs) != 22:
        raise IndexError(f"Expected exactly 22 degree values, got {len(degs)}")

    # 2. Calculate District Scores using Verbose Constant Slices
    # Slicing logic: [START : END + 1] to ensure the last index is included
    # IMPORTANT! Single body parts return an array so you must pop the first value [0] to get the score

    legs_score = leg_reba_score(
        degs[DI.RIGHT_KNEE_EXTENSION_FLEXION : DI.LEFT_KNEE_EXTENSION_FLEXION + 1]
    )[0]

    trunk_score = trunk_reba_score(
        degs[DI.SPINE_EXTENSION_FLEXION : DI.SPINE_ROTATION_TORSION + 1]
    )[0]

    neck_score = neck_reba_score(
        degs[DI.NECK_EXTENSION_FLEXION : DI.NECK_ROTATION + 1]
    )[0]

    upper_arm_score = upper_arm_reba_score(
        degs[DI.RIGHT_SHOULDER_EXTENSION_FLEXION : DI.LEFT_SHOULDER_RISE + 1]
    )[0]

    lower_arm_score = lower_arm_reba_score(
        degs[DI.RIGHT_ELBOW_EXTENSION_FLEXION : DI.LEFT_ELBOW_EXTENSION_FLEXION + 1]
    )[0]

    wrist_score = wrist_reba_score(
        degs[DI.RIGHT_HAND_EXTENSION_FLEXION : DI.LEFT_HAND_TWIST + 1]
    )[0]

    # Calculate Score A, B, and Final
    score_a = get_score_a(trunk_score, neck_score, legs_score)

    # TODO adjust for load score
    # If load < 11 lbs. : +0
    # If load 11 to 22 lbs. : +1
    # If load > 22 lbs.: +2
    # Adjust: If shock or rapid build up of force: add +1
    load_score = (
        0  # Placeholder for load score (to be integrated with actual load data)
    )
    adjusted_score_a = score_a + load_score

    score_b = get_score_b(
        upper_arm_score,
        lower_arm_score,
        wrist_score,
    )

    # TODO adjust for coupling/activity score
    # Coupling/Activity Score Adjustments (to be added to Score B):
    # Well fitting Handle and mid rang power grip, good: +0
    # Acceptable but not ideal hand hold or coupling
    # acceptable with another body part, fair: +1
    # Hand hold not acceptable but possible, poor: +2
    # No handles, awkward, unsafe with any body part,
    # Unacceptable: +3
    coupling_score = 0  # Placeholder for coupling/activity score (to be integrated with actual task data)
    adjusted_score_b = score_b + coupling_score

    final_reba_val = get_final_reba(adjusted_score_a, adjusted_score_b)

    final_scores = {  # TODO this dict is also hardcoded, should be a class if numba is unused
        "Legs_Score_REBA": int(legs_score),
        "Trunk_Score_REBA": int(trunk_score),
        "Neck_Score_REBA": int(neck_score),
        "Upper_Arm_Score_REBA": int(upper_arm_score),
        "Lower_Arm_Score_REBA": int(lower_arm_score),
        "Wrist_Score_REBA": int(wrist_score),
        "Final_Score_REBA": int(final_reba_val),
        "Score_A_REBA": int(adjusted_score_a),
        "Score_B_REBA": int(adjusted_score_b),
        "Score_C_REBA": int(final_reba_val),
    }

    return final_scores, {}

calculate_frame_rula_from_degs(degs, muscle_score=0, force_score=0, is_arm_supported=False, are_legs_unsupported=False)

Standardized RULA Entry Point using DegsIndexes Enum.

Calculates the complete RULA assessment for a single frame of data by performing lookups in Table A (Upper Limb), Table B (Neck/Trunk/Legs), and Table C (Grand Score).

Parameters:

Name Type Description Default
degs ndarray

A 1D numpy.ndarray containing 22 joint angle values.

required
muscle_score int

Static posture or repetition penalty (typically 0 or 1).

0
force_score int

Load or force penalty (0, 1, 2, or 3).

0
is_arm_supported bool

Whether the upper arm is supported.

False
are_legs_unsupported bool

Whether the legs and feet are poorly supported.

False

Raises:

Type Description
IndexError

If the degs array does not contain exactly 22 values.

Returns:

Type Description
tuple[dict[str, int], dict[str, Any]]

tuple[dict[str, int], dict[str, Any]]: A tuple containing: - final_scores: A dict with specific RULA keys (Upper_Arm_Score_RULA, etc.). - metadata: An empty dict for future compatibility.

Source code in calculators\rula_calculator\RULA_calculator.py
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
def calculate_frame_rula_from_degs(
    degs: NDArray[np.float64],
    muscle_score: int = 0,
    force_score: int = 0,
    is_arm_supported: bool = False,
    are_legs_unsupported: bool = False,
) -> tuple[dict[str, int], dict[str, Any]]:
    """
    Standardized RULA Entry Point using DegsIndexes Enum.

    Calculates the complete RULA assessment for a single frame of data by
    performing lookups in Table A (Upper Limb), Table B (Neck/Trunk/Legs),
    and Table C (Grand Score).

    Args:
        degs (numpy.ndarray): A 1D `numpy.ndarray` containing 22 joint angle values.
        muscle_score (int): Static posture or repetition penalty (typically 0 or 1).
        force_score (int): Load or force penalty (0, 1, 2, or 3).
        is_arm_supported (bool): Whether the upper arm is supported.
        are_legs_unsupported (bool): Whether the legs and feet are poorly supported.

    Raises:
        IndexError: If the `degs` array does not contain exactly 22 values.

    Returns:
        tuple[dict[str, int], dict[str, Any]]: A tuple containing:
            - `final_scores`: A `dict` with specific RULA keys (Upper_Arm_Score_RULA, etc.).
            - `metadata`: An empty `dict` for future compatibility.
    """

    if len(degs) != 22:
        raise IndexError(f"Expected 22 degree values, got {len(degs)}")

    # --- Group A: Arms & Wrist ---
    # Slicing from DI for clarity, but passing specific indices to RULA sub-functions
    upper_arm_score = upper_arm_rula_score(
        degs[DI.RIGHT_SHOULDER_EXTENSION_FLEXION],
        degs[DI.RIGHT_SHOULDER_ABDUCTION_ADDUCTION],
        degs[DI.RIGHT_SHOULDER_RISE],
        is_arm_supported,
    )

    lower_arm_score = lower_arm_rula_score(degs[DI.RIGHT_ELBOW_EXTENSION_FLEXION])

    wrist_score = wrist_rula_score(
        degs[DI.RIGHT_HAND_EXTENSION_FLEXION], degs[DI.RIGHT_HAND_LATERAL_SIDE]
    )

    # Wrist Twist: Penalty if rotation exceeds 40 degrees
    wrist_twist_score = wrist_twist_rula_score(
        degs[DI.RIGHT_HAND_TWIST],
    )  # TODO here only uses RIGHT Side and even before, address this

    # Score A Table Lookup
    score_a_raw = _TABLE_A_DATA[
        int(upper_arm_score) - 1,
        int(lower_arm_score) - 1,
        int(wrist_score) - 1,
        int(wrist_twist_score) - 1,
    ]
    grand_score_a = int(max(1, min(score_a_raw + muscle_score + force_score, 8)))

    # --- Group B: Neck, Trunk, Legs ---
    neck_score = neck_rula_score(
        degs[DI.NECK_EXTENSION_FLEXION],
        degs[DI.NECK_LATERAL_FLEXION],
        degs[DI.NECK_ROTATION],
    )

    trunk_score = trunk_rula_score(
        degs[DI.SPINE_EXTENSION_FLEXION],
        degs[DI.SPINE_LATERAL_FLEXION],
        degs[DI.SPINE_ROTATION_TORSION],
    )

    legs_score = 2 if are_legs_unsupported else 1

    # Score B Table Lookup
    score_b_raw = _TABLE_B_DATA[
        int(neck_score) - 1, int(trunk_score) - 1, int(legs_score) - 1
    ]
    grand_score_b = int(max(1, min(score_b_raw + muscle_score + force_score, 7)))

    # Final Synthesis (Table C)
    final_rula = _TABLE_C_DATA[grand_score_a - 1, grand_score_b - 1]

    # DO NOT CHANGE THESE NAMES
    final_scores = {
        "Upper_Arm_Score_RULA": upper_arm_score,
        "Lower_Arm_Score_RULA": lower_arm_score,
        "Trunk_Score_RULA": trunk_score,
        "Neck_Score_RULA": neck_score,
        "Wrist_Score_RULA": wrist_score,
        "Legs_Score_RULA": legs_score,
        "Score_A_RULA": grand_score_a,
        "Score_B_RULA": grand_score_b,
        "Final_Score_RULA": int(final_rula),
    }

    return final_scores, {}

calculate_frame_snook_index(snook_vars)

TODO: Calculate SNOOK Index.

Source code in calculators\snook_calculator\SNOOK_calculator.py
25
26
27
def calculate_frame_snook_index(snook_vars: Any) -> Any:
    """TODO: Calculate SNOOK Index."""
    pass

map_fmc_joint_angles_to_ergo_degs(row)

Maps FreeMoCap CSV columns to the flat array expected by REBA_calculator.

This function extracts specific joint angles from a DataFrame row and organizes them into a structured numpy.ndarray according to the DegsIndexes schema.

Parameters:

Name Type Description Default
row Series

A single row from a FreeMoCap joint angles DataFrame.

required

Returns:

Name Type Description
degs ndarray

A 22-element numpy.float64 array of joint angles.

Source code in calculators\adapters\freemocap_adapter.py
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
def map_fmc_joint_angles_to_ergo_degs(row: pd.Series) -> np.ndarray:
    """
    Maps FreeMoCap CSV columns to the flat array expected by REBA_calculator.

    This function extracts specific joint angles from a DataFrame row and
    organizes them into a structured `numpy.ndarray` according to the
    DegsIndexes schema.

    Args:
        row (pandas.Series): A single row from a FreeMoCap joint angles DataFrame.

    Returns:
        degs (numpy.ndarray): A 22-element `numpy.float64` array of joint angles.
    """
    # Initialize array of 22 zeros (matching your degs[16:22] logic)
    degs = np.zeros(22, dtype=np.float64)

    # 1. Legs [0:2] -> [right_knee, left_knee]
    degs[DI.RIGHT_KNEE_EXTENSION_FLEXION] = row["right_knee_extension_flexion"]
    degs[DI.LEFT_KNEE_EXTENSION_FLEXION] = row["left_knee_extension_flexion"]

    # 2. Trunk [2:5] -> [flexion, side_bending, torsion]
    degs[DI.SPINE_EXTENSION_FLEXION] = row["spine_extension_flexion"]
    degs[DI.SPINE_LATERAL_FLEXION] = row["spine_lateral_flexion"]
    degs[DI.SPINE_ROTATION_TORSION] = (
        0  # FreeMoCap spine rotation isn't always in base CSV, default to 0
    )

    # 3. Neck [5:8] -> [flexion, side_bend, twist]
    degs[DI.NECK_EXTENSION_FLEXION] = row["neck_extension_flexion"]
    degs[DI.NECK_LATERAL_FLEXION] = row["neck_lateral_flexion"]
    degs[DI.NECK_ROTATION] = row["neck_rotation"]

    # 4. Upper Arm [8:14] -> [R_flex, L_flex, R_side, L_side, R_rise, L_rise]
    degs[DI.RIGHT_SHOULDER_EXTENSION_FLEXION] = row["right_shoulder_extension_flexion"]
    degs[DI.LEFT_SHOULDER_EXTENSION_FLEXION] = row["left_shoulder_extension_flexion"]
    degs[DI.RIGHT_SHOULDER_ABDUCTION_ADDUCTION] = row[
        "right_shoulder_abduction_adduction"
    ]
    degs[DI.LEFT_SHOULDER_ABDUCTION_ADDUCTION] = row[
        "left_shoulder_abduction_adduction"
    ]
    # R/L Shoulder rise usually mapped from abduction or separate landmarks
    degs[DI.RIGHT_SHOULDER_RISE] = 0
    degs[DI.LEFT_SHOULDER_RISE] = 0

    # 5. Lower Arm [14:16] -> [right_elbow, left_elbow]
    degs[DI.RIGHT_ELBOW_EXTENSION_FLEXION] = row["right_elbow_extension_flexion"]
    degs[DI.LEFT_ELBOW_EXTENSION_FLEXION] = row["left_elbow_extension_flexion"]

    # 6. Wrist [16:22] -> [R_flex, L_flex, R_side, L_side, R_twist, L_twist]
    degs[DI.RIGHT_HAND_EXTENSION_FLEXION] = row["right_hand_extension_flexion"]
    degs[DI.LEFT_HAND_EXTENSION_FLEXION] = row["left_hand_extension_flexion"]

    # Side/Twist for wrist often 0 unless using high-fidelity FMC gloves/configs
    degs[DI.RIGHT_HAND_LATERAL_SIDE] = 0
    degs[DI.LEFT_HAND_LATERAL_SIDE] = 0
    degs[DI.RIGHT_HAND_TWIST] = 0
    degs[DI.LEFT_HAND_TWIST] = 0

    return degs

map_fmc_kinematics_to_ewas_vars(body_row)

Maps FreeMoCap joint angles to the variables required for EAWS Section 1.

This adapter extracts trunk and neck angles, ensuring they are narrowed to float types to satisfy static type checkers.

Parameters:

Name Type Description Default
body_row Series

A single frame of joint angle data.

required

Returns:

Name Type Description
eaws_vars dict[str, float]

Cleaned kinematic variables for EAWS calculation. - 'trunk_flexion': Degrees of forward bend. - 'trunk_lateral': Degrees of side bending. - 'neck_flexion': Degrees of neck bend.

Source code in calculators\adapters\freemocap_adapter.py
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
def map_fmc_kinematics_to_ewas_vars(body_row: pd.Series) -> dict[str, float]:
    """
    Maps FreeMoCap joint angles to the variables required for EAWS Section 1.

    This adapter extracts trunk and neck angles, ensuring they are narrowed
    to float types to satisfy static type checkers.

    Args:
        body_row (pd.Series): A single frame of joint angle data.

    Returns:
        eaws_vars (dict[str, float]): Cleaned kinematic variables for EAWS calculation.
            - 'trunk_flexion': Degrees of forward bend.
            - 'trunk_lateral': Degrees of side bending.
            - 'neck_flexion': Degrees of neck bend.
    """
    return {
        "trunk_flexion": float(body_row.get("spine_extension_flexion", 0.0)),
        "trunk_lateral": float(body_row.get("spine_lateral_flexion", 0.0)),
        "neck_flexion": float(body_row.get("neck_extension_flexion", 0.0)),
        "trunk_rotation": float(body_row.get("spine_rotation", 0.0)),
    }

map_fmc_kinematics_to_niosh_vars(body_row, load_weight=5.0)

Maps FreeMoCap 3D trajectory data to NIOSH Lifting Equation variables.

This function extracts the geometric spatial relationships between the worker's ankles, hips, and wrists to compute the horizontal, vertical, and asymmetric components of a lift.

Parameters:

Name Type Description Default
body_row Series

A single frame from 'mediapipe_body_3d_xyz.csv'.

required
load_weight float

The weight of the object in kg. Defaults to 5.0.

5.0

Returns:

Name Type Description
niosh_vars dict[str, float]

dictionary containing: - 'load': Actual mass (kg) - 'H': Horizontal distance (cm/units) - 'V': Vertical height (cm/units) - 'A': Asymmetry angle (degrees) - 'D': Vertical travel (default 0.0 for frame-based)

Source code in calculators\adapters\freemocap_adapter.py
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
def map_fmc_kinematics_to_niosh_vars(
    body_row: pd.Series, load_weight: float = 5.0
) -> dict[str, float]:
    """
    Maps FreeMoCap 3D trajectory data to NIOSH Lifting Equation variables.

    This function extracts the geometric spatial relationships between the
    worker's ankles, hips, and wrists to compute the horizontal, vertical,
    and asymmetric components of a lift.

    Args:
        body_row (pd.Series): A single frame from 'mediapipe_body_3d_xyz.csv'.
        load_weight (float): The weight of the object in kg. Defaults to 5.0.

    Returns:
        niosh_vars (dict[str, float]): dictionary containing:
            - 'load': Actual mass (kg)
            - 'H': Horizontal distance (cm/units)
            - 'V': Vertical height (cm/units)
            - 'A': Asymmetry angle (degrees)
            - 'D': Vertical travel (default 0.0 for frame-based)
    """
    # 1. Origin (Mid-Ankle) and Load (Mid-Wrist) positions
    l_ank_xz = np.array(
        [float(body_row["left_ankle_x"]), float(body_row["left_ankle_z"])]
    )
    r_ank_xz = np.array(
        [float(body_row["right_ankle_x"]), float(body_row["right_ankle_z"])]
    )
    mid_ankle_xz = (l_ank_xz + r_ank_xz) / 2.0

    l_wri_xz = np.array(
        [float(body_row["left_wrist_x"]), float(body_row["left_wrist_z"])]
    )
    r_wri_xz = np.array(
        [float(body_row["right_wrist_x"]), float(body_row["right_wrist_z"])]
    )
    mid_hand_xz = (l_wri_xz + r_wri_xz) / 2.0

    # 2. Compute H (Horizontal Distance)
    h_dist = float(np.linalg.norm(mid_hand_xz - mid_ankle_xz))

    # 3. Compute V (Vertical Height)
    ankle_y = (float(body_row["left_ankle_y"]) + float(body_row["right_ankle_y"])) / 2.0
    hand_y = (float(body_row["left_wrist_y"]) + float(body_row["right_wrist_y"])) / 2.0
    v_dist = abs(hand_y - ankle_y)

    # 4. Compute A (Asymmetry Angle)
    asymmetry_angle = calculate_asymmetry_angle_from_sagittal_plane(body_row)

    return {
        "load": float(load_weight),
        "H": h_dist,
        "V": v_dist,
        "A": asymmetry_angle,
        "D": 0.0,  # Displacement requires temporal start/end frames
    }

map_fmc_kinematics_to_ocra_vars(degs)

Translates FreeMoCap kinematic slices into OCRA-specific risk variables.

This mapper isolates upper-limb kinematics and categorizes them into postural 'Technical Actions' based on ISO 11228-3 thresholds. It evaluates both limbs and returns the highest risk found.

Parameters:

Name Type Description Default
degs NDArray[float64]

A 1D array containing 22 kinematic values. Expected slices: - [8:10] Upper Arm Flexion: [Right, Left] - [10:12] Upper Arm Abduction: [Right, Left] - [14:16] Lower Arm Flexion: [Right, Left] - [16:18] Wrist Flexion/Extension: [Right, Left] - [18:20] Wrist Deviation: [Right, Left]

required

Returns:

Name Type Description
ocra_flags dict[str, any]

Boolean risk flags for the scoring engine: - 'shoulder_extreme': True if Flex/Abd > 80°. - 'shoulder_heavy': True if Flex/Abd > 40°. - 'elbow_extreme': True if Flex < 40° or Flex > 150°. - 'wrist_extreme': True if Flex/Ext > 45° or Deviation > 15°.

Source code in calculators\adapters\freemocap_adapter.py
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
def map_fmc_kinematics_to_ocra_vars(degs: NDArray[np.float64]) -> dict[str, Any]:
    """
    Translates FreeMoCap kinematic slices into OCRA-specific risk variables.

    This mapper isolates upper-limb kinematics and categorizes them into
    postural 'Technical Actions' based on ISO 11228-3 thresholds. It
    evaluates both limbs and returns the highest risk found.

    Args:
        degs (NDArray[np.float64]): A 1D array containing 22 kinematic values.
            Expected slices:
            - [8:10]   Upper Arm Flexion: [Right, Left]
            - [10:12]  Upper Arm Abduction: [Right, Left]
            - [14:16]  Lower Arm Flexion: [Right, Left]
            - [16:18]  Wrist Flexion/Extension: [Right, Left]
            - [18:20]  Wrist Deviation: [Right, Left]

    Returns:
        ocra_flags (dict[str, any]): Boolean risk flags for the scoring engine:
            - 'shoulder_extreme': True if Flex/Abd > 80°.
            - 'shoulder_heavy': True if Flex/Abd > 40°.
            - 'elbow_extreme': True if Flex < 40° or Flex > 150°.
            - 'wrist_extreme': True if Flex/Ext > 45° or Deviation > 15°.
    """
    if len(degs) != 22:
        raise IndexError(f"OCRA Mapper expected 22 values, received {len(degs)}")

    # Evaluate both sides to find the worst-case posture
    # Indices: 8/9 (Flexion), 10/11 (Abduction), 14/15 (Elbow), 16/17 (Wrist Flex), 18/19 (Wrist Dev)
    r_shoulder = max(degs[8], degs[10])
    l_shoulder = max(degs[9], degs[11])
    max_shoulder = max(r_shoulder, l_shoulder)

    max_elbow_flex = max(degs[14], degs[15])
    min_elbow_flex = min(degs[14], degs[15])

    max_wrist_flex = max(abs(degs[16]), abs(degs[17]))
    max_wrist_dev = max(abs(degs[18]), abs(degs[19]))

    return {
        "shoulder_extreme": bool(max_shoulder > 80.0),
        "shoulder_heavy": bool(max_shoulder > 40.0),
        "elbow_extreme": bool(min_elbow_flex < 40.0 or max_elbow_flex > 150.0),
        "wrist_extreme": bool(max_wrist_flex > 45.0 or max_wrist_dev > 15.0),
    }

map_fmc_kinematics_to_snook_vars(body_3d_xyz, event_frames)

Extracts Snook/Liberty Mutual spatial variables from an FMC event segment.

Processes a temporal slice of 3D data to determine vertical travel and average horizontal reach during a specific lifting or lowering event.

Parameters:

Name Type Description Default
body_3d_xyz ndarray

A [Frames, Joints, 3] numpy.ndarray of coordinates.

required
event_frames tuple[int, int]

A tuple containing (start_frame, end_frame).

required

Returns:

Name Type Description
snook_vars dict[str, float]

Dictionary with 'v_start_cm', 'v_end_cm', 'v_travel_cm', and 'h_dist_cm'.

Source code in calculators\adapters\freemocap_adapter.py
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
def map_fmc_kinematics_to_snook_vars(
    body_3d_xyz: np.ndarray, event_frames: tuple[int, int]
) -> dict[str, float]:
    """
    Extracts Snook/Liberty Mutual spatial variables from an FMC event segment.

    Processes a temporal slice of 3D data to determine vertical travel and average
    horizontal reach during a specific lifting or lowering event.

    Args:
        body_3d_xyz (numpy.ndarray): A [Frames, Joints, 3] `numpy.ndarray` of coordinates.
        event_frames (tuple[int, int]): A `tuple` containing (start_frame, end_frame).

    Returns:
        snook_vars (dict[str, float]): Dictionary with 'v_start_cm', 'v_end_cm', 'v_travel_cm', and 'h_dist_cm'.
    """
    start_idx, end_idx = event_frames

    # 1. Vertical Heights (V) at start and end
    # Using Mid-Wrist (Average of 15, 16)
    wrists_y = (body_3d_xyz[:, 15, 1] + body_3d_xyz[:, 16, 1]) / 2.0
    v_start = wrists_y[start_idx]
    v_end = wrists_y[end_idx]
    v_travel = abs(v_end - v_start)

    # 2. Horizontal Distance (H)
    # Measured from Mid-Hip (23) to Mid-Wrist (15/16)
    hips_xz = (body_3d_xyz[:, 23, 0], body_3d_xyz[:, 23, 2])
    wrists_xz = (
        (body_3d_xyz[:, 15, 0] + body_3d_xyz[:, 16, 0]) / 2.0,
        (body_3d_xyz[:, 15, 2] + body_3d_xyz[:, 16, 2]) / 2.0,
    )

    # Calculate H for all frames in the event and take the mean
    h_dist = np.mean(
        np.sqrt((wrists_xz[0] - hips_xz[0]) ** 2 + (wrists_xz[1] - hips_xz[1]) ** 2)
    )

    # Snook adjustment: Subtract ~20cm for body depth (Abdomen origin)
    h_snook = float(max(h_dist - 20.0, 0.0))

    return {
        "v_start_cm": v_start,
        "v_end_cm": v_end,
        "v_travel_cm": v_travel,
        "h_dist_cm": h_snook,
    }

options: show_root_heading: true heading_level: 3

calculators.calculators_utils.conversion_utils

ErgoMoCap: Measurement Conversion Utilities

Unit normalization and conversion engine for anthropometric and environmental data.

This module provides a robust set of utilities for converting measurements between metric and imperial systems. It uses a base-unit normalization strategy (cm for length, kg for mass, and L for volume) to ensure precision and simplify the internal conversion logic. These utilities are essential for processing user-provided physical attributes and environmental parameters within the ergonomic analysis pipeline.

Key conversion categories: - Length: Supports metric (m, cm, mm) and imperial (inch, foot, yard) units. - Mass: Supports metric (kg, g) and imperial (lb, oz) units. - Volume: Supports metric (L, ml) and imperial (gal, qt) units. - Temperature: Provides precision scaling between Celsius, Fahrenheit, and Kelvin.

Functions

convert_length(value, pre_unit, post_unit)

Converts a length value from one unit to another using a metric base-cm scale.

Parameters:

Name Type Description Default
value float

The numeric value to be converted.

required
pre_unit str

The current unit of the value (e.g., 'm', 'cm', 'inch', 'foot').

required
post_unit str

The target unit for the conversion (e.g., 'mm', 'yard', 'cm').

required

Returns:

Name Type Description
float float

The converted value, rounded to 4 decimal places.

Raises:

Type Description
ValueError

If either pre_unit or post_unit is not found in the LENGTH_FACTORS registry.

Source code in calculators\calculators_utils\conversion_utils.py
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
def convert_length(value: float, pre_unit: str, post_unit: str) -> float:
    """
    Converts a length value from one unit to another using a metric base-cm scale.

    Args:
        value (float): The numeric value to be converted.
        pre_unit (str): The current unit of the value (e.g., 'm', 'cm', 'inch', 'foot').
        post_unit (str): The target unit for the conversion (e.g., 'mm', 'yard', 'cm').

    Returns:
        float: The converted value, rounded to 4 decimal places.

    Raises:
        ValueError: If either pre_unit or post_unit is not found in the LENGTH_FACTORS registry.
    """
    if pre_unit not in LENGTH_FACTORS or post_unit not in LENGTH_FACTORS:
        raise ValueError(
            f"Unsupported length unit. Supported: {list(LENGTH_FACTORS.keys())}"
        )

    # Convert input to cm, then cm to post_unit
    value_in_cm = value * LENGTH_FACTORS[pre_unit]
    result = value_in_cm / LENGTH_FACTORS[post_unit]
    return round(result, 4)

convert_mass(value, pre_unit, post_unit)

Converts a mass/weight value from one unit to another using a metric base-kg scale.

Parameters:

Name Type Description Default
value float

The numeric value to be converted.

required
pre_unit str

The current unit of the value (e.g., 'kg', 'lb', 'oz').

required
post_unit str

The target unit for the conversion (e.g., 'g', 'kg', 'lb').

required

Returns:

Name Type Description
float float

The converted value, rounded to 4 decimal places.

Raises:

Type Description
ValueError

If either pre_unit or post_unit is not found in the MASS_FACTORS registry.

Source code in calculators\calculators_utils\conversion_utils.py
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
def convert_mass(value: float, pre_unit: str, post_unit: str) -> float:
    """
    Converts a mass/weight value from one unit to another using a metric base-kg scale.

    Args:
        value (float): The numeric value to be converted.
        pre_unit (str): The current unit of the value (e.g., 'kg', 'lb', 'oz').
        post_unit (str): The target unit for the conversion (e.g., 'g', 'kg', 'lb').

    Returns:
        float: The converted value, rounded to 4 decimal places.

    Raises:
        ValueError: If either pre_unit or post_unit is not found in the MASS_FACTORS registry.
    """
    if pre_unit not in MASS_FACTORS or post_unit not in MASS_FACTORS:
        raise ValueError(
            f"Unsupported mass unit. Supported: {list(MASS_FACTORS.keys())}"
        )

    value_in_kg = value * MASS_FACTORS[pre_unit]
    result = value_in_kg / MASS_FACTORS[post_unit]
    return round(result, 4)

convert_temp(value, pre_unit, post_unit)

Converts a temperature value between Celsius, Fahrenheit, and Kelvin scales.

Parameters:

Name Type Description Default
value float

The numeric temperature to be converted.

required
pre_unit Literal['c', 'f', 'k']

The source scale ('c' for Celsius, 'f' for Fahrenheit, 'k' for Kelvin).

required
post_unit Literal['c', 'f', 'k']

The target scale for the conversion.

required

Returns:

Name Type Description
float float

The converted temperature, rounded to 2 decimal places.

Raises:

Type Description
ValueError

If an unsupported scale is provided.

Source code in calculators\calculators_utils\conversion_utils.py
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
def convert_temp(
    value: float, pre_unit: Literal["c", "f", "k"], post_unit: Literal["c", "f", "k"]
) -> float:
    """
    Converts a temperature value between Celsius, Fahrenheit, and Kelvin scales.

    Args:
        value (float): The numeric temperature to be converted.
        pre_unit (Literal['c', 'f', 'k']): The source scale ('c' for Celsius, 'f' for Fahrenheit, 'k' for Kelvin).
        post_unit (Literal['c', 'f', 'k']): The target scale for the conversion.

    Returns:
        float: The converted temperature, rounded to 2 decimal places.

    Raises:
        ValueError: If an unsupported scale is provided.
    """
    # 1. Normalize to Celsius
    if pre_unit == "c":
        temp_c = value
    elif pre_unit == "f":
        temp_c = (value - 32) * 5 / 9
    elif pre_unit == "k":
        temp_c = value - 273.15
    else:
        raise ValueError("Unit must be 'c', 'f', or 'k'")

    # 2. Convert Celsius to target
    if post_unit == "c":
        return round(temp_c, 2)
    if post_unit == "f":
        return round((temp_c * 9 / 5) + 32, 2)
    if post_unit == "k":
        return round(temp_c + 273.15, 2)

    return round(temp_c, 2)

convert_volume(value, pre_unit, post_unit)

Converts a volume value from one unit to another using a metric base-L scale.

Parameters:

Name Type Description Default
value float

The numeric volume to be converted.

required
pre_unit str

The current unit of the value (e.g., 'l', 'ml', 'gal').

required
post_unit str

The target unit for the conversion (e.g., 'qt', 'l', 'ml').

required

Returns:

Name Type Description
float float

The converted value, rounded to 4 decimal places.

Raises:

Type Description
ValueError

If either pre_unit or post_unit is not found in the VOLUME_FACTORS registry.

Source code in calculators\calculators_utils\conversion_utils.py
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
def convert_volume(value: float, pre_unit: str, post_unit: str) -> float:
    """
    Converts a volume value from one unit to another using a metric base-L scale.

    Args:
        value (float): The numeric volume to be converted.
        pre_unit (str): The current unit of the value (e.g., 'l', 'ml', 'gal').
        post_unit (str): The target unit for the conversion (e.g., 'qt', 'l', 'ml').

    Returns:
        float: The converted value, rounded to 4 decimal places.

    Raises:
        ValueError: If either pre_unit or post_unit is not found in the VOLUME_FACTORS registry.
    """
    if pre_unit not in VOLUME_FACTORS or post_unit not in VOLUME_FACTORS:
        raise ValueError(
            f"Unsupported volume unit. Supported: {list(VOLUME_FACTORS.keys())}"
        )

    value_in_l = value * VOLUME_FACTORS[pre_unit]
    result = value_in_l / VOLUME_FACTORS[post_unit]
    return round(result, 4)

options: show_root_heading: true heading_level: 3

calculators.calculators_utils.constants

options: show_root_heading: true heading_level: 3


Data Adapters

Adapters responsible for converting raw sensor, computer vision tracking, or force metrics into calculator-ready formats.

calculators.adapters.freemocap_adapter

ErgoMoCap: FreeMoCap Adapter

Kinematic Mapping and Biomechanical Variable Extraction.

This module acts as the primary translation layer between raw FreeMoCap output formats and the specific input requirements of various ergonomic assessment engines (RULA, REBA, NIOSH, OCRA, EAWS, and Snook).

It provides: - Index Mapping: A standardized enumeration (DegsIndexes) for internal kinematic arrays. - Coordinate Transformation: Calculation of spatial variables like asymmetry angles relative to the mid-sagittal plane. - Task Specific Extraction: Specialized mappers that isolate relevant joint angles and 3D landmarks for different ergonomic standards.

All functions are optimized for use within pandas.DataFrame application pipelines and maintain strict adherence to biomechanical coordinate conventions.

Classes

DegsIndexes

Bases: IntEnum

Standardized indices using EXACT FreeMoCap column nomenclature in CAPSLOCK.

This enumeration provides a semantic mapping for the flat kinematic arrays used throughout the ErgoMoCap project. It ensures that indices for legs, trunk, neck, and upper/lower limbs are consistent across different ergonomic calculators (RULA, REBA, EAWS).

Attributes:

Name Type Description
RIGHT_KNEE_EXTENSION_FLEXION int

Index 0.

LEFT_KNEE_EXTENSION_FLEXION int

Index 1.

SPINE_EXTENSION_FLEXION int

Index 2.

SPINE_LATERAL_FLEXION int

Index 3.

SPINE_ROTATION_TORSION int

Index 4.

NECK_EXTENSION_FLEXION int

Index 5.

NECK_LATERAL_FLEXION int

Index 6.

NECK_ROTATION int

Index 7.

RIGHT_SHOULDER_EXTENSION_FLEXION int

Index 8.

LEFT_SHOULDER_EXTENSION_FLEXION int

Index 9.

RIGHT_SHOULDER_ABDUCTION_ADDUCTION int

Index 10.

LEFT_SHOULDER_ABDUCTION_ADDUCTION int

Index 11.

RIGHT_SHOULDER_RISE int

Index 12.

LEFT_SHOULDER_RISE int

Index 13.

RIGHT_ELBOW_EXTENSION_FLEXION int

Index 14.

LEFT_ELBOW_EXTENSION_FLEXION int

Index 15.

RIGHT_HAND_EXTENSION_FLEXION int

Index 16.

LEFT_HAND_EXTENSION_FLEXION int

Index 17.

RIGHT_HAND_LATERAL_SIDE int

Index 18.

LEFT_HAND_LATERAL_SIDE int

Index 19.

RIGHT_HAND_TWIST int

Index 20.

LEFT_HAND_TWIST int

Index 21.

Source code in calculators\adapters\freemocap_adapter.py
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
class DegsIndexes(IntEnum):
    """
    Standardized indices using EXACT FreeMoCap column nomenclature in CAPSLOCK.

    This enumeration provides a semantic mapping for the flat kinematic arrays
    used throughout the ErgoMoCap project. It ensures that indices for legs,
    trunk, neck, and upper/lower limbs are consistent across different
    ergonomic calculators (RULA, REBA, EAWS).

    Attributes:
        RIGHT_KNEE_EXTENSION_FLEXION (int): Index 0.
        LEFT_KNEE_EXTENSION_FLEXION (int): Index 1.
        SPINE_EXTENSION_FLEXION (int): Index 2.
        SPINE_LATERAL_FLEXION (int): Index 3.
        SPINE_ROTATION_TORSION (int): Index 4.
        NECK_EXTENSION_FLEXION (int): Index 5.
        NECK_LATERAL_FLEXION (int): Index 6.
        NECK_ROTATION (int): Index 7.
        RIGHT_SHOULDER_EXTENSION_FLEXION (int): Index 8.
        LEFT_SHOULDER_EXTENSION_FLEXION (int): Index 9.
        RIGHT_SHOULDER_ABDUCTION_ADDUCTION (int): Index 10.
        LEFT_SHOULDER_ABDUCTION_ADDUCTION (int): Index 11.
        RIGHT_SHOULDER_RISE (int): Index 12.
        LEFT_SHOULDER_RISE (int): Index 13.
        RIGHT_ELBOW_EXTENSION_FLEXION (int): Index 14.
        LEFT_ELBOW_EXTENSION_FLEXION (int): Index 15.
        RIGHT_HAND_EXTENSION_FLEXION (int): Index 16.
        LEFT_HAND_EXTENSION_FLEXION (int): Index 17.
        RIGHT_HAND_LATERAL_SIDE (int): Index 18.
        LEFT_HAND_LATERAL_SIDE (int): Index 19.
        RIGHT_HAND_TWIST (int): Index 20.
        LEFT_HAND_TWIST (int): Index 21.
    """

    # 1. LEGS [0:2]
    RIGHT_KNEE_EXTENSION_FLEXION = 0
    LEFT_KNEE_EXTENSION_FLEXION = 1

    # 2. TRUNK [2:5]
    SPINE_EXTENSION_FLEXION = 2
    SPINE_LATERAL_FLEXION = 3
    SPINE_ROTATION_TORSION = 4  # Explicitly mapped for future-proofing

    # 3. NECK [5:8]
    NECK_EXTENSION_FLEXION = 5
    NECK_LATERAL_FLEXION = 6
    NECK_ROTATION = 7

    # 4. UPPER ARM [8:14]
    RIGHT_SHOULDER_EXTENSION_FLEXION = 8
    LEFT_SHOULDER_EXTENSION_FLEXION = 9
    RIGHT_SHOULDER_ABDUCTION_ADDUCTION = 10
    LEFT_SHOULDER_ABDUCTION_ADDUCTION = 11
    RIGHT_SHOULDER_RISE = 12
    LEFT_SHOULDER_RISE = 13

    # 5. LOWER ARM [14:16]
    RIGHT_ELBOW_EXTENSION_FLEXION = 14
    LEFT_ELBOW_EXTENSION_FLEXION = 15

    # 6. WRIST [16:22]
    RIGHT_HAND_EXTENSION_FLEXION = 16
    LEFT_HAND_EXTENSION_FLEXION = 17
    RIGHT_HAND_LATERAL_SIDE = 18
    LEFT_HAND_LATERAL_SIDE = 19
    RIGHT_HAND_TWIST = 20
    LEFT_HAND_TWIST = 21

Functions

calculate_asymmetry_angle_from_sagittal_plane(body_kinematic_row)

Calculates the angular displacement of the load relative to the mid-sagittal plane.

The calculation projects the body's forward orientation and the load position onto the horizontal XZ floor plane. The angle is determined by the displacement of the mid-wrist point relative to the forward vector originating from the mid-ankle point.

Parameters:

Name Type Description Default
body_kinematic_row Series

Frame data containing 'x' and 'z' 3D coordinates.

required

Returns:

Name Type Description
asymmetry_angle_in_degrees float

The calculated angle in degrees (0 to 180).

NOTE

USED for NIOSH (TODO maybe put it in the NIOSH folder)

Source code in calculators\adapters\freemocap_adapter.py
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
def calculate_asymmetry_angle_from_sagittal_plane(
    body_kinematic_row: pd.Series,
) -> float:
    """
    Calculates the angular displacement of the load relative to the mid-sagittal plane.

    The calculation projects the body's forward orientation and the load position
    onto the horizontal XZ floor plane. The angle is determined by the displacement
    of the mid-wrist point relative to the forward vector originating from the
    mid-ankle point.

    Args:
        body_kinematic_row (pandas.Series): Frame data containing 'x' and 'z' 3D coordinates.

    Returns:
        asymmetry_angle_in_degrees (float): The calculated angle in degrees (0 to 180).

    NOTE:
        USED for NIOSH (TODO maybe put it in the NIOSH folder)
    """

    # 1. DEFINE BODY ORIENTATION VECTOR (XZ PLANE)
    # We extract the 2D coordinates for the hips to establish the lateral axis
    left_pelvic_hip_joint_xz_coordinates = np.array(
        [
            float(body_kinematic_row["left_hip_x"]),
            float(body_kinematic_row["left_hip_z"]),
        ]
    )
    right_pelvic_hip_joint_xz_coordinates = np.array(
        [
            float(body_kinematic_row["right_hip_x"]),
            float(body_kinematic_row["right_hip_z"]),
        ]
    )

    # Calculate the vector representing the line between both hips
    hip_to_hip_lateral_axis_vector = (
        right_pelvic_hip_joint_xz_coordinates - left_pelvic_hip_joint_xz_coordinates
    )

    # The forward-facing orientation (sagittal plane) is defined as the
    # orthogonal vector to the lateral hip-to-hip line.
    # Rotation transformation: (dx, dz) -> (-dz, dx)
    body_forward_facing_sagittal_vector = np.array(
        [-hip_to_hip_lateral_axis_vector[1], hip_to_hip_lateral_axis_vector[0]]
    )

    # 2. DEFINE LOAD POSITION VECTOR (ORIGIN AT ANKLE MIDPOINT)
    # We establish the base of the user (mid-point between ankles)
    left_ankle_joint_xz_coordinates = np.array(
        [
            float(body_kinematic_row["left_ankle_x"]),
            float(body_kinematic_row["left_ankle_z"]),
        ]
    )
    right_ankle_joint_xz_coordinates = np.array(
        [
            float(body_kinematic_row["right_ankle_x"]),
            float(body_kinematic_row["right_ankle_z"]),
        ]
    )
    midpoint_origin_between_ankles = (
        left_ankle_joint_xz_coordinates + right_ankle_joint_xz_coordinates
    ) / 2.0

    # We establish the position of the load (mid-point between wrists)
    left_wrist_joint_xz_coordinates = np.array(
        [
            float(body_kinematic_row["left_wrist_x"]),
            float(body_kinematic_row["left_wrist_z"]),
        ]
    )
    right_wrist_joint_xz_coordinates = np.array(
        [
            float(body_kinematic_row["right_wrist_x"]),
            float(body_kinematic_row["right_wrist_z"]),
        ]
    )
    midpoint_load_position_between_wrists = (
        left_wrist_joint_xz_coordinates + right_wrist_joint_xz_coordinates
    ) / 2.0

    # The load vector represents the direction from the feet to the hands
    vector_pointing_towards_load_center = (
        midpoint_load_position_between_wrists - midpoint_origin_between_ankles
    )

    # 3. CALCULATE ANGULAR DISPLACEMENT (DOT PRODUCT METHOD)
    magnitude_of_forward_facing_vector = np.linalg.norm(
        body_forward_facing_sagittal_vector
    )
    magnitude_of_load_direction_vector = np.linalg.norm(
        vector_pointing_towards_load_center
    )

    # Handle division by zero for stationary or overlapping coordinates
    if (
        magnitude_of_forward_facing_vector == 0
        or magnitude_of_load_direction_vector == 0
    ):
        return 0.0

    # Calculate normalized dot product to find the cosine of the angle
    normalized_dot_product_of_vectors = np.dot(
        body_forward_facing_sagittal_vector, vector_pointing_towards_load_center
    ) / (magnitude_of_forward_facing_vector * magnitude_of_load_direction_vector)

    # Clip values to handle floating point precision errors outside the [-1, 1] range
    clamped_cosine_value = np.clip(normalized_dot_product_of_vectors, -1.0, 1.0)

    # Convert arc-cosine result from radians to degrees
    asymmetry_angle_in_degrees = np.degrees(np.arccos(clamped_cosine_value))

    return float(asymmetry_angle_in_degrees)

map_fmc_joint_angles_to_ergo_degs(row)

Maps FreeMoCap CSV columns to the flat array expected by REBA_calculator.

This function extracts specific joint angles from a DataFrame row and organizes them into a structured numpy.ndarray according to the DegsIndexes schema.

Parameters:

Name Type Description Default
row Series

A single row from a FreeMoCap joint angles DataFrame.

required

Returns:

Name Type Description
degs ndarray

A 22-element numpy.float64 array of joint angles.

Source code in calculators\adapters\freemocap_adapter.py
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
def map_fmc_joint_angles_to_ergo_degs(row: pd.Series) -> np.ndarray:
    """
    Maps FreeMoCap CSV columns to the flat array expected by REBA_calculator.

    This function extracts specific joint angles from a DataFrame row and
    organizes them into a structured `numpy.ndarray` according to the
    DegsIndexes schema.

    Args:
        row (pandas.Series): A single row from a FreeMoCap joint angles DataFrame.

    Returns:
        degs (numpy.ndarray): A 22-element `numpy.float64` array of joint angles.
    """
    # Initialize array of 22 zeros (matching your degs[16:22] logic)
    degs = np.zeros(22, dtype=np.float64)

    # 1. Legs [0:2] -> [right_knee, left_knee]
    degs[DI.RIGHT_KNEE_EXTENSION_FLEXION] = row["right_knee_extension_flexion"]
    degs[DI.LEFT_KNEE_EXTENSION_FLEXION] = row["left_knee_extension_flexion"]

    # 2. Trunk [2:5] -> [flexion, side_bending, torsion]
    degs[DI.SPINE_EXTENSION_FLEXION] = row["spine_extension_flexion"]
    degs[DI.SPINE_LATERAL_FLEXION] = row["spine_lateral_flexion"]
    degs[DI.SPINE_ROTATION_TORSION] = (
        0  # FreeMoCap spine rotation isn't always in base CSV, default to 0
    )

    # 3. Neck [5:8] -> [flexion, side_bend, twist]
    degs[DI.NECK_EXTENSION_FLEXION] = row["neck_extension_flexion"]
    degs[DI.NECK_LATERAL_FLEXION] = row["neck_lateral_flexion"]
    degs[DI.NECK_ROTATION] = row["neck_rotation"]

    # 4. Upper Arm [8:14] -> [R_flex, L_flex, R_side, L_side, R_rise, L_rise]
    degs[DI.RIGHT_SHOULDER_EXTENSION_FLEXION] = row["right_shoulder_extension_flexion"]
    degs[DI.LEFT_SHOULDER_EXTENSION_FLEXION] = row["left_shoulder_extension_flexion"]
    degs[DI.RIGHT_SHOULDER_ABDUCTION_ADDUCTION] = row[
        "right_shoulder_abduction_adduction"
    ]
    degs[DI.LEFT_SHOULDER_ABDUCTION_ADDUCTION] = row[
        "left_shoulder_abduction_adduction"
    ]
    # R/L Shoulder rise usually mapped from abduction or separate landmarks
    degs[DI.RIGHT_SHOULDER_RISE] = 0
    degs[DI.LEFT_SHOULDER_RISE] = 0

    # 5. Lower Arm [14:16] -> [right_elbow, left_elbow]
    degs[DI.RIGHT_ELBOW_EXTENSION_FLEXION] = row["right_elbow_extension_flexion"]
    degs[DI.LEFT_ELBOW_EXTENSION_FLEXION] = row["left_elbow_extension_flexion"]

    # 6. Wrist [16:22] -> [R_flex, L_flex, R_side, L_side, R_twist, L_twist]
    degs[DI.RIGHT_HAND_EXTENSION_FLEXION] = row["right_hand_extension_flexion"]
    degs[DI.LEFT_HAND_EXTENSION_FLEXION] = row["left_hand_extension_flexion"]

    # Side/Twist for wrist often 0 unless using high-fidelity FMC gloves/configs
    degs[DI.RIGHT_HAND_LATERAL_SIDE] = 0
    degs[DI.LEFT_HAND_LATERAL_SIDE] = 0
    degs[DI.RIGHT_HAND_TWIST] = 0
    degs[DI.LEFT_HAND_TWIST] = 0

    return degs

map_fmc_kinematics_to_ewas_vars(body_row)

Maps FreeMoCap joint angles to the variables required for EAWS Section 1.

This adapter extracts trunk and neck angles, ensuring they are narrowed to float types to satisfy static type checkers.

Parameters:

Name Type Description Default
body_row Series

A single frame of joint angle data.

required

Returns:

Name Type Description
eaws_vars dict[str, float]

Cleaned kinematic variables for EAWS calculation. - 'trunk_flexion': Degrees of forward bend. - 'trunk_lateral': Degrees of side bending. - 'neck_flexion': Degrees of neck bend.

Source code in calculators\adapters\freemocap_adapter.py
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
def map_fmc_kinematics_to_ewas_vars(body_row: pd.Series) -> dict[str, float]:
    """
    Maps FreeMoCap joint angles to the variables required for EAWS Section 1.

    This adapter extracts trunk and neck angles, ensuring they are narrowed
    to float types to satisfy static type checkers.

    Args:
        body_row (pd.Series): A single frame of joint angle data.

    Returns:
        eaws_vars (dict[str, float]): Cleaned kinematic variables for EAWS calculation.
            - 'trunk_flexion': Degrees of forward bend.
            - 'trunk_lateral': Degrees of side bending.
            - 'neck_flexion': Degrees of neck bend.
    """
    return {
        "trunk_flexion": float(body_row.get("spine_extension_flexion", 0.0)),
        "trunk_lateral": float(body_row.get("spine_lateral_flexion", 0.0)),
        "neck_flexion": float(body_row.get("neck_extension_flexion", 0.0)),
        "trunk_rotation": float(body_row.get("spine_rotation", 0.0)),
    }

map_fmc_kinematics_to_niosh_vars(body_row, load_weight=5.0)

Maps FreeMoCap 3D trajectory data to NIOSH Lifting Equation variables.

This function extracts the geometric spatial relationships between the worker's ankles, hips, and wrists to compute the horizontal, vertical, and asymmetric components of a lift.

Parameters:

Name Type Description Default
body_row Series

A single frame from 'mediapipe_body_3d_xyz.csv'.

required
load_weight float

The weight of the object in kg. Defaults to 5.0.

5.0

Returns:

Name Type Description
niosh_vars dict[str, float]

dictionary containing: - 'load': Actual mass (kg) - 'H': Horizontal distance (cm/units) - 'V': Vertical height (cm/units) - 'A': Asymmetry angle (degrees) - 'D': Vertical travel (default 0.0 for frame-based)

Source code in calculators\adapters\freemocap_adapter.py
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
def map_fmc_kinematics_to_niosh_vars(
    body_row: pd.Series, load_weight: float = 5.0
) -> dict[str, float]:
    """
    Maps FreeMoCap 3D trajectory data to NIOSH Lifting Equation variables.

    This function extracts the geometric spatial relationships between the
    worker's ankles, hips, and wrists to compute the horizontal, vertical,
    and asymmetric components of a lift.

    Args:
        body_row (pd.Series): A single frame from 'mediapipe_body_3d_xyz.csv'.
        load_weight (float): The weight of the object in kg. Defaults to 5.0.

    Returns:
        niosh_vars (dict[str, float]): dictionary containing:
            - 'load': Actual mass (kg)
            - 'H': Horizontal distance (cm/units)
            - 'V': Vertical height (cm/units)
            - 'A': Asymmetry angle (degrees)
            - 'D': Vertical travel (default 0.0 for frame-based)
    """
    # 1. Origin (Mid-Ankle) and Load (Mid-Wrist) positions
    l_ank_xz = np.array(
        [float(body_row["left_ankle_x"]), float(body_row["left_ankle_z"])]
    )
    r_ank_xz = np.array(
        [float(body_row["right_ankle_x"]), float(body_row["right_ankle_z"])]
    )
    mid_ankle_xz = (l_ank_xz + r_ank_xz) / 2.0

    l_wri_xz = np.array(
        [float(body_row["left_wrist_x"]), float(body_row["left_wrist_z"])]
    )
    r_wri_xz = np.array(
        [float(body_row["right_wrist_x"]), float(body_row["right_wrist_z"])]
    )
    mid_hand_xz = (l_wri_xz + r_wri_xz) / 2.0

    # 2. Compute H (Horizontal Distance)
    h_dist = float(np.linalg.norm(mid_hand_xz - mid_ankle_xz))

    # 3. Compute V (Vertical Height)
    ankle_y = (float(body_row["left_ankle_y"]) + float(body_row["right_ankle_y"])) / 2.0
    hand_y = (float(body_row["left_wrist_y"]) + float(body_row["right_wrist_y"])) / 2.0
    v_dist = abs(hand_y - ankle_y)

    # 4. Compute A (Asymmetry Angle)
    asymmetry_angle = calculate_asymmetry_angle_from_sagittal_plane(body_row)

    return {
        "load": float(load_weight),
        "H": h_dist,
        "V": v_dist,
        "A": asymmetry_angle,
        "D": 0.0,  # Displacement requires temporal start/end frames
    }

map_fmc_kinematics_to_ocra_vars(degs)

Translates FreeMoCap kinematic slices into OCRA-specific risk variables.

This mapper isolates upper-limb kinematics and categorizes them into postural 'Technical Actions' based on ISO 11228-3 thresholds. It evaluates both limbs and returns the highest risk found.

Parameters:

Name Type Description Default
degs NDArray[float64]

A 1D array containing 22 kinematic values. Expected slices: - [8:10] Upper Arm Flexion: [Right, Left] - [10:12] Upper Arm Abduction: [Right, Left] - [14:16] Lower Arm Flexion: [Right, Left] - [16:18] Wrist Flexion/Extension: [Right, Left] - [18:20] Wrist Deviation: [Right, Left]

required

Returns:

Name Type Description
ocra_flags dict[str, any]

Boolean risk flags for the scoring engine: - 'shoulder_extreme': True if Flex/Abd > 80°. - 'shoulder_heavy': True if Flex/Abd > 40°. - 'elbow_extreme': True if Flex < 40° or Flex > 150°. - 'wrist_extreme': True if Flex/Ext > 45° or Deviation > 15°.

Source code in calculators\adapters\freemocap_adapter.py
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
def map_fmc_kinematics_to_ocra_vars(degs: NDArray[np.float64]) -> dict[str, Any]:
    """
    Translates FreeMoCap kinematic slices into OCRA-specific risk variables.

    This mapper isolates upper-limb kinematics and categorizes them into
    postural 'Technical Actions' based on ISO 11228-3 thresholds. It
    evaluates both limbs and returns the highest risk found.

    Args:
        degs (NDArray[np.float64]): A 1D array containing 22 kinematic values.
            Expected slices:
            - [8:10]   Upper Arm Flexion: [Right, Left]
            - [10:12]  Upper Arm Abduction: [Right, Left]
            - [14:16]  Lower Arm Flexion: [Right, Left]
            - [16:18]  Wrist Flexion/Extension: [Right, Left]
            - [18:20]  Wrist Deviation: [Right, Left]

    Returns:
        ocra_flags (dict[str, any]): Boolean risk flags for the scoring engine:
            - 'shoulder_extreme': True if Flex/Abd > 80°.
            - 'shoulder_heavy': True if Flex/Abd > 40°.
            - 'elbow_extreme': True if Flex < 40° or Flex > 150°.
            - 'wrist_extreme': True if Flex/Ext > 45° or Deviation > 15°.
    """
    if len(degs) != 22:
        raise IndexError(f"OCRA Mapper expected 22 values, received {len(degs)}")

    # Evaluate both sides to find the worst-case posture
    # Indices: 8/9 (Flexion), 10/11 (Abduction), 14/15 (Elbow), 16/17 (Wrist Flex), 18/19 (Wrist Dev)
    r_shoulder = max(degs[8], degs[10])
    l_shoulder = max(degs[9], degs[11])
    max_shoulder = max(r_shoulder, l_shoulder)

    max_elbow_flex = max(degs[14], degs[15])
    min_elbow_flex = min(degs[14], degs[15])

    max_wrist_flex = max(abs(degs[16]), abs(degs[17]))
    max_wrist_dev = max(abs(degs[18]), abs(degs[19]))

    return {
        "shoulder_extreme": bool(max_shoulder > 80.0),
        "shoulder_heavy": bool(max_shoulder > 40.0),
        "elbow_extreme": bool(min_elbow_flex < 40.0 or max_elbow_flex > 150.0),
        "wrist_extreme": bool(max_wrist_flex > 45.0 or max_wrist_dev > 15.0),
    }

map_fmc_kinematics_to_snook_vars(body_3d_xyz, event_frames)

Extracts Snook/Liberty Mutual spatial variables from an FMC event segment.

Processes a temporal slice of 3D data to determine vertical travel and average horizontal reach during a specific lifting or lowering event.

Parameters:

Name Type Description Default
body_3d_xyz ndarray

A [Frames, Joints, 3] numpy.ndarray of coordinates.

required
event_frames tuple[int, int]

A tuple containing (start_frame, end_frame).

required

Returns:

Name Type Description
snook_vars dict[str, float]

Dictionary with 'v_start_cm', 'v_end_cm', 'v_travel_cm', and 'h_dist_cm'.

Source code in calculators\adapters\freemocap_adapter.py
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
def map_fmc_kinematics_to_snook_vars(
    body_3d_xyz: np.ndarray, event_frames: tuple[int, int]
) -> dict[str, float]:
    """
    Extracts Snook/Liberty Mutual spatial variables from an FMC event segment.

    Processes a temporal slice of 3D data to determine vertical travel and average
    horizontal reach during a specific lifting or lowering event.

    Args:
        body_3d_xyz (numpy.ndarray): A [Frames, Joints, 3] `numpy.ndarray` of coordinates.
        event_frames (tuple[int, int]): A `tuple` containing (start_frame, end_frame).

    Returns:
        snook_vars (dict[str, float]): Dictionary with 'v_start_cm', 'v_end_cm', 'v_travel_cm', and 'h_dist_cm'.
    """
    start_idx, end_idx = event_frames

    # 1. Vertical Heights (V) at start and end
    # Using Mid-Wrist (Average of 15, 16)
    wrists_y = (body_3d_xyz[:, 15, 1] + body_3d_xyz[:, 16, 1]) / 2.0
    v_start = wrists_y[start_idx]
    v_end = wrists_y[end_idx]
    v_travel = abs(v_end - v_start)

    # 2. Horizontal Distance (H)
    # Measured from Mid-Hip (23) to Mid-Wrist (15/16)
    hips_xz = (body_3d_xyz[:, 23, 0], body_3d_xyz[:, 23, 2])
    wrists_xz = (
        (body_3d_xyz[:, 15, 0] + body_3d_xyz[:, 16, 0]) / 2.0,
        (body_3d_xyz[:, 15, 2] + body_3d_xyz[:, 16, 2]) / 2.0,
    )

    # Calculate H for all frames in the event and take the mean
    h_dist = np.mean(
        np.sqrt((wrists_xz[0] - hips_xz[0]) ** 2 + (wrists_xz[1] - hips_xz[1]) ** 2)
    )

    # Snook adjustment: Subtract ~20cm for body depth (Abdomen origin)
    h_snook = float(max(h_dist - 20.0, 0.0))

    return {
        "v_start_cm": v_start,
        "v_end_cm": v_end,
        "v_travel_cm": v_travel,
        "h_dist_cm": h_snook,
    }

options: show_root_heading: true heading_level: 3

calculators.adapters.force_adapter

TODO make docstring

Classes

ForceDataAdapter

Adapter to synchronize high-frequency force sensor data with MoCap frames.

This implementation resolves Pylance 'reportArgumentType' by enforcing explicit NumPy float64 casting for the interpolation engine. It ensures that disparate sampling rates between force sensors and motion capture systems are aligned onto a unified temporal grid.

Attributes:

Name Type Description
mocap_df DataFrame

The primary motion capture data container.

force_raw DataFrame

Raw force data loaded from the provided CSV path.

fps int

Frames per second of the motion capture data, used for timeline generation.

Methods:

Name Description
sync_and_tag

Main entry point to align force data with MoCap timestamps.

_interpolate_force

Performs linear interpolation using explicitly casted NumPy arrays.

_identify_pulses

Groups continuous exertions into unique Action IDs.

Source code in calculators\adapters\force_adapter.py
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
class ForceDataAdapter:
    """
    Adapter to synchronize high-frequency force sensor data with MoCap frames.

    This implementation resolves Pylance 'reportArgumentType' by enforcing
    explicit NumPy float64 casting for the interpolation engine. It ensures that
    disparate sampling rates between force sensors and motion capture systems
    are aligned onto a unified temporal grid.

    Attributes:
        mocap_df (pandas.DataFrame): The primary motion capture data container.
        force_raw (pandas.DataFrame): Raw force data loaded from the provided CSV path.
        fps (int): Frames per second of the motion capture data, used for timeline generation.

    Methods:
        sync_and_tag: Main entry point to align force data with MoCap timestamps.
        _interpolate_force: Performs linear interpolation using explicitly casted NumPy arrays.
        _identify_pulses: Groups continuous exertions into unique Action IDs.
    """

    def __init__(self, mocap_df: pd.DataFrame, force_csv_path: str, fps: int = 30):
        """
        Initialize the adapter with MoCap data and force sensor file path.

        Args:
            mocap_df (pandas.DataFrame): The source motion capture data.
            force_csv_path (str): Local system path to the force sensor CSV file.
            fps (int): Sampling rate of the MoCap system. Defaults to 30.

        Returns:
            None (None): Initializes the instance attributes.
        """
        self.mocap_df = mocap_df
        # Load force data immediately into a DataFrame
        self.force_raw = pd.read_csv(force_csv_path)
        self.fps = fps

    def sync_and_tag(self) -> pd.DataFrame:
        """
        Main entry point to align force data with MoCap timestamps.

        This method orchestrates the temporal interpolation of force values and
        the subsequent identification of discrete physical actions based on
        force thresholds.

        Returns:
            mocap_df (pandas.DataFrame): The modified DataFrame containing "force_n" and "action_id" columns.
        """
        # 1. Temporal Synchronization
        synced_force = self._interpolate_force()

        # 2. Add to MoCap DataFrame
        self.mocap_df["force_n"] = synced_force
        self.mocap_df["action_id"] = self._identify_pulses(synced_force)

        return self.mocap_df

    def _interpolate_force(self) -> np.ndarray:
        """
        Performs linear interpolation using explicitly casted NumPy arrays.

        Resolves: reportArgumentType and reportCallIssue by ensuring all inputs
        to `numpy.interp` are `numpy.float64`. It maps the high-frequency sensor
        time series onto the MoCap timeline.

        Returns:
            interp_values (numpy.ndarray): The force values interpolated to match MoCap frame timestamps.
        """
        # Define target timestamps (X axis for interpolation)
        if "timestamp" in self.mocap_df.columns:
            target_times = self.mocap_df["timestamp"].to_numpy(dtype=np.float64)
        else:
            target_times = np.arange(len(self.mocap_df), dtype=np.float64) / self.fps

        # Define source timestamps and values (XP and FP axes)
        # We use .to_numpy() instead of .values to guarantee the correct array protocol
        sensor_times = self.force_raw["timestamp"].to_numpy(dtype=np.float64)
        sensor_values = self.force_raw["force_n"].to_numpy(dtype=np.float64)

        # np.interp(x, xp, fp) -> maps target_times onto the sensor's timeline
        return np.interp(target_times, sensor_times, sensor_values)

    def _identify_pulses(
        self, force_array: np.ndarray, threshold: float = 10.0
    ) -> np.ndarray:
        """
        Groups continuous exertions into unique Action IDs for Section 2.

        Identifies segments where force exceeds a specific threshold and assigns
        a unique integer ID to each contiguous block (pulse). This allows for
        per-action ergonomic analysis.

        Args:
            force_array (numpy.ndarray): Array of synchronized force values in Newtons.
            threshold (float): Force value above which an action is considered "active". Defaults to 10.0.

        Returns:
            action_ids (numpy.ndarray): An array of `int32` IDs where 0 is idle and N is the action index.
        """
        is_active = (force_array > threshold).astype(np.int32)

        # Use prepend=0 to keep the resulting diff array the same length as force_array
        changes = np.diff(is_active, prepend=0)

        starts = np.where(changes == 1)[0]
        ends = np.where(changes == -1)[0]

        action_ids = np.zeros(len(force_array), dtype=np.int32)

        # Zip pulse start/end pairs to label action windows
        for i, (start, end) in enumerate(zip(starts, ends), start=1):
            action_ids[start:end] = i

        # Edge Case: Pulse starts at the very end of the recording and never drops
        if len(starts) > len(ends):
            action_ids[starts[-1] :] = len(starts)

        return action_ids
Functions
_identify_pulses(force_array, threshold=10.0)

Groups continuous exertions into unique Action IDs for Section 2.

Identifies segments where force exceeds a specific threshold and assigns a unique integer ID to each contiguous block (pulse). This allows for per-action ergonomic analysis.

Parameters:

Name Type Description Default
force_array ndarray

Array of synchronized force values in Newtons.

required
threshold float

Force value above which an action is considered "active". Defaults to 10.0.

10.0

Returns:

Name Type Description
action_ids ndarray

An array of int32 IDs where 0 is idle and N is the action index.

Source code in calculators\adapters\force_adapter.py
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
def _identify_pulses(
    self, force_array: np.ndarray, threshold: float = 10.0
) -> np.ndarray:
    """
    Groups continuous exertions into unique Action IDs for Section 2.

    Identifies segments where force exceeds a specific threshold and assigns
    a unique integer ID to each contiguous block (pulse). This allows for
    per-action ergonomic analysis.

    Args:
        force_array (numpy.ndarray): Array of synchronized force values in Newtons.
        threshold (float): Force value above which an action is considered "active". Defaults to 10.0.

    Returns:
        action_ids (numpy.ndarray): An array of `int32` IDs where 0 is idle and N is the action index.
    """
    is_active = (force_array > threshold).astype(np.int32)

    # Use prepend=0 to keep the resulting diff array the same length as force_array
    changes = np.diff(is_active, prepend=0)

    starts = np.where(changes == 1)[0]
    ends = np.where(changes == -1)[0]

    action_ids = np.zeros(len(force_array), dtype=np.int32)

    # Zip pulse start/end pairs to label action windows
    for i, (start, end) in enumerate(zip(starts, ends), start=1):
        action_ids[start:end] = i

    # Edge Case: Pulse starts at the very end of the recording and never drops
    if len(starts) > len(ends):
        action_ids[starts[-1] :] = len(starts)

    return action_ids
_interpolate_force()

Performs linear interpolation using explicitly casted NumPy arrays.

Resolves: reportArgumentType and reportCallIssue by ensuring all inputs to numpy.interp are numpy.float64. It maps the high-frequency sensor time series onto the MoCap timeline.

Returns:

Name Type Description
interp_values ndarray

The force values interpolated to match MoCap frame timestamps.

Source code in calculators\adapters\force_adapter.py
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
def _interpolate_force(self) -> np.ndarray:
    """
    Performs linear interpolation using explicitly casted NumPy arrays.

    Resolves: reportArgumentType and reportCallIssue by ensuring all inputs
    to `numpy.interp` are `numpy.float64`. It maps the high-frequency sensor
    time series onto the MoCap timeline.

    Returns:
        interp_values (numpy.ndarray): The force values interpolated to match MoCap frame timestamps.
    """
    # Define target timestamps (X axis for interpolation)
    if "timestamp" in self.mocap_df.columns:
        target_times = self.mocap_df["timestamp"].to_numpy(dtype=np.float64)
    else:
        target_times = np.arange(len(self.mocap_df), dtype=np.float64) / self.fps

    # Define source timestamps and values (XP and FP axes)
    # We use .to_numpy() instead of .values to guarantee the correct array protocol
    sensor_times = self.force_raw["timestamp"].to_numpy(dtype=np.float64)
    sensor_values = self.force_raw["force_n"].to_numpy(dtype=np.float64)

    # np.interp(x, xp, fp) -> maps target_times onto the sensor's timeline
    return np.interp(target_times, sensor_times, sensor_values)
sync_and_tag()

Main entry point to align force data with MoCap timestamps.

This method orchestrates the temporal interpolation of force values and the subsequent identification of discrete physical actions based on force thresholds.

Returns:

Name Type Description
mocap_df DataFrame

The modified DataFrame containing "force_n" and "action_id" columns.

Source code in calculators\adapters\force_adapter.py
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
def sync_and_tag(self) -> pd.DataFrame:
    """
    Main entry point to align force data with MoCap timestamps.

    This method orchestrates the temporal interpolation of force values and
    the subsequent identification of discrete physical actions based on
    force thresholds.

    Returns:
        mocap_df (pandas.DataFrame): The modified DataFrame containing "force_n" and "action_id" columns.
    """
    # 1. Temporal Synchronization
    synced_force = self._interpolate_force()

    # 2. Add to MoCap DataFrame
    self.mocap_df["force_n"] = synced_force
    self.mocap_df["action_id"] = self._identify_pulses(synced_force)

    return self.mocap_df

options: show_root_heading: true heading_level: 3


GUI & Layered Architecture System

Detailed component definitions across Presentation, Domain, and Asynchronous Worker execution paths.

Domain Core Logic

gui.core.analysis_engine

ErgoMoCap: Analysis Engine

Core Computational Logic for Ergonomic Assessments.

This module implements the AnalysisEngine class, which serves as the high-performance processing core of the ErgoMoCap project. It utilizes a "Relay" pattern to decouple data iteration from specific ergonomic assessment logic (e.g., RULA, REBA, NIOSH).

By accepting arbitrary mapping and calculation functions, the engine can process both structured pandas.DataFrame objects from CSV files and raw numpy.ndarray landmark data exported directly from FreeMoCap.

Key Features
  • Agnostic data processing loop for multiple biomechanical standards.
  • Real-time risk level categorization using standardized RiskLevel Enums.
  • Support for multi-format input handling (Pandas and NumPy).
  • Synchronized frame-by-frame metadata generation for analysis reporting.

Classes

AnalysisEngine

Core computational engine for ergonomic assessments.

This engine acts as a high-performance processor that executes mapping and calculation functions provided by external adapters. It handles data iteration across various formats (Pandas DataFrames or NumPy arrays) without internalizing the specific ergonomic logic.

Methods:

Name Description
get_risk_level_enum

Maps a numerical score to a standardized RiskLevel Enum.

run_calculation

Executes a frame-by-frame processing loop for postural analysis.

Source code in gui\core\analysis_engine.py
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
class AnalysisEngine:
    """
    Core computational engine for ergonomic assessments.

    This engine acts as a high-performance processor that executes mapping and
    calculation functions provided by external adapters. It handles data
    iteration across various formats (Pandas DataFrames or NumPy arrays)
    without internalizing the specific ergonomic logic.

    Methods:
        get_risk_level_enum: Maps a numerical score to a standardized [RiskLevel][gui.utils.constants.RiskLevel] Enum.
        run_calculation: Executes a frame-by-frame processing loop for postural analysis.
    """

    @staticmethod
    def get_risk_level_enum(
        score: int, thresholds: list[tuple[int, RiskLevel]]
    ) -> RiskLevel:
        """
        Maps a numerical score to a standardized RiskLevel Enum.

        This method performs a range-check against a list of thresholds.
        It returns an Enum rather than a string to ensure the UI can
        handle localization (translations) and styling (colors) consistently.

        Args:
            score (int): The calculated ergonomic value (e.g., REBA total index).
            thresholds (list[tuple[int, RiskLevel]]): A list of (upper_limit, RiskLevel) tuples, sorted in ascending order.

        Returns:
            RiskLevel: The corresponding standardized [RiskLevel][gui.utils.constants.RiskLevel] category.
        """
        # Iterate through limits. If score is below or equal to limit, return that level.
        for limit, level in thresholds:
            if score <= limit:
                return level

        # Fallback to the final category (usually VERY_HIGH)
        return thresholds[-1][1]

    def run_calculation(
        self,
        current_data: Union[pd.DataFrame, np.ndarray],
        mapper_func: Callable[[Any], Any],
        calculator_func: Callable[[Any], tuple[dict[str, Any], Any]],
    ) -> list[dict[str, Any]]:
        """
        Executes a frame-by-frame processing loop for postural analysis.

        Handles the "Relay" pattern:
        1. Iterates through the input source (DataFrame rows or Array elements).
        2. Passes raw data to a 'mapper' to prepare assessment-specific joint data.
        3. Passes the mapped data to a 'calculator' to compute ergonomic scores.

        Args:
            current_data (pandas.DataFrame | numpy.ndarray): The input source (DataFrame for CSV, ndarray for NPY).
            mapper_func (Callable[[Any], Any]): Function that transforms a row/frame into the calculator's expected input structure.
            calculator_func (Callable[[Any], tuple[dict[str, Any], Any]]): Function that computes scores and returns a tuple of (results_dict, metadata).

        Returns:
            list[dict[str, Any]]: A list of dictionaries, where each dict contains standardized keys for a single frame.
        """
        results_list: list[dict[str, Any]] = []

        # Logic for Pandas DataFrames (Structured CSV data)
        if isinstance(current_data, pd.DataFrame):
            for _, row in current_data.iterrows():
                # Mapper transforms the row into the calculator's input vars
                input_vars = mapper_func(row)
                # Calculator computes ergonomic indices
                scores, _ = calculator_func(input_vars)
                results_list.append(scores)

        # Logic for raw NumPy arrays (Direct FreeMoCap landmark exports)
        elif isinstance(current_data, np.ndarray):
            for frame_data in current_data:
                # Typically, mappers for raw arrays are identity functions
                # or handled within the calculator_func for performance.
                input_vars = mapper_func(frame_data)
                scores, _ = calculator_func(input_vars)
                results_list.append(scores)

        return results_list
Functions
get_risk_level_enum(score, thresholds) staticmethod

Maps a numerical score to a standardized RiskLevel Enum.

This method performs a range-check against a list of thresholds. It returns an Enum rather than a string to ensure the UI can handle localization (translations) and styling (colors) consistently.

Parameters:

Name Type Description Default
score int

The calculated ergonomic value (e.g., REBA total index).

required
thresholds list[tuple[int, RiskLevel]]

A list of (upper_limit, RiskLevel) tuples, sorted in ascending order.

required

Returns:

Name Type Description
RiskLevel RiskLevel

The corresponding standardized RiskLevel category.

Source code in gui\core\analysis_engine.py
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
@staticmethod
def get_risk_level_enum(
    score: int, thresholds: list[tuple[int, RiskLevel]]
) -> RiskLevel:
    """
    Maps a numerical score to a standardized RiskLevel Enum.

    This method performs a range-check against a list of thresholds.
    It returns an Enum rather than a string to ensure the UI can
    handle localization (translations) and styling (colors) consistently.

    Args:
        score (int): The calculated ergonomic value (e.g., REBA total index).
        thresholds (list[tuple[int, RiskLevel]]): A list of (upper_limit, RiskLevel) tuples, sorted in ascending order.

    Returns:
        RiskLevel: The corresponding standardized [RiskLevel][gui.utils.constants.RiskLevel] category.
    """
    # Iterate through limits. If score is below or equal to limit, return that level.
    for limit, level in thresholds:
        if score <= limit:
            return level

    # Fallback to the final category (usually VERY_HIGH)
    return thresholds[-1][1]
run_calculation(current_data, mapper_func, calculator_func)

Executes a frame-by-frame processing loop for postural analysis.

Handles the "Relay" pattern: 1. Iterates through the input source (DataFrame rows or Array elements). 2. Passes raw data to a 'mapper' to prepare assessment-specific joint data. 3. Passes the mapped data to a 'calculator' to compute ergonomic scores.

Parameters:

Name Type Description Default
current_data DataFrame | ndarray

The input source (DataFrame for CSV, ndarray for NPY).

required
mapper_func Callable[[Any], Any]

Function that transforms a row/frame into the calculator's expected input structure.

required
calculator_func Callable[[Any], tuple[dict[str, Any], Any]]

Function that computes scores and returns a tuple of (results_dict, metadata).

required

Returns:

Type Description
list[dict[str, Any]]

list[dict[str, Any]]: A list of dictionaries, where each dict contains standardized keys for a single frame.

Source code in gui\core\analysis_engine.py
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
def run_calculation(
    self,
    current_data: Union[pd.DataFrame, np.ndarray],
    mapper_func: Callable[[Any], Any],
    calculator_func: Callable[[Any], tuple[dict[str, Any], Any]],
) -> list[dict[str, Any]]:
    """
    Executes a frame-by-frame processing loop for postural analysis.

    Handles the "Relay" pattern:
    1. Iterates through the input source (DataFrame rows or Array elements).
    2. Passes raw data to a 'mapper' to prepare assessment-specific joint data.
    3. Passes the mapped data to a 'calculator' to compute ergonomic scores.

    Args:
        current_data (pandas.DataFrame | numpy.ndarray): The input source (DataFrame for CSV, ndarray for NPY).
        mapper_func (Callable[[Any], Any]): Function that transforms a row/frame into the calculator's expected input structure.
        calculator_func (Callable[[Any], tuple[dict[str, Any], Any]]): Function that computes scores and returns a tuple of (results_dict, metadata).

    Returns:
        list[dict[str, Any]]: A list of dictionaries, where each dict contains standardized keys for a single frame.
    """
    results_list: list[dict[str, Any]] = []

    # Logic for Pandas DataFrames (Structured CSV data)
    if isinstance(current_data, pd.DataFrame):
        for _, row in current_data.iterrows():
            # Mapper transforms the row into the calculator's input vars
            input_vars = mapper_func(row)
            # Calculator computes ergonomic indices
            scores, _ = calculator_func(input_vars)
            results_list.append(scores)

    # Logic for raw NumPy arrays (Direct FreeMoCap landmark exports)
    elif isinstance(current_data, np.ndarray):
        for frame_data in current_data:
            # Typically, mappers for raw arrays are identity functions
            # or handled within the calculator_func for performance.
            input_vars = mapper_func(frame_data)
            scores, _ = calculator_func(input_vars)
            results_list.append(scores)

    return results_list

options: show_root_heading: true

gui.core.session_manager

ErgoMoCap: Session Management

Filesystem Operations and Data Asset Resolution Module.

This module implements the SessionManager class, which provides a high-level API for interacting with the ErgoMoCap and FreeMoCap data structures. It handles the discovery of recording sessions, resolution of associated video and CSV assets, and provides standardized loading mechanisms for analysis data.

The manager integrates with ErgoPaths to ensure cross-platform path resolution and compatibility with frozen environments (e.g., PyInstaller).

Key Features
  • Automatic discovery of session directories within defined root paths.
  • Heuristic-based resolution of joint angle CSVs and annotated MP4 videos.
  • Unified data loading interface for numpy.ndarray and pandas.DataFrame formats.
  • Support for arbitrary external path scanning for portable session review.

Classes

SessionManager

Handles filesystem operations related to ErgoMoCap recording sessions.

This manager abstracts the directory structure of the data storage, providing methods to scan for sessions, resolve specific assets (CSV/Video), and load data files into memory.

Attributes:

Name Type Description
sessions_dir Path

The base directory where session folders are stored.

Methods:

Name Description
get_initial_sessions

Scans the sessions_dir for valid session directories.

scan_custom_path

Scans an arbitrary external path for session folders.

resolve_session_assets

Locates primary data and video assets within a specific session.

load_file_data

Loads session data from the disk based on file extension.

Source code in gui\core\session_manager.py
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
class SessionManager:
    """
    Handles filesystem operations related to ErgoMoCap recording sessions.

    This manager abstracts the directory structure of the data storage,
    providing methods to scan for sessions, resolve specific assets (CSV/Video),
    and load data files into memory.

    Attributes:
        sessions_dir (Path): The base directory where session folders are stored.

    Methods:
        get_initial_sessions: Scans the sessions_dir for valid session directories.
        scan_custom_path: Scans an arbitrary external path for session folders.
        resolve_session_assets: Locates primary data and video assets within a specific session.
        load_file_data: Loads session data from the disk based on file extension.
    """

    def __init__(self, sessions_dir: Union[str, Path]) -> None:
        """Initializes the SessionManager with a root data directory.

        Args:
            sessions_dir: Path to the directory containing ergonomic sessions.

        Returns:
            None (None): Initializer does not return a value.

        NOTE:
        Uses get_external_root() as a fallback to ensure PyInstaller
        compatibility (Centralized Path Management).
        """
        self.sessions_dir = Path(sessions_dir) if sessions_dir else ErgoPaths.SESSIONS

    def get_initial_sessions(self) -> list[str]:
        """Scans the sessions_dir for valid session directories.

        Filters out hidden directories and non-directory files to identify
        potential FreeMoCap session folders.

        Returns:
            list[str]: A list of session directory names found at the root.
        """
        if not self.sessions_dir.exists() or not self.sessions_dir.is_dir():
            return []

        return [
            d.name
            for d in self.sessions_dir.iterdir()
            if d.is_dir() and not d.name.startswith(".")
        ]

    def scan_custom_path(self, path: Union[str, Path]) -> list[str]:
        """
        Scans an arbitrary external path for session folders.

        Args:
            path (str | Path): The directory path to scan.

        Returns:
            list[str]: A list of subdirectories found at the given path.
        """
        root: Path = Path(path)
        if not root.exists() or not root.is_dir():
            return []
        return [
            d.name for d in root.iterdir() if d.is_dir() and not d.name.startswith(".")
        ]

    def resolve_session_assets(
        self, session_name: str
    ) -> tuple[Path | None, str | None, list[str]]:
        """
        Locates primary data and video assets within a specific session.

        This method follows the FreeMoCap output convention, searching for
        joint angle CSVs in the data folder and MP4s in the video folder as
        defined by [ErgoPaths][gui.utils.app_paths.ErgoPaths].

        Args:
            session_name (str): The name of the session folder to inspect.

        Returns:
            tuple[Path | None, str | None, list[str]]: A tuple containing (target_csv, target_video, video_files).
        """
        # Resolve CSV Data (Searching for joint angles output)
        csv_dir = ErgoPaths.data_folder(session_name)
        video_dir = ErgoPaths.video_folder(session_name)

        # 1. Look for the CSV
        target_csv = None
        if csv_dir.exists():
            csv_files = list(csv_dir.rglob("*.csv"))
            target_csv = next(
                (f for f in csv_files if "joint_angles" in f.name.lower()), None
            )

        # 2. Look for the Videos
        video_files = []
        if video_dir.exists():
            video_files = [f.name for f in video_dir.rglob("*.mp4")]

        target_video = video_files[0] if video_files else None

        # print(target_csv, target_video, video_files) TODO print_reactivate

        return target_csv, target_video, video_files

    def load_file_data(self, file_path: Union[str, Path]) -> tuple[Any, Path]:
        """
        Loads session data from the disk based on file extension.

        Supports NumPy (`.npy`) for raw landmark data and Pandas (`.csv`) for
        calculated angles or scores.

        Args:
            file_path (str | Path): The path to the file to load.

        Returns:
            tuple[Any, Path]: A tuple containing the loaded object (`numpy.ndarray` or `pandas.DataFrame`) and its confirmed `Path` object.

        Raises:
            ValueError (ValueError): If the file format is not supported (`.npy` or `.csv` only).
            FileNotFoundError (FileNotFoundError): If the provided path does not exist.
        """
        path: Path = Path(file_path)

        if not path.exists():
            raise FileNotFoundError(f"Session data file not found: {path}")

        if path.suffix == ".npy":
            return np.load(path), path
        elif path.suffix == ".csv":
            return pd.read_csv(path), path

        raise ValueError(
            f"Unsupported file format: {path.suffix}. Expected .npy or .csv"
        )
Functions
get_initial_sessions()

Scans the sessions_dir for valid session directories.

Filters out hidden directories and non-directory files to identify potential FreeMoCap session folders.

Returns:

Type Description
list[str]

list[str]: A list of session directory names found at the root.

Source code in gui\core\session_manager.py
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
def get_initial_sessions(self) -> list[str]:
    """Scans the sessions_dir for valid session directories.

    Filters out hidden directories and non-directory files to identify
    potential FreeMoCap session folders.

    Returns:
        list[str]: A list of session directory names found at the root.
    """
    if not self.sessions_dir.exists() or not self.sessions_dir.is_dir():
        return []

    return [
        d.name
        for d in self.sessions_dir.iterdir()
        if d.is_dir() and not d.name.startswith(".")
    ]
load_file_data(file_path)

Loads session data from the disk based on file extension.

Supports NumPy (.npy) for raw landmark data and Pandas (.csv) for calculated angles or scores.

Parameters:

Name Type Description Default
file_path str | Path

The path to the file to load.

required

Returns:

Type Description
tuple[Any, Path]

tuple[Any, Path]: A tuple containing the loaded object (numpy.ndarray or pandas.DataFrame) and its confirmed Path object.

Raises:

Type Description
ValueError(ValueError)

If the file format is not supported (.npy or .csv only).

FileNotFoundError(FileNotFoundError)

If the provided path does not exist.

Source code in gui\core\session_manager.py
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
def load_file_data(self, file_path: Union[str, Path]) -> tuple[Any, Path]:
    """
    Loads session data from the disk based on file extension.

    Supports NumPy (`.npy`) for raw landmark data and Pandas (`.csv`) for
    calculated angles or scores.

    Args:
        file_path (str | Path): The path to the file to load.

    Returns:
        tuple[Any, Path]: A tuple containing the loaded object (`numpy.ndarray` or `pandas.DataFrame`) and its confirmed `Path` object.

    Raises:
        ValueError (ValueError): If the file format is not supported (`.npy` or `.csv` only).
        FileNotFoundError (FileNotFoundError): If the provided path does not exist.
    """
    path: Path = Path(file_path)

    if not path.exists():
        raise FileNotFoundError(f"Session data file not found: {path}")

    if path.suffix == ".npy":
        return np.load(path), path
    elif path.suffix == ".csv":
        return pd.read_csv(path), path

    raise ValueError(
        f"Unsupported file format: {path.suffix}. Expected .npy or .csv"
    )
resolve_session_assets(session_name)

Locates primary data and video assets within a specific session.

This method follows the FreeMoCap output convention, searching for joint angle CSVs in the data folder and MP4s in the video folder as defined by ErgoPaths.

Parameters:

Name Type Description Default
session_name str

The name of the session folder to inspect.

required

Returns:

Type Description
tuple[Path | None, str | None, list[str]]

tuple[Path | None, str | None, list[str]]: A tuple containing (target_csv, target_video, video_files).

Source code in gui\core\session_manager.py
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
def resolve_session_assets(
    self, session_name: str
) -> tuple[Path | None, str | None, list[str]]:
    """
    Locates primary data and video assets within a specific session.

    This method follows the FreeMoCap output convention, searching for
    joint angle CSVs in the data folder and MP4s in the video folder as
    defined by [ErgoPaths][gui.utils.app_paths.ErgoPaths].

    Args:
        session_name (str): The name of the session folder to inspect.

    Returns:
        tuple[Path | None, str | None, list[str]]: A tuple containing (target_csv, target_video, video_files).
    """
    # Resolve CSV Data (Searching for joint angles output)
    csv_dir = ErgoPaths.data_folder(session_name)
    video_dir = ErgoPaths.video_folder(session_name)

    # 1. Look for the CSV
    target_csv = None
    if csv_dir.exists():
        csv_files = list(csv_dir.rglob("*.csv"))
        target_csv = next(
            (f for f in csv_files if "joint_angles" in f.name.lower()), None
        )

    # 2. Look for the Videos
    video_files = []
    if video_dir.exists():
        video_files = [f.name for f in video_dir.rglob("*.mp4")]

    target_video = video_files[0] if video_files else None

    # print(target_csv, target_video, video_files) TODO print_reactivate

    return target_csv, target_video, video_files
scan_custom_path(path)

Scans an arbitrary external path for session folders.

Parameters:

Name Type Description Default
path str | Path

The directory path to scan.

required

Returns:

Type Description
list[str]

list[str]: A list of subdirectories found at the given path.

Source code in gui\core\session_manager.py
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
def scan_custom_path(self, path: Union[str, Path]) -> list[str]:
    """
    Scans an arbitrary external path for session folders.

    Args:
        path (str | Path): The directory path to scan.

    Returns:
        list[str]: A list of subdirectories found at the given path.
    """
    root: Path = Path(path)
    if not root.exists() or not root.is_dir():
        return []
    return [
        d.name for d in root.iterdir() if d.is_dir() and not d.name.startswith(".")
    ]

options: show_root_heading: true

gui.core.calculators_adapter

ErgoMoCap: Calculators Adapter

Standardized Interface for Multi-Method Ergonomic Assessments.

This module implements the BaseErgoAdapter pattern, providing a unified pipeline for converting raw FreeMoCap motion data into standardized ergonomic scores. It acts as a structural bridge between disparate biomechanical calculation functions and the ErgoMoCap gui system.

The adapters handle the coordination between data mapping, frame-by-frame calculation, and statistical aggregation for several international standards including REBA, RULA, NIOSH, OCRA, EWAS, and Snook & Ciriello.

Key Features
  • Abstract interface for unified execution across different assessment methods.
  • Integration with freemocap_adapter for kinematics mapping.
  • Automated frequency distribution (stats) calculation for risk level bucketing.
  • Standardized output format using MetricType and RiskLevel.

Classes

BaseErgoAdapter

Bases: ABC

Abstract Base Class for ergonomic assessment adapters.

This class handles the standard pipeline of converting raw motion data (DataFrame rows) into ergonomic scores and statistical distributions.

Methods:

Name Description
get_thresholds

Returns a list of (upper_limit, RiskLevel) tuples for risk bucketing.

get_relay_tools

Returns the specific mapping and calculation functions for the method.

run_on_dataframe

Iterates through a DataFrame to calculate scores for every frame.

process

Converts raw score dictionaries into a processed pandas DataFrame.

get_stats

Calculates the frequency distribution of scores across risk levels.

Source code in gui\core\calculators_adapter.py
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
class BaseErgoAdapter(ABC):
    """
    Abstract Base Class for ergonomic assessment adapters.

    This class handles the standard pipeline of converting raw motion data
    (DataFrame rows) into ergonomic scores and statistical distributions.

    Methods:
        get_thresholds: Returns a list of (upper_limit, RiskLevel) tuples for risk bucketing.
        get_relay_tools: Returns the specific mapping and calculation functions for the method.
        run_on_dataframe: Iterates through a DataFrame to calculate scores for every frame.
        process: Converts raw score dictionaries into a processed pandas DataFrame.
        get_stats: Calculates the frequency distribution of scores across risk levels.
    """

    @staticmethod
    @abstractmethod
    def get_thresholds() -> list[tuple[int, RiskLevel]]:
        """Returns a list of (upper_limit, RiskLevel) tuples for risk bucketing.

        Returns:
            list[tuple[int, RiskLevel]]: Thresholds ordered by limit ascending.
        """
        pass

    @staticmethod
    @abstractmethod
    def get_relay_tools() -> tuple[Callable, Callable]:
        """Returns the specific mapping and calculation functions for the method.

        Returns:
            tuple[Callable, Callable]: (mapping_function, calculation_function)
        """
        pass

    @classmethod
    def run_on_dataframe(cls, df: pd.DataFrame) -> list[dict[str, Any]]:
        """Iterates through a DataFrame to calculate scores for every frame.

        Args:
            df: Input motion data where each row represents one time frame.

        Returns:
            list[dict[str, Any]]: A list of score dictionaries (one per frame).
        """
        mapper, calculator = cls.get_relay_tools()
        results: list[dict[str, Any]] = []

        for _, row in df.iterrows():
            input_data = mapper(row)
            scores, _ = calculator(input_data)
            results.append(scores)
        return results

    @classmethod
    def process(
        cls,
        results_list: list[dict[str, Any]],
        risk_callback: Callable[[int], RiskLevel],
    ) -> pd.DataFrame:
        """Converts raw score dictionaries into a processed pandas DataFrame.

        Args:
            results_list: The raw output from run_on_dataframe.
            risk_callback: A function to map numerical scores to RiskLevel Enums.

        Returns:
            pd.DataFrame: A DataFrame with standardized score and risk columns.
        """
        df = pd.DataFrame(results_list)
        if df.empty:
            return df

        internal_key = getattr(
            cls, "INTERNAL_SCORE_KEY", df.columns[-1]
        )  # TODO this bullshit is breaking my balls and my code

        df[MetricType.SCORE.value] = df[internal_key]

        df[MetricType.RISK.value] = [
            risk_callback(int(score)).value for score in df[MetricType.SCORE.value]
        ]

        return df

    @classmethod
    def get_stats(cls, scores_list: list[int]) -> dict[str, int]:
        """Calculates the frequency distribution of scores across risk levels.

        Args:
            scores_list: A list of numerical scores.

        Returns:
            dict[RiskLevel, int]: Mapping of RiskLevel members to frame counts.
        """
        scores = np.array(scores_list)
        stats: dict[str, int] = {}
        thresholds: list[tuple[int, RiskLevel]] = cls.get_thresholds()

        prev_limit = -np.inf
        for limit, level in thresholds:
            count = np.sum((scores > prev_limit) & (scores <= limit))
            stats[level.value] = int(count)
            prev_limit = limit

        return stats
Functions
get_relay_tools() abstractmethod staticmethod

Returns the specific mapping and calculation functions for the method.

Returns:

Type Description
tuple[Callable, Callable]

tuple[Callable, Callable]: (mapping_function, calculation_function)

Source code in gui\core\calculators_adapter.py
104
105
106
107
108
109
110
111
112
@staticmethod
@abstractmethod
def get_relay_tools() -> tuple[Callable, Callable]:
    """Returns the specific mapping and calculation functions for the method.

    Returns:
        tuple[Callable, Callable]: (mapping_function, calculation_function)
    """
    pass
get_stats(scores_list) classmethod

Calculates the frequency distribution of scores across risk levels.

Parameters:

Name Type Description Default
scores_list list[int]

A list of numerical scores.

required

Returns:

Type Description
dict[str, int]

dict[RiskLevel, int]: Mapping of RiskLevel members to frame counts.

Source code in gui\core\calculators_adapter.py
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
@classmethod
def get_stats(cls, scores_list: list[int]) -> dict[str, int]:
    """Calculates the frequency distribution of scores across risk levels.

    Args:
        scores_list: A list of numerical scores.

    Returns:
        dict[RiskLevel, int]: Mapping of RiskLevel members to frame counts.
    """
    scores = np.array(scores_list)
    stats: dict[str, int] = {}
    thresholds: list[tuple[int, RiskLevel]] = cls.get_thresholds()

    prev_limit = -np.inf
    for limit, level in thresholds:
        count = np.sum((scores > prev_limit) & (scores <= limit))
        stats[level.value] = int(count)
        prev_limit = limit

    return stats
get_thresholds() abstractmethod staticmethod

Returns a list of (upper_limit, RiskLevel) tuples for risk bucketing.

Returns:

Type Description
list[tuple[int, RiskLevel]]

list[tuple[int, RiskLevel]]: Thresholds ordered by limit ascending.

Source code in gui\core\calculators_adapter.py
 94
 95
 96
 97
 98
 99
100
101
102
@staticmethod
@abstractmethod
def get_thresholds() -> list[tuple[int, RiskLevel]]:
    """Returns a list of (upper_limit, RiskLevel) tuples for risk bucketing.

    Returns:
        list[tuple[int, RiskLevel]]: Thresholds ordered by limit ascending.
    """
    pass
process(results_list, risk_callback) classmethod

Converts raw score dictionaries into a processed pandas DataFrame.

Parameters:

Name Type Description Default
results_list list[dict[str, Any]]

The raw output from run_on_dataframe.

required
risk_callback Callable[[int], RiskLevel]

A function to map numerical scores to RiskLevel Enums.

required

Returns:

Type Description
DataFrame

pd.DataFrame: A DataFrame with standardized score and risk columns.

Source code in gui\core\calculators_adapter.py
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
@classmethod
def process(
    cls,
    results_list: list[dict[str, Any]],
    risk_callback: Callable[[int], RiskLevel],
) -> pd.DataFrame:
    """Converts raw score dictionaries into a processed pandas DataFrame.

    Args:
        results_list: The raw output from run_on_dataframe.
        risk_callback: A function to map numerical scores to RiskLevel Enums.

    Returns:
        pd.DataFrame: A DataFrame with standardized score and risk columns.
    """
    df = pd.DataFrame(results_list)
    if df.empty:
        return df

    internal_key = getattr(
        cls, "INTERNAL_SCORE_KEY", df.columns[-1]
    )  # TODO this bullshit is breaking my balls and my code

    df[MetricType.SCORE.value] = df[internal_key]

    df[MetricType.RISK.value] = [
        risk_callback(int(score)).value for score in df[MetricType.SCORE.value]
    ]

    return df
run_on_dataframe(df) classmethod

Iterates through a DataFrame to calculate scores for every frame.

Parameters:

Name Type Description Default
df DataFrame

Input motion data where each row represents one time frame.

required

Returns:

Type Description
list[dict[str, Any]]

list[dict[str, Any]]: A list of score dictionaries (one per frame).

Source code in gui\core\calculators_adapter.py
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
@classmethod
def run_on_dataframe(cls, df: pd.DataFrame) -> list[dict[str, Any]]:
    """Iterates through a DataFrame to calculate scores for every frame.

    Args:
        df: Input motion data where each row represents one time frame.

    Returns:
        list[dict[str, Any]]: A list of score dictionaries (one per frame).
    """
    mapper, calculator = cls.get_relay_tools()
    results: list[dict[str, Any]] = []

    for _, row in df.iterrows():
        input_data = mapper(row)
        scores, _ = calculator(input_data)
        results.append(scores)
    return results

EWASAdapter

Bases: BaseErgoAdapter

Adapter for Ergo-Work Assessment System (EWAS).

Source code in gui\core\calculators_adapter.py
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
class EWASAdapter(BaseErgoAdapter):
    """Adapter for Ergo-Work Assessment System (EWAS)."""

    @staticmethod
    def get_relay_tools() -> tuple[Callable, Callable]:
        """Returns tools for EWAS mapping and calculation.

        Returns:
            tuple[Callable, Callable]: (mapper, calculator) functions.
        """
        # TODO all ewas_calculator/ code and relative adapter/ is to be done
        return map_fmc_kinematics_to_ewas_vars, calculate_frame_ewas_score

    @staticmethod
    def get_thresholds() -> list[tuple[int, RiskLevel]]:
        """Returns EWAS score risk thresholds mapped to RiskLevel Enums.

        Returns:
            list[tuple[int, RiskLevel]]: Threshold limits and levels.
        """
        return [
            (25, RiskLevel.LOW),
            (50, RiskLevel.MEDIUM),
            (51, RiskLevel.HIGH),
        ]
Functions
get_relay_tools() staticmethod

Returns tools for EWAS mapping and calculation.

Returns:

Type Description
tuple[Callable, Callable]

tuple[Callable, Callable]: (mapper, calculator) functions.

Source code in gui\core\calculators_adapter.py
282
283
284
285
286
287
288
289
290
@staticmethod
def get_relay_tools() -> tuple[Callable, Callable]:
    """Returns tools for EWAS mapping and calculation.

    Returns:
        tuple[Callable, Callable]: (mapper, calculator) functions.
    """
    # TODO all ewas_calculator/ code and relative adapter/ is to be done
    return map_fmc_kinematics_to_ewas_vars, calculate_frame_ewas_score
get_thresholds() staticmethod

Returns EWAS score risk thresholds mapped to RiskLevel Enums.

Returns:

Type Description
list[tuple[int, RiskLevel]]

list[tuple[int, RiskLevel]]: Threshold limits and levels.

Source code in gui\core\calculators_adapter.py
292
293
294
295
296
297
298
299
300
301
302
303
@staticmethod
def get_thresholds() -> list[tuple[int, RiskLevel]]:
    """Returns EWAS score risk thresholds mapped to RiskLevel Enums.

    Returns:
        list[tuple[int, RiskLevel]]: Threshold limits and levels.
    """
    return [
        (25, RiskLevel.LOW),
        (50, RiskLevel.MEDIUM),
        (51, RiskLevel.HIGH),
    ]

NIOSHAdapter

Bases: BaseErgoAdapter

Adapter for NIOSH Lifting Equation.

Source code in gui\core\calculators_adapter.py
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
class NIOSHAdapter(BaseErgoAdapter):
    """Adapter for NIOSH Lifting Equation."""

    @staticmethod
    def get_relay_tools() -> tuple[Callable, Callable]:
        """Returns tools for NIOSH mapping and calculation."""
        # TODO all niosh_calculator/ code and relative adapter/ is to be done
        return map_fmc_kinematics_to_niosh_vars, calculate_frame_niosh_li

    @staticmethod
    def get_thresholds() -> list[tuple[int, RiskLevel]]:
        """Returns standardized NIOSH lifting index risk thresholds."""
        return [
            (1, RiskLevel.LOW),
            (3, RiskLevel.MEDIUM),
            (4, RiskLevel.HIGH),
        ]
Functions
get_relay_tools() staticmethod

Returns tools for NIOSH mapping and calculation.

Source code in gui\core\calculators_adapter.py
234
235
236
237
238
@staticmethod
def get_relay_tools() -> tuple[Callable, Callable]:
    """Returns tools for NIOSH mapping and calculation."""
    # TODO all niosh_calculator/ code and relative adapter/ is to be done
    return map_fmc_kinematics_to_niosh_vars, calculate_frame_niosh_li
get_thresholds() staticmethod

Returns standardized NIOSH lifting index risk thresholds.

Source code in gui\core\calculators_adapter.py
240
241
242
243
244
245
246
247
@staticmethod
def get_thresholds() -> list[tuple[int, RiskLevel]]:
    """Returns standardized NIOSH lifting index risk thresholds."""
    return [
        (1, RiskLevel.LOW),
        (3, RiskLevel.MEDIUM),
        (4, RiskLevel.HIGH),
    ]

OCRAAdapter

Bases: BaseErgoAdapter

Adapter for Occupational Repetitive Actions (OCRA) Index.

Source code in gui\core\calculators_adapter.py
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
class OCRAAdapter(BaseErgoAdapter):
    """Adapter for Occupational Repetitive Actions (OCRA) Index."""

    @staticmethod
    def get_relay_tools() -> tuple[Callable, Callable]:
        """Returns tools for OCRA mapping and calculation.

        Returns:
            tuple[Callable, Callable]: (mapper, calculator) functions.
        """
        # TODO all ocra_calculator/ code and relative adapter/ is to be done
        return map_fmc_kinematics_to_ocra_vars, calculate_frame_ocra_index

    @staticmethod
    def get_thresholds() -> list[tuple[int, RiskLevel]]:
        """Returns OCRA index risk thresholds mapped to RiskLevel Enums.

        Returns:
            list[tuple[int, RiskLevel]]: Threshold limits and levels.
        """
        return [
            (7, RiskLevel.NEGLIGIBLE),
            (11, RiskLevel.LOW),
            (14, RiskLevel.MEDIUM),
            (22, RiskLevel.HIGH),
            (23, RiskLevel.VERY_HIGH),
        ]
Functions
get_relay_tools() staticmethod

Returns tools for OCRA mapping and calculation.

Returns:

Type Description
tuple[Callable, Callable]

tuple[Callable, Callable]: (mapper, calculator) functions.

Source code in gui\core\calculators_adapter.py
253
254
255
256
257
258
259
260
261
@staticmethod
def get_relay_tools() -> tuple[Callable, Callable]:
    """Returns tools for OCRA mapping and calculation.

    Returns:
        tuple[Callable, Callable]: (mapper, calculator) functions.
    """
    # TODO all ocra_calculator/ code and relative adapter/ is to be done
    return map_fmc_kinematics_to_ocra_vars, calculate_frame_ocra_index
get_thresholds() staticmethod

Returns OCRA index risk thresholds mapped to RiskLevel Enums.

Returns:

Type Description
list[tuple[int, RiskLevel]]

list[tuple[int, RiskLevel]]: Threshold limits and levels.

Source code in gui\core\calculators_adapter.py
263
264
265
266
267
268
269
270
271
272
273
274
275
276
@staticmethod
def get_thresholds() -> list[tuple[int, RiskLevel]]:
    """Returns OCRA index risk thresholds mapped to RiskLevel Enums.

    Returns:
        list[tuple[int, RiskLevel]]: Threshold limits and levels.
    """
    return [
        (7, RiskLevel.NEGLIGIBLE),
        (11, RiskLevel.LOW),
        (14, RiskLevel.MEDIUM),
        (22, RiskLevel.HIGH),
        (23, RiskLevel.VERY_HIGH),
    ]

REBAAdapter

Bases: BaseErgoAdapter

Adapter for Rapid Entire Body Assessment (REBA).

Source code in gui\core\calculators_adapter.py
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
class REBAAdapter(BaseErgoAdapter):
    """Adapter for Rapid Entire Body Assessment (REBA)."""

    @staticmethod
    def get_relay_tools() -> tuple[Callable, Callable]:
        """Returns tools for REBA mapping and calculation."""
        return map_fmc_joint_angles_to_ergo_degs, calculate_frame_reba_from_degs

    @staticmethod
    def get_thresholds() -> list[tuple[int, RiskLevel]]:
        """Returns standardized REBA risk thresholds."""
        return [
            (1, RiskLevel.NEGLIGIBLE),
            (3, RiskLevel.LOW),
            (7, RiskLevel.MEDIUM),
            (10, RiskLevel.HIGH),
            (11, RiskLevel.VERY_HIGH),
        ]
Functions
get_relay_tools() staticmethod

Returns tools for REBA mapping and calculation.

Source code in gui\core\calculators_adapter.py
195
196
197
198
@staticmethod
def get_relay_tools() -> tuple[Callable, Callable]:
    """Returns tools for REBA mapping and calculation."""
    return map_fmc_joint_angles_to_ergo_degs, calculate_frame_reba_from_degs
get_thresholds() staticmethod

Returns standardized REBA risk thresholds.

Source code in gui\core\calculators_adapter.py
200
201
202
203
204
205
206
207
208
209
@staticmethod
def get_thresholds() -> list[tuple[int, RiskLevel]]:
    """Returns standardized REBA risk thresholds."""
    return [
        (1, RiskLevel.NEGLIGIBLE),
        (3, RiskLevel.LOW),
        (7, RiskLevel.MEDIUM),
        (10, RiskLevel.HIGH),
        (11, RiskLevel.VERY_HIGH),
    ]

RULAAdapter

Bases: BaseErgoAdapter

Adapter for Rapid Upper Limb Assessment (RULA).

Source code in gui\core\calculators_adapter.py
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
class RULAAdapter(BaseErgoAdapter):
    """Adapter for Rapid Upper Limb Assessment (RULA)."""

    @staticmethod
    def get_relay_tools() -> tuple[Callable, Callable]:
        """Returns tools for RULA mapping and calculation."""
        return map_fmc_joint_angles_to_ergo_degs, calculate_frame_rula_from_degs

    @staticmethod
    def get_thresholds() -> list[tuple[int, RiskLevel]]:
        """Returns standardized RULA risk thresholds."""
        return [
            (1, RiskLevel.NEGLIGIBLE),
            (3, RiskLevel.LOW),
            (5, RiskLevel.MEDIUM),
            (7, RiskLevel.HIGH),
        ]
Functions
get_relay_tools() staticmethod

Returns tools for RULA mapping and calculation.

Source code in gui\core\calculators_adapter.py
215
216
217
218
@staticmethod
def get_relay_tools() -> tuple[Callable, Callable]:
    """Returns tools for RULA mapping and calculation."""
    return map_fmc_joint_angles_to_ergo_degs, calculate_frame_rula_from_degs
get_thresholds() staticmethod

Returns standardized RULA risk thresholds.

Source code in gui\core\calculators_adapter.py
220
221
222
223
224
225
226
227
228
@staticmethod
def get_thresholds() -> list[tuple[int, RiskLevel]]:
    """Returns standardized RULA risk thresholds."""
    return [
        (1, RiskLevel.NEGLIGIBLE),
        (3, RiskLevel.LOW),
        (5, RiskLevel.MEDIUM),
        (7, RiskLevel.HIGH),
    ]

SNOOKAdapter

Bases: BaseErgoAdapter

Adapter for Snook & Ciriello Tables (Lifting/Lowering/Pushing).

Source code in gui\core\calculators_adapter.py
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
class SNOOKAdapter(BaseErgoAdapter):
    """Adapter for Snook & Ciriello Tables (Lifting/Lowering/Pushing)."""

    @staticmethod
    def get_relay_tools() -> tuple[Callable, Callable]:
        """Returns tools for SNOOK mapping and calculation.

        Returns:
            tuple[Callable, Callable]: (mapper, calculator) functions.
        """
        # TODO all snook_calculator/ code and relative adapter/ is to be done
        return map_fmc_kinematics_to_snook_vars, calculate_frame_snook_index

    @staticmethod
    def get_thresholds() -> list[tuple[int, RiskLevel]]:
        """Returns SNOOK ratio risk thresholds mapped to RiskLevel Enums.

        Returns:
            list[tuple[int, RiskLevel]]: Threshold limits and levels.
        """
        return [
            (1, RiskLevel.LOW),
            (2, RiskLevel.HIGH),
        ]
Functions
get_relay_tools() staticmethod

Returns tools for SNOOK mapping and calculation.

Returns:

Type Description
tuple[Callable, Callable]

tuple[Callable, Callable]: (mapper, calculator) functions.

Source code in gui\core\calculators_adapter.py
309
310
311
312
313
314
315
316
317
@staticmethod
def get_relay_tools() -> tuple[Callable, Callable]:
    """Returns tools for SNOOK mapping and calculation.

    Returns:
        tuple[Callable, Callable]: (mapper, calculator) functions.
    """
    # TODO all snook_calculator/ code and relative adapter/ is to be done
    return map_fmc_kinematics_to_snook_vars, calculate_frame_snook_index
get_thresholds() staticmethod

Returns SNOOK ratio risk thresholds mapped to RiskLevel Enums.

Returns:

Type Description
list[tuple[int, RiskLevel]]

list[tuple[int, RiskLevel]]: Threshold limits and levels.

Source code in gui\core\calculators_adapter.py
319
320
321
322
323
324
325
326
327
328
329
@staticmethod
def get_thresholds() -> list[tuple[int, RiskLevel]]:
    """Returns SNOOK ratio risk thresholds mapped to RiskLevel Enums.

    Returns:
        list[tuple[int, RiskLevel]]: Threshold limits and levels.
    """
    return [
        (1, RiskLevel.LOW),
        (2, RiskLevel.HIGH),
    ]

Functions

options: show_root_heading: true

gui.core.report_strategies

ErgoMoCap: Report Strategies

Strategy Pattern Implementation for Multi-Method Ergonomic Reporting.

This module defines the architectural contract and concrete implementations for transforming raw calculation data into display-ready structures. It utilizes the Strategy design pattern to decouple the TableReportWidget from specific assessment logic (RULA, REBA, etc.).

Each strategy is responsible for mapping internal dictionary keys to human-readable labels and defining the visual hierarchy of the generated report tables.

Key Components
  • ResultRow: The atomic data structure representing a table entry.
  • ReportStrategy: The Protocol defining the required interface for all calculators.
  • RulaStrategy: Formatting logic for Rapid Upper Limb Assessment.
  • RebaStrategy: Formatting logic for Rapid Entire Body Assessment.

Classes

RebaStrategy

Transform raw ergonomic data into a list of formatted REBA report rows.

This strategy maps keys specific to the Rapid Entire Body Assessment (REBA) protocol into a structured visual format.

Attributes:

Name Type Description
name str

The identifier "REBA".

Methods:

Name Description
format

Format RULA specific data into table rows.

Source code in gui\core\report_strategies.py
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
class RebaStrategy:
    """
    Transform raw ergonomic data into a list of formatted REBA report rows.

    This strategy maps keys specific to the Rapid Entire Body Assessment (REBA)
    protocol into a structured visual format.

    Attributes:
        name (str): The identifier "REBA".

    Methods:
        format: Format RULA specific data into table rows.
    """

    name = "REBA"

    def format(self, data: dict[str, str]) -> list[ResultRow]:
        """
        Format REBA specific data into table rows.

        Args:
            data (dict[str, str]): A dictionary containing REBA specific keys such as
                `Neck_Score_REBA` and `Final_Score_REBA`.

        Returns:
            list[ResultRow] (list[ResultRow]): A `list` of [ResultRow][gui.core.report_strategies.ResultRow]
                objects structured for the REBA table layout.
        """
        rows = [
            ResultRow("Group A (Neck, Trunk, legs)", "---", is_header=True),
            ResultRow("Neck", data.get("Neck_Score_REBA", "-")),
            ResultRow("Trunk", data.get("Trunk_Score_REBA", "-")),
            ResultRow("Legs", data.get("Legs_Score_REBA", "-")),
            ResultRow("Group B (Upper Limbs)", "---", is_header=True),
            ResultRow("Upper Arm", data.get("Upper_Arm_Score_REBA", "-")),
            ResultRow("Lower Arm", data.get("Lower_Arm_Score_REBA", "-")),
            ResultRow("Wrist", data.get("Wrist_Score_REBA", "-")),
            ResultRow(
                "FINAL REBA", data.get("Final_Score_REBA", "-"), is_critical=True
            ),
        ]

        return rows
Functions
format(data)

Format REBA specific data into table rows.

Parameters:

Name Type Description Default
data dict[str, str]

A dictionary containing REBA specific keys such as Neck_Score_REBA and Final_Score_REBA.

required

Returns:

Type Description
list[ResultRow]

list[ResultRow] (list[ResultRow]): A list of ResultRow objects structured for the REBA table layout.

Source code in gui\core\report_strategies.py
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
def format(self, data: dict[str, str]) -> list[ResultRow]:
    """
    Format REBA specific data into table rows.

    Args:
        data (dict[str, str]): A dictionary containing REBA specific keys such as
            `Neck_Score_REBA` and `Final_Score_REBA`.

    Returns:
        list[ResultRow] (list[ResultRow]): A `list` of [ResultRow][gui.core.report_strategies.ResultRow]
            objects structured for the REBA table layout.
    """
    rows = [
        ResultRow("Group A (Neck, Trunk, legs)", "---", is_header=True),
        ResultRow("Neck", data.get("Neck_Score_REBA", "-")),
        ResultRow("Trunk", data.get("Trunk_Score_REBA", "-")),
        ResultRow("Legs", data.get("Legs_Score_REBA", "-")),
        ResultRow("Group B (Upper Limbs)", "---", is_header=True),
        ResultRow("Upper Arm", data.get("Upper_Arm_Score_REBA", "-")),
        ResultRow("Lower Arm", data.get("Lower_Arm_Score_REBA", "-")),
        ResultRow("Wrist", data.get("Wrist_Score_REBA", "-")),
        ResultRow(
            "FINAL REBA", data.get("Final_Score_REBA", "-"), is_critical=True
        ),
    ]

    return rows

ReportStrategy

Bases: Protocol

Protocol defining how to transform raw data into report rows.

Any concrete strategy implemented in calculators must adhere to this interface to ensure compatibility with TableReportWidget.

Attributes:

Name Type Description
name str

The display name of the ergonomic assessment method (e.g., "RULA").

Methods:

Name Description
format

Transform raw data into a list of report rows.

Source code in gui\core\report_strategies.py
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
class ReportStrategy(Protocol):
    """
    Protocol defining how to transform raw data into report rows.

    Any concrete strategy implemented in [calculators](reference.md#calculators) must adhere to
    this interface to ensure compatibility with [TableReportWidget][gui.widgets.table_report_widget.TableReportWidget].

    Attributes:
        name (str): The display name of the ergonomic assessment method (e.g., "RULA").

    Methods:
        format: Transform raw data into a list of report rows.
    """

    name: str

    def format(self, data: dict[str, Any]) -> list[ResultRow]:
        """
        Transform raw data into a list of report rows.

        Args:
            data (dict[str, Any]): Dictionary containing raw calculation results.

        Returns:
            list[ResultRow] (list[ResultRow]): A list of formatted rows for table rendering.
        """
        ...
Functions
format(data)

Transform raw data into a list of report rows.

Parameters:

Name Type Description Default
data dict[str, Any]

Dictionary containing raw calculation results.

required

Returns:

Type Description
list[ResultRow]

list[ResultRow] (list[ResultRow]): A list of formatted rows for table rendering.

Source code in gui\core\report_strategies.py
88
89
90
91
92
93
94
95
96
97
98
def format(self, data: dict[str, Any]) -> list[ResultRow]:
    """
    Transform raw data into a list of report rows.

    Args:
        data (dict[str, Any]): Dictionary containing raw calculation results.

    Returns:
        list[ResultRow] (list[ResultRow]): A list of formatted rows for table rendering.
    """
    ...

ResultRow dataclass

Standardized data structure for a report row within the ErgoMoCap project.

This dataclass encapsulates the visual and structural properties of a single row in the analysis report tables located in the gui module.

Attributes:

Name Type Description
label str

The display name of the ergonomic metric.

value Any

The actual measurement or score associated with the metric.

is_header bool

Whether the row acts as a category separator. Defaults to False.

is_critical bool

Whether the value represents a final risk score requiring highlighting. Defaults to False.

is_angle bool

Whether the value should be formatted with a degree symbol. Defaults to False.

Source code in gui\core\report_strategies.py
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
@dataclass
class ResultRow:
    """
    Standardized data structure for a report row within the ErgoMoCap project.

    This dataclass encapsulates the visual and structural properties of a single row in
    the analysis report tables located in the [gui](reference.md#gui) module.

    Attributes:
        label (str): The display name of the ergonomic metric.
        value (Any): The actual measurement or score associated with the metric.
        is_header (bool): Whether the row acts as a category separator. Defaults to `False`.
        is_critical (bool): Whether the value represents a final risk score requiring highlighting. Defaults to `False`.
        is_angle (bool): Whether the value should be formatted with a degree symbol. Defaults to `False`.
    """

    label: str
    value: Any
    is_header: bool = False
    is_critical: bool = False
    is_angle: bool = False

RulaStrategy

Transform raw ergonomic data into a list of formatted RULA report rows.

This strategy maps keys specific to the Rapid Upper Limb Assessment (RULA) protocol into a structured visual format.

Attributes:

Name Type Description
name str

The identifier "RULA".

Methods:

Name Description
format

Format RULA specific data into table rows.

Source code in gui\core\report_strategies.py
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
class RulaStrategy:
    """
    Transform raw ergonomic data into a list of formatted RULA report rows.

    This strategy maps keys specific to the Rapid Upper Limb Assessment (RULA)
    protocol into a structured visual format.

    Attributes:
        name (str): The identifier "RULA".

    Methods:
        format: Format RULA specific data into table rows.
    """

    name = "RULA"

    def format(self, data: dict[str, str]) -> list[ResultRow]:
        """
        Format RULA specific data into table rows.

        Args:
            data (dict[str, str]): A dictionary containing RULA specific keys such as
                `Upper_Arm_Score_RULA` and `Final_Score_RULA`.

        Returns:
            list[ResultRow] (list[ResultRow]): A `list` of [ResultRow][gui.core.report_strategies.ResultRow]
                objects structured for the RULA table layout.
        """

        rows = [
            ResultRow("Group A (Upper Limbs)", "---", is_header=True),
            ResultRow("Upper Arm", data.get("Upper_Arm_Score_RULA", "-")),
            ResultRow("Wrist", data.get("Wrist_Score_RULA", "-")),
            ResultRow("Raw Score A", data.get("Score_A_RULA", "-")),
            ResultRow("Group B (Neck/Trunk)", "---", is_header=True),
            ResultRow("Neck", data.get("Neck_Score_RULA", "-")),
            ResultRow("Trunk", data.get("Trunk_Score_RULA", "-")),
            ResultRow("Legs", data.get("Legs_Score_RULA", "-")),
            ResultRow(
                "FINAL RULA", data.get("Final_Score_RULA", "-"), is_critical=True
            ),
        ]
        return rows
Functions
format(data)

Format RULA specific data into table rows.

Parameters:

Name Type Description Default
data dict[str, str]

A dictionary containing RULA specific keys such as Upper_Arm_Score_RULA and Final_Score_RULA.

required

Returns:

Type Description
list[ResultRow]

list[ResultRow] (list[ResultRow]): A list of ResultRow objects structured for the RULA table layout.

Source code in gui\core\report_strategies.py
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
def format(self, data: dict[str, str]) -> list[ResultRow]:
    """
    Format RULA specific data into table rows.

    Args:
        data (dict[str, str]): A dictionary containing RULA specific keys such as
            `Upper_Arm_Score_RULA` and `Final_Score_RULA`.

    Returns:
        list[ResultRow] (list[ResultRow]): A `list` of [ResultRow][gui.core.report_strategies.ResultRow]
            objects structured for the RULA table layout.
    """

    rows = [
        ResultRow("Group A (Upper Limbs)", "---", is_header=True),
        ResultRow("Upper Arm", data.get("Upper_Arm_Score_RULA", "-")),
        ResultRow("Wrist", data.get("Wrist_Score_RULA", "-")),
        ResultRow("Raw Score A", data.get("Score_A_RULA", "-")),
        ResultRow("Group B (Neck/Trunk)", "---", is_header=True),
        ResultRow("Neck", data.get("Neck_Score_RULA", "-")),
        ResultRow("Trunk", data.get("Trunk_Score_RULA", "-")),
        ResultRow("Legs", data.get("Legs_Score_RULA", "-")),
        ResultRow(
            "FINAL RULA", data.get("Final_Score_RULA", "-"), is_critical=True
        ),
    ]
    return rows

options: show_root_heading: true

Presenters (MVP Coordination)

gui.backend.backend

ErgoMoCap: Backend Controller

Central Orchestration and Application Logic Module.

This module implements the ErgoBackend class, which serves as the primary controller for the ErgoMoCap project. It coordinates asynchronous operations between the SessionManager, the AnalysisEngine, and the VideoWorker.

The backend manages the lifecycle of ergonomic assessments, from launching external FreeMoCap processes to executing multi-method calculations and managing synchronized video playback.

Key Features
  • Centralized registry for ergonomic assessment adapters (RULA, REBA, etc.).
  • Subprocess management for external FreeMoCap integration.
  • Automated session asset resolution and data importation.
  • Signal-based communication for real-time GUI updates and error handling.

Classes

ErgoBackend

Bases: QObject

The central controller for the ErgoMoCap application.

Coordinates data loading via SessionManager, triggers ergonomic calculations via the AnalysisEngine using specialized adapters, and manages the VideoWorker.

Attributes:

Name Type Description
frame_ready Signal

Signal emitted with a FrameData object containing rendering matrices and calculations metadata.

position_changed Signal

Signal emitted with a VideoPosition object tracking playback counters.

session_loaded Signal

Signal emitted with a SessionData object providing resolved session context.

error_occurred Signal

Signal emitted with an ErrorInfo object when processing crashes.

status_updated Signal

Signal emitted with a str displaying application progress strings in the visual status bar.

playback_state_changed Signal

Signal emitted with a bool representing whether a video ticker is active.

video_load_requested Signal

Signal emitted with a VideoLoadRequest to initialize a resource context.

video_control_requested Signal

Signal emitted with a VideoControl object altering video worker tickers.

analysis_finished Signal

Signal Emitted with an AnalysisResult object with ergonomic analysis results.

freemocap_process QProcess | None

Running instance handler for the external subprocess, or None if inactive.

engine AnalysisEngine

The core computation engine instance AnalysisEngine.

session_manager SessionManager

The asset parsing and disk lookup entity SessionManager.

video_thread QThread

Dedicated tracking execution runtime loop processing video file buffers.

video_worker VideoWorker

Background operational worker parsing video stream indices.

current_data DataFrame | None

The currently active dataset metrics container, or None if completely empty.

current_file_path Path | None

Absolute filesystem location reference path to the loaded matrix data asset.

scores_list list[int]

Sequential array structure holding processed single frame evaluation integers.

Methods:

Name Description
_setup_video_engine

Initialize or re-initialize the video worker thread infrastructure.

_ensure_video_engine_ready

Ensure the video thread is running; restart if needed.

set_current_method

Set the internal assessment protocol target selection configuration.

launch_freemocap

Launches the external FreeMoCap GUI as a subprocess.

get_adapter

Retrieves the adapter class for a specific ergonomic method.

get_summary_statistics

Calculates frequency distribution of risk levels for the current scores.

run_analysis

The main dispatching logic for ergonomic analysis.

get_score_list_from_video_source

Retrieves synchronized scores matching the specific video context.

load_video_source

Initializes a new video thread context for the given file path.

import_joint_data

Loads CSV or NPY joint data into the backend via the session manager.

set_root_and_scan

Scans a custom directory for session folders.

get_initial_sessions

Scan the default sessions directory for available session folders.

load_session_automatically

Locates and loads all assets for a session (Data + Video).

export_headless_frames

Triggers background worker execution frames assembly writing out files.

Source code in gui\backend\backend.py
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
class ErgoBackend(QObject):
    """
    The central controller for the ErgoMoCap application.

    Coordinates data loading via [SessionManager][gui.core.session_manager.SessionManager],
    triggers ergonomic calculations via the [AnalysisEngine][gui.core.analysis_engine.AnalysisEngine]
    using specialized adapters, and manages the [VideoWorker][gui.workers.video_worker.VideoWorker].

    Attributes:
        frame_ready (Signal): Signal emitted with a [FrameData][gui.utils.models.FrameData] object containing rendering matrices and calculations metadata.
        position_changed (Signal): Signal emitted with a [VideoPosition][gui.utils.models.VideoPosition] object tracking playback counters.
        session_loaded (Signal): Signal emitted with a [SessionData][gui.utils.models.SessionData] object providing resolved session context.
        error_occurred (Signal): Signal emitted with an [ErrorInfo][gui.utils.models.ErrorInfo] object when processing crashes.
        status_updated (Signal): Signal emitted with a `str` displaying application progress strings in the visual status bar.
        playback_state_changed (Signal): Signal emitted with a `bool` representing whether a video ticker is active.
        video_load_requested (Signal): Signal emitted with a [VideoLoadRequest][gui.utils.models.VideoLoadRequest] to initialize a resource context.
        video_control_requested (Signal): Signal emitted with a [VideoControl][gui.utils.models.VideoControl] object altering video worker tickers.
        analysis_finished (Signal): Signal Emitted with an [AnalysisResult][gui.utils.models.AnalysisResult] object with ergonomic analysis results.
        freemocap_process (QProcess | None): Running instance handler for the external subprocess, or `None` if inactive.
        engine (AnalysisEngine): The core computation engine instance [AnalysisEngine][gui.core.analysis_engine.AnalysisEngine].
        session_manager (SessionManager): The asset parsing and disk lookup entity [SessionManager][gui.core.session_manager.SessionManager].
        video_thread (QThread): Dedicated tracking execution runtime loop processing video file buffers.
        video_worker (VideoWorker): Background operational worker parsing video stream indices.
        current_data (pandas.DataFrame | None): The currently active dataset metrics container, or `None` if completely empty.
        current_file_path (Path | None): Absolute filesystem location reference path to the loaded matrix data asset.
        scores_list (list[int]): Sequential array structure holding processed single frame evaluation integers.

    Methods:
        _setup_video_engine: Initialize or re-initialize the video worker thread infrastructure.
        _ensure_video_engine_ready: Ensure the video thread is running; restart if needed.
        set_current_method: Set the internal assessment protocol target selection configuration.
        launch_freemocap: Launches the external FreeMoCap GUI as a subprocess.
        get_adapter: Retrieves the adapter class for a specific ergonomic method.
        get_summary_statistics: Calculates frequency distribution of risk levels for the current scores.
        run_analysis: The main dispatching logic for ergonomic analysis.
        get_score_list_from_video_source: Retrieves synchronized scores matching the specific video context.
        load_video_source: Initializes a new video thread context for the given file path.
        import_joint_data: Loads CSV or NPY joint data into the backend via the session manager.
        set_root_and_scan: Scans a custom directory for session folders.
        get_initial_sessions: Scan the default sessions directory for available session folders.
        load_session_automatically: Locates and loads all assets for a session (Data + Video).
        export_headless_frames: Triggers background worker execution frames assembly writing out files.
    """

    frame_ready = Signal(FrameData)

    position_changed = Signal(VideoPosition)

    session_loaded = Signal(SessionData)

    error_occurred = Signal(ErrorInfo)

    status_updated = Signal(str)

    playback_state_changed = Signal(bool)

    video_load_requested = Signal(VideoLoadRequest)
    video_control_requested = Signal(VideoControl)

    analysis_finished = Signal(AnalysisResult)

    def __init__(self) -> None:
        """
        Initializes the `ErgoBackend` controller and its core components.

        Sets up the internal project structure paths, instantiates the [AnalysisEngine][gui.core.analysis_engine.AnalysisEngine]
        and [SessionManager][gui.core.session_manager.SessionManager], and registers the mapping
        of ergonomic assessment methods to their respective adapter classes.

        The constructor establishes the default sessions directory at `freemocap_data/recording_sessions`
        relative to the application root.

        Returns:
            None (None): The return value is always None.
        """
        super().__init__()
        self.freemocap_process = None
        self._current_method: AssessmentMethod = AssessmentMethod.REBA
        self.current_data = None
        self.current_file_path = None
        self.scores_list = []

        self.engine = AnalysisEngine()
        self.session_manager = SessionManager(ErgoPaths.SESSIONS)

        self._adapters = {
            "REBA": REBAAdapter,
            "RULA": RULAAdapter,
            "OCRA": OCRAAdapter,
            "EWAS": EWASAdapter,
            "NIOSH": NIOSHAdapter,
            "SNOOK": SNOOKAdapter,
        }

        self._setup_video_engine()

    def _setup_video_engine(self) -> None:
        """
        Initialize or re-initialize the video worker thread infrastructure.

        Safely handles the lifecycle deletion of pre-existing execution context pipelines,
        creates isolated instances, links cross-thread execution hooks, and binds worker signals.

        Returns:
            None (None): Reconstructs internal thread contexts.
        """

        if hasattr(self, "video_thread") and self.video_thread:
            if self.video_thread.isRunning():
                self.video_thread.quit()
                self.video_thread.wait()
            self.video_thread.deleteLater()

        if hasattr(self, "video_worker") and self.video_worker:
            self.video_worker.deleteLater()

        # Create fresh instances
        self.video_thread = QThread()
        self.video_worker = VideoWorker()
        self.video_worker.moveToThread(self.video_thread)

        # Re-establish signal/slot connections
        self.video_thread.started.connect(self.video_worker.init_timer)

        self.video_load_requested.connect(
            self.video_worker.initialize_video, type=Qt.ConnectionType.QueuedConnection
        )

        self.video_control_requested.connect(self.video_worker.handle_video_control)

        # Proxy worker signals to backend
        self.video_worker.frame_ready.connect(self.frame_ready)
        self.video_worker.position_changed.connect(self.position_changed)
        self.video_thread.finished.connect(self.video_worker.cleanup)

        # Start the thread
        self.video_thread.start()
        logger.debug("Video engine thread started")

    def _ensure_video_engine_ready(self) -> bool:
        """
        Ensure the video thread is running; restart if needed.

        Returns:
            bool (bool): `True` if active or successfully restarted, `False` if thread boot failed.
        """
        if not hasattr(self, "video_thread") or not self.video_thread.isRunning():
            logger.warning("Video thread not running. Re-initializing")
            try:
                self._setup_video_engine()
                return True
            except Exception as e:
                logger.error(f"Failed to restart video engine: {e}")
                self.error_occurred.emit(
                    ErrorInfo(title="Video engine restart failed:", message=f"{e}")
                )
                return False
        return True

    def set_current_method(self, new_method: AssessmentMethod) -> None:
        """
        Set the internal assessment protocol target selection configuration.

        Args:
            new_method (AssessmentMethod): The targeting enumeration choice selection parameter.

        Returns:
            None (None): Updates the internal monitoring option property field.
        """
        self._current_method = new_method

    def launch_freemocap(self) -> tuple[bool, str]:
        """Launches the external FreeMoCap GUI as an isolated subprocess,
        by redispatching the execution path back to the primary compiled executable.

        Returns:
            tuple[bool, str]: A tuple containing (success_status, status_message).
        """
        if (
            hasattr(self, "freemocap_process")
            and self.freemocap_process
            and self.freemocap_process.poll() is None
        ):
            return False, self.tr("FreeMoCap is already running.")

        try:
            if getattr(sys, "frozen", False):
                # --- PYINSTALLER EXE MODE ---
                # Call your own ErgoMoCap.exe with a custom routing switch
                args = [sys.executable, "--run-freemocap-gui"]
            else:
                # --- VS CODE DEVELOPMENT MODE ---
                args = [sys.executable, "-m", "freemocap"]

            creation_flags = 0
            if sys.platform == "win32":
                creation_flags = subprocess.CREATE_NO_WINDOW

            # Fire the subprocess using the self-contained interpreter context
            self.freemocap_process = subprocess.Popen(
                args, creationflags=creation_flags
            )  # nosec B603 args are hardcoded, no user input that could inject malicious code

            return True, self.tr(
                "FreeMoCap is starting successfully. Please wait until it opens."
            )

        except Exception as e:
            return False, str(e)

    def get_adapter(self, method: AssessmentMethod) -> BaseErgoAdapter:
        """
        Retrieves the adapter class for a specific ergonomic method.

        Args:
            method (AssessmentMethod): The key string of the assessment method (e.g., 'REBA', 'RULA').

        Returns:
            BaseErgoAdapter: The corresponding [BaseErgoAdapter][gui.core.calculators_adapter.BaseErgoAdapter] subclass.

        Raises:
            NotImplementedError: If the requested method key is not found in the registry.
        """
        adapter = self._adapters.get(method.value.upper())
        if not adapter:
            raise NotImplementedError(f"{method} integration in progress.")
        return adapter

    def get_summary_statistics(
        self, method: AssessmentMethod = AssessmentMethod.REBA
    ) -> dict[str, int]:
        """
        Calculates frequency distribution of risk levels for the current scores.

        Args:
            method (AssessmentMethod): Threshold protocol mapping definitions engine choice. Defaults to [AssessmentMethod.REBA][gui.utils.constants.AssessmentMethod].

        Returns:
            dict[str, int] (dict): A frequency counts dictionary lookup mapping text evaluation string tags to numerical frame integers.
        """
        if (
            not self.scores_list
        ):  # TODO do better handling here and signal error to frontend
            return {}
        try:
            adapter = self.get_adapter(method)
            return adapter.get_stats(self.scores_list)
        except NotImplementedError:
            return {}

    def run_analysis(self, method: AssessmentMethod = AssessmentMethod.REBA) -> None:
        """Dispatches the ergonomic analysis process.

        Selects the appropriate adapter, routes motion capture data through the calculation
        sequence, and delegates the heavy computation to a background worker thread to
        keep the UI responsive.

        Args:
            method (AssessmentMethod): The assessment method to execute. Defaults to
                [`AssessmentMethod.REBA`][gui.utils.constants.AssessmentMethod].
        """
        if self.current_data is None:
            logger.warning("Analysis attempted with no data loaded.")
            self.analysis_finished.emit(
                AnalysisResult(
                    success=False, message=self.tr("NO_DATA_LOADED"), output_path=None
                )
            )
            return

        try:
            adapter = self.get_adapter(method)

            if hasattr(self, "_analysis_thread") and self._analysis_thread is not None:
                try:
                    if self._analysis_thread.isRunning():
                        self._analysis_thread.quit()
                        if not self._analysis_thread.wait(1000):
                            self._analysis_thread.terminate()
                            self._analysis_thread.wait()
                    self._analysis_thread.deleteLater()
                except RuntimeError:
                    pass
                finally:
                    self._analysis_thread = None

            if hasattr(self, "_analysis_worker") and self._analysis_worker is not None:
                try:
                    self._analysis_worker.deleteLater()
                except RuntimeError:
                    pass
                finally:
                    self._analysis_worker = None

            analysis_thread = QThread()
            analysis_worker = AnalysisWorker()

            # Store data as attributes before moving to thread to avoid Qt serialization issues
            analysis_worker._pending_data = self.current_data
            analysis_worker._pending_adapter = adapter
            analysis_worker._pending_method = method

            analysis_worker.moveToThread(analysis_thread)

            analysis_worker.finished.connect(
                self.analysis_finished,
                type=Qt.ConnectionType.QueuedConnection,
            )

            analysis_worker.finished.connect(analysis_thread.quit)
            analysis_worker.finished.connect(analysis_worker.deleteLater)
            analysis_thread.finished.connect(analysis_thread.deleteLater)

            self._analysis_thread = analysis_thread
            self._analysis_worker = analysis_worker

            analysis_thread.started.connect(
                analysis_worker.run,
                type=Qt.ConnectionType.QueuedConnection,
            )

            self.status_updated.emit(
                self.tr("Running {} analysis...").format(method.value)
            )
            analysis_thread.start()

        except NotImplementedError as e:
            self.analysis_finished.emit(
                AnalysisResult(success=False, message=self.tr(str(e)), output_path=None)
            )
        except Exception as e:
            logger.error(f"Analysis setup failed: {e}", exc_info=True)
            self.analysis_finished.emit(
                AnalysisResult(
                    success=False,
                    message=self.tr("Analysis failed: {}").format(str(e)),
                    output_path=None,
                )
            )

    def get_score_list_from_video_source(
        self, video_path: str, method: AssessmentMethod = AssessmentMethod.REBA
    ) -> tuple[list[int], list[tuple[int, RiskLevel]]]:
        """
        Retrieves synchronized scores matching the specific video context.

        Parses targeted source contexts dynamically to match parameters and builds safe
        fallbacks to universal processing summaries when file checks are missing.

        Args:
            video_path (str): The local system filepath target locating visual recording footage streams.
            method (AssessmentMethod): Structural targeting calculation metric layout definition. Defaults to [AssessmentMethod.REBA][gui.utils.constants.AssessmentMethod].

        Returns: TODO change return type to custom model
            tuple[list[int], list[tuple[int, RiskLevel]]] (tuple): Composed array elements holding:
                * score_list (`list`): List of per-frame calculated ergonomic evaluation score integers.
                * threshold_mapping (`list`): Risk interval parameters definition array associated with the method.
        """
        adapter = self.get_adapter(method)
        current_thresholds = adapter.get_thresholds()

        # Isolate video file base stem safely (e.g., "cam_1.mp4" -> "cam_1")
        video_stem = Path(video_path).stem
        analysis_filename = f"{video_stem}_{method.value.lower()}_metrics.csv"
        analysis_path = ErgoPaths.analysis_output() / analysis_filename

        # Fallback to shared general configuration file if contextual analytics don't exist
        if not analysis_path.exists():
            analysis_path = ErgoPaths.analysis_output()

        if not analysis_path.exists():
            # If no tracking data sheets are found, run analysis generation directly
            self.run_analysis(method=method)
            analysis_path = ErgoPaths.analysis_output()

        if not analysis_path.exists():
            return [], current_thresholds

        try:
            analysis_df = pd.read_csv(analysis_path)
            self.scores_list = analysis_df[MetricType.SCORE.value].tolist()
            return self.scores_list, current_thresholds
        except Exception as e:
            logger.error(f"Failed parsing analytics matrix: {e}")
            return [], current_thresholds

    def load_video_source(
        self, path: str, scores_list: list[int] | None = None
    ) -> VideoLoadResult:
        """
        Initializes a new video thread context for the given file path.

        Halts ongoing loop cycles safely, binds evaluation scores targets array inputs
        parameters, and triggers background tracking setup updates.

        Args:
            path (str): Absolute systemic locator string indicating local video data targets.
            scores_list (list[int] | None): Sequential score array updates parameter overlay data. Defaults to `None`.

        Returns:
            VideoLoadResult (VideoLoadResult): Structured model detailing file preparation success parameters.
        """
        try:
            if not self._ensure_video_engine_ready():
                return VideoLoadResult(
                    success=False, message=self.tr("Video engine unavailable")
                )

            fresh_score_list, thresholds = self.get_score_list_from_video_source(
                path, method=self._current_method
            )
            self.scores_list = fresh_score_list

            if scores_list is not None:
                self.scores_list = scores_list

            # Push asset target changes down to the video_worker via queued slots across threads
            video_load_request = VideoLoadRequest(
                path=Path(path), scores=self.scores_list, thresholds=thresholds
            )
            #
            self.video_load_requested.emit(video_load_request)

            self.playback_state_changed.emit(
                PlaybackState.PAUSED
            )  # Load state naturally starts paused
            return VideoLoadResult(
                success=True, message=self.tr("Video loaded and ready.")
            )

        except Exception as e:
            return VideoLoadResult(
                success=False, message=self.tr("Video error: {}").format(str(e))
            )

    def import_joint_data(self, file_path: str | Path) -> tuple[bool, str]:
        """
        Loads CSV or NPY joint data into the backend via the session manager.

        Args:
            file_path (str | Path): Path targeting metric information structures asset data files.

        Returns:
            tuple[bool, str] (tuple): A sequence collection tracking:
                * success_status (`bool`): Operational status index verification tracking flag.
                * status_message (`str`): Detail message tracking diagnostic logs strings output context.
        """
        try:
            logger.debug(f"Attempting to import joint data from: {file_path}")
            # session_manager returns (data, path_object)
            self.current_data, self.current_file_path = (
                self.session_manager.load_file_data(file_path)
            )
            return True, self.tr("Successfully loaded: {}").format(
                self.current_file_path.name if self.current_file_path else "Data"
            )
        except Exception as e:
            return False, self.tr("Failed to load data: {}").format(str(e))

    def set_root_and_scan(self, path: str | Path) -> list[str]:
        """
        Scans a custom directory for session folders.

        Args:
            path (str | Path): The directory path to scan.

        Returns:
            list[str]: A list of session directory names found.
        """
        return self.session_manager.scan_custom_path(path)

    def get_initial_sessions(self) -> list[str]:
        """
        Scan the default sessions directory for available session folders.

        Returns:
            list[str]: A list of session directory names found via the session manager.
        """
        return self.session_manager.get_initial_sessions()

    def load_session_automatically(self, session_name: str) -> SessionData:
        """
        Locates and loads all assets for a session (Data + Video).

        Automatically identifies joint movement metrics records sheets data sets and selects the core video
        capture file corresponding to the requested tracking string indicator key parameters.

        Args:
            session_name (str): Label identifier key targeting target recordings assets directory groupings.

        Returns:
            SessionData (SessionData): Unified tracking model layout storing data configuration resolution success variables.
        """
        logger.info(f"Loading session: {session_name}")

        # 1. Ask ErgoPaths for the address
        session_path = ErgoPaths.session_folder(session_name)

        if not session_path.exists():
            logger.error(
                f"Session folder with name {session_name} not found at: {session_path}"
            )
            return SessionData(
                name=session_name,
                success=False,
                message=self.tr("Session folder not found at: {}").format(session_path),
            )

        # 2. Let SessionManager handle the deep dive
        target_csv, target_video, video_files = (
            self.session_manager.resolve_session_assets(session_name)
        )

        if not target_csv:
            return SessionData(
                name=session_name,
                success=False,
                message=self.tr("No 'joint_angles' CSV found."),
            )

        # 3. Load Data
        success, msg = self.import_joint_data(target_csv)

        if not success or self.current_data is None:
            logger.error(f"Data Load Failed: {msg}")
            self.error_occurred.emit(
                ErrorInfo(title="Failed to load session", message=msg)
            )
            return SessionData(
                name=session_name,
                success=False,
                message=msg,
            )

        # 4. Load Video using ErgoPaths for the full path construction
        if target_video:
            video_full_path = ErgoPaths.video_folder(session_name) / target_video
            video_result = self.load_video_source(str(video_full_path))
            # TODO check this and test it
            if not video_result.success:
                logger.error(f"Video Load Failed: {video_result.message}")
                return SessionData(
                    name=session_name,
                    success=False,
                    message=video_result.message,
                )

        session_data = SessionData(
            name=session_name,
            success=True,
            message=self.tr("Loaded Session: {}").format(session_name),
            video_paths=video_files,
        )
        self.session_loaded.emit(session_data)
        return session_data

    def export_headless_frames(self, video_name: str, session_name: str) -> bool:
        """
        Triggers background worker execution frames assembly writing out files.

        Kicks off an asynchronous background processing tracking routine extracting matrix overlays
        and saving raw frame images sequentially into standalone target directories without lockups.

        Args:
            video_name (str): String file name indicator targeting video selection asset files.
            session_name (str): Recording grouping label index indicator parameter.

        Returns:
            bool (bool): Thread kickoff invocation execution verification tracking parameter indicator status.
        """
        if not video_name or not session_name:
            return False

        video_path = ErgoPaths.video_folder(session_name) / video_name

        frames_dir = ErgoPaths.frames_folder(session_name, video_name)

        # 2. Clean up existing export thread assets if running
        if hasattr(self, "export_thread") and self.export_thread:
            try:
                if self.export_thread.isRunning():
                    logger.info(
                        "Stopping active export thread before starting new one..."
                    )
                    if hasattr(self, "export_worker") and self.export_worker:
                        self.export_worker.stop()
                    self.export_thread.quit()
                    self.export_thread.wait()
            except RuntimeError:
                pass  # C++ object was already garbage collected

        # --- Threading Setup ---
        self.export_thread = QThread()
        self.export_worker = FramesExportWorker(
            video_path, frames_dir, self.scores_list
        )
        self.export_worker.moveToThread(self.export_thread)

        # 4. Connect Cross-Thread Signals
        # Forward the worker's progress straight out through backend proxy boundary
        @Slot(VideoPosition)
        def format_progress_message(video_position: VideoPosition):
            """
            Interceptor callback translating raw positions inputs sequences parameters into output logging strings messages.

            Args:
                video_position (VideoPosition): Data model recording frame metrics update indexes parameters.

            Returns:
                None (None): Dispatches a modified tracking state string directly to listener objects via UI slots.
            """
            message = f"⏳ Exporting Frames: {video_position.current_frame} / {video_position.total_frames}"
            self.status_updated.emit(message)

        self.export_worker.progress.connect(format_progress_message)

        self.export_thread.started.connect(self.export_worker.run)
        # Clean up strategies when finished
        self.export_worker.finished.connect(self.export_thread.quit)
        self.export_worker.finished.connect(
            lambda: self.status_updated.emit("✅ Export Complete")
        )

        # Safe C++ deletions
        self.export_worker.finished.connect(self.export_worker.deleteLater)
        self.export_thread.finished.connect(self.export_thread.deleteLater)

        # 5. Kick off background execution
        self.export_thread.start()
        self.status_updated.emit("⏳ Exporting Frames (Headless Mode)...")
        return True
Functions
_ensure_video_engine_ready()

Ensure the video thread is running; restart if needed.

Returns:

Name Type Description
bool bool

True if active or successfully restarted, False if thread boot failed.

Source code in gui\backend\backend.py
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
def _ensure_video_engine_ready(self) -> bool:
    """
    Ensure the video thread is running; restart if needed.

    Returns:
        bool (bool): `True` if active or successfully restarted, `False` if thread boot failed.
    """
    if not hasattr(self, "video_thread") or not self.video_thread.isRunning():
        logger.warning("Video thread not running. Re-initializing")
        try:
            self._setup_video_engine()
            return True
        except Exception as e:
            logger.error(f"Failed to restart video engine: {e}")
            self.error_occurred.emit(
                ErrorInfo(title="Video engine restart failed:", message=f"{e}")
            )
            return False
    return True
_setup_video_engine()

Initialize or re-initialize the video worker thread infrastructure.

Safely handles the lifecycle deletion of pre-existing execution context pipelines, creates isolated instances, links cross-thread execution hooks, and binds worker signals.

Returns:

Name Type Description
None None

Reconstructs internal thread contexts.

Source code in gui\backend\backend.py
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
def _setup_video_engine(self) -> None:
    """
    Initialize or re-initialize the video worker thread infrastructure.

    Safely handles the lifecycle deletion of pre-existing execution context pipelines,
    creates isolated instances, links cross-thread execution hooks, and binds worker signals.

    Returns:
        None (None): Reconstructs internal thread contexts.
    """

    if hasattr(self, "video_thread") and self.video_thread:
        if self.video_thread.isRunning():
            self.video_thread.quit()
            self.video_thread.wait()
        self.video_thread.deleteLater()

    if hasattr(self, "video_worker") and self.video_worker:
        self.video_worker.deleteLater()

    # Create fresh instances
    self.video_thread = QThread()
    self.video_worker = VideoWorker()
    self.video_worker.moveToThread(self.video_thread)

    # Re-establish signal/slot connections
    self.video_thread.started.connect(self.video_worker.init_timer)

    self.video_load_requested.connect(
        self.video_worker.initialize_video, type=Qt.ConnectionType.QueuedConnection
    )

    self.video_control_requested.connect(self.video_worker.handle_video_control)

    # Proxy worker signals to backend
    self.video_worker.frame_ready.connect(self.frame_ready)
    self.video_worker.position_changed.connect(self.position_changed)
    self.video_thread.finished.connect(self.video_worker.cleanup)

    # Start the thread
    self.video_thread.start()
    logger.debug("Video engine thread started")
export_headless_frames(video_name, session_name)

Triggers background worker execution frames assembly writing out files.

Kicks off an asynchronous background processing tracking routine extracting matrix overlays and saving raw frame images sequentially into standalone target directories without lockups.

Parameters:

Name Type Description Default
video_name str

String file name indicator targeting video selection asset files.

required
session_name str

Recording grouping label index indicator parameter.

required

Returns:

Name Type Description
bool bool

Thread kickoff invocation execution verification tracking parameter indicator status.

Source code in gui\backend\backend.py
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
def export_headless_frames(self, video_name: str, session_name: str) -> bool:
    """
    Triggers background worker execution frames assembly writing out files.

    Kicks off an asynchronous background processing tracking routine extracting matrix overlays
    and saving raw frame images sequentially into standalone target directories without lockups.

    Args:
        video_name (str): String file name indicator targeting video selection asset files.
        session_name (str): Recording grouping label index indicator parameter.

    Returns:
        bool (bool): Thread kickoff invocation execution verification tracking parameter indicator status.
    """
    if not video_name or not session_name:
        return False

    video_path = ErgoPaths.video_folder(session_name) / video_name

    frames_dir = ErgoPaths.frames_folder(session_name, video_name)

    # 2. Clean up existing export thread assets if running
    if hasattr(self, "export_thread") and self.export_thread:
        try:
            if self.export_thread.isRunning():
                logger.info(
                    "Stopping active export thread before starting new one..."
                )
                if hasattr(self, "export_worker") and self.export_worker:
                    self.export_worker.stop()
                self.export_thread.quit()
                self.export_thread.wait()
        except RuntimeError:
            pass  # C++ object was already garbage collected

    # --- Threading Setup ---
    self.export_thread = QThread()
    self.export_worker = FramesExportWorker(
        video_path, frames_dir, self.scores_list
    )
    self.export_worker.moveToThread(self.export_thread)

    # 4. Connect Cross-Thread Signals
    # Forward the worker's progress straight out through backend proxy boundary
    @Slot(VideoPosition)
    def format_progress_message(video_position: VideoPosition):
        """
        Interceptor callback translating raw positions inputs sequences parameters into output logging strings messages.

        Args:
            video_position (VideoPosition): Data model recording frame metrics update indexes parameters.

        Returns:
            None (None): Dispatches a modified tracking state string directly to listener objects via UI slots.
        """
        message = f"⏳ Exporting Frames: {video_position.current_frame} / {video_position.total_frames}"
        self.status_updated.emit(message)

    self.export_worker.progress.connect(format_progress_message)

    self.export_thread.started.connect(self.export_worker.run)
    # Clean up strategies when finished
    self.export_worker.finished.connect(self.export_thread.quit)
    self.export_worker.finished.connect(
        lambda: self.status_updated.emit("✅ Export Complete")
    )

    # Safe C++ deletions
    self.export_worker.finished.connect(self.export_worker.deleteLater)
    self.export_thread.finished.connect(self.export_thread.deleteLater)

    # 5. Kick off background execution
    self.export_thread.start()
    self.status_updated.emit("⏳ Exporting Frames (Headless Mode)...")
    return True
get_adapter(method)

Retrieves the adapter class for a specific ergonomic method.

Parameters:

Name Type Description Default
method AssessmentMethod

The key string of the assessment method (e.g., 'REBA', 'RULA').

required

Returns:

Name Type Description
BaseErgoAdapter BaseErgoAdapter

The corresponding BaseErgoAdapter subclass.

Raises:

Type Description
NotImplementedError

If the requested method key is not found in the registry.

Source code in gui\backend\backend.py
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
def get_adapter(self, method: AssessmentMethod) -> BaseErgoAdapter:
    """
    Retrieves the adapter class for a specific ergonomic method.

    Args:
        method (AssessmentMethod): The key string of the assessment method (e.g., 'REBA', 'RULA').

    Returns:
        BaseErgoAdapter: The corresponding [BaseErgoAdapter][gui.core.calculators_adapter.BaseErgoAdapter] subclass.

    Raises:
        NotImplementedError: If the requested method key is not found in the registry.
    """
    adapter = self._adapters.get(method.value.upper())
    if not adapter:
        raise NotImplementedError(f"{method} integration in progress.")
    return adapter
get_initial_sessions()

Scan the default sessions directory for available session folders.

Returns:

Type Description
list[str]

list[str]: A list of session directory names found via the session manager.

Source code in gui\backend\backend.py
554
555
556
557
558
559
560
561
def get_initial_sessions(self) -> list[str]:
    """
    Scan the default sessions directory for available session folders.

    Returns:
        list[str]: A list of session directory names found via the session manager.
    """
    return self.session_manager.get_initial_sessions()
get_score_list_from_video_source(video_path, method=AssessmentMethod.REBA)

Retrieves synchronized scores matching the specific video context.

Parses targeted source contexts dynamically to match parameters and builds safe fallbacks to universal processing summaries when file checks are missing.

Parameters:

Name Type Description Default
video_path str

The local system filepath target locating visual recording footage streams.

required
method AssessmentMethod

Structural targeting calculation metric layout definition. Defaults to AssessmentMethod.REBA.

REBA

TODO change return type to custom model

Type Description
tuple[list[int], list[tuple[int, RiskLevel]]]

tuple[list[int], list[tuple[int, RiskLevel]]] (tuple): Composed array elements holding: * score_list (list): List of per-frame calculated ergonomic evaluation score integers. * threshold_mapping (list): Risk interval parameters definition array associated with the method.

Source code in gui\backend\backend.py
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
def get_score_list_from_video_source(
    self, video_path: str, method: AssessmentMethod = AssessmentMethod.REBA
) -> tuple[list[int], list[tuple[int, RiskLevel]]]:
    """
    Retrieves synchronized scores matching the specific video context.

    Parses targeted source contexts dynamically to match parameters and builds safe
    fallbacks to universal processing summaries when file checks are missing.

    Args:
        video_path (str): The local system filepath target locating visual recording footage streams.
        method (AssessmentMethod): Structural targeting calculation metric layout definition. Defaults to [AssessmentMethod.REBA][gui.utils.constants.AssessmentMethod].

    Returns: TODO change return type to custom model
        tuple[list[int], list[tuple[int, RiskLevel]]] (tuple): Composed array elements holding:
            * score_list (`list`): List of per-frame calculated ergonomic evaluation score integers.
            * threshold_mapping (`list`): Risk interval parameters definition array associated with the method.
    """
    adapter = self.get_adapter(method)
    current_thresholds = adapter.get_thresholds()

    # Isolate video file base stem safely (e.g., "cam_1.mp4" -> "cam_1")
    video_stem = Path(video_path).stem
    analysis_filename = f"{video_stem}_{method.value.lower()}_metrics.csv"
    analysis_path = ErgoPaths.analysis_output() / analysis_filename

    # Fallback to shared general configuration file if contextual analytics don't exist
    if not analysis_path.exists():
        analysis_path = ErgoPaths.analysis_output()

    if not analysis_path.exists():
        # If no tracking data sheets are found, run analysis generation directly
        self.run_analysis(method=method)
        analysis_path = ErgoPaths.analysis_output()

    if not analysis_path.exists():
        return [], current_thresholds

    try:
        analysis_df = pd.read_csv(analysis_path)
        self.scores_list = analysis_df[MetricType.SCORE.value].tolist()
        return self.scores_list, current_thresholds
    except Exception as e:
        logger.error(f"Failed parsing analytics matrix: {e}")
        return [], current_thresholds
get_summary_statistics(method=AssessmentMethod.REBA)

Calculates frequency distribution of risk levels for the current scores.

Parameters:

Name Type Description Default
method AssessmentMethod

Threshold protocol mapping definitions engine choice. Defaults to AssessmentMethod.REBA.

REBA

Returns:

Type Description
dict[str, int]

dict[str, int] (dict): A frequency counts dictionary lookup mapping text evaluation string tags to numerical frame integers.

Source code in gui\backend\backend.py
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
def get_summary_statistics(
    self, method: AssessmentMethod = AssessmentMethod.REBA
) -> dict[str, int]:
    """
    Calculates frequency distribution of risk levels for the current scores.

    Args:
        method (AssessmentMethod): Threshold protocol mapping definitions engine choice. Defaults to [AssessmentMethod.REBA][gui.utils.constants.AssessmentMethod].

    Returns:
        dict[str, int] (dict): A frequency counts dictionary lookup mapping text evaluation string tags to numerical frame integers.
    """
    if (
        not self.scores_list
    ):  # TODO do better handling here and signal error to frontend
        return {}
    try:
        adapter = self.get_adapter(method)
        return adapter.get_stats(self.scores_list)
    except NotImplementedError:
        return {}
import_joint_data(file_path)

Loads CSV or NPY joint data into the backend via the session manager.

Parameters:

Name Type Description Default
file_path str | Path

Path targeting metric information structures asset data files.

required

Returns:

Type Description
tuple[bool, str]

tuple[bool, str] (tuple): A sequence collection tracking: * success_status (bool): Operational status index verification tracking flag. * status_message (str): Detail message tracking diagnostic logs strings output context.

Source code in gui\backend\backend.py
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
def import_joint_data(self, file_path: str | Path) -> tuple[bool, str]:
    """
    Loads CSV or NPY joint data into the backend via the session manager.

    Args:
        file_path (str | Path): Path targeting metric information structures asset data files.

    Returns:
        tuple[bool, str] (tuple): A sequence collection tracking:
            * success_status (`bool`): Operational status index verification tracking flag.
            * status_message (`str`): Detail message tracking diagnostic logs strings output context.
    """
    try:
        logger.debug(f"Attempting to import joint data from: {file_path}")
        # session_manager returns (data, path_object)
        self.current_data, self.current_file_path = (
            self.session_manager.load_file_data(file_path)
        )
        return True, self.tr("Successfully loaded: {}").format(
            self.current_file_path.name if self.current_file_path else "Data"
        )
    except Exception as e:
        return False, self.tr("Failed to load data: {}").format(str(e))
launch_freemocap()

Launches the external FreeMoCap GUI as an isolated subprocess, by redispatching the execution path back to the primary compiled executable.

Returns:

Type Description
tuple[bool, str]

tuple[bool, str]: A tuple containing (success_status, status_message).

Source code in gui\backend\backend.py
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
def launch_freemocap(self) -> tuple[bool, str]:
    """Launches the external FreeMoCap GUI as an isolated subprocess,
    by redispatching the execution path back to the primary compiled executable.

    Returns:
        tuple[bool, str]: A tuple containing (success_status, status_message).
    """
    if (
        hasattr(self, "freemocap_process")
        and self.freemocap_process
        and self.freemocap_process.poll() is None
    ):
        return False, self.tr("FreeMoCap is already running.")

    try:
        if getattr(sys, "frozen", False):
            # --- PYINSTALLER EXE MODE ---
            # Call your own ErgoMoCap.exe with a custom routing switch
            args = [sys.executable, "--run-freemocap-gui"]
        else:
            # --- VS CODE DEVELOPMENT MODE ---
            args = [sys.executable, "-m", "freemocap"]

        creation_flags = 0
        if sys.platform == "win32":
            creation_flags = subprocess.CREATE_NO_WINDOW

        # Fire the subprocess using the self-contained interpreter context
        self.freemocap_process = subprocess.Popen(
            args, creationflags=creation_flags
        )  # nosec B603 args are hardcoded, no user input that could inject malicious code

        return True, self.tr(
            "FreeMoCap is starting successfully. Please wait until it opens."
        )

    except Exception as e:
        return False, str(e)
load_session_automatically(session_name)

Locates and loads all assets for a session (Data + Video).

Automatically identifies joint movement metrics records sheets data sets and selects the core video capture file corresponding to the requested tracking string indicator key parameters.

Parameters:

Name Type Description Default
session_name str

Label identifier key targeting target recordings assets directory groupings.

required

Returns:

Name Type Description
SessionData SessionData

Unified tracking model layout storing data configuration resolution success variables.

Source code in gui\backend\backend.py
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
def load_session_automatically(self, session_name: str) -> SessionData:
    """
    Locates and loads all assets for a session (Data + Video).

    Automatically identifies joint movement metrics records sheets data sets and selects the core video
    capture file corresponding to the requested tracking string indicator key parameters.

    Args:
        session_name (str): Label identifier key targeting target recordings assets directory groupings.

    Returns:
        SessionData (SessionData): Unified tracking model layout storing data configuration resolution success variables.
    """
    logger.info(f"Loading session: {session_name}")

    # 1. Ask ErgoPaths for the address
    session_path = ErgoPaths.session_folder(session_name)

    if not session_path.exists():
        logger.error(
            f"Session folder with name {session_name} not found at: {session_path}"
        )
        return SessionData(
            name=session_name,
            success=False,
            message=self.tr("Session folder not found at: {}").format(session_path),
        )

    # 2. Let SessionManager handle the deep dive
    target_csv, target_video, video_files = (
        self.session_manager.resolve_session_assets(session_name)
    )

    if not target_csv:
        return SessionData(
            name=session_name,
            success=False,
            message=self.tr("No 'joint_angles' CSV found."),
        )

    # 3. Load Data
    success, msg = self.import_joint_data(target_csv)

    if not success or self.current_data is None:
        logger.error(f"Data Load Failed: {msg}")
        self.error_occurred.emit(
            ErrorInfo(title="Failed to load session", message=msg)
        )
        return SessionData(
            name=session_name,
            success=False,
            message=msg,
        )

    # 4. Load Video using ErgoPaths for the full path construction
    if target_video:
        video_full_path = ErgoPaths.video_folder(session_name) / target_video
        video_result = self.load_video_source(str(video_full_path))
        # TODO check this and test it
        if not video_result.success:
            logger.error(f"Video Load Failed: {video_result.message}")
            return SessionData(
                name=session_name,
                success=False,
                message=video_result.message,
            )

    session_data = SessionData(
        name=session_name,
        success=True,
        message=self.tr("Loaded Session: {}").format(session_name),
        video_paths=video_files,
    )
    self.session_loaded.emit(session_data)
    return session_data
load_video_source(path, scores_list=None)

Initializes a new video thread context for the given file path.

Halts ongoing loop cycles safely, binds evaluation scores targets array inputs parameters, and triggers background tracking setup updates.

Parameters:

Name Type Description Default
path str

Absolute systemic locator string indicating local video data targets.

required
scores_list list[int] | None

Sequential score array updates parameter overlay data. Defaults to None.

None

Returns:

Name Type Description
VideoLoadResult VideoLoadResult

Structured model detailing file preparation success parameters.

Source code in gui\backend\backend.py
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
def load_video_source(
    self, path: str, scores_list: list[int] | None = None
) -> VideoLoadResult:
    """
    Initializes a new video thread context for the given file path.

    Halts ongoing loop cycles safely, binds evaluation scores targets array inputs
    parameters, and triggers background tracking setup updates.

    Args:
        path (str): Absolute systemic locator string indicating local video data targets.
        scores_list (list[int] | None): Sequential score array updates parameter overlay data. Defaults to `None`.

    Returns:
        VideoLoadResult (VideoLoadResult): Structured model detailing file preparation success parameters.
    """
    try:
        if not self._ensure_video_engine_ready():
            return VideoLoadResult(
                success=False, message=self.tr("Video engine unavailable")
            )

        fresh_score_list, thresholds = self.get_score_list_from_video_source(
            path, method=self._current_method
        )
        self.scores_list = fresh_score_list

        if scores_list is not None:
            self.scores_list = scores_list

        # Push asset target changes down to the video_worker via queued slots across threads
        video_load_request = VideoLoadRequest(
            path=Path(path), scores=self.scores_list, thresholds=thresholds
        )
        #
        self.video_load_requested.emit(video_load_request)

        self.playback_state_changed.emit(
            PlaybackState.PAUSED
        )  # Load state naturally starts paused
        return VideoLoadResult(
            success=True, message=self.tr("Video loaded and ready.")
        )

    except Exception as e:
        return VideoLoadResult(
            success=False, message=self.tr("Video error: {}").format(str(e))
        )
run_analysis(method=AssessmentMethod.REBA)

Dispatches the ergonomic analysis process.

Selects the appropriate adapter, routes motion capture data through the calculation sequence, and delegates the heavy computation to a background worker thread to keep the UI responsive.

Parameters:

Name Type Description Default
method AssessmentMethod

The assessment method to execute. Defaults to AssessmentMethod.REBA.

REBA
Source code in gui\backend\backend.py
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
def run_analysis(self, method: AssessmentMethod = AssessmentMethod.REBA) -> None:
    """Dispatches the ergonomic analysis process.

    Selects the appropriate adapter, routes motion capture data through the calculation
    sequence, and delegates the heavy computation to a background worker thread to
    keep the UI responsive.

    Args:
        method (AssessmentMethod): The assessment method to execute. Defaults to
            [`AssessmentMethod.REBA`][gui.utils.constants.AssessmentMethod].
    """
    if self.current_data is None:
        logger.warning("Analysis attempted with no data loaded.")
        self.analysis_finished.emit(
            AnalysisResult(
                success=False, message=self.tr("NO_DATA_LOADED"), output_path=None
            )
        )
        return

    try:
        adapter = self.get_adapter(method)

        if hasattr(self, "_analysis_thread") and self._analysis_thread is not None:
            try:
                if self._analysis_thread.isRunning():
                    self._analysis_thread.quit()
                    if not self._analysis_thread.wait(1000):
                        self._analysis_thread.terminate()
                        self._analysis_thread.wait()
                self._analysis_thread.deleteLater()
            except RuntimeError:
                pass
            finally:
                self._analysis_thread = None

        if hasattr(self, "_analysis_worker") and self._analysis_worker is not None:
            try:
                self._analysis_worker.deleteLater()
            except RuntimeError:
                pass
            finally:
                self._analysis_worker = None

        analysis_thread = QThread()
        analysis_worker = AnalysisWorker()

        # Store data as attributes before moving to thread to avoid Qt serialization issues
        analysis_worker._pending_data = self.current_data
        analysis_worker._pending_adapter = adapter
        analysis_worker._pending_method = method

        analysis_worker.moveToThread(analysis_thread)

        analysis_worker.finished.connect(
            self.analysis_finished,
            type=Qt.ConnectionType.QueuedConnection,
        )

        analysis_worker.finished.connect(analysis_thread.quit)
        analysis_worker.finished.connect(analysis_worker.deleteLater)
        analysis_thread.finished.connect(analysis_thread.deleteLater)

        self._analysis_thread = analysis_thread
        self._analysis_worker = analysis_worker

        analysis_thread.started.connect(
            analysis_worker.run,
            type=Qt.ConnectionType.QueuedConnection,
        )

        self.status_updated.emit(
            self.tr("Running {} analysis...").format(method.value)
        )
        analysis_thread.start()

    except NotImplementedError as e:
        self.analysis_finished.emit(
            AnalysisResult(success=False, message=self.tr(str(e)), output_path=None)
        )
    except Exception as e:
        logger.error(f"Analysis setup failed: {e}", exc_info=True)
        self.analysis_finished.emit(
            AnalysisResult(
                success=False,
                message=self.tr("Analysis failed: {}").format(str(e)),
                output_path=None,
            )
        )
set_current_method(new_method)

Set the internal assessment protocol target selection configuration.

Parameters:

Name Type Description Default
new_method AssessmentMethod

The targeting enumeration choice selection parameter.

required

Returns:

Name Type Description
None None

Updates the internal monitoring option property field.

Source code in gui\backend\backend.py
242
243
244
245
246
247
248
249
250
251
252
def set_current_method(self, new_method: AssessmentMethod) -> None:
    """
    Set the internal assessment protocol target selection configuration.

    Args:
        new_method (AssessmentMethod): The targeting enumeration choice selection parameter.

    Returns:
        None (None): Updates the internal monitoring option property field.
    """
    self._current_method = new_method
set_root_and_scan(path)

Scans a custom directory for session folders.

Parameters:

Name Type Description Default
path str | Path

The directory path to scan.

required

Returns:

Type Description
list[str]

list[str]: A list of session directory names found.

Source code in gui\backend\backend.py
542
543
544
545
546
547
548
549
550
551
552
def set_root_and_scan(self, path: str | Path) -> list[str]:
    """
    Scans a custom directory for session folders.

    Args:
        path (str | Path): The directory path to scan.

    Returns:
        list[str]: A list of session directory names found.
    """
    return self.session_manager.scan_custom_path(path)

options: show_root_heading: true

gui.backend.report_backend

ErgoMoCap: Report Backend

Logic and Data Processing for the ErgoMoCap Report System.

This module handles pure data manipulation (Pandas), metric calculations, and template generation (Jinja2/DocxTemplate). It strictly avoids PySide6 GUI components (like QTextDocument or QPrinter) to ensure it can be safely moved to a QThread for asynchronous execution without causing segfaults.

Key Features
  • Asynchronous data parsing for CSV and Excel formats via pandas.DataFrame.
  • Thread-safe HTML report rendering using jinja2 environments.
  • Synchronous DOCX generation with embedded Matplotlib visualizations.
  • Heuristic and strict column targeting for RULA/REBA assessment methods.

Classes

ReportBackend

Bases: QObject

Data processing core for the Report module.

This class serves as the backend controller for ergonomic report generation. It inherits from QObject to facilitate thread-safe communication via signals and slots, allowing data-intensive operations (like pandas parsing and jinja2 rendering) to be offloaded to background threads. It handles file I/O, metric aggregation, and template preparation for both PDF and DOCX exports.

Attributes:

Name Type Description
data_processed Signal

Signal emitted with a ReportData object when data is successfully loaded and processed.

pdf_html_ready Signal

Signal emitted with a str containing the fully rendered HTML markup ready for GUI-thread printing.

report_export_finished Signal

Signal emitted with a ReportExportResult instance upon completion of a document export.

error_occurred Signal

Signal emitted with an ErrorInfo object when an exception is caught during processing.

current_file Path | None

The absolute filesystem Path to the currently active data source, or None if no data is loaded.

current_method AssessmentMethod

The active assessment protocol configuration, defaults to AssessmentMethod.REBA.

template_dir Path

The file system path locating the directory containing the Jinja2 and Word templates.

jinja_env Environment

The securely configured jinja2.Environment instance optimized with autoescaping for report compilation.

Methods:

Name Description
load_data_and_run

Asynchronously loads and parses ergonomic data from a given CSV/Excel file.

prepare_pdf_export

Compiles the HTML report context using Jinja2 and emits it.

export_to_docx

Generates and saves a Word document report synchronously.

get_chart_distribution_data

Calculates distributions using strict column targeting based on the active method.

Source code in gui\backend\report_backend.py
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
class ReportBackend(QObject):
    """
    Data processing core for the Report module.

    This class serves as the backend controller for ergonomic report generation. It
    inherits from `QObject` to facilitate thread-safe communication via signals and
    slots, allowing data-intensive operations (like `pandas` parsing and `jinja2`
    rendering) to be offloaded to background threads. It handles file I/O,
    metric aggregation, and template preparation for both PDF and DOCX exports.

    Attributes:
        data_processed (Signal): Signal emitted with a [ReportData][gui.utils.models.ReportData] object when data is successfully loaded and processed.
        pdf_html_ready (Signal): Signal emitted with a `str` containing the fully rendered HTML markup ready for GUI-thread printing.
        report_export_finished (Signal): Signal emitted with a [ReportExportResult][gui.utils.models.ReportExportResult] instance upon completion of a document export.
        error_occurred (Signal): Signal emitted with an [ErrorInfo][gui.utils.models.ErrorInfo] object when an exception is caught during processing.
        current_file (Path | None): The absolute filesystem `Path` to the currently active data source, or `None` if no data is loaded.
        current_method (AssessmentMethod): The active assessment protocol configuration, defaults to [AssessmentMethod.REBA][gui.utils.constants.AssessmentMethod].
        template_dir (Path): The file system path locating the directory containing the Jinja2 and Word templates.
        jinja_env (Environment): The securely configured `jinja2.Environment` instance optimized with autoescaping for report compilation.

    Methods:
        load_data_and_run: Asynchronously loads and parses ergonomic data from a given CSV/Excel file.
        prepare_pdf_export: Compiles the HTML report context using Jinja2 and emits it.
        export_to_docx: Generates and saves a Word document report synchronously.
        get_chart_distribution_data: Calculates distributions using strict column targeting based on the active method.
    """

    # --- Signals ---

    data_processed = Signal(ReportData)

    # Emits: HTML string, target save path (Allows Frontend to handle GUI-printing)
    pdf_html_ready = Signal(str)

    # Emits: Success boolean, Status Message
    report_export_finished = Signal(ReportExportResult)

    # Emits: Error Title, Error Message
    error_occurred = Signal(ErrorInfo)

    def __init__(self) -> None:
        """
        Initializes the backend state and sets up secure template handling.

        Returns:
            None (None): Initializes the state attributes of the `ReportBackend` object.
        """
        super().__init__()
        self.current_file: Path | None = None
        self.current_method = AssessmentMethod.REBA

        # Bandit Security: Secure Jinja environment setup
        self.template_dir = ErgoPaths.TEMPLATES
        self.jinja_env = Environment(
            loader=FileSystemLoader(self.template_dir),
            autoescape=select_autoescape(["html", "xml", "j2"]),
        )

    @Slot(Path)
    def load_data_and_run(self, file_path: Path) -> None:
        """
        Asynchronously loads and parses ergonomic data from a given CSV/Excel file.

        This method performs heuristic column detection to find risk and score columns
        regardless of the assessment method. On success, it calculates primary metrics,
        structures them into a data container, and emits the `data_processed` signal.
        If the operation fails, an `error_occurred` signal containing structural error
        details is dispatched.

        Args:
            file_path (Path): Absolute filesystem path to the source data file (supports `.csv` and `.xlsx`).

        Returns:
            None (None): Results or errors are transmitted asynchronously via Qt Signals.

        Raises:
            ValueError (ValueError): Raised if the chosen file contains an empty dataset.
            KeyError (KeyError): Raised if critical score or risk tracking columns cannot be identified.
        """
        try:
            # Handle encoding and format fallbacks
            if file_path.suffix == ".xlsx":
                df: pd.DataFrame = pd.read_excel(file_path)
            else:
                try:
                    df: pd.DataFrame = pd.read_csv(file_path)
                except UnicodeDecodeError:
                    df: pd.DataFrame = pd.read_csv(file_path, encoding="latin-1")

            if df.empty:
                raise ValueError("The selected file contains no data.")

            score_col: str = MetricType.SCORE.value
            risk_col: str = MetricType.RISK.value

            df = df.dropna(subset=[score_col, risk_col])

            self.current_file = file_path

            # Calculate primary metrics
            total_frames: int = len(df)

            avg_score: float = df[score_col].mean() if total_frames > 0 else 0.0

            summary_rows: list[tuple[str, str]] = get_dynamic_metrics(
                df, MetricType.SCORE, self.current_method
            )

            # print(summary_rows, "SUMMARY ROWS", "\n") TODO print_reactivate

            summary: dict = dict(summary_rows)

            # print(summary, "SUMMARY ROWS", "\n") TODO print_reactivate

            # Transmit structured data to frontend
            processed_data = ReportData(
                df=df,
                file_path=file_path,
                total_frames=total_frames,
                average_score=avg_score,
                summary_dict=summary,
            )
            self.data_processed.emit(processed_data)

        except Exception as e:
            self.error_occurred.emit(
                ErrorInfo(
                    title="Load Error",
                    message=f"Could not parse analysis data: {str(e)}",
                )
            )

    def update_method(self, method: AssessmentMethod) -> None:
        """
        Update the active ergonomic assessment method.

        Args:
            method (AssessmentMethod): The name of the method to apply (e.g., AssessmentMethod.RULA, AssessmentMethod.REBA).

        Returns:
            None (None): Updates internal state and clears previous strategy if necessary.
        """
        if hasattr(self, "current_method") and self.current_method == method:
            return  # Don't change if it's the same

        self.current_method = method

    def prepare_pdf_export(self, report_export_request: ReportExportRequest) -> None:
        """
        Compiles the HTML report context using Jinja2 and emits the generated markup.

        This method processes the active dataset file to map internal ergonomic metrics
        to template variables. It encodes the binary chart image bytes into a base64
        string representation suitable for direct HTML document embedding. The compiled
        HTML is transmitted via the `pdf_html_ready` signal, freeing the background thread
        from engaging in unsafe GUI-bound rendering calls.

        Args:
            report_export_request (ReportExportRequest): A structured data model instance containing the destination path and chart data.

        Returns:
            None (None): The structural HTML content string is emitted via signals.
        """
        if not self.current_file:
            self.error_occurred.emit(
                ErrorInfo(title="Export Error", message="No active data loaded.")
            )
            return

        try:
            # 1. Use existing dataframe or load securely
            # Note: If self.df is stored during load_data_and_run, use it here to avoid re-reading
            df = pd.read_csv(self.current_file)
            method_suffix = self.current_method.value.upper()  # "REBA" or "RULA"

            # 2. Get dynamic metrics (Calculates means and formats names)
            # Result is list[tuple("Trunk Score Rula", "3.45"), ...]
            metrics_list = get_dynamic_metrics(
                df, MetricType.SCORE, self.current_method
            )

            # Create a lookup map where keys are standardized title-case strings
            m_raw = {label: float(val) for label, val in metrics_list}

            # 3. Map to Jinja2 context (m)
            # We target the specific names generated by your utility: .replace("_", " ").title()
            context_metrics = {
                "n_frames": len(df),
                "confidenza": "90%",
                "durata": f"{len(df) / 30:.1f}s" if len(df) > 0 else "0.0s",
                "Trunk": m_raw.get(f"Trunk_Score_{method_suffix}", 0.0),
                "Neck": m_raw.get(f"Neck_Score_{method_suffix}", 0.0),
                "Legs": m_raw.get(f"Legs_Score_{method_suffix}", 0.0),
                "Upper_Arm": m_raw.get(f"Upper_Arm_Score_{method_suffix}", 0.0),
                "Lower_Arm": m_raw.get(f"Lower_Arm_Score_{method_suffix}", 0.0),
                "Wrist": m_raw.get(f"Wrist_Score_{method_suffix}", 0.0),
                # Scores A/B/C
                "Total_A": m_raw.get(f"Score_A_{method_suffix}", 0.0),
                "Total_B": m_raw.get(f"Score_B_{method_suffix}", 0.0),
                "Score_C": m_raw.get(f"Score_C_{method_suffix}", 0.0),
                # Final Score (Matches the "Final Score RULA" or "Final Score REBA" logic)
                "Final": m_raw.get(f"Final_Score_{method_suffix}", 0.0),
                "Method": method_suffix,
            }

            risk_col = next(
                (
                    c
                    for c in df.columns
                    if "RISK" in c.upper() or "RISCHIO" in c.upper()
                ),
                None,
            )
            risk_value = df[risk_col].mode()[0] if risk_col and not df.empty else "N/A"

            translated_risk = "N/A"

            match risk_value:
                case "negligible":
                    translated_risk = "trascurabile"
                case "low":
                    translated_risk = "basso"
                case "medium":
                    translated_risk = "medio"
                case "high":
                    translated_risk = "alto"
                case "very_high":
                    translated_risk = "altissimo"

            context_metrics["risk"] = translated_risk

            # Convert chart bytes to base64 for HTML embedding
            img_base64: str = base64.b64encode(report_export_request.chart_data).decode(
                "utf-8"
            )

            # Render Template securely
            template = self.jinja_env.get_template(f"{method_suffix}_report.j2")
            html: str = template.render(m=context_metrics, chart=img_base64)

            # Hand HTML back to main thread for printing
            self.pdf_html_ready.emit(html)

        except Exception as e:
            self.error_occurred.emit(
                ErrorInfo(title="PDF Preparation Error", message=str(e))
            )

    @Slot(ReportExportRequest)
    def export_to_docx(self, report_export_request: ReportExportRequest) -> None:
        """
        Generates and saves a Word document report synchronously.

        Utilizes `docxtpl` to inject calculated dynamic ergonomic metrics and an
        inline `matplotlib` chart layout directly into a pre-defined `.docx` template.
        This operation avoids direct GUI dependencies making it entirely safe for background
        thread execution. Completion status is dispatched via the `report_export_finished` signal.

        Args:
            report_export_request (ReportExportRequest): A data model instance containing the target file save path and raw chart image bytes.

        Returns:
            None (None): Operation results are outputted asynchronously via execution signals.
        """
        if not self.current_file:
            return

        try:
            df: pd.DataFrame = pd.read_csv(self.current_file)

            print(df.columns)

            method_suffix = self.current_method.value.upper()

            metrics_list = get_dynamic_metrics(
                df, MetricType.SCORE, self.current_method
            )
            m_raw = {label: float(val) for label, val in metrics_list}

            # TODO make this programmatic, now is REBA centric

            context: dict[str, Any] = {
                "n_frames": len(df),
                "confidenza": "90%",
                "durata": f"{len(df) / 30:.1f}s" if len(df) > 0 else "0.0s",
                # Using the .title() format produced by your get_dynamic_metrics
                "tronco": m_raw.get(f"Trunk_Score_{method_suffix}", 0.0),
                "collo": m_raw.get(f"Neck_Score_{method_suffix}", 0.0),
                "gambe": m_raw.get(f"Legs_Score_{method_suffix}", 0.0),
                "braccio": m_raw.get(f"Upper_Arm_Score_{method_suffix}", 0.0),
                "avambraccio": m_raw.get(f"Lower_Arm_Score_{method_suffix}", 0.0),
                "polso": m_raw.get(f"Wrist_Score_{method_suffix}", 0.0),
                # Scores A/B/C and Final
                "tot_a": m_raw.get(f"Score_A_{method_suffix}", 0.0),
                "tot_b": m_raw.get(f"Score_B_{method_suffix}", 0.0),
                "score_c": m_raw.get(f"Score_C_{method_suffix}", 0.0),
                "score_finale": m_raw.get(f"Final_Score_{method_suffix}", 0.0),
            }

            risk_col = next(
                (
                    c
                    for c in df.columns
                    if "RISK" in c.upper() or "RISCHIO" in c.upper()
                ),
                None,
            )
            risk_value = df[risk_col].mode()[0] if risk_col and not df.empty else "N/A"
            translated_risk = "N/A"

            match risk_value:
                case "negligible":
                    translated_risk = "trascurabile"
                case "low":
                    translated_risk = "basso"
                case "medium":
                    translated_risk = "medio"
                case "high":
                    translated_risk = "alto"
                case "very_high":
                    translated_risk = "altissimo"

            context["rischio"] = translated_risk

            template_path: Path = (
                self.template_dir / f"{method_suffix}_report_template.docx"
            )
            doc = DocxTemplate(template_path)

            # Stream chart bytes directly into docx
            buf = BytesIO(report_export_request.chart_data)
            context["chart"] = InlineImage(doc, buf, width=Mm(140))

            doc.render(context)
            doc.save(report_export_request.save_path)

            self.report_export_finished.emit(
                ReportExportResult(
                    success=True,
                    message="Word Document Generated successfully.",
                    report_path=str(
                        report_export_request.save_path,
                    ),
                )
            )

        except Exception as e:
            self.error_occurred.emit(
                ErrorInfo(title="Docx Export Error:", message=str(e))
            )

    def get_chart_distribution_data(self, df: pd.DataFrame) -> dict[str, pd.Series]:
        """
        Calculates distributions using strict column targeting based on the active method.

        Processes the provided structural dataset to generate frequency counts for nominal risk levels
        and binned continuous integer score groups matching the target assessment rules (e.g., 1-7 for RULA)
        for presentation inside frontend graphical visualizations.

        Args:
            df (pandas.DataFrame): The active runtime dataset containing calculated joint metrics and risk assignments.

        Returns:
            dict[str, pandas.Series] (dict): A standard dictionary mapping containing two `pandas.Series` entries:
                * `"risk_counts"`: Frequencies of occurred categorical risk descriptions.
                * `"score_groups"`: Sorted index frequency tracking of continuous binned operational values.
        """
        if df.empty:
            return {"risk_counts": pd.Series(), "score_groups": pd.Series()}

        # 1. Target the exact columns based on your project's naming convention
        # Format: [part]_[metric]_[method] -> e.g., final_score_rula
        method_str = self.current_method.value.lower()

        # Target the 'Final' score column specifically
        score_col = f"final_score_{method_str}"

        # Target the 'risk' column (Standardized across your exports)
        # If your CSV uses "risk", look for that; otherwise, use the method suffix
        risk_col = "risk" if "risk" in df.columns else f"risk_level_{method_str}"

        # 2. Validation: Fallback to heuristic ONLY if strict naming fails
        if score_col not in df.columns:
            score_col = next(
                (
                    c
                    for c in df.columns
                    if "FINAL" in c.upper() and method_str.upper() in c.upper()
                ),
                None,
            )

        if not score_col or score_col not in df.columns:
            return {"risk_counts": pd.Series(), "score_groups": pd.Series()}

        # 3. Categorical Distribution (Risk)
        # Use value_counts but ensure we handle missing risk columns gracefully
        risk_counts = (
            df[risk_col].value_counts() if risk_col in df.columns else pd.Series()
        )

        # 4. Dynamic Binning (Score)
        # RULA/REBA scores are integers. We want bins for each possible score.
        # We use min/max from the actual data to define the range.
        actual_max = int(df[score_col].max())
        upper_bound = max(actual_max, 7)  # Ensure at least 1-7 for RULA

        # Create bins: [0.5, 1.5, 2.5 ...] so that integers fall in the middle
        bins = [i + 0.5 for i in range(upper_bound + 1)]
        bins.insert(0, -0.5)

        labels = [str(i) for i in range(upper_bound + 1)]

        score_groups = (
            pd.cut(df[score_col], bins=bins, labels=labels, include_lowest=True)  # type: ignore
            .value_counts()
            .sort_index()
        )

        return {"risk_counts": risk_counts, "score_groups": score_groups}
Functions
export_to_docx(report_export_request)

Generates and saves a Word document report synchronously.

Utilizes docxtpl to inject calculated dynamic ergonomic metrics and an inline matplotlib chart layout directly into a pre-defined .docx template. This operation avoids direct GUI dependencies making it entirely safe for background thread execution. Completion status is dispatched via the report_export_finished signal.

Parameters:

Name Type Description Default
report_export_request ReportExportRequest

A data model instance containing the target file save path and raw chart image bytes.

required

Returns:

Name Type Description
None None

Operation results are outputted asynchronously via execution signals.

Source code in gui\backend\report_backend.py
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
@Slot(ReportExportRequest)
def export_to_docx(self, report_export_request: ReportExportRequest) -> None:
    """
    Generates and saves a Word document report synchronously.

    Utilizes `docxtpl` to inject calculated dynamic ergonomic metrics and an
    inline `matplotlib` chart layout directly into a pre-defined `.docx` template.
    This operation avoids direct GUI dependencies making it entirely safe for background
    thread execution. Completion status is dispatched via the `report_export_finished` signal.

    Args:
        report_export_request (ReportExportRequest): A data model instance containing the target file save path and raw chart image bytes.

    Returns:
        None (None): Operation results are outputted asynchronously via execution signals.
    """
    if not self.current_file:
        return

    try:
        df: pd.DataFrame = pd.read_csv(self.current_file)

        print(df.columns)

        method_suffix = self.current_method.value.upper()

        metrics_list = get_dynamic_metrics(
            df, MetricType.SCORE, self.current_method
        )
        m_raw = {label: float(val) for label, val in metrics_list}

        # TODO make this programmatic, now is REBA centric

        context: dict[str, Any] = {
            "n_frames": len(df),
            "confidenza": "90%",
            "durata": f"{len(df) / 30:.1f}s" if len(df) > 0 else "0.0s",
            # Using the .title() format produced by your get_dynamic_metrics
            "tronco": m_raw.get(f"Trunk_Score_{method_suffix}", 0.0),
            "collo": m_raw.get(f"Neck_Score_{method_suffix}", 0.0),
            "gambe": m_raw.get(f"Legs_Score_{method_suffix}", 0.0),
            "braccio": m_raw.get(f"Upper_Arm_Score_{method_suffix}", 0.0),
            "avambraccio": m_raw.get(f"Lower_Arm_Score_{method_suffix}", 0.0),
            "polso": m_raw.get(f"Wrist_Score_{method_suffix}", 0.0),
            # Scores A/B/C and Final
            "tot_a": m_raw.get(f"Score_A_{method_suffix}", 0.0),
            "tot_b": m_raw.get(f"Score_B_{method_suffix}", 0.0),
            "score_c": m_raw.get(f"Score_C_{method_suffix}", 0.0),
            "score_finale": m_raw.get(f"Final_Score_{method_suffix}", 0.0),
        }

        risk_col = next(
            (
                c
                for c in df.columns
                if "RISK" in c.upper() or "RISCHIO" in c.upper()
            ),
            None,
        )
        risk_value = df[risk_col].mode()[0] if risk_col and not df.empty else "N/A"
        translated_risk = "N/A"

        match risk_value:
            case "negligible":
                translated_risk = "trascurabile"
            case "low":
                translated_risk = "basso"
            case "medium":
                translated_risk = "medio"
            case "high":
                translated_risk = "alto"
            case "very_high":
                translated_risk = "altissimo"

        context["rischio"] = translated_risk

        template_path: Path = (
            self.template_dir / f"{method_suffix}_report_template.docx"
        )
        doc = DocxTemplate(template_path)

        # Stream chart bytes directly into docx
        buf = BytesIO(report_export_request.chart_data)
        context["chart"] = InlineImage(doc, buf, width=Mm(140))

        doc.render(context)
        doc.save(report_export_request.save_path)

        self.report_export_finished.emit(
            ReportExportResult(
                success=True,
                message="Word Document Generated successfully.",
                report_path=str(
                    report_export_request.save_path,
                ),
            )
        )

    except Exception as e:
        self.error_occurred.emit(
            ErrorInfo(title="Docx Export Error:", message=str(e))
        )
get_chart_distribution_data(df)

Calculates distributions using strict column targeting based on the active method.

Processes the provided structural dataset to generate frequency counts for nominal risk levels and binned continuous integer score groups matching the target assessment rules (e.g., 1-7 for RULA) for presentation inside frontend graphical visualizations.

Parameters:

Name Type Description Default
df DataFrame

The active runtime dataset containing calculated joint metrics and risk assignments.

required

Returns:

Type Description
dict[str, Series]

dict[str, pandas.Series] (dict): A standard dictionary mapping containing two pandas.Series entries: * "risk_counts": Frequencies of occurred categorical risk descriptions. * "score_groups": Sorted index frequency tracking of continuous binned operational values.

Source code in gui\backend\report_backend.py
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
def get_chart_distribution_data(self, df: pd.DataFrame) -> dict[str, pd.Series]:
    """
    Calculates distributions using strict column targeting based on the active method.

    Processes the provided structural dataset to generate frequency counts for nominal risk levels
    and binned continuous integer score groups matching the target assessment rules (e.g., 1-7 for RULA)
    for presentation inside frontend graphical visualizations.

    Args:
        df (pandas.DataFrame): The active runtime dataset containing calculated joint metrics and risk assignments.

    Returns:
        dict[str, pandas.Series] (dict): A standard dictionary mapping containing two `pandas.Series` entries:
            * `"risk_counts"`: Frequencies of occurred categorical risk descriptions.
            * `"score_groups"`: Sorted index frequency tracking of continuous binned operational values.
    """
    if df.empty:
        return {"risk_counts": pd.Series(), "score_groups": pd.Series()}

    # 1. Target the exact columns based on your project's naming convention
    # Format: [part]_[metric]_[method] -> e.g., final_score_rula
    method_str = self.current_method.value.lower()

    # Target the 'Final' score column specifically
    score_col = f"final_score_{method_str}"

    # Target the 'risk' column (Standardized across your exports)
    # If your CSV uses "risk", look for that; otherwise, use the method suffix
    risk_col = "risk" if "risk" in df.columns else f"risk_level_{method_str}"

    # 2. Validation: Fallback to heuristic ONLY if strict naming fails
    if score_col not in df.columns:
        score_col = next(
            (
                c
                for c in df.columns
                if "FINAL" in c.upper() and method_str.upper() in c.upper()
            ),
            None,
        )

    if not score_col or score_col not in df.columns:
        return {"risk_counts": pd.Series(), "score_groups": pd.Series()}

    # 3. Categorical Distribution (Risk)
    # Use value_counts but ensure we handle missing risk columns gracefully
    risk_counts = (
        df[risk_col].value_counts() if risk_col in df.columns else pd.Series()
    )

    # 4. Dynamic Binning (Score)
    # RULA/REBA scores are integers. We want bins for each possible score.
    # We use min/max from the actual data to define the range.
    actual_max = int(df[score_col].max())
    upper_bound = max(actual_max, 7)  # Ensure at least 1-7 for RULA

    # Create bins: [0.5, 1.5, 2.5 ...] so that integers fall in the middle
    bins = [i + 0.5 for i in range(upper_bound + 1)]
    bins.insert(0, -0.5)

    labels = [str(i) for i in range(upper_bound + 1)]

    score_groups = (
        pd.cut(df[score_col], bins=bins, labels=labels, include_lowest=True)  # type: ignore
        .value_counts()
        .sort_index()
    )

    return {"risk_counts": risk_counts, "score_groups": score_groups}
load_data_and_run(file_path)

Asynchronously loads and parses ergonomic data from a given CSV/Excel file.

This method performs heuristic column detection to find risk and score columns regardless of the assessment method. On success, it calculates primary metrics, structures them into a data container, and emits the data_processed signal. If the operation fails, an error_occurred signal containing structural error details is dispatched.

Parameters:

Name Type Description Default
file_path Path

Absolute filesystem path to the source data file (supports .csv and .xlsx).

required

Returns:

Name Type Description
None None

Results or errors are transmitted asynchronously via Qt Signals.

Raises:

Type Description
ValueError(ValueError)

Raised if the chosen file contains an empty dataset.

KeyError(KeyError)

Raised if critical score or risk tracking columns cannot be identified.

Source code in gui\backend\report_backend.py
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
@Slot(Path)
def load_data_and_run(self, file_path: Path) -> None:
    """
    Asynchronously loads and parses ergonomic data from a given CSV/Excel file.

    This method performs heuristic column detection to find risk and score columns
    regardless of the assessment method. On success, it calculates primary metrics,
    structures them into a data container, and emits the `data_processed` signal.
    If the operation fails, an `error_occurred` signal containing structural error
    details is dispatched.

    Args:
        file_path (Path): Absolute filesystem path to the source data file (supports `.csv` and `.xlsx`).

    Returns:
        None (None): Results or errors are transmitted asynchronously via Qt Signals.

    Raises:
        ValueError (ValueError): Raised if the chosen file contains an empty dataset.
        KeyError (KeyError): Raised if critical score or risk tracking columns cannot be identified.
    """
    try:
        # Handle encoding and format fallbacks
        if file_path.suffix == ".xlsx":
            df: pd.DataFrame = pd.read_excel(file_path)
        else:
            try:
                df: pd.DataFrame = pd.read_csv(file_path)
            except UnicodeDecodeError:
                df: pd.DataFrame = pd.read_csv(file_path, encoding="latin-1")

        if df.empty:
            raise ValueError("The selected file contains no data.")

        score_col: str = MetricType.SCORE.value
        risk_col: str = MetricType.RISK.value

        df = df.dropna(subset=[score_col, risk_col])

        self.current_file = file_path

        # Calculate primary metrics
        total_frames: int = len(df)

        avg_score: float = df[score_col].mean() if total_frames > 0 else 0.0

        summary_rows: list[tuple[str, str]] = get_dynamic_metrics(
            df, MetricType.SCORE, self.current_method
        )

        # print(summary_rows, "SUMMARY ROWS", "\n") TODO print_reactivate

        summary: dict = dict(summary_rows)

        # print(summary, "SUMMARY ROWS", "\n") TODO print_reactivate

        # Transmit structured data to frontend
        processed_data = ReportData(
            df=df,
            file_path=file_path,
            total_frames=total_frames,
            average_score=avg_score,
            summary_dict=summary,
        )
        self.data_processed.emit(processed_data)

    except Exception as e:
        self.error_occurred.emit(
            ErrorInfo(
                title="Load Error",
                message=f"Could not parse analysis data: {str(e)}",
            )
        )
prepare_pdf_export(report_export_request)

Compiles the HTML report context using Jinja2 and emits the generated markup.

This method processes the active dataset file to map internal ergonomic metrics to template variables. It encodes the binary chart image bytes into a base64 string representation suitable for direct HTML document embedding. The compiled HTML is transmitted via the pdf_html_ready signal, freeing the background thread from engaging in unsafe GUI-bound rendering calls.

Parameters:

Name Type Description Default
report_export_request ReportExportRequest

A structured data model instance containing the destination path and chart data.

required

Returns:

Name Type Description
None None

The structural HTML content string is emitted via signals.

Source code in gui\backend\report_backend.py
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
def prepare_pdf_export(self, report_export_request: ReportExportRequest) -> None:
    """
    Compiles the HTML report context using Jinja2 and emits the generated markup.

    This method processes the active dataset file to map internal ergonomic metrics
    to template variables. It encodes the binary chart image bytes into a base64
    string representation suitable for direct HTML document embedding. The compiled
    HTML is transmitted via the `pdf_html_ready` signal, freeing the background thread
    from engaging in unsafe GUI-bound rendering calls.

    Args:
        report_export_request (ReportExportRequest): A structured data model instance containing the destination path and chart data.

    Returns:
        None (None): The structural HTML content string is emitted via signals.
    """
    if not self.current_file:
        self.error_occurred.emit(
            ErrorInfo(title="Export Error", message="No active data loaded.")
        )
        return

    try:
        # 1. Use existing dataframe or load securely
        # Note: If self.df is stored during load_data_and_run, use it here to avoid re-reading
        df = pd.read_csv(self.current_file)
        method_suffix = self.current_method.value.upper()  # "REBA" or "RULA"

        # 2. Get dynamic metrics (Calculates means and formats names)
        # Result is list[tuple("Trunk Score Rula", "3.45"), ...]
        metrics_list = get_dynamic_metrics(
            df, MetricType.SCORE, self.current_method
        )

        # Create a lookup map where keys are standardized title-case strings
        m_raw = {label: float(val) for label, val in metrics_list}

        # 3. Map to Jinja2 context (m)
        # We target the specific names generated by your utility: .replace("_", " ").title()
        context_metrics = {
            "n_frames": len(df),
            "confidenza": "90%",
            "durata": f"{len(df) / 30:.1f}s" if len(df) > 0 else "0.0s",
            "Trunk": m_raw.get(f"Trunk_Score_{method_suffix}", 0.0),
            "Neck": m_raw.get(f"Neck_Score_{method_suffix}", 0.0),
            "Legs": m_raw.get(f"Legs_Score_{method_suffix}", 0.0),
            "Upper_Arm": m_raw.get(f"Upper_Arm_Score_{method_suffix}", 0.0),
            "Lower_Arm": m_raw.get(f"Lower_Arm_Score_{method_suffix}", 0.0),
            "Wrist": m_raw.get(f"Wrist_Score_{method_suffix}", 0.0),
            # Scores A/B/C
            "Total_A": m_raw.get(f"Score_A_{method_suffix}", 0.0),
            "Total_B": m_raw.get(f"Score_B_{method_suffix}", 0.0),
            "Score_C": m_raw.get(f"Score_C_{method_suffix}", 0.0),
            # Final Score (Matches the "Final Score RULA" or "Final Score REBA" logic)
            "Final": m_raw.get(f"Final_Score_{method_suffix}", 0.0),
            "Method": method_suffix,
        }

        risk_col = next(
            (
                c
                for c in df.columns
                if "RISK" in c.upper() or "RISCHIO" in c.upper()
            ),
            None,
        )
        risk_value = df[risk_col].mode()[0] if risk_col and not df.empty else "N/A"

        translated_risk = "N/A"

        match risk_value:
            case "negligible":
                translated_risk = "trascurabile"
            case "low":
                translated_risk = "basso"
            case "medium":
                translated_risk = "medio"
            case "high":
                translated_risk = "alto"
            case "very_high":
                translated_risk = "altissimo"

        context_metrics["risk"] = translated_risk

        # Convert chart bytes to base64 for HTML embedding
        img_base64: str = base64.b64encode(report_export_request.chart_data).decode(
            "utf-8"
        )

        # Render Template securely
        template = self.jinja_env.get_template(f"{method_suffix}_report.j2")
        html: str = template.render(m=context_metrics, chart=img_base64)

        # Hand HTML back to main thread for printing
        self.pdf_html_ready.emit(html)

    except Exception as e:
        self.error_occurred.emit(
            ErrorInfo(title="PDF Preparation Error", message=str(e))
        )
update_method(method)

Update the active ergonomic assessment method.

Parameters:

Name Type Description Default
method AssessmentMethod

The name of the method to apply (e.g., AssessmentMethod.RULA, AssessmentMethod.REBA).

required

Returns:

Name Type Description
None None

Updates internal state and clears previous strategy if necessary.

Source code in gui\backend\report_backend.py
194
195
196
197
198
199
200
201
202
203
204
205
206
207
def update_method(self, method: AssessmentMethod) -> None:
    """
    Update the active ergonomic assessment method.

    Args:
        method (AssessmentMethod): The name of the method to apply (e.g., AssessmentMethod.RULA, AssessmentMethod.REBA).

    Returns:
        None (None): Updates internal state and clears previous strategy if necessary.
    """
    if hasattr(self, "current_method") and self.current_method == method:
        return  # Don't change if it's the same

    self.current_method = method

Functions

options: show_root_heading: true

Asynchronous Concurrency Workers

gui.workers.analysis_worker

ErgoMoCap: Analysis Engine Worker

Asynchronous Processing Core Wrapper for Ergonomic Calculations.

This module implements the AnalysisWorker, a specialized background execution component designed to run within a dedicated worker thread. It decouples long-running frame processing loops, adapter transformations, and system file I/O operations from the primary user interface layer.

By encapsulating the synchronous AnalysisEngine, the worker handles dispatch requests gracefully using Qt's cross-thread signal slot matrix and dispatches typed structural analysis receipts upon operation boundaries.

Classes

AnalysisWorker

Bases: QObject

Stateful worker managing background ergonomic calculations and file serialization.

This component runs inside its own dedicated worker thread managed by the application backend. It provisions an internal AnalysisEngine instance, extracts programmatic tools via external methodology adapters, and updates application state domains via asynchronous signals.

Attributes:

Name Type Description
finished Signal

Signal emitted on calculation cycle completions tracking structural receipts (AnalysisResult).

engine AnalysisEngine

Core non-blocking calculation engine execution instance.

_pending_data DataFrame | ndarray | None

Temporary storage buffer for input metrics queued before thread execution.

_pending_adapter BaseErgoAdapter | None

Temporary storage buffer for the methodology adapter queued before thread execution.

_pending_method AssessmentMethod | None

Temporary storage buffer for the assessment protocol queued before thread execution.

Methods:

Name Description
run

Parameterless entry point for queued execution on the worker thread.

start_analysis

Thread-safe public entry execution slot accepting runtime processing parameters.

Source code in gui\workers\analysis_worker.py
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
class AnalysisWorker(QObject):
    """
    Stateful worker managing background ergonomic calculations and file serialization.

    This component runs inside its own dedicated worker thread managed by the application backend.
    It provisions an internal `AnalysisEngine` instance, extracts programmatic tools via
    external methodology adapters, and updates application state domains via asynchronous signals.

    Attributes:
        finished (Signal): Signal emitted on calculation cycle completions tracking structural receipts ([AnalysisResult][gui.utils.models.AnalysisResult]).
        engine (AnalysisEngine): Core non-blocking calculation engine execution instance.
        _pending_data (pandas.DataFrame | numpy.ndarray | None): Temporary storage buffer for input metrics queued before thread execution.
        _pending_adapter (BaseErgoAdapter | None): Temporary storage buffer for the methodology adapter queued before thread execution.
        _pending_method (AssessmentMethod | None): Temporary storage buffer for the assessment protocol queued before thread execution.

    Methods:
        run: Parameterless entry point for queued execution on the worker thread.
        start_analysis: Thread-safe public entry execution slot accepting runtime processing parameters.
    """

    finished = Signal(AnalysisResult)

    def __init__(self) -> None:
        """
        Initializes the `AnalysisWorker` component.

        Sets up the base QObject infrastructure, allocates the structural computational core logic engine,
        and prepares internal storage buffers for asynchronous parameter queuing.

        Returns:
            None (None): The return value is always None.
        """
        super().__init__()
        self.engine: AnalysisEngine = AnalysisEngine()

        # Pending data for async execution (bypasses Qt meta-type serialization)
        self._pending_data: Union[pd.DataFrame, np.ndarray, None] = None
        self._pending_adapter: Union[BaseErgoAdapter, None] = None
        self._pending_method: Union[AssessmentMethod, None] = None

    def run(self) -> None:
        """
        Parameterless entry point for queued execution on the worker thread.

        Extracts runtime parameters from internal staging buffers, clears references to free memory,
        and delegates execution to the core analysis routine. This design bypasses Qt's meta-type
        serialization constraints for complex objects like pandas DataFrames.

        Returns:
            None (None): Delegates to `start_analysis` for background processing.
        """
        # Extract pending data
        data = self._pending_data
        adapter = self._pending_adapter
        method = self._pending_method

        # Clear refs to free memory and prevent leaks
        self._pending_data = None
        self._pending_adapter = None
        self._pending_method = None

        # Execute analysis
        self.start_analysis(data, adapter, method)

    @Slot(object, BaseErgoAdapter, AssessmentMethod)
    def start_analysis(
        self,
        current_data: Union[pd.DataFrame, np.ndarray],
        adapter: BaseErgoAdapter,
        method: AssessmentMethod = AssessmentMethod.REBA,
    ) -> None:
        """
        Executes the main dispatching background routine for ergonomic processing.

        Routes raw metrics through data mapping functions, loops frames asynchronously,
        compiles structured analysis tracking matrices, and posts unified diagnostic receipts.

        Args:
            current_data (pandas.DataFrame | numpy.ndarray): The raw matrix or sheet data tracking biomechanical data elements.
            adapter (BaseErgoAdapter): The methodology adapter class mapping scoring routines and criteria.
            method (AssessmentMethod): Structural framework metric enumeration settings tracking computation variants. Defaults to REBA.

        Returns:
            None (None): Dispatches output results directly back upstream using the `finished` signal pipeline.
        """

        import threading

        logger.debug(
            f"🔹 Worker: start_analysis running on thread: {threading.current_thread().name}"
        )

        if current_data is None:
            logger.warning("Background analysis attempted with no data payload.")
            self.finished.emit(
                AnalysisResult(
                    success=False,
                    message="NO_DATA_LOADED",
                    output_path=None,
                )
            )
            return

        try:
            mapper, calculator = adapter.get_relay_tools()

            raw_results = self.engine.run_calculation(current_data, mapper, calculator)

            if not raw_results:
                self.finished.emit(
                    AnalysisResult(
                        success=False,
                        message="No results generated.",
                        output_path=None,
                    )
                )
                return

            current_thresholds = adapter.get_thresholds()

            def risk_callback(score: int) -> RiskLevel:
                """
                Nested routing handler providing adapter-level enum metrics transformations.

                Args:
                    score (int): Frame index structural calculation metrics points score tracking index.

                Returns:
                    RiskLevel: Categorical qualitative priority enumeration classifications.
                """
                return self.engine.get_risk_level_enum(
                    score,
                    current_thresholds,
                )

            analysis_df = adapter.process(raw_results, risk_callback)

            scores_list = analysis_df[MetricType.SCORE.value].tolist()

            stats_dict = analysis_df[MetricType.SCORE.value].value_counts().to_dict()
            stats = {str(k): int(v) for k, v in stats_dict.items()}

            output_path: Path = ErgoPaths.analysis_output()
            analysis_df.to_csv(output_path, index=False)

            msg: str = f"Analysis Complete.\n{method.name} executed on {len(raw_results)} frames"

            # 7. Dispatch structural analytics result capsule back upstream to tracking receivers
            self.finished.emit(
                AnalysisResult(
                    success=True,
                    message=msg,
                    output_path=output_path,
                    scores=scores_list,
                    stats=stats,
                )
            )

        except NotImplementedError as e:
            self.finished.emit(
                AnalysisResult(
                    success=False,
                    message=str(e),
                    output_path=None,
                )
            )
        except Exception as e:
            self.finished.emit(
                AnalysisResult(
                    success=False,
                    message=f"Analysis failed: {str(e)}",
                    output_path=None,
                )
            )
Functions
run()

Parameterless entry point for queued execution on the worker thread.

Extracts runtime parameters from internal staging buffers, clears references to free memory, and delegates execution to the core analysis routine. This design bypasses Qt's meta-type serialization constraints for complex objects like pandas DataFrames.

Returns:

Name Type Description
None None

Delegates to start_analysis for background processing.

Source code in gui\workers\analysis_worker.py
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
def run(self) -> None:
    """
    Parameterless entry point for queued execution on the worker thread.

    Extracts runtime parameters from internal staging buffers, clears references to free memory,
    and delegates execution to the core analysis routine. This design bypasses Qt's meta-type
    serialization constraints for complex objects like pandas DataFrames.

    Returns:
        None (None): Delegates to `start_analysis` for background processing.
    """
    # Extract pending data
    data = self._pending_data
    adapter = self._pending_adapter
    method = self._pending_method

    # Clear refs to free memory and prevent leaks
    self._pending_data = None
    self._pending_adapter = None
    self._pending_method = None

    # Execute analysis
    self.start_analysis(data, adapter, method)
start_analysis(current_data, adapter, method=AssessmentMethod.REBA)

Executes the main dispatching background routine for ergonomic processing.

Routes raw metrics through data mapping functions, loops frames asynchronously, compiles structured analysis tracking matrices, and posts unified diagnostic receipts.

Parameters:

Name Type Description Default
current_data DataFrame | ndarray

The raw matrix or sheet data tracking biomechanical data elements.

required
adapter BaseErgoAdapter

The methodology adapter class mapping scoring routines and criteria.

required
method AssessmentMethod

Structural framework metric enumeration settings tracking computation variants. Defaults to REBA.

REBA

Returns:

Name Type Description
None None

Dispatches output results directly back upstream using the finished signal pipeline.

Source code in gui\workers\analysis_worker.py
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
@Slot(object, BaseErgoAdapter, AssessmentMethod)
def start_analysis(
    self,
    current_data: Union[pd.DataFrame, np.ndarray],
    adapter: BaseErgoAdapter,
    method: AssessmentMethod = AssessmentMethod.REBA,
) -> None:
    """
    Executes the main dispatching background routine for ergonomic processing.

    Routes raw metrics through data mapping functions, loops frames asynchronously,
    compiles structured analysis tracking matrices, and posts unified diagnostic receipts.

    Args:
        current_data (pandas.DataFrame | numpy.ndarray): The raw matrix or sheet data tracking biomechanical data elements.
        adapter (BaseErgoAdapter): The methodology adapter class mapping scoring routines and criteria.
        method (AssessmentMethod): Structural framework metric enumeration settings tracking computation variants. Defaults to REBA.

    Returns:
        None (None): Dispatches output results directly back upstream using the `finished` signal pipeline.
    """

    import threading

    logger.debug(
        f"🔹 Worker: start_analysis running on thread: {threading.current_thread().name}"
    )

    if current_data is None:
        logger.warning("Background analysis attempted with no data payload.")
        self.finished.emit(
            AnalysisResult(
                success=False,
                message="NO_DATA_LOADED",
                output_path=None,
            )
        )
        return

    try:
        mapper, calculator = adapter.get_relay_tools()

        raw_results = self.engine.run_calculation(current_data, mapper, calculator)

        if not raw_results:
            self.finished.emit(
                AnalysisResult(
                    success=False,
                    message="No results generated.",
                    output_path=None,
                )
            )
            return

        current_thresholds = adapter.get_thresholds()

        def risk_callback(score: int) -> RiskLevel:
            """
            Nested routing handler providing adapter-level enum metrics transformations.

            Args:
                score (int): Frame index structural calculation metrics points score tracking index.

            Returns:
                RiskLevel: Categorical qualitative priority enumeration classifications.
            """
            return self.engine.get_risk_level_enum(
                score,
                current_thresholds,
            )

        analysis_df = adapter.process(raw_results, risk_callback)

        scores_list = analysis_df[MetricType.SCORE.value].tolist()

        stats_dict = analysis_df[MetricType.SCORE.value].value_counts().to_dict()
        stats = {str(k): int(v) for k, v in stats_dict.items()}

        output_path: Path = ErgoPaths.analysis_output()
        analysis_df.to_csv(output_path, index=False)

        msg: str = f"Analysis Complete.\n{method.name} executed on {len(raw_results)} frames"

        # 7. Dispatch structural analytics result capsule back upstream to tracking receivers
        self.finished.emit(
            AnalysisResult(
                success=True,
                message=msg,
                output_path=output_path,
                scores=scores_list,
                stats=stats,
            )
        )

    except NotImplementedError as e:
        self.finished.emit(
            AnalysisResult(
                success=False,
                message=str(e),
                output_path=None,
            )
        )
    except Exception as e:
        self.finished.emit(
            AnalysisResult(
                success=False,
                message=f"Analysis failed: {str(e)}",
                output_path=None,
            )
        )

options: show_root_heading: true

gui.workers.video_worker

ErgoMoCap: Video Engine Worker

Stateful Video I/O and Telemetry Synchronization Engine.

This module implements the VideoWorker, a specialized background execution component designed to run within a dedicated worker thread. It isolates heavy file system operations, frame decoding using OpenCV, and unthrottled media encoding workflows from the primary user interface thread.

By utilizing a non-blocking QTimer lifecycle architecture rather than blocking loops, the worker delivers fluid playback frame buffers synchronized frame-by-frame with precalculated ergonomic risk metrics and categorical data payloads.

Classes

VideoWorker

Bases: QObject

Stateful worker managing video I/O, scoring overlays, and frame exporting.

This component runs inside its own dedicated worker thread managed by the application backend. It uses a non-blocking QTimer lifecycle architecture to decode media frames sequentially, synchronize them with risk metadata matrices, and emit rendering capsules to the GUI layer.

Attributes:

Name Type Description
frame_ready Signal

Signal emitted when a new frame is decoded and processed (FrameData).

position_changed Signal

Signal emitted on playback updates containing timeline values (VideoPosition).

export_progress Signal

Signal emitted to track frame write cycles (VideoPosition).

frames_export_finished Signal

Signal emitted on export completion (FramesExportResult).

cap VideoCapture | None

The open file stream capture decoder wrapper instance.

video_path str

File system path pointing to the active media asset.

scores_list list[int]

Array sequence tracking calculated analytical scores matching frame indices.

thresholds list[tuple[int, Any]]

Boundary score structures used to determine qualitative classification steps.

total_frames int

Total frame count value assigned by the video header stream analyzer.

current_frame_idx int

Current frame playback pointer index counter.

playback_timer QTimer | None

Internal non-blocking loop scheduler driving periodic frame steps.

Methods:

Name Description
init_timer

Create and initialize the internal timer inside the worker thread space.

initialize_video

Configures the current video asset context safely.

cleanup

Shuts down any active playback intervals and releases open media stream file handles.

handle_video_control

Processes video commands safely inside the worker thread.

toggle_playback

Starts or stops the frame ticker timer.

seek

Public slot accepting external target navigation frames.

step_frame

Steps sequentially up or down one tick.

_seek_to_index

Updates internal stream pointers and reads video matrix segments.

_process_playback_frame

Handles cyclic timer ticks to read, increment, and emit media data frames.

_emit_current_frame_payload

Bundles spatial features and risk labels into metadata packets.

execute_frames_export

Runs an unthrottled loop to combine frame saving and rendering into a single worker script.

Source code in gui\workers\video_worker.py
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
class VideoWorker(QObject):
    """
    Stateful worker managing video I/O, scoring overlays, and frame exporting.

    This component runs inside its own dedicated worker thread managed by the application backend.
    It uses a non-blocking `QTimer` lifecycle architecture to decode media frames sequentially,
    synchronize them with risk metadata matrices, and emit rendering capsules to the GUI layer.

    Attributes:
        frame_ready (Signal): Signal emitted when a new frame is decoded and processed ([FrameData][gui.utils.models.FrameData]).
        position_changed (Signal): Signal emitted on playback updates containing timeline values ([VideoPosition][gui.utils.models.VideoPosition]).
        export_progress (Signal): Signal emitted to track frame write cycles ([VideoPosition][gui.utils.models.VideoPosition]).
        frames_export_finished (Signal): Signal emitted on export completion ([FramesExportResult][gui.utils.models.FramesExportResult]).
        cap (cv2.VideoCapture | None): The open file stream capture decoder wrapper instance.
        video_path (str): File system path pointing to the active media asset.
        scores_list (list[int]): Array sequence tracking calculated analytical scores matching frame indices.
        thresholds (list[tuple[int, Any]]): Boundary score structures used to determine qualitative classification steps.
        total_frames (int): Total frame count value assigned by the video header stream analyzer.
        current_frame_idx (int): Current frame playback pointer index counter.
        playback_timer (QTimer | None): Internal non-blocking loop scheduler driving periodic frame steps.

    Methods:
        init_timer: Create and initialize the internal timer inside the worker thread space.
        initialize_video: Configures the current video asset context safely.
        cleanup: Shuts down any active playback intervals and releases open media stream file handles.
        handle_video_control: Processes video commands safely inside the worker thread.
        toggle_playback: Starts or stops the frame ticker timer.
        seek: Public slot accepting external target navigation frames.
        step_frame: Steps sequentially up or down one tick.
        _seek_to_index: Updates internal stream pointers and reads video matrix segments.
        _process_playback_frame: Handles cyclic timer ticks to read, increment, and emit media data frames.
        _emit_current_frame_payload: Bundles spatial features and risk labels into metadata packets.
        execute_frames_export: Runs an unthrottled loop to combine frame saving and rendering into a single worker script.
    """

    frame_ready: Signal = Signal(FrameData)
    position_changed: Signal = Signal(VideoPosition)
    export_progress: Signal = Signal(VideoPosition)  # current, total
    frames_export_finished: Signal = Signal(FramesExportResult)

    def __init__(self) -> None:
        super().__init__()
        self.cap: Optional[cv2.VideoCapture] = None
        self.video_path: str = ""
        self.scores_list: list[int] = []
        self.thresholds: list[tuple[int, Any]] = []

        self.total_frames: int = 0
        self.current_frame_idx: int = 0

        # Drive playback via a Qt Timer rather than blocking loops
        self.playback_timer: Optional[QTimer] = None

    @Slot()
    def init_timer(self) -> None:
        """
        Create and initialize the internal timer inside the worker thread space.

        Instantiates the `QTimer` framework container directly inside the executing thread context
        to maintain thread-safe affinity boundaries and hooks up the loop timeout callback.

        Returns:
            None (None): Modifies the object state in-place.
        """
        self.playback_timer = QTimer(self)
        self.playback_timer.timeout.connect(self._process_playback_frame)

    @Slot(VideoLoadRequest)
    def initialize_video(
        self,
        video_load_request: VideoLoadRequest,
    ) -> None:
        """
        Configures the current video asset context safely.

        Resets ongoing playback loops, releases any pre-allocated system video capture handles,
        parses technical properties from the target file configuration payload, and renders
        the initial frame slice.

        Args:
            video_load_request (VideoLoadRequest): Configuration descriptor mapping paths, scores,
                and boundaries via [VideoLoadRequest][gui.utils.models.VideoLoadRequest].

        Returns:
            None (None): Dispatches a preview frame or reinitializes structural attributes.
        """

        if not self.playback_timer:
            self.init_timer()

        if not self.playback_timer:
            return
        self.playback_timer.stop()
        if self.cap:
            self.cap.release()

        self.video_path = str(video_load_request.path)
        self.scores_list = list(video_load_request.scores)
        self.thresholds = video_load_request.thresholds

        self.cap = cv2.VideoCapture(self.video_path)
        fps = self.cap.get(cv2.CAP_PROP_FPS) or 30.0
        self.total_frames = int(self.cap.get(cv2.CAP_PROP_FRAME_COUNT))
        self.current_frame_idx = 0

        # Calculate dynamic frame timing interval in milliseconds

        self.playback_interval_ms = int(1000 / fps)

        # Render the initial preview frame
        self._seek_to_index(0)

    @Slot()
    def cleanup(self) -> None:
        """
        Shuts down any active playback intervals and releases open media stream file handles.

        Returns:
            None (None): In-place cleanup execution wrapper.
        """
        if self.playback_timer:
            self.playback_timer.stop()
        if self.cap:
            self.cap.release()

    @Slot(VideoControl)
    def handle_video_control(self, action: VideoControl) -> None:
        """
        Processes video commands safely inside the worker thread.

        Parses incoming action commands to adjust playback states, perform hard index skips,
        or step through sequential frames frame-by-frame.

        Args:
            action (VideoControl): Message bundle specifying state commands mapped by
                [VideoControl][gui.utils.models.VideoControl].

        Returns:
            None (None): Performs state routing and state updates.
        """
        if action.command == VideoCommand.TOGGLE:
            self.toggle_playback()  # Starts/stops your QTimer safely here!

        elif action.command == VideoCommand.SEEK:
            if action.target_frame is not None:
                self.current_frame_idx = max(
                    0, min(action.target_frame, self.total_frames - 1)
                )
                self.seek(frame_idx=self.current_frame_idx)

        elif action.command == VideoCommand.STEP_FORWARD:
            self.current_frame_idx = min(
                self.current_frame_idx + 1, self.total_frames - 1
            )
            self.step_frame(forward=True)

        elif action.command == VideoCommand.STEP_BACKWARD:
            self.current_frame_idx = max(0, self.current_frame_idx - 1)
            self.step_frame(forward=False)

    @Slot()
    def toggle_playback(self) -> bool:
        """
        Starts or stops the frame ticker timer.

        Evaluates operational flags, state loops, and active timers to cleanly toggle
        periodic media processing routines.

        Returns:
            bool (`bool`): True if a timer sequence successfully started, False if it was paused or failed.
        """
        if not self.cap or not self.cap.isOpened():
            return False

        if not self.playback_timer:
            return False

        if self.playback_timer.isActive():
            self.playback_timer.stop()
            return False
        else:
            self.playback_timer.start(self.playback_interval_ms)
            return True

    @Slot(int)
    def seek(self, frame_idx: int) -> None:
        """
        Public slot accepting external target navigation frames.

        Args:
            frame_idx (int): Absolute destination index path targeting targeted index segments.

        Returns:
            None (None): Dispatches internal seek handlers.
        """
        self._seek_to_index(frame_idx)

    @Slot(bool)
    def step_frame(self, forward: bool) -> None:
        """
        Steps sequentially up or down one tick.

        Args:
            forward (bool): Set to True to increment the timeline frame index, False to decrement.

        Returns:
            None (None): Dispatches updated coordinate mappings.
        """
        target = self.current_frame_idx + 1 if forward else self.current_frame_idx - 1
        self._seek_to_index(target)

    def _seek_to_index(self, frame_idx: int) -> None:
        """
        Updates internal stream pointers and reads video matrix segments.

        Handles hardware pointer relocations inside `cv2.VideoCapture` wrappers and invokes
        the payload packager instantly to prevent rendering lag.

        Args:
            frame_idx (int): Clean target bounding parameter mapping specific file indexes.

        Returns:
            None (None): Restores layout tracking bounds.
        """
        if not self.cap or not self.cap.isOpened():
            return

        target = max(0, min(frame_idx, self.total_frames - 1))
        self.cap.set(cv2.CAP_PROP_POS_FRAMES, target)
        self.current_frame_idx = target

        # Instantly render the target frame context
        ret, frame = self.cap.read()
        if ret:
            self._emit_current_frame_payload(frame)
            # Re-seek back to catch the frame for future regular playback ticks
            self.cap.set(cv2.CAP_PROP_POS_FRAMES, target)

    def _process_playback_frame(self) -> None:
        """
        Handles cyclic timer ticks to read, increment, and emit media data frames.

        Monitors frame index bounds and terminates timer execution loops automatically
        if end-of-file flags or validation faults occur during extraction.

        Returns:
            None (None): Advances timeline state metrics or halts active timers.
        """
        if not self.playback_timer:
            return

        if not self.cap or not self.cap.isOpened():
            self.playback_timer.stop()
            return

        ret, frame = self.cap.read()
        if not ret or self.current_frame_idx >= self.total_frames:
            self.playback_timer.stop()
            return

        self._emit_current_frame_payload(frame)
        self.current_frame_idx += 1

    def _emit_current_frame_payload(self, frame: np.ndarray) -> None:
        """
        Bundles spatial features and risk labels into metadata packets.

        Evaluates index points against calculated assessment tables, extracts structural enum items
        via [AnalysisEngine.get_risk_level_enum][gui.core.analysis_engine.AnalysisEngine.get_risk_level_enum],
        and emits telemetry packages.

        Args:
            frame (numpy.ndarray): Multi-dimensional matrix array tracking pixel layouts.

        Returns:
            None (None): Dispatches tracking telemetry signals out to attached subscribers.
        """

        score = None
        risk = None

        if self.scores_list and self.current_frame_idx < len(self.scores_list):
            score = self.scores_list[self.current_frame_idx]

            if self.thresholds:
                risk = AnalysisEngine.get_risk_level_enum(score, self.thresholds)

        self.frame_ready.emit(
            FrameData(
                image=frame,
                frame_idx=self.current_frame_idx,
                landmarks=[],
                score=score,
                risk=risk,
            )
        )
        self.position_changed.emit(
            VideoPosition(
                current_frame=self.current_frame_idx,
                total_frames=self.total_frames,
            )
        )

    @Slot(str)
    def execute_frames_export(self, output_path: str) -> None:
        """
        Runs an unthrottled loop to combine frame saving and rendering into a single worker script.

        Freezes interactive timeline cycles, sets file structures up via `cv2.VideoWriter`, and loops
        sequentially through every frames slice to serialize an overlay-ready raw output file.

        Args:
            output_path (str): Intended file system path string destination where the generated media output should reside.

        Returns:
            None (None): Emits asynchronous progress telemetry bundles during processing.
        """
        if not self.playback_timer:
            return

        self.playback_timer.stop()
        if not self.cap or not self.cap.isOpened():
            self.frames_export_finished.emit(
                FramesExportResult(
                    success=False, message="No video stream initialized."
                ),
            )
            return

        try:
            # Re-verify layout specifications
            self.cap.set(cv2.CAP_PROP_POS_FRAMES, 0)
            width = int(self.cap.get(cv2.CAP_PROP_FRAME_WIDTH))
            height = int(self.cap.get(cv2.CAP_PROP_FRAME_HEIGHT))
            fps = self.cap.get(cv2.CAP_PROP_FPS) or 30.0

            fourcc = cv2.VideoWriter.fourcc(*"mp4v")
            writer = cv2.VideoWriter(output_path, fourcc, fps, (width, height))

            for idx in range(self.total_frames):
                ret, frame = self.cap.read()
                if not ret:
                    break

                # Apply overlays directly here if baked exports are needed
                writer.write(frame)

                if idx % 5 == 0:
                    self.export_progress.emit(
                        VideoPosition(
                            current_frame=idx,
                            total_frames=self.total_frames,
                        )
                    )

            writer.release()
            self.frames_export_finished.emit(
                FramesExportResult(
                    success=True,
                    message=f"Export successful: {Path(output_path).name}",
                )
            )

            # Reset timeline layout preview
            self._seek_to_index(0)
        except Exception as e:
            self.frames_export_finished.emit(
                FramesExportResult(
                    success=False,
                    message=str(e),
                )
            )
Functions
_emit_current_frame_payload(frame)

Bundles spatial features and risk labels into metadata packets.

Evaluates index points against calculated assessment tables, extracts structural enum items via AnalysisEngine.get_risk_level_enum, and emits telemetry packages.

Parameters:

Name Type Description Default
frame ndarray

Multi-dimensional matrix array tracking pixel layouts.

required

Returns:

Name Type Description
None None

Dispatches tracking telemetry signals out to attached subscribers.

Source code in gui\workers\video_worker.py
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
def _emit_current_frame_payload(self, frame: np.ndarray) -> None:
    """
    Bundles spatial features and risk labels into metadata packets.

    Evaluates index points against calculated assessment tables, extracts structural enum items
    via [AnalysisEngine.get_risk_level_enum][gui.core.analysis_engine.AnalysisEngine.get_risk_level_enum],
    and emits telemetry packages.

    Args:
        frame (numpy.ndarray): Multi-dimensional matrix array tracking pixel layouts.

    Returns:
        None (None): Dispatches tracking telemetry signals out to attached subscribers.
    """

    score = None
    risk = None

    if self.scores_list and self.current_frame_idx < len(self.scores_list):
        score = self.scores_list[self.current_frame_idx]

        if self.thresholds:
            risk = AnalysisEngine.get_risk_level_enum(score, self.thresholds)

    self.frame_ready.emit(
        FrameData(
            image=frame,
            frame_idx=self.current_frame_idx,
            landmarks=[],
            score=score,
            risk=risk,
        )
    )
    self.position_changed.emit(
        VideoPosition(
            current_frame=self.current_frame_idx,
            total_frames=self.total_frames,
        )
    )
_process_playback_frame()

Handles cyclic timer ticks to read, increment, and emit media data frames.

Monitors frame index bounds and terminates timer execution loops automatically if end-of-file flags or validation faults occur during extraction.

Returns:

Name Type Description
None None

Advances timeline state metrics or halts active timers.

Source code in gui\workers\video_worker.py
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
def _process_playback_frame(self) -> None:
    """
    Handles cyclic timer ticks to read, increment, and emit media data frames.

    Monitors frame index bounds and terminates timer execution loops automatically
    if end-of-file flags or validation faults occur during extraction.

    Returns:
        None (None): Advances timeline state metrics or halts active timers.
    """
    if not self.playback_timer:
        return

    if not self.cap or not self.cap.isOpened():
        self.playback_timer.stop()
        return

    ret, frame = self.cap.read()
    if not ret or self.current_frame_idx >= self.total_frames:
        self.playback_timer.stop()
        return

    self._emit_current_frame_payload(frame)
    self.current_frame_idx += 1
_seek_to_index(frame_idx)

Updates internal stream pointers and reads video matrix segments.

Handles hardware pointer relocations inside cv2.VideoCapture wrappers and invokes the payload packager instantly to prevent rendering lag.

Parameters:

Name Type Description Default
frame_idx int

Clean target bounding parameter mapping specific file indexes.

required

Returns:

Name Type Description
None None

Restores layout tracking bounds.

Source code in gui\workers\video_worker.py
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
def _seek_to_index(self, frame_idx: int) -> None:
    """
    Updates internal stream pointers and reads video matrix segments.

    Handles hardware pointer relocations inside `cv2.VideoCapture` wrappers and invokes
    the payload packager instantly to prevent rendering lag.

    Args:
        frame_idx (int): Clean target bounding parameter mapping specific file indexes.

    Returns:
        None (None): Restores layout tracking bounds.
    """
    if not self.cap or not self.cap.isOpened():
        return

    target = max(0, min(frame_idx, self.total_frames - 1))
    self.cap.set(cv2.CAP_PROP_POS_FRAMES, target)
    self.current_frame_idx = target

    # Instantly render the target frame context
    ret, frame = self.cap.read()
    if ret:
        self._emit_current_frame_payload(frame)
        # Re-seek back to catch the frame for future regular playback ticks
        self.cap.set(cv2.CAP_PROP_POS_FRAMES, target)
cleanup()

Shuts down any active playback intervals and releases open media stream file handles.

Returns:

Name Type Description
None None

In-place cleanup execution wrapper.

Source code in gui\workers\video_worker.py
168
169
170
171
172
173
174
175
176
177
178
179
@Slot()
def cleanup(self) -> None:
    """
    Shuts down any active playback intervals and releases open media stream file handles.

    Returns:
        None (None): In-place cleanup execution wrapper.
    """
    if self.playback_timer:
        self.playback_timer.stop()
    if self.cap:
        self.cap.release()
execute_frames_export(output_path)

Runs an unthrottled loop to combine frame saving and rendering into a single worker script.

Freezes interactive timeline cycles, sets file structures up via cv2.VideoWriter, and loops sequentially through every frames slice to serialize an overlay-ready raw output file.

Parameters:

Name Type Description Default
output_path str

Intended file system path string destination where the generated media output should reside.

required

Returns:

Name Type Description
None None

Emits asynchronous progress telemetry bundles during processing.

Source code in gui\workers\video_worker.py
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
@Slot(str)
def execute_frames_export(self, output_path: str) -> None:
    """
    Runs an unthrottled loop to combine frame saving and rendering into a single worker script.

    Freezes interactive timeline cycles, sets file structures up via `cv2.VideoWriter`, and loops
    sequentially through every frames slice to serialize an overlay-ready raw output file.

    Args:
        output_path (str): Intended file system path string destination where the generated media output should reside.

    Returns:
        None (None): Emits asynchronous progress telemetry bundles during processing.
    """
    if not self.playback_timer:
        return

    self.playback_timer.stop()
    if not self.cap or not self.cap.isOpened():
        self.frames_export_finished.emit(
            FramesExportResult(
                success=False, message="No video stream initialized."
            ),
        )
        return

    try:
        # Re-verify layout specifications
        self.cap.set(cv2.CAP_PROP_POS_FRAMES, 0)
        width = int(self.cap.get(cv2.CAP_PROP_FRAME_WIDTH))
        height = int(self.cap.get(cv2.CAP_PROP_FRAME_HEIGHT))
        fps = self.cap.get(cv2.CAP_PROP_FPS) or 30.0

        fourcc = cv2.VideoWriter.fourcc(*"mp4v")
        writer = cv2.VideoWriter(output_path, fourcc, fps, (width, height))

        for idx in range(self.total_frames):
            ret, frame = self.cap.read()
            if not ret:
                break

            # Apply overlays directly here if baked exports are needed
            writer.write(frame)

            if idx % 5 == 0:
                self.export_progress.emit(
                    VideoPosition(
                        current_frame=idx,
                        total_frames=self.total_frames,
                    )
                )

        writer.release()
        self.frames_export_finished.emit(
            FramesExportResult(
                success=True,
                message=f"Export successful: {Path(output_path).name}",
            )
        )

        # Reset timeline layout preview
        self._seek_to_index(0)
    except Exception as e:
        self.frames_export_finished.emit(
            FramesExportResult(
                success=False,
                message=str(e),
            )
        )
handle_video_control(action)

Processes video commands safely inside the worker thread.

Parses incoming action commands to adjust playback states, perform hard index skips, or step through sequential frames frame-by-frame.

Parameters:

Name Type Description Default
action VideoControl

Message bundle specifying state commands mapped by VideoControl.

required

Returns:

Name Type Description
None None

Performs state routing and state updates.

Source code in gui\workers\video_worker.py
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
@Slot(VideoControl)
def handle_video_control(self, action: VideoControl) -> None:
    """
    Processes video commands safely inside the worker thread.

    Parses incoming action commands to adjust playback states, perform hard index skips,
    or step through sequential frames frame-by-frame.

    Args:
        action (VideoControl): Message bundle specifying state commands mapped by
            [VideoControl][gui.utils.models.VideoControl].

    Returns:
        None (None): Performs state routing and state updates.
    """
    if action.command == VideoCommand.TOGGLE:
        self.toggle_playback()  # Starts/stops your QTimer safely here!

    elif action.command == VideoCommand.SEEK:
        if action.target_frame is not None:
            self.current_frame_idx = max(
                0, min(action.target_frame, self.total_frames - 1)
            )
            self.seek(frame_idx=self.current_frame_idx)

    elif action.command == VideoCommand.STEP_FORWARD:
        self.current_frame_idx = min(
            self.current_frame_idx + 1, self.total_frames - 1
        )
        self.step_frame(forward=True)

    elif action.command == VideoCommand.STEP_BACKWARD:
        self.current_frame_idx = max(0, self.current_frame_idx - 1)
        self.step_frame(forward=False)
init_timer()

Create and initialize the internal timer inside the worker thread space.

Instantiates the QTimer framework container directly inside the executing thread context to maintain thread-safe affinity boundaries and hooks up the loop timeout callback.

Returns:

Name Type Description
None None

Modifies the object state in-place.

Source code in gui\workers\video_worker.py
109
110
111
112
113
114
115
116
117
118
119
120
121
@Slot()
def init_timer(self) -> None:
    """
    Create and initialize the internal timer inside the worker thread space.

    Instantiates the `QTimer` framework container directly inside the executing thread context
    to maintain thread-safe affinity boundaries and hooks up the loop timeout callback.

    Returns:
        None (None): Modifies the object state in-place.
    """
    self.playback_timer = QTimer(self)
    self.playback_timer.timeout.connect(self._process_playback_frame)
initialize_video(video_load_request)

Configures the current video asset context safely.

Resets ongoing playback loops, releases any pre-allocated system video capture handles, parses technical properties from the target file configuration payload, and renders the initial frame slice.

Parameters:

Name Type Description Default
video_load_request VideoLoadRequest

Configuration descriptor mapping paths, scores, and boundaries via VideoLoadRequest.

required

Returns:

Name Type Description
None None

Dispatches a preview frame or reinitializes structural attributes.

Source code in gui\workers\video_worker.py
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
@Slot(VideoLoadRequest)
def initialize_video(
    self,
    video_load_request: VideoLoadRequest,
) -> None:
    """
    Configures the current video asset context safely.

    Resets ongoing playback loops, releases any pre-allocated system video capture handles,
    parses technical properties from the target file configuration payload, and renders
    the initial frame slice.

    Args:
        video_load_request (VideoLoadRequest): Configuration descriptor mapping paths, scores,
            and boundaries via [VideoLoadRequest][gui.utils.models.VideoLoadRequest].

    Returns:
        None (None): Dispatches a preview frame or reinitializes structural attributes.
    """

    if not self.playback_timer:
        self.init_timer()

    if not self.playback_timer:
        return
    self.playback_timer.stop()
    if self.cap:
        self.cap.release()

    self.video_path = str(video_load_request.path)
    self.scores_list = list(video_load_request.scores)
    self.thresholds = video_load_request.thresholds

    self.cap = cv2.VideoCapture(self.video_path)
    fps = self.cap.get(cv2.CAP_PROP_FPS) or 30.0
    self.total_frames = int(self.cap.get(cv2.CAP_PROP_FRAME_COUNT))
    self.current_frame_idx = 0

    # Calculate dynamic frame timing interval in milliseconds

    self.playback_interval_ms = int(1000 / fps)

    # Render the initial preview frame
    self._seek_to_index(0)
seek(frame_idx)

Public slot accepting external target navigation frames.

Parameters:

Name Type Description Default
frame_idx int

Absolute destination index path targeting targeted index segments.

required

Returns:

Name Type Description
None None

Dispatches internal seek handlers.

Source code in gui\workers\video_worker.py
240
241
242
243
244
245
246
247
248
249
250
251
@Slot(int)
def seek(self, frame_idx: int) -> None:
    """
    Public slot accepting external target navigation frames.

    Args:
        frame_idx (int): Absolute destination index path targeting targeted index segments.

    Returns:
        None (None): Dispatches internal seek handlers.
    """
    self._seek_to_index(frame_idx)
step_frame(forward)

Steps sequentially up or down one tick.

Parameters:

Name Type Description Default
forward bool

Set to True to increment the timeline frame index, False to decrement.

required

Returns:

Name Type Description
None None

Dispatches updated coordinate mappings.

Source code in gui\workers\video_worker.py
253
254
255
256
257
258
259
260
261
262
263
264
265
@Slot(bool)
def step_frame(self, forward: bool) -> None:
    """
    Steps sequentially up or down one tick.

    Args:
        forward (bool): Set to True to increment the timeline frame index, False to decrement.

    Returns:
        None (None): Dispatches updated coordinate mappings.
    """
    target = self.current_frame_idx + 1 if forward else self.current_frame_idx - 1
    self._seek_to_index(target)
toggle_playback()

Starts or stops the frame ticker timer.

Evaluates operational flags, state loops, and active timers to cleanly toggle periodic media processing routines.

Returns:

Name Type Description
bool `bool`

True if a timer sequence successfully started, False if it was paused or failed.

Source code in gui\workers\video_worker.py
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
@Slot()
def toggle_playback(self) -> bool:
    """
    Starts or stops the frame ticker timer.

    Evaluates operational flags, state loops, and active timers to cleanly toggle
    periodic media processing routines.

    Returns:
        bool (`bool`): True if a timer sequence successfully started, False if it was paused or failed.
    """
    if not self.cap or not self.cap.isOpened():
        return False

    if not self.playback_timer:
        return False

    if self.playback_timer.isActive():
        self.playback_timer.stop()
        return False
    else:
        self.playback_timer.start(self.playback_interval_ms)
        return True

options: show_root_heading: true

gui.workers.frames_export_worker

ErgoMoCap: Frames Export Worker

Headless frame serialization and data synchronization utilities.

This module provides multithreaded and headless components designed to extract individual frame buffers from video recording files, write them to disk, and synchronize them with calculated ergonomic metrics into manageable data frames.

Classes

FramesExportWorker

Bases: QObject

Asynchronous worker for executing frame extraction operations inside a background thread.

Manages the operational state lifecycle of a headless extraction run, providing cooperative cancellation hooks and proxying progress telemetry via Qt Signals to avoid blocking the primary user interface thread.

Attributes:

Name Type Description
finished Signal

Signal emitted when the frame extraction sequence completes.

progress Signal

Signal emitted every 5 frames containing a VideoPosition state telemetry capsule.

video_path Path | str

File system path referencing the source video capture file.

frames_dir Path

Destination folder path where image frames will be written.

scores_list list[int]

Ordered sequence of calculated ergonomic risk scores.

_is_running bool

Internal control flag indicating active thread processing state.

Methods:

Name Description
__init__

Initialize the worker instance with data paths and scores.

stop

Request a cooperative cancellation of the running extraction routine.

run

Execute the headless frame extraction loop and serialize synchronized telemetry.

Source code in gui\workers\frames_export_worker.py
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
class FramesExportWorker(QObject):
    """
    Asynchronous worker for executing frame extraction operations inside a background thread.

    Manages the operational state lifecycle of a headless extraction run, providing cooperative
    cancellation hooks and proxying progress telemetry via Qt Signals to avoid blocking the
    primary user interface thread.

    Attributes:
        finished (Signal): Signal emitted when the frame extraction sequence completes.
        progress (Signal): Signal emitted every 5 frames containing a [VideoPosition][gui.utils.models.VideoPosition] state telemetry capsule.
        video_path (Path | str): File system path referencing the source video capture file.
        frames_dir (Path): Destination folder path where image frames will be written.
        scores_list (list[int]): Ordered sequence of calculated ergonomic risk scores.
        _is_running (bool): Internal control flag indicating active thread processing state.

    Methods:
        __init__: Initialize the worker instance with data paths and scores.
        stop: Request a cooperative cancellation of the running extraction routine.
        run: Execute the headless frame extraction loop and serialize synchronized telemetry.
    """

    finished = Signal()

    progress = Signal(VideoPosition)

    def __init__(self, video_path, frames_dir, scores_list):
        """
        Initialize the worker instance with data paths and scores.

        Args:
            video_path (Path | str): File system path referencing the source video capture file.
            frames_dir (Path): Destination folder path where image frames will be written.
            scores_list (list[int]): Ordered sequence of calculated ergonomic risk scores.

        Returns:
            None (None): Initializer return.
        """
        super().__init__()
        self.video_path = video_path
        self.frames_dir = frames_dir
        self.scores_list = scores_list

        self._is_running = False

    def stop(self):
        """
        Request a cooperative cancellation of the running extraction routine.

        Sets the internal execution flags to false, prompting the underlying headless processor
        loop to break operations at the next evaluation interval.

        Returns:
            None (None): Updates the internal running state.
        """
        self._is_running = False

    def run(self):
        """
        Execute the headless frame extraction loop and serialize synchronized telemetry.

        Launches the core engine runner, tracks progress metrics via signal emitters,
        and upon valid completion writes out a structured mapping index file using
        [pandas.DataFrame.to_csv][pandas.DataFrame.to_csv].

        Returns:
            None (None): Dispatches lifecycle termination signals to the main listener thread.
        """
        self._is_running = True
        # We pass self.progress.emit directly as the callback
        df = export_frames_headless(
            video_path=self.video_path,
            output_folder=self.frames_dir,
            scores_list=self.scores_list,
            progress_callback=self.progress.emit,
            should_stop=lambda: not self._is_running,
        )

        if df is not None and not df.empty:
            df.to_csv(self.frames_dir / "synchronized_data.csv", index=False)

        self.finished.emit()
Functions
run()

Execute the headless frame extraction loop and serialize synchronized telemetry.

Launches the core engine runner, tracks progress metrics via signal emitters, and upon valid completion writes out a structured mapping index file using pandas.DataFrame.to_csv.

Returns:

Name Type Description
None None

Dispatches lifecycle termination signals to the main listener thread.

Source code in gui\workers\frames_export_worker.py
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
def run(self):
    """
    Execute the headless frame extraction loop and serialize synchronized telemetry.

    Launches the core engine runner, tracks progress metrics via signal emitters,
    and upon valid completion writes out a structured mapping index file using
    [pandas.DataFrame.to_csv][pandas.DataFrame.to_csv].

    Returns:
        None (None): Dispatches lifecycle termination signals to the main listener thread.
    """
    self._is_running = True
    # We pass self.progress.emit directly as the callback
    df = export_frames_headless(
        video_path=self.video_path,
        output_folder=self.frames_dir,
        scores_list=self.scores_list,
        progress_callback=self.progress.emit,
        should_stop=lambda: not self._is_running,
    )

    if df is not None and not df.empty:
        df.to_csv(self.frames_dir / "synchronized_data.csv", index=False)

    self.finished.emit()
stop()

Request a cooperative cancellation of the running extraction routine.

Sets the internal execution flags to false, prompting the underlying headless processor loop to break operations at the next evaluation interval.

Returns:

Name Type Description
None None

Updates the internal running state.

Source code in gui\workers\frames_export_worker.py
87
88
89
90
91
92
93
94
95
96
97
def stop(self):
    """
    Request a cooperative cancellation of the running extraction routine.

    Sets the internal execution flags to false, prompting the underlying headless processor
    loop to break operations at the next evaluation interval.

    Returns:
        None (None): Updates the internal running state.
    """
    self._is_running = False

Functions

export_frames_headless(video_path, output_folder, scores_list=[], progress_callback=None, should_stop=None)

Headless pipeline to extract frames sequentially and compile an indexed telemetry dataset.

Parses raw video frames using cv2.VideoCapture, serializes compressed jpeg imagery directly to disk, and returns a cumulative pandas.DataFrame correlating index positions to associated risk profiles.

Parameters:

Name Type Description Default
video_path Path | str

File system path referencing the source video capture file.

required
output_folder Path | str

Target filesystem directory where frame slices should reside.

required
scores_list list[int]

Optional ordered array containing specific ergonomic scores per frame index. Defaults to [].

[]
progress_callback Callable[[VideoPosition], None] | None

Callback function invoked with VideoPosition elements every 5 processed loops. Defaults to None.

None
should_stop Callable[[], bool] | None

Lambda function evaluated at loop cycle boundaries to handle execution abort requests. Defaults to None.

None

Returns:

Type Description
DataFrame

pandas.DataFrame (pandas.DataFrame): Master index log mapping specific frame indices, generated file paths, and metadata metrics.

Source code in gui\workers\frames_export_worker.py
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
def export_frames_headless(
    video_path, output_folder, scores_list=[], progress_callback=None, should_stop=None
) -> pd.DataFrame:
    """
    Headless pipeline to extract frames sequentially and compile an indexed telemetry dataset.

    Parses raw video frames using `cv2.VideoCapture`, serializes compressed jpeg imagery
    directly to disk, and returns a cumulative `pandas.DataFrame` correlating index positions
    to associated risk profiles.

    Args:
        video_path (Path | str): File system path referencing the source video capture file.
        output_folder (Path | str): Target filesystem directory where frame slices should reside.
        scores_list (list[int]): Optional ordered array containing specific ergonomic scores per frame index. Defaults to `[]`.
        progress_callback (Callable[[VideoPosition], None] | None): Callback function invoked with [VideoPosition][gui.utils.models.VideoPosition] elements every 5 processed loops. Defaults to `None`.
        should_stop (Callable[[], bool] | None): Lambda function evaluated at loop cycle boundaries to handle execution abort requests. Defaults to `None`.

    Returns:
        pandas.DataFrame (`pandas.DataFrame`): Master index log mapping specific frame indices, generated file paths, and metadata metrics.
    """
    cap = cv2.VideoCapture(str(video_path))
    total_frames = int(cap.get(cv2.CAP_PROP_FRAME_COUNT))
    frame_idx = 0
    exported_data = []

    while True:
        if should_stop and should_stop():
            print("Export stopped by user.")
            break
        ret, frame = cap.read()
        if not ret:
            break

        file_name = f"frame_{frame_idx:06d}.jpg"
        cv2.imwrite(str(Path(output_folder) / file_name), frame)

        row = {"frame_idx": frame_idx, "filename": file_name}
        if scores_list and frame_idx < len(scores_list):
            row.update({"reba_score": scores_list[frame_idx]})

        exported_data.append(row)
        frame_idx += 1

        # Emit current and total
        if progress_callback and frame_idx % 5 == 0:
            progress_callback(
                VideoPosition(
                    current_frame=frame_idx,
                    total_frames=total_frames,
                )
            )

    cap.release()
    return pd.DataFrame(exported_data)

options: show_root_heading: true

Passive Views & Reusable UI Components

gui.frontend

ErgoMoCap Main Application Window

Primary user interface controller and orchestration layer for ErgoMoCap.

This module implements the MainWindow, which coordinates interactions between the ergonomic configuration sidebar, the video rendering canvas, and the background execution engine (ErgoBackend). It manages the top-level window lifecycle, application-wide themes, asynchronous thread cleanup, and shortcuts.

Classes

MainWindow

Bases: QMainWindow

The primary application window for the ErgoMoCap GUI.

This class manages the user interface, handles interactions between the sidebar controls and the video canvas, and coordinates with the ErgoBackend for data processing and analysis.

Attributes:

Name Type Description
backend ErgoBackend

The core logic handler for data and video processing.

current_theme ErgoTheme

Tracks the active UI theme ('dark' or 'light').

report_window ReportView

A persistent window instance for displaying results.

canvas VideoCanvas

The central widget for video rendering.

menu_actions MenuActions

Logic handler for menu bar commands.

sidebar ErgoSidebar

The left-hand control panel for user input.

_menu_bar MenuBar

The top-level application menu bar.

Methods:

Name Description
setup_ui

Constructs the main layout and widget hierarchy.

handle_reboot

Restarts the application process.

kill_running_threads

Safely terminates active backend threads.

safe_close

Safely terminates running threads and closes the main window.

handle_new_recording

Placeholder for starting a new capture session.

handle_load_recording

Placeholder for loading existing recordings.

open_settings

Placeholder for the settings configuration window.

open_docs

Opens the locally shipped documentation homepage.

open_tutorial

Opens the locally shipped tutorial page.

open_source

Opens the live GitHub repository on the web.

connect_signals

Establishes connections between UI signals and handlers.

toggle_theme

Switches the UI between dark and light stylesheets.

toggle_sidebar

Shows or hides the ergonomic sidebar.

init_root

Sets up the initial data directory and scans for sessions.

handle_select_root

Slot to update the data root directory.

handle_session_selected

Slot to load data for a specific session.

handle_video_selection_changed

Slot to switch the active video source.

handle_load_video

Slot to manually browse for a video file.

_reconnect_video_signals

Internal helper to manage frame stream connections.

step_video

Sends a relative step request to the backend safely across threads.

keyPressEvent

Overrides keyboard interaction to trigger shortcuts.

_handle_canvas_seek

Handles the seek request from the video canvas overlay.

handle_toggle_video

Slot to play or pause video playback.

handle_run_fmc

Slot to trigger external FreeMoCap processing.

handle_import

Slot to manually import joint data files.

show_report

Displays the analysis reporting window.

run_analysis

Triggers the ergonomic calculation engine.

_update_export_status

Unified status formatter for background processing.

handle_headless_export

Delegates frame export processing to the backend.

Source code in gui\frontend.py
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
class MainWindow(QMainWindow):
    """
    The primary application window for the ErgoMoCap GUI.

    This class manages the user interface, handles interactions between the
    sidebar controls and the video canvas, and coordinates with the ErgoBackend
    for data processing and analysis.

    Attributes:
        backend (ErgoBackend): The core logic handler for data and video processing.
        current_theme (ErgoTheme): Tracks the active UI theme ('dark' or 'light').
        report_window (ReportView): A persistent window instance for displaying results.
        canvas (VideoCanvas): The central widget for video rendering.
        menu_actions (MenuActions): Logic handler for menu bar commands.
        sidebar (ErgoSidebar): The left-hand control panel for user input.
        _menu_bar (MenuBar): The top-level application menu bar.

    Methods:
        setup_ui: Constructs the main layout and widget hierarchy.
        handle_reboot: Restarts the application process.
        kill_running_threads: Safely terminates active backend threads.
        safe_close: Safely terminates running threads and closes the main window.
        handle_new_recording: Placeholder for starting a new capture session.
        handle_load_recording: Placeholder for loading existing recordings.
        open_settings: Placeholder for the settings configuration window.
        open_docs: Opens the locally shipped documentation homepage.
        open_tutorial: Opens the locally shipped tutorial page.
        open_source: Opens the live GitHub repository on the web.
        connect_signals: Establishes connections between UI signals and handlers.
        toggle_theme: Switches the UI between dark and light stylesheets.
        toggle_sidebar: Shows or hides the ergonomic sidebar.
        init_root: Sets up the initial data directory and scans for sessions.
        handle_select_root: Slot to update the data root directory.
        handle_session_selected: Slot to load data for a specific session.
        handle_video_selection_changed: Slot to switch the active video source.
        handle_load_video: Slot to manually browse for a video file.
        _reconnect_video_signals: Internal helper to manage frame stream connections.
        step_video: Sends a relative step request to the backend safely across threads.
        keyPressEvent: Overrides keyboard interaction to trigger shortcuts.
        _handle_canvas_seek: Handles the seek request from the video canvas overlay.
        handle_toggle_video: Slot to play or pause video playback.
        handle_run_fmc: Slot to trigger external FreeMoCap processing.
        handle_import: Slot to manually import joint data files.
        show_report: Displays the analysis reporting window.
        run_analysis: Triggers the ergonomic calculation engine.
        _update_export_status: Unified status formatter for background processing.
        handle_headless_export: Delegates frame export processing to the backend.
    """

    def __init__(self) -> None:
        """
        Initializes the MainWindow, sets up the backend, and triggers UI construction.

        Returns:
            None (None): Initializes the instance state.
        """
        super().__init__()
        self.backend: ErgoBackend = ErgoBackend()
        self.current_theme: ErgoTheme = ErgoTheme.DARK

        self.setWindowTitle(self.tr("ErgoMoCap - Ergonomics Motion Capture"))

        icon_path = ErgoPaths.LOGO

        self.setWindowIcon(QIcon(str(icon_path)))
        self.setMinimumSize(1200, 800)

        # Initialize your ReportView as a persistent separate window
        self.report_window: ReportView = ReportView(self)

        self.setup_ui()
        self.connect_signals()
        self.init_root()

    def setup_ui(self) -> None:
        """
        Constructs the main layout, widgets, and signal-slot connections.

        This method initializes the central widget, the `VideoCanvas`, the
        `ErgoSidebar`, and the application `MenuBar`.

        Returns:
            None (None): Modifies the `MainWindow` state.
        """
        central: QWidget = QWidget()
        self.setCentralWidget(central)

        self.setFocusPolicy(Qt.FocusPolicy.StrongFocus)

        master_layout: QVBoxLayout = QVBoxLayout(central)
        master_layout.setContentsMargins(0, 0, 0, 0)
        master_layout.setSpacing(0)

        # 2. CONTENT AREA
        content_area: QWidget = QWidget()
        content_layout: QHBoxLayout = QHBoxLayout(content_area)
        content_layout.setContentsMargins(0, 0, 0, 0)
        content_layout.setSpacing(0)

        # --- VIDEO AREA ---
        self.canvas: VideoCanvas = VideoCanvas()
        content_layout.addWidget(self.canvas, 1)

        # Add Content Area to Master Layout
        master_layout.addWidget(content_area)

        # 1. Plug in the menu from the other file
        self.menu_actions = MenuActions(self)

        self._menu_bar = MenuBar(actions=self.menu_actions, parent=self)
        self.setMenuBar(self._menu_bar)

        self.sidebar = ErgoSidebar(self)
        self.addDockWidget(Qt.DockWidgetArea.LeftDockWidgetArea, self.sidebar)

    # --- Methods for Actions ---
    def handle_reboot(self) -> None:
        """
        Restarts the application by executing a new Python process.

        Returns:
            None (None): Terminates the current process.
        """
        python = sys.executable  # nosec B606 TODO replace if possible
        os.execl(python, python, *sys.argv)  # nosec B606 TODO replace if possible

    def kill_running_threads(self) -> None:
        """
        Safely terminates active backend threads to prevent memory leaks or crashes.

        Returns:
            None (None): Stops background threads.
        """
        killed_threads: str = ""

        # 1. Safely stop video playback thread using native QThread methods
        if hasattr(self.backend, "video_thread") and self.backend.video_thread:
            if self.backend.video_thread.isRunning():
                self.backend.video_thread.quit()
                self.backend.video_thread.wait()
                killed_threads += "- video_thread\n"

        # 2. Safely clean up the background frame exporter thread if it's currently processing
        if hasattr(self.backend, "export_thread") and self.backend.export_thread:
            try:
                if self.backend.export_thread.isRunning():
                    # 1. Break internal cv2 worker loop safely
                    if (
                        hasattr(self.backend, "export_worker")
                        and self.backend.export_worker
                    ):
                        self.backend.export_worker.stop()

                    # 2. Tear down thread infrastructure
                    self.backend.export_thread.quit()

                    if not self.backend.export_thread.wait(2000):
                        self.backend.export_thread.terminate()
                        self.backend.export_thread.wait()

                    killed_threads += "- export_thread\n"
            except RuntimeError:
                # Catch and dismiss cases where C++ lifecycle already ended
                pass

        self.sidebar.set_status(f"Threads safely terminated:\n{killed_threads}")

        # TODO implement a better kill switch for all threads and processes of the app in particular freemocap

    def safe_close(self) -> None:
        """
        Safely terminates running backend threads and closes the application window.

        Returns:
            None (None): Closes the window hierarchy.
        """
        # TODO add docsrings and include this function in MainWindow Docstringas
        self.kill_running_threads()
        self.close()

    def handle_new_recording(self) -> None:
        """
        Placeholder for starting a new motion capture recording session.

        Returns:
            None (None): Not yet implemented.
        """
        pass

    def handle_load_recording(self) -> None:
        """
        Placeholder for loading an existing historical recording session.

        Returns:
            None (None): Not yet implemented.
        """
        pass

    def open_settings(self) -> None:
        """
        Placeholder for configuring application preferences and window paths.

        Returns:
            None (None): Not yet implemented.
        """
        # TODO make setting view and open as secondary window
        pass

    def open_docs(self) -> None:
        """
        Opens the locally shipped documentation homepage.

        Returns:
            None (None): Launches system default desktop browser.
        """
        url = ErgoPaths.get_local_site_url(page_name="index.html")
        QDesktopServices.openUrl(url)

    def open_tutorial(self) -> None:
        """
        Opens the locally shipped tutorial page.

        Returns:
            None (None): Launches system default desktop browser.
        """
        url = ErgoPaths.get_local_site_url(page_name="tutorial.html")
        QDesktopServices.openUrl(url)

    def open_source(self) -> None:
        """
        Opens the live GitHub repository on the web.

        Returns:
            None (None): Launches system default desktop browser.
        """
        url = QUrl("https://github.com/freemocap/freemocap")
        QDesktopServices.openUrl(url)

    def connect_signals(self) -> None:
        """
        Wiring the Sidebar Public API to the existing MainWindow handlers.

        Returns:
            None (None): Establishes Qt signal-slot connections.
        """
        s = self.sidebar

        # Connect internal sidebar signals to MainWindow handlers
        s.btn_fmc.clicked.connect(self.handle_run_fmc)
        s.btn_select_root.clicked.connect(self.handle_select_root)
        s.combo_sessions.currentIndexChanged.connect(self.handle_session_selected)
        s.run_analysis_clicked.connect(self.run_analysis)
        s.btn_report.clicked.connect(self.show_report)
        s.btn_load_video.clicked.connect(self.handle_load_video)
        s.btn_play_video.clicked.connect(self.handle_toggle_video)
        s.combo_videos.currentIndexChanged.connect(self.handle_video_selection_changed)

        # --- NEW CANVAS INTERACTION CONNECTIONS ---
        c = self.canvas
        # When you click the seeker bar on the video
        c.seek_requested.connect(self._handle_canvas_seek)
        # When you click the video (not the bar) to play/pause
        c.toggle_requested.connect(self.handle_toggle_video)

        # New Button Connections
        s.btn_prev_frame.clicked.connect(lambda: self.step_video(-1))
        s.btn_next_frame.clicked.connect(lambda: self.step_video(1))

        self.backend.status_updated.connect(self.sidebar.set_status)

        self.backend.analysis_finished.connect(
            self._handle_analysis_finished,
            type=Qt.ConnectionType.QueuedConnection,  # ← CRITICAL: UI updates must run on main thread
        )

    def toggle_theme(self) -> None:
        """
        Switches the application stylesheet between dark and light modes.

        Returns:
            None (None): Updates the `QApplication` stylesheet and theme icons.
        """

        self.current_theme = (
            ErgoTheme.LIGHT if self.current_theme == ErgoTheme.DARK else ErgoTheme.DARK
        )
        app = QApplication.instance()
        if isinstance(app, QApplication):
            app.setStyleSheet(get_stylesheet(self.current_theme))
            icon: str = "☀️" if self.current_theme == ErgoTheme.LIGHT else "🌓"
            self._menu_bar.theme_btn.setText(icon)

    def toggle_sidebar(self) -> None:
        """
        Toggle the visibility of the sidebar.

        Returns:
            None (None): Updates the visibility state of the `sidebar` widget.
        """
        self.sidebar.setVisible(not self.sidebar.isVisible())

    def init_root(self) -> None:
        """
        Initializes the data root from the backend configuration on startup.

        Scans the default directory for sessions and attempts to load assets
        (CSV data and video) for the first found session.

        Returns:
            None (None): Populates UI widgets with initial data.
        """
        root_path: Path | None = ErgoPaths.SESSIONS
        if not root_path:
            return

        sessions: list[str] = self.backend.set_root_and_scan(root_path)

        if not sessions:
            logger.warning(
                "No Session Data Found. Check if the root folder is the correct 'freemocap_data' folder."
            )
            self.sidebar.set_status(
                self.tr(
                    "No Session Data Found. Check if the root folder is the correct one."
                )
            )
            return

        # Update UI with available sessions
        self.sidebar.update_sessions(sessions)

        # Attempt to automatically load the first session
        session_data = self.backend.load_session_automatically(sessions[0])

        if not session_data:
            logger.warning(
                "No Session Data Found. Check if the root folder is the correct 'freemocap_data' folder."
            )
            return

        if not session_data.success:
            self.sidebar.set_status(
                self.tr("Error during initialization: No Success {}.").format(
                    session_data.message
                )
            )
            return

        if not session_data.video_paths or len(session_data.video_paths) == 0:
            self.sidebar.set_status(
                self.tr("Error during initialization: No Videos {}.").format(
                    session_data.message
                )
            )
            return

        # Populates UI with videos found
        self.sidebar.update_videos(session_data.video_paths)

        # Safe to extract target video now that length check has passed
        target_video = session_data.video_paths[0]
        video_result = self.backend.load_video_source(target_video)

        # Check video loading outcome
        if video_result.success:
            self.handle_video_selection_changed()
            # Set the final successful status message here so it doesn't get overwritten unexpectedly
            self.sidebar.set_status(
                self.tr("Found {} sessions. Loaded {} videos").format(
                    len(sessions), len(session_data.video_paths)
                )
            )
        else:
            self.sidebar.set_status(
                self.tr("Video Load Error: {}").format(video_result.message)
            )

    @Slot()
    def handle_select_root(self) -> None:
        """
        Updates the backend root and refreshes the session list.

        Opens a `QFileDialog` for the user to select a new directory.

        Returns:
            None (None): Updates the [ErgoSidebar][gui.widgets.sidebar.ErgoSidebar].
        """
        root_path: str | None = QFileDialog.getExistingDirectory(
            self, self.tr("Select FreeMoCap Data Folder")
        )
        if root_path:
            chosen_path = Path(root_path)
            ErgoPaths.update_user_root(chosen_path)  # resets constant class paths

            sessions_folder: Path = Path(root_path) / ErgoPaths.SESSIONS_FOLDER_NAME
            sessions: list[str] = self.backend.set_root_and_scan(sessions_folder)
            self.sidebar.update_sessions(sessions)
            self.sidebar.set_status(self.tr("Found {} sessions.").format(len(sessions)))

    @Slot()
    def handle_session_selected(self) -> None:
        """
        Loads metadata and populates videos for the selected session.

        Returns:
            None (None): Updates backend state and UI enabled/disabled statuses.
        """
        session_name: str = self.sidebar.get_current_session()
        if not session_name or session_name == "":
            return

        # Clear previous state to avoid analyzing old data if new load fails
        self.sidebar.set_status(self.tr("Loading session data..."))

        session_data = self.backend.load_session_automatically(session_name)

        if session_data.success:
            videos_num: int = len(session_data.video_paths)
            if session_data.video_paths and videos_num > 0:
                self.sidebar.update_videos(session_data.video_paths)
                self.handle_video_selection_changed()

            self.sidebar.btn_play_video.setEnabled(True)
            self.sidebar.btn_next_frame.setEnabled(True)
            self.sidebar.btn_prev_frame.setEnabled(True)
            self.sidebar.btn_analysis.setEnabled(True)  # Ensure this is enabled
            self.sidebar.set_status(
                self.tr("Session Loaded: {}. Found {} videos").format(
                    session_name, videos_num
                )
            )

        else:
            # This is likely where your error is happening
            self.sidebar.set_status(self.tr("ERROR: {}").format(session_data.message))
            self.sidebar.btn_analysis.setEnabled(False)

    @Slot()
    def handle_video_selection_changed(self) -> None:
        """
        Loads a specific video file into the backend based on sidebar selection.

        Returns:
            None (None): Updates the video source in [ErgoBackend][gui.backend.backend.ErgoBackend].
        """
        video_name: str = self.sidebar.get_current_video()
        session_name: str = self.sidebar.get_current_session()

        if not video_name or not session_name:
            return

        video_path: Path = ErgoPaths.video_folder(session_name) / video_name

        if not video_path.exists():
            self.sidebar.set_status(f"ERROR: Video not found at {video_path.name}")
            return

        # Safety: Reconnect frame signals
        self._reconnect_video_signals()

        video_result = self.backend.load_video_source(str(video_path))
        if video_result.success:
            self.sidebar.btn_play_video.setEnabled(True)
            self.sidebar.set_status(self.tr("Loaded {}").format(video_name))

    @Slot()
    def handle_load_video(self) -> None:
        """
        Opens a file dialog to manually browse and select a video file.

        Returns:
            None (None): Updates the backend video source and UI status.
        """
        session_name = self.sidebar.get_current_session()
        initial_path: str = (
            str(ErgoPaths.video_folder(session_name))
            if session_name
            else str(ErgoPaths.SESSIONS)
        )

        path, _ = QFileDialog.getOpenFileName(
            self,
            self.tr("Select Video"),
            initial_path,
            self.tr("Videos (*.mp4 *.avi *.mov *.mkv)"),
        )
        if path:
            video_result = self.backend.load_video_source(path)

            self.sidebar.set_status(self.tr("{}").format(video_result.message))
            if video_result.success:
                self._reconnect_video_signals()
                self.sidebar.btn_play_video.setEnabled(True)
                self.sidebar.btn_next_frame.setEnabled(True)
                self.sidebar.btn_prev_frame.setEnabled(True)

    def _reconnect_video_signals(self) -> None:
        """
        Helper to safely handle frame connections.

        Ensures that the `frame_ready` signal from the backend is correctly
        routed to the [VideoCanvas][gui.widgets.video_canvas.VideoCanvas].

        Returns:
            None (None): Re-establishes signal connections.
        """
        try:
            self.backend.frame_ready.disconnect()
            self.backend.position_changed.disconnect()
        except (RuntimeError, TypeError):
            pass

        # Connect the image to the canvas
        self.backend.frame_ready.connect(self.canvas.update_frame)

        # Connect the seeker data (current/total frames) to the canvas
        # This makes the progress bar actually move!
        self.backend.position_changed.connect(self.canvas.update_position)

    def step_video(self, delta: int) -> None:
        """
        Sends a relative step request to the backend safely across threads.

        Args:
            delta (int): The number of frames to step (positive for forward, negative for backward).

        Returns:
            None (None): Emits cross-thread signaling.
        """
        command = VideoCommand.STEP_FORWARD if delta > 0 else VideoCommand.STEP_BACKWARD

        # Emitting a copy-safe dataclass across threads is 100% thread-safe!
        self.backend.video_control_requested.emit(VideoControl(command=command))
        self.sidebar.set_status(f"Stepped {command.name}")

    def keyPressEvent(self, event) -> None:
        """
        Overrides standard key presses to handle shortcut bindings.

        Maps arrow keys to frame stepping and spacebar to video toggle commands.

        Args:
            event (QKeyEvent): The incoming key keyboard event configuration.

        Returns:
            None (None): Accepts or passes the incoming key event structure.
        """

        # Ensure we have a video thread running before trying to step
        if not hasattr(self, "backend") or not self.backend.video_worker:
            super().keyPressEvent(event)
            return

        key = event.key()

        if key == Qt.Key.Key_Left:
            self.step_video(-1)
            event.accept()
        elif key == Qt.Key.Key_Right:
            self.step_video(1)
            event.accept()
        elif key == Qt.Key.Key_Space:
            self.handle_toggle_video()
            event.accept()
        else:
            super().keyPressEvent(event)

    @Slot(int)
    def _handle_canvas_seek(self, frame_idx: int) -> None:
        """
        Handles the seek request from the video canvas overlay.

        Args:
            frame_idx (int): Target frame absolute indexing point.

        Returns:
            None (None): Dispatches a structured `VideoControl` request.
        """
        self.backend.video_control_requested.emit(
            VideoControl(
                command=VideoCommand.SEEK,
                target_frame=frame_idx,
            ),
        )
        self.sidebar.set_status(f"Seeking to frame: {frame_idx}")

    @Slot()
    def handle_toggle_video(self) -> None:
        """
        Toggles Play/Pause state of the video playback.

        Returns:
            None (None): Updates the UI status label and backend playback state.
        """
        self.backend.video_control_requested.emit(
            VideoControl(
                command=VideoCommand.TOGGLE,
            )
        )

    @Slot()
    def handle_run_fmc(self) -> None:
        """Triggers FreeMoCap processing with a forced 2-second visual delay.

        Returns:
            None
        """
        self.wait_dialog = QDialog(parent=self)
        self.wait_dialog.setWindowModality(Qt.WindowModality.WindowModal)

        layout = QVBoxLayout()
        layout.addWidget(
            QLabel(
                self.tr("Initializing FreeMoCap... Please wait until it opens."),
                self.wait_dialog,
            )
        )

        self.wait_dialog.setLayout(layout)
        self.wait_dialog.show()
        QCoreApplication.processEvents()

        start_time = time.time()
        success, msg = self.backend.launch_freemocap()
        self.sidebar.set_status(self.tr("{}").format(msg))

        elapsed = time.time() - start_time
        if elapsed < 3.0:
            time.sleep(3.0 - elapsed)

        self.wait_dialog.accept()
        self.wait_dialog.deleteLater()

    @Slot()
    def handle_import(self) -> None:
        """
        Opens a file dialog to manually import joint coordinate data.

        Returns:
            None (None): Updates the backend data buffer.
        """
        path, _ = QFileDialog.getOpenFileName(
            self, self.tr("Select Data"), "", self.tr("Data (*.csv *.xlsx *.npy)")
        )
        if path:
            success, msg = self.backend.import_joint_data(path)
            self.sidebar.set_status(self.tr("{}").format(msg))

    @Slot()
    def show_report(self) -> None:
        """
        Displays the ReportView window and updates it with the current method.

        Returns:
            None (None): Shows or raises the `report_window`.
        """
        selected_method: str = self.sidebar.get_selected_method().upper()
        method: AssessmentMethod = AssessmentMethod[selected_method]
        self.report_window.set_method(method)
        self.report_window.update_current_strategy()

        if self.report_window.isHidden():
            self.report_window.show()
        else:
            self.report_window.raise_()
            self.report_window.activateWindow()

    @Slot(AnalysisRequest)
    def run_analysis(self, analysis_request: AnalysisRequest) -> None:
        """Executes the ergonomic assessment and opens the results.

        Calculates scores based on the selected method (RULA/REBA) and loads
        the resulting data into the [`ReportView`][gui.views.report_view.ReportView].

        Args:
            analysis_request (AnalysisRequest): Parameters configuring the method and frame export triggers.
        """
        self._pending_analysis_request = analysis_request
        self.report_window.set_method(analysis_request.method)

        self.sidebar.btn_analysis.setEnabled(False)
        self.sidebar.set_status(
            self.tr("Starting {} analysis...").format(analysis_request.method.value)
        )

        self.backend.run_analysis(method=analysis_request.method)

    @Slot(AnalysisResult)
    def _handle_analysis_finished(self, result: AnalysisResult) -> None:
        """Handles the `analysis_finished` signal from the backend.

        Processes the [`AnalysisResult`][gui.utils.models.AnalysisResult] emitted by the
        backend after the asynchronous calculation completes. It updates the UI state,
        triggers frame exports if requested, and displays the report window. This slot
        always executes on the main UI thread via a queued connection.

        Args:
            result (AnalysisResult): The analysis result containing the success status,
                message, output path, and optional scores or stats.
        """
        import threading

        logger.debug(
            f"Frontend: _handle_analysis_finished on thread: {threading.current_thread().name}"
        )

        self.sidebar.btn_analysis.setEnabled(True)

        msg = result.message if result.message else "Analysis completed"
        self.sidebar.set_status(self.tr(msg))

        if result.success and result.output_path:
            if (
                hasattr(self, "_pending_analysis_request")
                and self._pending_analysis_request
            ):
                if self._pending_analysis_request.export_frames:
                    self.handle_headless_export()
                self._pending_analysis_request = None

            self.report_window.backend.load_data_and_run(file_path=result.output_path)

            if self.report_window.isHidden():
                self.report_window.show()
            else:
                self.report_window.raise_()
                self.report_window.activateWindow()
        else:
            logger.warning(f"Analysis failed: {result.message}")

    def _update_export_status(self, current: int, total: int) -> None:
        """
        Unified status formatter for processing progress indicators. (1/10 or 10%)

        Args:
            current (int): Current frame index processed.
            total (int): Comprehensive index total.

        Returns:
            None (None): Textually reformats the application sidebar status bar.
        """
        percent = (current / total) * 100 if total > 0 else 0
        status_msg = f"⏳ Exporting Frames: {current}/{total} frames ({percent:.1f}%)"
        self.sidebar.set_status(status_msg)

    def handle_headless_export(self) -> None:
        """
        Gathers UI state parameters and delegates frame export processing to the backend.

        Returns:
            None (None): Launches asynchronous background calculations.
        """
        video_name: str = self.sidebar.get_current_video()
        session_name: str = self.sidebar.get_current_session()

        # Delegate core tracking work down to the backend controller
        self.backend.export_headless_frames(
            session_name=session_name, video_name=video_name
        )
Functions
_handle_analysis_finished(result)

Handles the analysis_finished signal from the backend.

Processes the AnalysisResult emitted by the backend after the asynchronous calculation completes. It updates the UI state, triggers frame exports if requested, and displays the report window. This slot always executes on the main UI thread via a queued connection.

Parameters:

Name Type Description Default
result AnalysisResult

The analysis result containing the success status, message, output path, and optional scores or stats.

required
Source code in gui\frontend.py
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
@Slot(AnalysisResult)
def _handle_analysis_finished(self, result: AnalysisResult) -> None:
    """Handles the `analysis_finished` signal from the backend.

    Processes the [`AnalysisResult`][gui.utils.models.AnalysisResult] emitted by the
    backend after the asynchronous calculation completes. It updates the UI state,
    triggers frame exports if requested, and displays the report window. This slot
    always executes on the main UI thread via a queued connection.

    Args:
        result (AnalysisResult): The analysis result containing the success status,
            message, output path, and optional scores or stats.
    """
    import threading

    logger.debug(
        f"Frontend: _handle_analysis_finished on thread: {threading.current_thread().name}"
    )

    self.sidebar.btn_analysis.setEnabled(True)

    msg = result.message if result.message else "Analysis completed"
    self.sidebar.set_status(self.tr(msg))

    if result.success and result.output_path:
        if (
            hasattr(self, "_pending_analysis_request")
            and self._pending_analysis_request
        ):
            if self._pending_analysis_request.export_frames:
                self.handle_headless_export()
            self._pending_analysis_request = None

        self.report_window.backend.load_data_and_run(file_path=result.output_path)

        if self.report_window.isHidden():
            self.report_window.show()
        else:
            self.report_window.raise_()
            self.report_window.activateWindow()
    else:
        logger.warning(f"Analysis failed: {result.message}")
_handle_canvas_seek(frame_idx)

Handles the seek request from the video canvas overlay.

Parameters:

Name Type Description Default
frame_idx int

Target frame absolute indexing point.

required

Returns:

Name Type Description
None None

Dispatches a structured VideoControl request.

Source code in gui\frontend.py
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
@Slot(int)
def _handle_canvas_seek(self, frame_idx: int) -> None:
    """
    Handles the seek request from the video canvas overlay.

    Args:
        frame_idx (int): Target frame absolute indexing point.

    Returns:
        None (None): Dispatches a structured `VideoControl` request.
    """
    self.backend.video_control_requested.emit(
        VideoControl(
            command=VideoCommand.SEEK,
            target_frame=frame_idx,
        ),
    )
    self.sidebar.set_status(f"Seeking to frame: {frame_idx}")
_reconnect_video_signals()

Helper to safely handle frame connections.

Ensures that the frame_ready signal from the backend is correctly routed to the VideoCanvas.

Returns:

Name Type Description
None None

Re-establishes signal connections.

Source code in gui\frontend.py
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
def _reconnect_video_signals(self) -> None:
    """
    Helper to safely handle frame connections.

    Ensures that the `frame_ready` signal from the backend is correctly
    routed to the [VideoCanvas][gui.widgets.video_canvas.VideoCanvas].

    Returns:
        None (None): Re-establishes signal connections.
    """
    try:
        self.backend.frame_ready.disconnect()
        self.backend.position_changed.disconnect()
    except (RuntimeError, TypeError):
        pass

    # Connect the image to the canvas
    self.backend.frame_ready.connect(self.canvas.update_frame)

    # Connect the seeker data (current/total frames) to the canvas
    # This makes the progress bar actually move!
    self.backend.position_changed.connect(self.canvas.update_position)
_update_export_status(current, total)

Unified status formatter for processing progress indicators. (1/10 or 10%)

Parameters:

Name Type Description Default
current int

Current frame index processed.

required
total int

Comprehensive index total.

required

Returns:

Name Type Description
None None

Textually reformats the application sidebar status bar.

Source code in gui\frontend.py
796
797
798
799
800
801
802
803
804
805
806
807
808
809
def _update_export_status(self, current: int, total: int) -> None:
    """
    Unified status formatter for processing progress indicators. (1/10 or 10%)

    Args:
        current (int): Current frame index processed.
        total (int): Comprehensive index total.

    Returns:
        None (None): Textually reformats the application sidebar status bar.
    """
    percent = (current / total) * 100 if total > 0 else 0
    status_msg = f"⏳ Exporting Frames: {current}/{total} frames ({percent:.1f}%)"
    self.sidebar.set_status(status_msg)
connect_signals()

Wiring the Sidebar Public API to the existing MainWindow handlers.

Returns:

Name Type Description
None None

Establishes Qt signal-slot connections.

Source code in gui\frontend.py
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
def connect_signals(self) -> None:
    """
    Wiring the Sidebar Public API to the existing MainWindow handlers.

    Returns:
        None (None): Establishes Qt signal-slot connections.
    """
    s = self.sidebar

    # Connect internal sidebar signals to MainWindow handlers
    s.btn_fmc.clicked.connect(self.handle_run_fmc)
    s.btn_select_root.clicked.connect(self.handle_select_root)
    s.combo_sessions.currentIndexChanged.connect(self.handle_session_selected)
    s.run_analysis_clicked.connect(self.run_analysis)
    s.btn_report.clicked.connect(self.show_report)
    s.btn_load_video.clicked.connect(self.handle_load_video)
    s.btn_play_video.clicked.connect(self.handle_toggle_video)
    s.combo_videos.currentIndexChanged.connect(self.handle_video_selection_changed)

    # --- NEW CANVAS INTERACTION CONNECTIONS ---
    c = self.canvas
    # When you click the seeker bar on the video
    c.seek_requested.connect(self._handle_canvas_seek)
    # When you click the video (not the bar) to play/pause
    c.toggle_requested.connect(self.handle_toggle_video)

    # New Button Connections
    s.btn_prev_frame.clicked.connect(lambda: self.step_video(-1))
    s.btn_next_frame.clicked.connect(lambda: self.step_video(1))

    self.backend.status_updated.connect(self.sidebar.set_status)

    self.backend.analysis_finished.connect(
        self._handle_analysis_finished,
        type=Qt.ConnectionType.QueuedConnection,  # ← CRITICAL: UI updates must run on main thread
    )
handle_headless_export()

Gathers UI state parameters and delegates frame export processing to the backend.

Returns:

Name Type Description
None None

Launches asynchronous background calculations.

Source code in gui\frontend.py
811
812
813
814
815
816
817
818
819
820
821
822
823
824
def handle_headless_export(self) -> None:
    """
    Gathers UI state parameters and delegates frame export processing to the backend.

    Returns:
        None (None): Launches asynchronous background calculations.
    """
    video_name: str = self.sidebar.get_current_video()
    session_name: str = self.sidebar.get_current_session()

    # Delegate core tracking work down to the backend controller
    self.backend.export_headless_frames(
        session_name=session_name, video_name=video_name
    )
handle_import()

Opens a file dialog to manually import joint coordinate data.

Returns:

Name Type Description
None None

Updates the backend data buffer.

Source code in gui\frontend.py
699
700
701
702
703
704
705
706
707
708
709
710
711
712
@Slot()
def handle_import(self) -> None:
    """
    Opens a file dialog to manually import joint coordinate data.

    Returns:
        None (None): Updates the backend data buffer.
    """
    path, _ = QFileDialog.getOpenFileName(
        self, self.tr("Select Data"), "", self.tr("Data (*.csv *.xlsx *.npy)")
    )
    if path:
        success, msg = self.backend.import_joint_data(path)
        self.sidebar.set_status(self.tr("{}").format(msg))
handle_load_recording()

Placeholder for loading an existing historical recording session.

Returns:

Name Type Description
None None

Not yet implemented.

Source code in gui\frontend.py
255
256
257
258
259
260
261
262
def handle_load_recording(self) -> None:
    """
    Placeholder for loading an existing historical recording session.

    Returns:
        None (None): Not yet implemented.
    """
    pass
handle_load_video()

Opens a file dialog to manually browse and select a video file.

Returns:

Name Type Description
None None

Updates the backend video source and UI status.

Source code in gui\frontend.py
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
@Slot()
def handle_load_video(self) -> None:
    """
    Opens a file dialog to manually browse and select a video file.

    Returns:
        None (None): Updates the backend video source and UI status.
    """
    session_name = self.sidebar.get_current_session()
    initial_path: str = (
        str(ErgoPaths.video_folder(session_name))
        if session_name
        else str(ErgoPaths.SESSIONS)
    )

    path, _ = QFileDialog.getOpenFileName(
        self,
        self.tr("Select Video"),
        initial_path,
        self.tr("Videos (*.mp4 *.avi *.mov *.mkv)"),
    )
    if path:
        video_result = self.backend.load_video_source(path)

        self.sidebar.set_status(self.tr("{}").format(video_result.message))
        if video_result.success:
            self._reconnect_video_signals()
            self.sidebar.btn_play_video.setEnabled(True)
            self.sidebar.btn_next_frame.setEnabled(True)
            self.sidebar.btn_prev_frame.setEnabled(True)
handle_new_recording()

Placeholder for starting a new motion capture recording session.

Returns:

Name Type Description
None None

Not yet implemented.

Source code in gui\frontend.py
246
247
248
249
250
251
252
253
def handle_new_recording(self) -> None:
    """
    Placeholder for starting a new motion capture recording session.

    Returns:
        None (None): Not yet implemented.
    """
    pass
handle_reboot()

Restarts the application by executing a new Python process.

Returns:

Name Type Description
None None

Terminates the current process.

Source code in gui\frontend.py
182
183
184
185
186
187
188
189
190
def handle_reboot(self) -> None:
    """
    Restarts the application by executing a new Python process.

    Returns:
        None (None): Terminates the current process.
    """
    python = sys.executable  # nosec B606 TODO replace if possible
    os.execl(python, python, *sys.argv)  # nosec B606 TODO replace if possible
handle_run_fmc()

Triggers FreeMoCap processing with a forced 2-second visual delay.

Returns:

Type Description
None

None

Source code in gui\frontend.py
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
@Slot()
def handle_run_fmc(self) -> None:
    """Triggers FreeMoCap processing with a forced 2-second visual delay.

    Returns:
        None
    """
    self.wait_dialog = QDialog(parent=self)
    self.wait_dialog.setWindowModality(Qt.WindowModality.WindowModal)

    layout = QVBoxLayout()
    layout.addWidget(
        QLabel(
            self.tr("Initializing FreeMoCap... Please wait until it opens."),
            self.wait_dialog,
        )
    )

    self.wait_dialog.setLayout(layout)
    self.wait_dialog.show()
    QCoreApplication.processEvents()

    start_time = time.time()
    success, msg = self.backend.launch_freemocap()
    self.sidebar.set_status(self.tr("{}").format(msg))

    elapsed = time.time() - start_time
    if elapsed < 3.0:
        time.sleep(3.0 - elapsed)

    self.wait_dialog.accept()
    self.wait_dialog.deleteLater()
handle_select_root()

Updates the backend root and refreshes the session list.

Opens a QFileDialog for the user to select a new directory.

Returns:

Name Type Description
None None

Updates the ErgoSidebar.

Source code in gui\frontend.py
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
@Slot()
def handle_select_root(self) -> None:
    """
    Updates the backend root and refreshes the session list.

    Opens a `QFileDialog` for the user to select a new directory.

    Returns:
        None (None): Updates the [ErgoSidebar][gui.widgets.sidebar.ErgoSidebar].
    """
    root_path: str | None = QFileDialog.getExistingDirectory(
        self, self.tr("Select FreeMoCap Data Folder")
    )
    if root_path:
        chosen_path = Path(root_path)
        ErgoPaths.update_user_root(chosen_path)  # resets constant class paths

        sessions_folder: Path = Path(root_path) / ErgoPaths.SESSIONS_FOLDER_NAME
        sessions: list[str] = self.backend.set_root_and_scan(sessions_folder)
        self.sidebar.update_sessions(sessions)
        self.sidebar.set_status(self.tr("Found {} sessions.").format(len(sessions)))
handle_session_selected()

Loads metadata and populates videos for the selected session.

Returns:

Name Type Description
None None

Updates backend state and UI enabled/disabled statuses.

Source code in gui\frontend.py
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
@Slot()
def handle_session_selected(self) -> None:
    """
    Loads metadata and populates videos for the selected session.

    Returns:
        None (None): Updates backend state and UI enabled/disabled statuses.
    """
    session_name: str = self.sidebar.get_current_session()
    if not session_name or session_name == "":
        return

    # Clear previous state to avoid analyzing old data if new load fails
    self.sidebar.set_status(self.tr("Loading session data..."))

    session_data = self.backend.load_session_automatically(session_name)

    if session_data.success:
        videos_num: int = len(session_data.video_paths)
        if session_data.video_paths and videos_num > 0:
            self.sidebar.update_videos(session_data.video_paths)
            self.handle_video_selection_changed()

        self.sidebar.btn_play_video.setEnabled(True)
        self.sidebar.btn_next_frame.setEnabled(True)
        self.sidebar.btn_prev_frame.setEnabled(True)
        self.sidebar.btn_analysis.setEnabled(True)  # Ensure this is enabled
        self.sidebar.set_status(
            self.tr("Session Loaded: {}. Found {} videos").format(
                session_name, videos_num
            )
        )

    else:
        # This is likely where your error is happening
        self.sidebar.set_status(self.tr("ERROR: {}").format(session_data.message))
        self.sidebar.btn_analysis.setEnabled(False)
handle_toggle_video()

Toggles Play/Pause state of the video playback.

Returns:

Name Type Description
None None

Updates the UI status label and backend playback state.

Source code in gui\frontend.py
652
653
654
655
656
657
658
659
660
661
662
663
664
@Slot()
def handle_toggle_video(self) -> None:
    """
    Toggles Play/Pause state of the video playback.

    Returns:
        None (None): Updates the UI status label and backend playback state.
    """
    self.backend.video_control_requested.emit(
        VideoControl(
            command=VideoCommand.TOGGLE,
        )
    )
handle_video_selection_changed()

Loads a specific video file into the backend based on sidebar selection.

Returns:

Name Type Description
None None

Updates the video source in ErgoBackend.

Source code in gui\frontend.py
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
@Slot()
def handle_video_selection_changed(self) -> None:
    """
    Loads a specific video file into the backend based on sidebar selection.

    Returns:
        None (None): Updates the video source in [ErgoBackend][gui.backend.backend.ErgoBackend].
    """
    video_name: str = self.sidebar.get_current_video()
    session_name: str = self.sidebar.get_current_session()

    if not video_name or not session_name:
        return

    video_path: Path = ErgoPaths.video_folder(session_name) / video_name

    if not video_path.exists():
        self.sidebar.set_status(f"ERROR: Video not found at {video_path.name}")
        return

    # Safety: Reconnect frame signals
    self._reconnect_video_signals()

    video_result = self.backend.load_video_source(str(video_path))
    if video_result.success:
        self.sidebar.btn_play_video.setEnabled(True)
        self.sidebar.set_status(self.tr("Loaded {}").format(video_name))
init_root()

Initializes the data root from the backend configuration on startup.

Scans the default directory for sessions and attempts to load assets (CSV data and video) for the first found session.

Returns:

Name Type Description
None None

Populates UI widgets with initial data.

Source code in gui\frontend.py
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
def init_root(self) -> None:
    """
    Initializes the data root from the backend configuration on startup.

    Scans the default directory for sessions and attempts to load assets
    (CSV data and video) for the first found session.

    Returns:
        None (None): Populates UI widgets with initial data.
    """
    root_path: Path | None = ErgoPaths.SESSIONS
    if not root_path:
        return

    sessions: list[str] = self.backend.set_root_and_scan(root_path)

    if not sessions:
        logger.warning(
            "No Session Data Found. Check if the root folder is the correct 'freemocap_data' folder."
        )
        self.sidebar.set_status(
            self.tr(
                "No Session Data Found. Check if the root folder is the correct one."
            )
        )
        return

    # Update UI with available sessions
    self.sidebar.update_sessions(sessions)

    # Attempt to automatically load the first session
    session_data = self.backend.load_session_automatically(sessions[0])

    if not session_data:
        logger.warning(
            "No Session Data Found. Check if the root folder is the correct 'freemocap_data' folder."
        )
        return

    if not session_data.success:
        self.sidebar.set_status(
            self.tr("Error during initialization: No Success {}.").format(
                session_data.message
            )
        )
        return

    if not session_data.video_paths or len(session_data.video_paths) == 0:
        self.sidebar.set_status(
            self.tr("Error during initialization: No Videos {}.").format(
                session_data.message
            )
        )
        return

    # Populates UI with videos found
    self.sidebar.update_videos(session_data.video_paths)

    # Safe to extract target video now that length check has passed
    target_video = session_data.video_paths[0]
    video_result = self.backend.load_video_source(target_video)

    # Check video loading outcome
    if video_result.success:
        self.handle_video_selection_changed()
        # Set the final successful status message here so it doesn't get overwritten unexpectedly
        self.sidebar.set_status(
            self.tr("Found {} sessions. Loaded {} videos").format(
                len(sessions), len(session_data.video_paths)
            )
        )
    else:
        self.sidebar.set_status(
            self.tr("Video Load Error: {}").format(video_result.message)
        )
keyPressEvent(event)

Overrides standard key presses to handle shortcut bindings.

Maps arrow keys to frame stepping and spacebar to video toggle commands.

Parameters:

Name Type Description Default
event QKeyEvent

The incoming key keyboard event configuration.

required

Returns:

Name Type Description
None None

Accepts or passes the incoming key event structure.

Source code in gui\frontend.py
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
def keyPressEvent(self, event) -> None:
    """
    Overrides standard key presses to handle shortcut bindings.

    Maps arrow keys to frame stepping and spacebar to video toggle commands.

    Args:
        event (QKeyEvent): The incoming key keyboard event configuration.

    Returns:
        None (None): Accepts or passes the incoming key event structure.
    """

    # Ensure we have a video thread running before trying to step
    if not hasattr(self, "backend") or not self.backend.video_worker:
        super().keyPressEvent(event)
        return

    key = event.key()

    if key == Qt.Key.Key_Left:
        self.step_video(-1)
        event.accept()
    elif key == Qt.Key.Key_Right:
        self.step_video(1)
        event.accept()
    elif key == Qt.Key.Key_Space:
        self.handle_toggle_video()
        event.accept()
    else:
        super().keyPressEvent(event)
kill_running_threads()

Safely terminates active backend threads to prevent memory leaks or crashes.

Returns:

Name Type Description
None None

Stops background threads.

Source code in gui\frontend.py
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
def kill_running_threads(self) -> None:
    """
    Safely terminates active backend threads to prevent memory leaks or crashes.

    Returns:
        None (None): Stops background threads.
    """
    killed_threads: str = ""

    # 1. Safely stop video playback thread using native QThread methods
    if hasattr(self.backend, "video_thread") and self.backend.video_thread:
        if self.backend.video_thread.isRunning():
            self.backend.video_thread.quit()
            self.backend.video_thread.wait()
            killed_threads += "- video_thread\n"

    # 2. Safely clean up the background frame exporter thread if it's currently processing
    if hasattr(self.backend, "export_thread") and self.backend.export_thread:
        try:
            if self.backend.export_thread.isRunning():
                # 1. Break internal cv2 worker loop safely
                if (
                    hasattr(self.backend, "export_worker")
                    and self.backend.export_worker
                ):
                    self.backend.export_worker.stop()

                # 2. Tear down thread infrastructure
                self.backend.export_thread.quit()

                if not self.backend.export_thread.wait(2000):
                    self.backend.export_thread.terminate()
                    self.backend.export_thread.wait()

                killed_threads += "- export_thread\n"
        except RuntimeError:
            # Catch and dismiss cases where C++ lifecycle already ended
            pass

    self.sidebar.set_status(f"Threads safely terminated:\n{killed_threads}")
open_docs()

Opens the locally shipped documentation homepage.

Returns:

Name Type Description
None None

Launches system default desktop browser.

Source code in gui\frontend.py
274
275
276
277
278
279
280
281
282
def open_docs(self) -> None:
    """
    Opens the locally shipped documentation homepage.

    Returns:
        None (None): Launches system default desktop browser.
    """
    url = ErgoPaths.get_local_site_url(page_name="index.html")
    QDesktopServices.openUrl(url)
open_settings()

Placeholder for configuring application preferences and window paths.

Returns:

Name Type Description
None None

Not yet implemented.

Source code in gui\frontend.py
264
265
266
267
268
269
270
271
272
def open_settings(self) -> None:
    """
    Placeholder for configuring application preferences and window paths.

    Returns:
        None (None): Not yet implemented.
    """
    # TODO make setting view and open as secondary window
    pass
open_source()

Opens the live GitHub repository on the web.

Returns:

Name Type Description
None None

Launches system default desktop browser.

Source code in gui\frontend.py
294
295
296
297
298
299
300
301
302
def open_source(self) -> None:
    """
    Opens the live GitHub repository on the web.

    Returns:
        None (None): Launches system default desktop browser.
    """
    url = QUrl("https://github.com/freemocap/freemocap")
    QDesktopServices.openUrl(url)
open_tutorial()

Opens the locally shipped tutorial page.

Returns:

Name Type Description
None None

Launches system default desktop browser.

Source code in gui\frontend.py
284
285
286
287
288
289
290
291
292
def open_tutorial(self) -> None:
    """
    Opens the locally shipped tutorial page.

    Returns:
        None (None): Launches system default desktop browser.
    """
    url = ErgoPaths.get_local_site_url(page_name="tutorial.html")
    QDesktopServices.openUrl(url)
run_analysis(analysis_request)

Executes the ergonomic assessment and opens the results.

Calculates scores based on the selected method (RULA/REBA) and loads the resulting data into the ReportView.

Parameters:

Name Type Description Default
analysis_request AnalysisRequest

Parameters configuring the method and frame export triggers.

required
Source code in gui\frontend.py
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
@Slot(AnalysisRequest)
def run_analysis(self, analysis_request: AnalysisRequest) -> None:
    """Executes the ergonomic assessment and opens the results.

    Calculates scores based on the selected method (RULA/REBA) and loads
    the resulting data into the [`ReportView`][gui.views.report_view.ReportView].

    Args:
        analysis_request (AnalysisRequest): Parameters configuring the method and frame export triggers.
    """
    self._pending_analysis_request = analysis_request
    self.report_window.set_method(analysis_request.method)

    self.sidebar.btn_analysis.setEnabled(False)
    self.sidebar.set_status(
        self.tr("Starting {} analysis...").format(analysis_request.method.value)
    )

    self.backend.run_analysis(method=analysis_request.method)
safe_close()

Safely terminates running backend threads and closes the application window.

Returns:

Name Type Description
None None

Closes the window hierarchy.

Source code in gui\frontend.py
235
236
237
238
239
240
241
242
243
244
def safe_close(self) -> None:
    """
    Safely terminates running backend threads and closes the application window.

    Returns:
        None (None): Closes the window hierarchy.
    """
    # TODO add docsrings and include this function in MainWindow Docstringas
    self.kill_running_threads()
    self.close()
setup_ui()

Constructs the main layout, widgets, and signal-slot connections.

This method initializes the central widget, the VideoCanvas, the ErgoSidebar, and the application MenuBar.

Returns:

Name Type Description
None None

Modifies the MainWindow state.

Source code in gui\frontend.py
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
def setup_ui(self) -> None:
    """
    Constructs the main layout, widgets, and signal-slot connections.

    This method initializes the central widget, the `VideoCanvas`, the
    `ErgoSidebar`, and the application `MenuBar`.

    Returns:
        None (None): Modifies the `MainWindow` state.
    """
    central: QWidget = QWidget()
    self.setCentralWidget(central)

    self.setFocusPolicy(Qt.FocusPolicy.StrongFocus)

    master_layout: QVBoxLayout = QVBoxLayout(central)
    master_layout.setContentsMargins(0, 0, 0, 0)
    master_layout.setSpacing(0)

    # 2. CONTENT AREA
    content_area: QWidget = QWidget()
    content_layout: QHBoxLayout = QHBoxLayout(content_area)
    content_layout.setContentsMargins(0, 0, 0, 0)
    content_layout.setSpacing(0)

    # --- VIDEO AREA ---
    self.canvas: VideoCanvas = VideoCanvas()
    content_layout.addWidget(self.canvas, 1)

    # Add Content Area to Master Layout
    master_layout.addWidget(content_area)

    # 1. Plug in the menu from the other file
    self.menu_actions = MenuActions(self)

    self._menu_bar = MenuBar(actions=self.menu_actions, parent=self)
    self.setMenuBar(self._menu_bar)

    self.sidebar = ErgoSidebar(self)
    self.addDockWidget(Qt.DockWidgetArea.LeftDockWidgetArea, self.sidebar)
show_report()

Displays the ReportView window and updates it with the current method.

Returns:

Name Type Description
None None

Shows or raises the report_window.

Source code in gui\frontend.py
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
@Slot()
def show_report(self) -> None:
    """
    Displays the ReportView window and updates it with the current method.

    Returns:
        None (None): Shows or raises the `report_window`.
    """
    selected_method: str = self.sidebar.get_selected_method().upper()
    method: AssessmentMethod = AssessmentMethod[selected_method]
    self.report_window.set_method(method)
    self.report_window.update_current_strategy()

    if self.report_window.isHidden():
        self.report_window.show()
    else:
        self.report_window.raise_()
        self.report_window.activateWindow()
step_video(delta)

Sends a relative step request to the backend safely across threads.

Parameters:

Name Type Description Default
delta int

The number of frames to step (positive for forward, negative for backward).

required

Returns:

Name Type Description
None None

Emits cross-thread signaling.

Source code in gui\frontend.py
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
def step_video(self, delta: int) -> None:
    """
    Sends a relative step request to the backend safely across threads.

    Args:
        delta (int): The number of frames to step (positive for forward, negative for backward).

    Returns:
        None (None): Emits cross-thread signaling.
    """
    command = VideoCommand.STEP_FORWARD if delta > 0 else VideoCommand.STEP_BACKWARD

    # Emitting a copy-safe dataclass across threads is 100% thread-safe!
    self.backend.video_control_requested.emit(VideoControl(command=command))
    self.sidebar.set_status(f"Stepped {command.name}")
toggle_sidebar()

Toggle the visibility of the sidebar.

Returns:

Name Type Description
None None

Updates the visibility state of the sidebar widget.

Source code in gui\frontend.py
358
359
360
361
362
363
364
365
def toggle_sidebar(self) -> None:
    """
    Toggle the visibility of the sidebar.

    Returns:
        None (None): Updates the visibility state of the `sidebar` widget.
    """
    self.sidebar.setVisible(not self.sidebar.isVisible())
toggle_theme()

Switches the application stylesheet between dark and light modes.

Returns:

Name Type Description
None None

Updates the QApplication stylesheet and theme icons.

Source code in gui\frontend.py
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
def toggle_theme(self) -> None:
    """
    Switches the application stylesheet between dark and light modes.

    Returns:
        None (None): Updates the `QApplication` stylesheet and theme icons.
    """

    self.current_theme = (
        ErgoTheme.LIGHT if self.current_theme == ErgoTheme.DARK else ErgoTheme.DARK
    )
    app = QApplication.instance()
    if isinstance(app, QApplication):
        app.setStyleSheet(get_stylesheet(self.current_theme))
        icon: str = "☀️" if self.current_theme == ErgoTheme.LIGHT else "🌓"
        self._menu_bar.theme_btn.setText(icon)

Functions

options: show_root_heading: true

gui.views.report_view

ErgoMoCap: Report View

Analytics Dashboard and Visualization Module for Ergonomic Assessment.

This module implements the ReportView class, the primary user interface for post-analysis data review. It integrates Matplotlib for data visualization, Pandas for dataset manipulation, and a Strategy-based reporting widget to display multi-method results (RULA/REBA).

The view supports professional document generation (PDF/DOCX) by communicating with the ReportBackend.

Key Features
  • Data visualization using Matplotlib pie charts.
  • Dynamic metric calculation via Pandas.
  • Professional reporting in PDF (via Jinja2/QtPrintSupport) and DOCX (via DocxTemplate).

Classes

ReportView

Bases: QMainWindow

The main dashboard window for ergonomic report generation and visualization.

This class provides a comprehensive interface to load analysis datasets, visualize risk distributions through interactive charts, and export data into medical-grade reports.

Attributes:

Name Type Description
current_theme ErgoTheme

The active UI theme configuration.

current_method AssessmentMethod

The active assessment protocol (e.g., REBA, RULA).

current_strategy RebaStrategy | RulaStrategy

The active evaluation strategy logic.

backend ReportBackend

The processing engine for data and exports.

current_file Path | None

File path to the active dataset.

sidebar QFrame

The navigation and control sidebar widget.

btn_import QPushButton

Button to trigger data loading.

btn_pdf QPushButton

Button to trigger PDF report export.

btn_docx QPushButton

Button to trigger Word document export.

file_info QTextEdit

Text area displaying information about the loaded file.

card_total QFrame

Dashboard card displaying total frames processed.

card_avg QFrame

Dashboard card displaying average risk score.

chart_risk ChartReportWidget

Matplotlib canvas for risk level distribution.

chart_score ChartReportWidget

Matplotlib canvas for total score frequency.

report_widget TableReportWidget

The dynamic table display for metrics.

Methods:

Name Description
__init__

Initialize the Report View dashboard.

_setup_ui

Initializes the graphical user interface layout and components.

_create_stat_card

Factory method to create a stylized 'Stat Card' for the dashboard.

_connect_signals

Connects UI signals to their respective backend slots and handlers.

_handle_export_success

Slot to handle the UI notification after a successful file export.

_handle_error

Slot to handle and display error messages from the backend.

_on_data_ready

Internal slot triggered when the backend finishes processing data.

_handle_import_dialog

Triggers a QFileDialog to allow users to select a new data source.

_handle_pdf_request

GUI-side handling of PDF printing requests.

_print_pdf

Renders the generated HTML report to a PDF file using Qt's print system.

_handle_docx_request

Trigger backend export with a screenshot of the current chart.

_update_charts

Logic outsourced to specialized chart widgets for Matplotlib rendering.

set_method

Update the active ergonomic assessment method.

update_current_strategy

Synchronize the UI strategy with the currently selected assessment method.

Source code in gui\views\report_view.py
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
class ReportView(QMainWindow):
    """
    The main dashboard window for ergonomic report generation and visualization.

    This class provides a comprehensive interface to load analysis datasets,
    visualize risk distributions through interactive charts, and export data
    into medical-grade reports.

    Attributes:
        current_theme (ErgoTheme): The active UI theme configuration.
        current_method (AssessmentMethod): The active assessment protocol (e.g., REBA, RULA).
        current_strategy (RebaStrategy | RulaStrategy): The active evaluation strategy logic.
        backend (gui.backend.report_backend.ReportBackend): The processing engine for data and exports.
        current_file (Path | None): File path to the active dataset.
        sidebar (QFrame): The navigation and control sidebar widget.
        btn_import (QPushButton): Button to trigger data loading.
        btn_pdf (QPushButton): Button to trigger PDF report export.
        btn_docx (QPushButton): Button to trigger Word document export.
        file_info (QTextEdit): Text area displaying information about the loaded file.
        card_total (QFrame): Dashboard card displaying total frames processed.
        card_avg (QFrame): Dashboard card displaying average risk score.
        chart_risk (gui.widgets.chart_report_widget.ChartReportWidget): Matplotlib canvas for risk level distribution.
        chart_score (gui.widgets.chart_report_widget.ChartReportWidget): Matplotlib canvas for total score frequency.
        report_widget (gui.widgets.table_report_widget.TableReportWidget): The dynamic table display for metrics.

    Methods:
        __init__: Initialize the Report View dashboard.
        _setup_ui: Initializes the graphical user interface layout and components.
        _create_stat_card: Factory method to create a stylized 'Stat Card' for the dashboard.
        _connect_signals: Connects UI signals to their respective backend slots and handlers.
        _handle_export_success: Slot to handle the UI notification after a successful file export.
        _handle_error: Slot to handle and display error messages from the backend.
        _on_data_ready: Internal slot triggered when the backend finishes processing data.
        _handle_import_dialog: Triggers a QFileDialog to allow users to select a new data source.
        _handle_pdf_request: GUI-side handling of PDF printing requests.
        _print_pdf: Renders the generated HTML report to a PDF file using Qt's print system.
        _handle_docx_request: Trigger backend export with a screenshot of the current chart.
        _update_charts: Logic outsourced to specialized chart widgets for Matplotlib rendering.
        set_method: Update the active ergonomic assessment method.
        update_current_strategy: Synchronize the UI strategy with the currently selected assessment method.
    """

    #############################################
    # --- Start of Initialization Functions --- #

    def __init__(
        self,
        parent,
        initial_csv: str | None = None,
        current_theme=ErgoTheme.DARK,
        current_method=AssessmentMethod.REBA,
    ) -> None:
        """
        Initialize the Report View dashboard.

        Args:
            parent (QWidget | None): The parent widget of this window.
            initial_csv (str | None): Optional path to a CSV file to load on startup.
            current_theme (ErgoTheme): The initial theme identifier. Defaults to "dark".
            current_method (AssessmentMethod): The initial assessment method. Defaults to AssessmentMethod.REBA.

        Returns:
            None (None): Initializer return.
        """
        super().__init__(parent)

        self.setWindowFlags(Qt.WindowType.Window)

        self.current_theme: ErgoTheme = current_theme
        self.current_method: AssessmentMethod = current_method
        self.current_strategy = RebaStrategy()
        self.backend = ReportBackend()
        self.current_file: Path | None = Path(initial_csv) if initial_csv else None

        self.setWindowTitle(self.tr("ErgoMoCap Reports"))
        self.resize(1280, 720)
        icon_path: Path = ErgoPaths.LOGO
        self.setWindowIcon(QIcon(str(icon_path)))

        self._setup_ui()
        self._connect_signals()
        self.update_current_strategy()

        if self.current_file:
            self.backend.load_data_and_run(self.current_file)

    def _setup_ui(self) -> None:
        """
        Initializes the graphical user interface layout and components.

        This method constructs a hierarchical nested layout consisting of:
        1. A top-level vertical layout to host the global menu_bar.
        2. A horizontal content area containing a Sidebar and a Dashboard.
        3. Stylized stat cards, Matplotlib canvases, and a reporting table.

        References internal components like [TableReportWidget][gui.widgets.table_report_widget.TableReportWidget].

        Returns:
            None (None): Modifies the window state in-place.

        Note:
            Requires the following instance attributes to be pre-initialized:
            - `THEMES`: A `dict` containing color hex codes.
            - `current_theme`: A `str` ("light" or "dark") for theme selection.
        """

        central_widget: QWidget = QWidget()
        self.setCentralWidget(central_widget)

        # ROOT LAYOUT (Vertical)
        root_layout: QVBoxLayout = QVBoxLayout(central_widget)
        root_layout.setContentsMargins(0, 0, 0, 0)
        root_layout.setSpacing(0)

        # CONTENT AREA (Horizontal)
        content_layout: QHBoxLayout = QHBoxLayout()
        content_layout.setSpacing(0)
        root_layout.addLayout(content_layout)

        # --- SIDEBAR ---
        self.sidebar: QFrame = QFrame()
        self.sidebar.setObjectName("Sidebar")
        self.sidebar.setFixedWidth(260)
        side_layout: QVBoxLayout = QVBoxLayout(self.sidebar)

        lbl_menu: QLabel = QLabel(self.tr("REPORT CONTROLS"))
        lbl_menu.setProperty("class", "h2")

        self.btn_import: QPushButton = QPushButton(self.tr("📁 LOAD DATA"))

        self.btn_pdf: QPushButton = QPushButton(self.tr("📜 EXPORT TO PDF"))
        self.btn_pdf.setEnabled(False)

        self.btn_docx: QPushButton = QPushButton(self.tr("📄 EXPORT TO DOCX"))
        self.btn_docx.setEnabled(False)

        side_layout.addWidget(lbl_menu)
        side_layout.addSpacing(20)
        side_layout.addWidget(self.btn_import)
        side_layout.addSpacing(10)
        side_layout.addWidget(self.btn_pdf)
        side_layout.addWidget(self.btn_docx)
        side_layout.addStretch()

        # Create a QTextEdit instead of a QLabel
        self.file_info: QTextEdit = QTextEdit()
        self.file_info.setReadOnly(True)
        self.file_info.setText(self.tr("No file loaded"))

        # Styling it to look like a label
        self.file_info.setFrameStyle(QFrame.Shape.NoFrame)
        self.file_info.viewport().setAutoFillBackground(False)
        self.file_info.setFixedHeight(150)

        side_layout.addWidget(self.file_info)

        # --- DASHBOARD ---
        dashboard = QWidget()
        dash_lay = QVBoxLayout(dashboard)

        # Stats
        stats_row = QHBoxLayout()
        self.card_total = self._create_stat_card(
            self.tr("TOTAL FRAMES"), "0", "total_val"
        )
        self.card_avg = self._create_stat_card(
            self.tr("AVERAGE SCORE"), "0.0", "avg_val"
        )
        stats_row.addWidget(self.card_total)
        stats_row.addWidget(self.card_avg)
        dash_lay.addLayout(stats_row)

        # Charts (Utilizing specialized ChartReportWidget)
        chart_row = QHBoxLayout()
        self.chart_risk = ChartReportWidget(self.current_theme)
        self.chart_score = ChartReportWidget(self.current_theme)
        chart_row.addWidget(self.chart_risk)
        chart_row.addWidget(self.chart_score)
        dash_lay.addLayout(chart_row)

        # Table
        self.report_widget = TableReportWidget(
            title=self.current_method.name, strategy=self.current_strategy
        )
        dash_lay.addWidget(self.report_widget)

        content_layout.addWidget(self.sidebar)
        content_layout.addWidget(dashboard)

    def _create_stat_card(self, title: str, value: str, internal_name: str) -> QFrame:
        """
        Factory method to create a stylized 'Stat Card' for the dashboard.

        Args:
            title (str): The label for the metric (e.g., "AVG REBA SCORE").
            value (str): The initial value to display.
            internal_name (str): The unique ID used to update the label text later.

        Returns:
            QFrame (QFrame): A stylized frame containing the metric labels.
        """
        card: QFrame = QFrame()
        card.setObjectName("blockquote")

        lay: QVBoxLayout = QVBoxLayout(card)

        v_lbl: QLabel = QLabel(value)
        v_lbl.setObjectName(internal_name)
        v_lbl.setProperty("class", "h3")

        t_lbl: QLabel = QLabel(title)
        t_lbl.setProperty("class", "text-muted")

        lay.addWidget(t_lbl)
        lay.addWidget(v_lbl)

        return card

    def _connect_signals(self) -> None:
        """
        Connects UI signals to their respective backend slots and handlers.

        Returns:
            None (None): Establishes signal-slot connections.
        """
        self.btn_import.clicked.connect(self._handle_import_dialog)
        self.btn_pdf.clicked.connect(self._handle_pdf_request)
        self.btn_docx.clicked.connect(self._handle_docx_request)
        self.backend.data_processed.connect(self._on_data_ready)
        self.backend.pdf_html_ready.connect(self._print_pdf)
        self.backend.report_export_finished.connect(self._handle_export_success)
        self.backend.error_occurred.connect(self._handle_error)

    # --- End of Initialization Functions --- #
    ###########################################

    #############################################
    # --- Slots for Backend Comunication
    #############################################

    @Slot(ReportExportResult)
    def _handle_export_success(self, report_export_result: ReportExportResult):
        """
        Slot to handle the UI notification after a successful file export.

        Args:
            report_export_result (ReportExportResult): Data class object containing details of the export result.

        Returns:
            None (None): Triggers a `QMessageBox`.
        """
        if report_export_result.success:
            QMessageBox.information(
                self, self.tr("Success"), report_export_result.message
            )

    @Slot(ErrorInfo)
    def _handle_error(self, error_info):
        """
        Slot to handle and display error messages from the backend.

        Args:
            error_info (ErrorInfo): Data class object containing the error title and descriptive message.

        Returns:
            None (None): Triggers a critical `QMessageBox`.
        """
        QMessageBox.critical(self, error_info.title, error_info.message)

    @Slot(ReportData)
    def _on_data_ready(self, report_data: ReportData) -> None:
        """
        Internal slot triggered when the backend finishes processing data.

        Args:
            report_data (ReportData): ReportData from report backend
        Returns:
            None (None): Populates the dashboard UI with live data.
        """
        # Pylance-safe casting for finding children in the themed cards
        total_lbl = self.card_total.findChild(QLabel, "total_val")
        if isinstance(total_lbl, QLabel):
            total_lbl.setText(str(report_data.total_frames))

        avg_lbl = self.card_avg.findChild(QLabel, "avg_val")
        if isinstance(avg_lbl, QLabel):
            avg_lbl.setText(f"{report_data.average_score:.2f}")

        # print(summary) TODO print_reactivate

        self.update_current_strategy()
        self.report_widget.update_results(report_data.summary_dict)
        self._update_charts(report_data.df)
        self.btn_pdf.setEnabled(True)
        self.btn_docx.setEnabled(True)
        self.file_info.setPlainText(
            f"Analysis Run on data from:\n{report_data.file_path}"
        )

    #############################################
    # --- Handle Import Requests (Import Buttons)
    #############################################

    def _handle_import_dialog(self) -> None:  # TODO fix not working
        """
        Triggers a QFileDialog to allow users to select a new data source.

        Supports .csv and .xlsx extensions. If a path is selected, it triggers
        the `load_data_and_run` method in the [ReportBackend][gui.backend.report_backend.ReportBackend].

        Returns:
            None (None): Updates the report backend with the new file path.
        """
        path: str | None = None
        path, _ = QFileDialog.getOpenFileName(
            self, self.tr("Open Data"), "", self.tr("Data Files (*.csv *.xlsx)")
        )
        if path:
            self.backend.load_data_and_run(Path(path))

    #############################################
    # --- Handle Export Requests (Export Buttons)
    #############################################

    def _handle_pdf_request(self) -> None:
        """
        GUI-side handling of PDF printing (requires access to Printer/Canvas).

        Captures the current Matplotlib buffer as `bytes` and passes it to the
        [ReportBackend][gui.backend.report_backend.ReportBackend] for template rendering.

        Returns:
            None (None): Triggers the PDF generation sequence.
        """

        # Capture chart as bytes to send to backend (thread-safe way to handle images)
        buf = BytesIO()
        self.chart_score.canvas.figure.savefig(
            buf, format="png"
        )  # using only chart_score score distribution (instead of risk dostribution)
        chart_bytes = buf.getvalue()

        self.backend.prepare_pdf_export(
            ReportExportRequest(save_path=Path(""), chart_data=chart_bytes)
        )

    def _print_pdf(self, html: str) -> None:
        """
        Renders the generated HTML report to a PDF file using Qt's print system.

        Args:
            html (str): The rendered HTML content from the Jinja2 template.

        Returns:
            None (None): Generates the physical PDF file.

        Raises:
            Exception (Exception): If the printer or document fails to write to the path.
        """

        try:
            filename, _ = QFileDialog.getSaveFileName(
                self, self.tr("Save PDF"), "", self.tr("PDF Files (*.pdf)")
            )
            if filename:
                doc: QTextDocument = QTextDocument()
                doc.setHtml(html)

                printer: QPrinter = QPrinter(QPrinter.PrinterMode.HighResolution)
                printer.setOutputFormat(QPrinter.OutputFormat.PdfFormat)
                printer.setOutputFileName(filename)

                doc.print_(printer)

                if (
                    printer.outputFileName() == filename
                ):  # Checks if user confirmed the save, pass if cancelled the save in file dialog
                    QMessageBox.information(
                        self, self.tr("Success"), self.tr("PDF Generated.")
                    )
                else:
                    pass
        except Exception as e:
            QMessageBox.critical(self, self.tr("Export Error"), str(e))

    def _handle_docx_request(self) -> None:
        """
        Trigger backend export with a screenshot of the current chart.

        Converts the `chart_risk` canvas into a `bytes` buffer for insertion into a
        DOCX template via the [ReportBackend][gui.backend.report_backend.ReportBackend].

        Returns:
            None (None): Initiates the Word document generation.
        """
        save_path, _ = QFileDialog.getSaveFileName(
            self, self.tr("Save Word"), "", "Word (*.docx)"
        )
        if not save_path:
            return

        # Capture chart as bytes to send to backend (thread-safe way to handle images)
        buf = BytesIO()
        self.chart_risk.canvas.figure.savefig(buf, format="png")
        chart_bytes = buf.getvalue()

        self.backend.export_to_docx(
            ReportExportRequest(save_path=Path(save_path), chart_data=chart_bytes)
        )

    #############################################
    # --- Charts Rendering with matplotlib
    #############################################

    def _update_charts(self, df: pd.DataFrame) -> None:
        """
        Logic outsourced to specialized chart widgets.

        Updates both the risk distribution and score frequency visualizations using
        the provided dataset.

        Args:
            df (pandas.DataFrame): The dataset containing ergonomic metrics.

        Returns:
            None (None): Redraws the Matplotlib canvases via the chart widgets.
        """
        self.chart_risk.update_chart(
            df,
            MetricType.RISK,
        )
        self.chart_score.update_chart(
            df,
            MetricType.SCORE,
        )

    #############################################
    # --- Public API to implement User Inputs ()
    #############################################

    def set_method(self, method: AssessmentMethod) -> None:
        """
        Update the active ergonomic assessment method.

        Args:
            method (AssessmentMethod): The name of the method to apply (e.g., AssessmentMethod.RULA, AssessmentMethod.REBA).

        Returns:
            None (None): Updates internal state and clears previous strategy if necessary.
        """
        if hasattr(self, "current_method") and self.current_method == method:
            return  # Don't recreate the strategy if it's the same
        if hasattr(self, "strategy"):
            self.strategy = None

        self.current_method = method

        self.update_current_strategy()

    def update_current_strategy(self):
        """
        Synchronize the UI strategy with the currently selected assessment method.

        Returns:
            None (None): Updates the `report_widget` with the correct
                [ReportStrategy][gui.core.report_strategies.ReportStrategy].
        """
        match self.current_method:
            case AssessmentMethod.REBA:
                self.current_strategy = RebaStrategy()

            case AssessmentMethod.RULA:
                self.current_strategy = RulaStrategy()

        self.report_widget.update_strategy(self.current_strategy)
        self.backend.update_method(self.current_method)
        self.update()
Functions
_connect_signals()

Connects UI signals to their respective backend slots and handlers.

Returns:

Name Type Description
None None

Establishes signal-slot connections.

Source code in gui\views\report_view.py
299
300
301
302
303
304
305
306
307
308
309
310
311
312
def _connect_signals(self) -> None:
    """
    Connects UI signals to their respective backend slots and handlers.

    Returns:
        None (None): Establishes signal-slot connections.
    """
    self.btn_import.clicked.connect(self._handle_import_dialog)
    self.btn_pdf.clicked.connect(self._handle_pdf_request)
    self.btn_docx.clicked.connect(self._handle_docx_request)
    self.backend.data_processed.connect(self._on_data_ready)
    self.backend.pdf_html_ready.connect(self._print_pdf)
    self.backend.report_export_finished.connect(self._handle_export_success)
    self.backend.error_occurred.connect(self._handle_error)
_create_stat_card(title, value, internal_name)

Factory method to create a stylized 'Stat Card' for the dashboard.

Parameters:

Name Type Description Default
title str

The label for the metric (e.g., "AVG REBA SCORE").

required
value str

The initial value to display.

required
internal_name str

The unique ID used to update the label text later.

required

Returns:

Name Type Description
QFrame QFrame

A stylized frame containing the metric labels.

Source code in gui\views\report_view.py
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
def _create_stat_card(self, title: str, value: str, internal_name: str) -> QFrame:
    """
    Factory method to create a stylized 'Stat Card' for the dashboard.

    Args:
        title (str): The label for the metric (e.g., "AVG REBA SCORE").
        value (str): The initial value to display.
        internal_name (str): The unique ID used to update the label text later.

    Returns:
        QFrame (QFrame): A stylized frame containing the metric labels.
    """
    card: QFrame = QFrame()
    card.setObjectName("blockquote")

    lay: QVBoxLayout = QVBoxLayout(card)

    v_lbl: QLabel = QLabel(value)
    v_lbl.setObjectName(internal_name)
    v_lbl.setProperty("class", "h3")

    t_lbl: QLabel = QLabel(title)
    t_lbl.setProperty("class", "text-muted")

    lay.addWidget(t_lbl)
    lay.addWidget(v_lbl)

    return card
_handle_docx_request()

Trigger backend export with a screenshot of the current chart.

Converts the chart_risk canvas into a bytes buffer for insertion into a DOCX template via the ReportBackend.

Returns:

Name Type Description
None None

Initiates the Word document generation.

Source code in gui\views\report_view.py
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
def _handle_docx_request(self) -> None:
    """
    Trigger backend export with a screenshot of the current chart.

    Converts the `chart_risk` canvas into a `bytes` buffer for insertion into a
    DOCX template via the [ReportBackend][gui.backend.report_backend.ReportBackend].

    Returns:
        None (None): Initiates the Word document generation.
    """
    save_path, _ = QFileDialog.getSaveFileName(
        self, self.tr("Save Word"), "", "Word (*.docx)"
    )
    if not save_path:
        return

    # Capture chart as bytes to send to backend (thread-safe way to handle images)
    buf = BytesIO()
    self.chart_risk.canvas.figure.savefig(buf, format="png")
    chart_bytes = buf.getvalue()

    self.backend.export_to_docx(
        ReportExportRequest(save_path=Path(save_path), chart_data=chart_bytes)
    )
_handle_error(error_info)

Slot to handle and display error messages from the backend.

Parameters:

Name Type Description Default
error_info ErrorInfo

Data class object containing the error title and descriptive message.

required

Returns:

Name Type Description
None None

Triggers a critical QMessageBox.

Source code in gui\views\report_view.py
337
338
339
340
341
342
343
344
345
346
347
348
@Slot(ErrorInfo)
def _handle_error(self, error_info):
    """
    Slot to handle and display error messages from the backend.

    Args:
        error_info (ErrorInfo): Data class object containing the error title and descriptive message.

    Returns:
        None (None): Triggers a critical `QMessageBox`.
    """
    QMessageBox.critical(self, error_info.title, error_info.message)
_handle_export_success(report_export_result)

Slot to handle the UI notification after a successful file export.

Parameters:

Name Type Description Default
report_export_result ReportExportResult

Data class object containing details of the export result.

required

Returns:

Name Type Description
None None

Triggers a QMessageBox.

Source code in gui\views\report_view.py
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
@Slot(ReportExportResult)
def _handle_export_success(self, report_export_result: ReportExportResult):
    """
    Slot to handle the UI notification after a successful file export.

    Args:
        report_export_result (ReportExportResult): Data class object containing details of the export result.

    Returns:
        None (None): Triggers a `QMessageBox`.
    """
    if report_export_result.success:
        QMessageBox.information(
            self, self.tr("Success"), report_export_result.message
        )
_handle_import_dialog()

Triggers a QFileDialog to allow users to select a new data source.

Supports .csv and .xlsx extensions. If a path is selected, it triggers the load_data_and_run method in the ReportBackend.

Returns:

Name Type Description
None None

Updates the report backend with the new file path.

Source code in gui\views\report_view.py
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
def _handle_import_dialog(self) -> None:  # TODO fix not working
    """
    Triggers a QFileDialog to allow users to select a new data source.

    Supports .csv and .xlsx extensions. If a path is selected, it triggers
    the `load_data_and_run` method in the [ReportBackend][gui.backend.report_backend.ReportBackend].

    Returns:
        None (None): Updates the report backend with the new file path.
    """
    path: str | None = None
    path, _ = QFileDialog.getOpenFileName(
        self, self.tr("Open Data"), "", self.tr("Data Files (*.csv *.xlsx)")
    )
    if path:
        self.backend.load_data_and_run(Path(path))
_handle_pdf_request()

GUI-side handling of PDF printing (requires access to Printer/Canvas).

Captures the current Matplotlib buffer as bytes and passes it to the ReportBackend for template rendering.

Returns:

Name Type Description
None None

Triggers the PDF generation sequence.

Source code in gui\views\report_view.py
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
def _handle_pdf_request(self) -> None:
    """
    GUI-side handling of PDF printing (requires access to Printer/Canvas).

    Captures the current Matplotlib buffer as `bytes` and passes it to the
    [ReportBackend][gui.backend.report_backend.ReportBackend] for template rendering.

    Returns:
        None (None): Triggers the PDF generation sequence.
    """

    # Capture chart as bytes to send to backend (thread-safe way to handle images)
    buf = BytesIO()
    self.chart_score.canvas.figure.savefig(
        buf, format="png"
    )  # using only chart_score score distribution (instead of risk dostribution)
    chart_bytes = buf.getvalue()

    self.backend.prepare_pdf_export(
        ReportExportRequest(save_path=Path(""), chart_data=chart_bytes)
    )
_on_data_ready(report_data)

Internal slot triggered when the backend finishes processing data.

Parameters:

Name Type Description Default
report_data ReportData

ReportData from report backend

required

Returns: None (None): Populates the dashboard UI with live data.

Source code in gui\views\report_view.py
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
@Slot(ReportData)
def _on_data_ready(self, report_data: ReportData) -> None:
    """
    Internal slot triggered when the backend finishes processing data.

    Args:
        report_data (ReportData): ReportData from report backend
    Returns:
        None (None): Populates the dashboard UI with live data.
    """
    # Pylance-safe casting for finding children in the themed cards
    total_lbl = self.card_total.findChild(QLabel, "total_val")
    if isinstance(total_lbl, QLabel):
        total_lbl.setText(str(report_data.total_frames))

    avg_lbl = self.card_avg.findChild(QLabel, "avg_val")
    if isinstance(avg_lbl, QLabel):
        avg_lbl.setText(f"{report_data.average_score:.2f}")

    # print(summary) TODO print_reactivate

    self.update_current_strategy()
    self.report_widget.update_results(report_data.summary_dict)
    self._update_charts(report_data.df)
    self.btn_pdf.setEnabled(True)
    self.btn_docx.setEnabled(True)
    self.file_info.setPlainText(
        f"Analysis Run on data from:\n{report_data.file_path}"
    )
_print_pdf(html)

Renders the generated HTML report to a PDF file using Qt's print system.

Parameters:

Name Type Description Default
html str

The rendered HTML content from the Jinja2 template.

required

Returns:

Name Type Description
None None

Generates the physical PDF file.

Raises:

Type Description
Exception(Exception)

If the printer or document fails to write to the path.

Source code in gui\views\report_view.py
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
def _print_pdf(self, html: str) -> None:
    """
    Renders the generated HTML report to a PDF file using Qt's print system.

    Args:
        html (str): The rendered HTML content from the Jinja2 template.

    Returns:
        None (None): Generates the physical PDF file.

    Raises:
        Exception (Exception): If the printer or document fails to write to the path.
    """

    try:
        filename, _ = QFileDialog.getSaveFileName(
            self, self.tr("Save PDF"), "", self.tr("PDF Files (*.pdf)")
        )
        if filename:
            doc: QTextDocument = QTextDocument()
            doc.setHtml(html)

            printer: QPrinter = QPrinter(QPrinter.PrinterMode.HighResolution)
            printer.setOutputFormat(QPrinter.OutputFormat.PdfFormat)
            printer.setOutputFileName(filename)

            doc.print_(printer)

            if (
                printer.outputFileName() == filename
            ):  # Checks if user confirmed the save, pass if cancelled the save in file dialog
                QMessageBox.information(
                    self, self.tr("Success"), self.tr("PDF Generated.")
                )
            else:
                pass
    except Exception as e:
        QMessageBox.critical(self, self.tr("Export Error"), str(e))
_setup_ui()

Initializes the graphical user interface layout and components.

This method constructs a hierarchical nested layout consisting of: 1. A top-level vertical layout to host the global menu_bar. 2. A horizontal content area containing a Sidebar and a Dashboard. 3. Stylized stat cards, Matplotlib canvases, and a reporting table.

References internal components like TableReportWidget.

Returns:

Name Type Description
None None

Modifies the window state in-place.

Note

Requires the following instance attributes to be pre-initialized: - THEMES: A dict containing color hex codes. - current_theme: A str ("light" or "dark") for theme selection.

Source code in gui\views\report_view.py
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
def _setup_ui(self) -> None:
    """
    Initializes the graphical user interface layout and components.

    This method constructs a hierarchical nested layout consisting of:
    1. A top-level vertical layout to host the global menu_bar.
    2. A horizontal content area containing a Sidebar and a Dashboard.
    3. Stylized stat cards, Matplotlib canvases, and a reporting table.

    References internal components like [TableReportWidget][gui.widgets.table_report_widget.TableReportWidget].

    Returns:
        None (None): Modifies the window state in-place.

    Note:
        Requires the following instance attributes to be pre-initialized:
        - `THEMES`: A `dict` containing color hex codes.
        - `current_theme`: A `str` ("light" or "dark") for theme selection.
    """

    central_widget: QWidget = QWidget()
    self.setCentralWidget(central_widget)

    # ROOT LAYOUT (Vertical)
    root_layout: QVBoxLayout = QVBoxLayout(central_widget)
    root_layout.setContentsMargins(0, 0, 0, 0)
    root_layout.setSpacing(0)

    # CONTENT AREA (Horizontal)
    content_layout: QHBoxLayout = QHBoxLayout()
    content_layout.setSpacing(0)
    root_layout.addLayout(content_layout)

    # --- SIDEBAR ---
    self.sidebar: QFrame = QFrame()
    self.sidebar.setObjectName("Sidebar")
    self.sidebar.setFixedWidth(260)
    side_layout: QVBoxLayout = QVBoxLayout(self.sidebar)

    lbl_menu: QLabel = QLabel(self.tr("REPORT CONTROLS"))
    lbl_menu.setProperty("class", "h2")

    self.btn_import: QPushButton = QPushButton(self.tr("📁 LOAD DATA"))

    self.btn_pdf: QPushButton = QPushButton(self.tr("📜 EXPORT TO PDF"))
    self.btn_pdf.setEnabled(False)

    self.btn_docx: QPushButton = QPushButton(self.tr("📄 EXPORT TO DOCX"))
    self.btn_docx.setEnabled(False)

    side_layout.addWidget(lbl_menu)
    side_layout.addSpacing(20)
    side_layout.addWidget(self.btn_import)
    side_layout.addSpacing(10)
    side_layout.addWidget(self.btn_pdf)
    side_layout.addWidget(self.btn_docx)
    side_layout.addStretch()

    # Create a QTextEdit instead of a QLabel
    self.file_info: QTextEdit = QTextEdit()
    self.file_info.setReadOnly(True)
    self.file_info.setText(self.tr("No file loaded"))

    # Styling it to look like a label
    self.file_info.setFrameStyle(QFrame.Shape.NoFrame)
    self.file_info.viewport().setAutoFillBackground(False)
    self.file_info.setFixedHeight(150)

    side_layout.addWidget(self.file_info)

    # --- DASHBOARD ---
    dashboard = QWidget()
    dash_lay = QVBoxLayout(dashboard)

    # Stats
    stats_row = QHBoxLayout()
    self.card_total = self._create_stat_card(
        self.tr("TOTAL FRAMES"), "0", "total_val"
    )
    self.card_avg = self._create_stat_card(
        self.tr("AVERAGE SCORE"), "0.0", "avg_val"
    )
    stats_row.addWidget(self.card_total)
    stats_row.addWidget(self.card_avg)
    dash_lay.addLayout(stats_row)

    # Charts (Utilizing specialized ChartReportWidget)
    chart_row = QHBoxLayout()
    self.chart_risk = ChartReportWidget(self.current_theme)
    self.chart_score = ChartReportWidget(self.current_theme)
    chart_row.addWidget(self.chart_risk)
    chart_row.addWidget(self.chart_score)
    dash_lay.addLayout(chart_row)

    # Table
    self.report_widget = TableReportWidget(
        title=self.current_method.name, strategy=self.current_strategy
    )
    dash_lay.addWidget(self.report_widget)

    content_layout.addWidget(self.sidebar)
    content_layout.addWidget(dashboard)
_update_charts(df)

Logic outsourced to specialized chart widgets.

Updates both the risk distribution and score frequency visualizations using the provided dataset.

Parameters:

Name Type Description Default
df DataFrame

The dataset containing ergonomic metrics.

required

Returns:

Name Type Description
None None

Redraws the Matplotlib canvases via the chart widgets.

Source code in gui\views\report_view.py
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
def _update_charts(self, df: pd.DataFrame) -> None:
    """
    Logic outsourced to specialized chart widgets.

    Updates both the risk distribution and score frequency visualizations using
    the provided dataset.

    Args:
        df (pandas.DataFrame): The dataset containing ergonomic metrics.

    Returns:
        None (None): Redraws the Matplotlib canvases via the chart widgets.
    """
    self.chart_risk.update_chart(
        df,
        MetricType.RISK,
    )
    self.chart_score.update_chart(
        df,
        MetricType.SCORE,
    )
set_method(method)

Update the active ergonomic assessment method.

Parameters:

Name Type Description Default
method AssessmentMethod

The name of the method to apply (e.g., AssessmentMethod.RULA, AssessmentMethod.REBA).

required

Returns:

Name Type Description
None None

Updates internal state and clears previous strategy if necessary.

Source code in gui\views\report_view.py
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
def set_method(self, method: AssessmentMethod) -> None:
    """
    Update the active ergonomic assessment method.

    Args:
        method (AssessmentMethod): The name of the method to apply (e.g., AssessmentMethod.RULA, AssessmentMethod.REBA).

    Returns:
        None (None): Updates internal state and clears previous strategy if necessary.
    """
    if hasattr(self, "current_method") and self.current_method == method:
        return  # Don't recreate the strategy if it's the same
    if hasattr(self, "strategy"):
        self.strategy = None

    self.current_method = method

    self.update_current_strategy()
update_current_strategy()

Synchronize the UI strategy with the currently selected assessment method.

Returns:

Name Type Description
None None

Updates the report_widget with the correct ReportStrategy.

Source code in gui\views\report_view.py
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
def update_current_strategy(self):
    """
    Synchronize the UI strategy with the currently selected assessment method.

    Returns:
        None (None): Updates the `report_widget` with the correct
            [ReportStrategy][gui.core.report_strategies.ReportStrategy].
    """
    match self.current_method:
        case AssessmentMethod.REBA:
            self.current_strategy = RebaStrategy()

        case AssessmentMethod.RULA:
            self.current_strategy = RulaStrategy()

    self.report_widget.update_strategy(self.current_strategy)
    self.backend.update_method(self.current_method)
    self.update()

options: show_root_heading: true

gui.views.settings_view

options: show_root_heading: true

gui.widgets.sidebar

ErgoMoCap: Ergonomic Sidebar

Control Panel and Configuration Interface for the ErgoMoCap Application.

This module implements the ErgoSidebar, a specialized QDockWidget that serves as the primary control hub for the user. It organizes recording source selection, data management, analytics parameters, and video visualization controls into a scrollable vertical interface.

The sidebar follows a "Deaf-Mute" component pattern, communicating exclusively through Qt Signals to maintain strict decoupling from the project's backend and processing logic.

Classes

ErgoSidebar

Bases: QDockWidget

A scrollable control panel for managing ergonomic analysis workflows.

The sidebar is divided into logical sections: - Capture Source: Execute external processing via FreeMoCap. - Data Management: File system navigation and session selection. - Analytics: Method selection (RULA/REBA) and analysis execution. - Reports: Navigation to the dashboard. - Video Visualizer: Media control and playback selection.

Attributes:

Name Type Description
session_changed Signal

Signal emitted when a new recording session is selected (str).

video_changed Signal

Signal emitted when a new video file is selected (str).

run_analysis_clicked Signal

Signal emitted with the formatted payload (AnalysisRequest).

root_selection_requested Signal

Signal emitted when the user requests a directory change.

main_container QWidget

Central container hosting the main vertical layout structure.

scroll_area QScrollArea

Main viewport allowing scrolling layout behaviors for small screens.

container QWidget

Child container acting as the canvas within the scroll area.

btn_fmc QPushButton

Push button to execute the FreeMoCap runner application.

btn_select_root QPushButton

File explorer launcher button for selecting a root folder.

lbl_session QLabel

Label descriptive title for session combos.

combo_sessions QComboBox

Dropdown selection menu displaying discovered local capture sessions.

lbl_method QLabel

Label descriptive title for calculation method selection combo box.

combo_method QComboBox

Dropdown selection layout offering supported framework algorithms.

export_frames_checkbox QCheckBox

Checkbox indicating whether output frame buffers should persist.

btn_analysis QPushButton

Trigger execution wrapper to launch backend processing logic.

btn_report QPushButton

Navigation trigger shortcut to deploy dashboard analytics displays.

lbl_video_select QLabel

Title layout text label header for picking file streams.

combo_videos QComboBox

Dropdown selector populated with multi-camera recordings if existing.

btn_load_video QPushButton

Fallback local file system dialog trigger for arbitrary videos.

btn_play_video QPushButton

Playback action control toggle button interface.

btn_prev_frame QPushButton

Manual single frame step backward hotkey layout link button.

btn_next_frame QPushButton

Manual single frame step forward hotkey layout link button.

status_label QLabel

Message terminal line positioned near footer boundary blocks.

Methods:

Name Description
__init__

Initialize the sidebar and its internal UI components.

_setup_ui

Construct the visual layout of the sidebar.

_connect_internal_signals

Establish signal-slot connections for internal child widgets.

update_sessions

Refresh the session selection list.

update_videos

Refresh the available video list and update playback controls.

set_status

Update the status label text in the UI.

get_current_session

Returns the currently selected session name.

get_current_video

Returns the currently selected video name.

get_selected_method

Returns the currently selected analysis method.

handle_run_analysis

Map selected GUI configurations to structured downstream events.

Source code in gui\widgets\sidebar.py
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
class ErgoSidebar(QDockWidget):
    """
    A scrollable control panel for managing ergonomic analysis workflows.

    The sidebar is divided into logical sections:
    - **Capture Source**: Execute external processing via FreeMoCap.
    - **Data Management**: File system navigation and session selection.
    - **Analytics**: Method selection (RULA/REBA) and analysis execution.
    - **Reports**: Navigation to the dashboard.
    - **Video Visualizer**: Media control and playback selection.

    Attributes:
        session_changed (Signal): Signal emitted when a new recording session is selected (str).
        video_changed (Signal): Signal emitted when a new video file is selected (str).
        run_analysis_clicked (Signal): Signal emitted with the formatted payload (`AnalysisRequest`).
        root_selection_requested (Signal): Signal emitted when the user requests a directory change.
        main_container (QWidget): Central container hosting the main vertical layout structure.
        scroll_area (QScrollArea): Main viewport allowing scrolling layout behaviors for small screens.
        container (QWidget): Child container acting as the canvas within the scroll area.
        btn_fmc (QPushButton): Push button to execute the FreeMoCap runner application.
        btn_select_root (QPushButton): File explorer launcher button for selecting a root folder.
        lbl_session (QLabel): Label descriptive title for session combos.
        combo_sessions (QComboBox): Dropdown selection menu displaying discovered local capture sessions.
        lbl_method (QLabel): Label descriptive title for calculation method selection combo box.
        combo_method (QComboBox): Dropdown selection layout offering supported framework algorithms.
        export_frames_checkbox (QCheckBox): Checkbox indicating whether output frame buffers should persist.
        btn_analysis (QPushButton): Trigger execution wrapper to launch backend processing logic.
        btn_report (QPushButton): Navigation trigger shortcut to deploy dashboard analytics displays.
        lbl_video_select (QLabel): Title layout text label header for picking file streams.
        combo_videos (QComboBox): Dropdown selector populated with multi-camera recordings if existing.
        btn_load_video (QPushButton): Fallback local file system dialog trigger for arbitrary videos.
        btn_play_video (QPushButton): Playback action control toggle button interface.
        btn_prev_frame (QPushButton): Manual single frame step backward hotkey layout link button.
        btn_next_frame (QPushButton): Manual single frame step forward hotkey layout link button.
        status_label (QLabel): Message terminal line positioned near footer boundary blocks.

    Methods:
        __init__: Initialize the sidebar and its internal UI components.
        _setup_ui: Construct the visual layout of the sidebar.
        _connect_internal_signals: Establish signal-slot connections for internal child widgets.
        update_sessions: Refresh the session selection list.
        update_videos: Refresh the available video list and update playback controls.
        set_status: Update the status label text in the UI.
        get_current_session: Returns the currently selected session name.
        get_current_video: Returns the currently selected video name.
        get_selected_method: Returns the currently selected analysis method.
        handle_run_analysis: Map selected GUI configurations to structured downstream events.
    """

    # Custom signals: The Sidebar "talks" to the app without knowing the Backend
    session_changed = Signal(str)
    video_changed = Signal(str)
    run_analysis_clicked = Signal(
        AnalysisRequest
    )  # sends the method name (REBA for now)
    root_selection_requested = Signal()

    def __init__(self, parent=None) -> None:
        """
        Initialize the sidebar and its internal UI components.

        Args:
            parent (QWidget | None): The parent widget, typically
                [MainWindow][gui.frontend.MainWindow]. Defaults to `None`.

        Returns:
            None (None): Initializer return.
        """
        super().__init__(parent)
        self._setup_ui()
        self._connect_internal_signals()

    def _setup_ui(self) -> None:
        """
        Construct the visual layout of the sidebar.

        Creates the main container, scroll area, and group boxes for organized
        control placement. It also configures the widget as a non-closable,
        left-aligned dock.

        Returns:
            None (None): Modifies the widget state in-place.
        """
        self.setObjectName("Sidebar")
        self.setFixedWidth(320)

        # --- THE FIX: Create a central widget for the Dock ---
        self.main_container: QWidget = QWidget()
        self.setWidget(self.main_container)

        # All layouts now go inside 'self.main_container'
        main_layout: QVBoxLayout = QVBoxLayout(self.main_container)
        main_layout.setContentsMargins(0, 0, 0, 0)

        # 1. Create the Scroll Area
        self.scroll_area: QScrollArea = QScrollArea()
        self.scroll_area.setWidgetResizable(True)
        self.scroll_area.setFrameShape(QFrame.Shape.NoFrame)
        self.scroll_area.setHorizontalScrollBarPolicy(
            Qt.ScrollBarPolicy.ScrollBarAlwaysOff
        )

        # 2. Create a Container Widget for the scroll area
        self.container: QWidget = QWidget()
        self.container.setFixedWidth(300)
        self.container.setSizePolicy(
            QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Expanding
        )

        self.scroll_area.setWidget(self.container)

        # 3. The actual layout for your buttons
        layout: QVBoxLayout = QVBoxLayout(self.container)
        layout.setContentsMargins(10, 10, 10, 10)
        layout.setSpacing(15)

        # --- CAPTURE SECTION ---
        cap_group: QGroupBox = QGroupBox(self.tr("CAPTURE SOURCE"))
        cap_lay: QVBoxLayout = QVBoxLayout(cap_group)
        self.btn_fmc: QPushButton = QPushButton(self.tr("💀 RUN FREEMOCAP"))
        self.btn_fmc.setObjectName("FMCBtn")
        cap_lay.addWidget(self.btn_fmc)
        layout.addWidget(cap_group)

        # --- DATA MANAGEMENT ---
        data_group: QGroupBox = QGroupBox(self.tr("DATA MANAGEMENT"))
        data_lay: QVBoxLayout = QVBoxLayout(data_group)
        self.btn_select_root: QPushButton = QPushButton(
            self.tr("📂 SELECT FREEMOCAP ROOT")
        )
        self.btn_select_root.setToolTip(
            "Select the path of your 'freemocap_data' folder."
        )

        data_lay.addWidget(self.btn_select_root)
        self.lbl_session: QLabel = QLabel(self.tr("Select Recording Session:"))
        self.lbl_session.setObjectName("FieldLabel")
        data_lay.addWidget(self.lbl_session)
        self.combo_sessions: QComboBox = QComboBox()
        data_lay.addWidget(self.combo_sessions)
        layout.addWidget(data_group)

        # --- ANALYTICS ---
        analysis_group: QGroupBox = QGroupBox(self.tr("ANALYTICS"))
        analysis_lay: QVBoxLayout = QVBoxLayout(analysis_group)
        self.lbl_method: QLabel = QLabel(self.tr("Select Ergonomic Method:"))
        self.lbl_method.setObjectName("FieldLabel")
        analysis_lay.addWidget(self.lbl_method)
        self.combo_method: QComboBox = QComboBox()
        # self.combo_method.addItems(
        #     ["REBA", "RULA", "OCRA (Planned)", "NIOSH (Planned)"]
        # )
        self.combo_method.addItems(
            [
                self.tr("REBA"),
                self.tr("RULA"),
                # NOTE IF U RENAME THESE AND ADD THE CORRESPONDING TO THE ASSESSMENT METHOD ENUM \
                # IT WILL CAUSE BUGS IF THE CALCULATOR IS NOT IMPLEMENTED! KEEP THESE NAMES UNTIL THE END.
                self.tr("OCRA (Planned)"),
                self.tr("EWAS (Planned)"),
                self.tr("NIOSH (Planned)"),
                self.tr("SNOOK (Planned)"),
            ]
        )
        self.export_frames_checkbox = QCheckBox(text="Export Frames?")
        analysis_lay.addWidget(self.combo_method)
        analysis_lay.addWidget(self.export_frames_checkbox)
        self.btn_analysis: QPushButton = QPushButton(self.tr("🏃 RUN ANALYSIS"))
        self.btn_analysis.setObjectName("AnalyzeBtn")
        analysis_lay.addWidget(self.btn_analysis)
        layout.addWidget(analysis_group)

        # --- REPORTS ---
        report_group: QGroupBox = QGroupBox(self.tr("REPORTS"))
        report_lay: QVBoxLayout = QVBoxLayout(report_group)
        self.btn_report: QPushButton = QPushButton(self.tr("📊 OPEN REPORT DASHBOARD"))
        self.btn_report.setObjectName("ReportBtn")
        report_lay.addWidget(self.btn_report)
        layout.addWidget(report_group)

        # --- VIDEO VISUALIZER ---
        video_group: QGroupBox = QGroupBox(self.tr("VIDEO VISUALIZER"))
        video_lay: QVBoxLayout = QVBoxLayout(video_group)
        self.lbl_video_select: QLabel = QLabel(self.tr("Select Video:"))
        self.lbl_video_select.setObjectName("FieldLabel")
        video_lay.addWidget(self.lbl_video_select)

        self.combo_videos: QComboBox = QComboBox()
        video_lay.addWidget(self.combo_videos)

        self.btn_load_video: QPushButton = QPushButton(self.tr("🎞️ BROWSE OTHER VIDEO"))
        self.btn_play_video: QPushButton = QPushButton(self.tr("▶ PLAY / PAUSE"))
        self.btn_play_video.setEnabled(False)

        # New: Frame Control Layout
        frame_ctrl_lay = QHBoxLayout()
        self.btn_prev_frame = QPushButton("Back (←)")
        self.btn_next_frame = QPushButton("Fwd (→)")
        self.btn_prev_frame.setEnabled(False)
        self.btn_next_frame.setEnabled(False)

        frame_ctrl_lay.addWidget(self.btn_prev_frame)
        frame_ctrl_lay.addWidget(self.btn_next_frame)

        video_lay.addWidget(self.btn_load_video)
        video_lay.addWidget(self.btn_play_video)
        video_lay.addLayout(frame_ctrl_lay)

        layout.addWidget(video_group)

        layout.addStretch()

        # Inside ErgoSidebar._setup_ui, apply this to your buttons:
        self.btn_fmc.setFocusPolicy(Qt.FocusPolicy.NoFocus)
        self.btn_analysis.setFocusPolicy(Qt.FocusPolicy.NoFocus)
        self.btn_play_video.setFocusPolicy(Qt.FocusPolicy.NoFocus)
        self.btn_prev_frame.setFocusPolicy(Qt.FocusPolicy.NoFocus)
        self.btn_next_frame.setFocusPolicy(Qt.FocusPolicy.NoFocus)

        # --- STATUS BOX ---
        self.status_label: QTextEdit = QTextEdit()
        self.status_label.setReadOnly(True)
        self.status_label.setPlainText(self.tr("STATUS: READY"))

        self.status_label.setFrameStyle(QFrame.Shape.NoFrame)
        self.status_label.viewport().setAutoFillBackground(False)
        self.status_label.setMinimumHeight(120)
        self.status_label.setMaximumWidth(300)
        self.status_label.setSizePolicy(
            QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Expanding
        )

        self.scroll_area.setWidget(self.container)
        self.status_label.setAlignment(
            Qt.AlignmentFlag.AlignTop | Qt.AlignmentFlag.AlignLeft
        )
        layout.addWidget(self.status_label)

        # Remove the Docker default features
        self.setFeatures(QDockWidget.DockWidgetFeature.NoDockWidgetFeatures)

        # Optional: Prevents the user from accidentally dragging it out
        self.setAllowedAreas(Qt.DockWidgetArea.LeftDockWidgetArea)

        # 4. Finalize by adding the scroll area to the Sidebar's main layout
        main_layout.addWidget(self.scroll_area)

    def _connect_internal_signals(self) -> None:
        """
        Establish signal-slot connections for internal child widgets.

        Connects button clicks and combo box changes to the sidebar's public
        signals, effectively proxying widget interactions to the application controller.

        Returns:
            None (None): Sets up signal connections.
        """
        self.btn_select_root.clicked.connect(self.root_selection_requested.emit)
        self.combo_sessions.currentTextChanged.connect(self.session_changed.emit)
        self.combo_videos.currentTextChanged.connect(self.video_changed.emit)
        self.btn_analysis.clicked.connect(self.handle_run_analysis)

    # --- PUBLIC API: Logic moved from MainWindow to here ---

    def update_sessions(self, sessions: list[str]) -> None:
        """
        Refresh the session selection list.

        Args:
            sessions (list[str]): A `list` of session folder names found in the root directory.

        Returns:
            None (None): Updates the `combo_sessions` widget.
        """
        self.combo_sessions.blockSignals(True)
        self.combo_sessions.clear()
        self.combo_sessions.addItems(sessions)
        self.combo_sessions.blockSignals(False)
        self.set_status(f"Found {len(sessions)} sessions.")

    def update_videos(self, videos: list[str]) -> None:
        """
        Refresh the available video list and update playback controls.

        Args:
            videos (list[str]): A `list` of video file names associated with the current session.

        Returns:
            None (None): Updates the `combo_videos` widget and enables/disables the play button.
        """

        self.combo_videos.blockSignals(True)
        self.combo_videos.clear()
        self.combo_videos.addItems(videos)
        self.combo_videos.blockSignals(False)
        self.btn_play_video.setEnabled(len(videos) > 0)
        self.btn_next_frame.setEnabled(True)
        self.btn_prev_frame.setEnabled(True)

    def set_status(self, text: str) -> None:
        """
        Update the status label text in the UI.

        Args:
            text (str): The status message to display.

        Returns:
            None (None): Updates the `status_label` widget text.
        """
        self.status_label.setText(f"STATUS: {text}")

    def get_current_session(self) -> str:
        """
        Returns the currently selected session name.

        Returns:
            str (str): The text content of the active session combo box.
        """
        return self.combo_sessions.currentText()

    def get_current_video(self) -> str:
        """
        Returns the currently selected video name.

        Returns:
            str (str): The text content of the active video combo box.
        """
        return self.combo_videos.currentText()

    def get_selected_method(self) -> str:
        """
        Returns the currently selected analysis method.

        Returns:
            str (str): The selected method name (e.g., "REBA", "RULA").
        """

        return self.combo_method.currentText()

    def handle_run_analysis(self):
        """
        Map selected GUI configurations to structured downstream events.

        Constructs an instance of [AnalysisRequest][gui.utils.models.AnalysisRequest]
        and triggers the `run_analysis_clicked` signal if parsing matches known
        [AssessmentMethod][gui.utils.constants.AssessmentMethod] mappings. This
        signal proxies the configuration payload directly to the application's
        frontend orchestrator slot [run_analysis][gui.frontend.MainWindow.run_analysis].

        Returns:
            None (None): Emits Qt signals or writes parsing faults to the local terminal view.
        """
        selected_method: str = self.get_selected_method().upper()
        try:
            selected_method: str = self.combo_method.currentText().upper()
            method: AssessmentMethod = AssessmentMethod[selected_method]
            self.run_analysis_clicked.emit(
                AnalysisRequest(
                    method=method,
                    export_frames=self.export_frames_checkbox.isChecked(),
                )
            )
        except KeyError:
            self.status_label.setText(f"{selected_method} is not implemented.")
Functions
_connect_internal_signals()

Establish signal-slot connections for internal child widgets.

Connects button clicks and combo box changes to the sidebar's public signals, effectively proxying widget interactions to the application controller.

Returns:

Name Type Description
None None

Sets up signal connections.

Source code in gui\widgets\sidebar.py
305
306
307
308
309
310
311
312
313
314
315
316
317
318
def _connect_internal_signals(self) -> None:
    """
    Establish signal-slot connections for internal child widgets.

    Connects button clicks and combo box changes to the sidebar's public
    signals, effectively proxying widget interactions to the application controller.

    Returns:
        None (None): Sets up signal connections.
    """
    self.btn_select_root.clicked.connect(self.root_selection_requested.emit)
    self.combo_sessions.currentTextChanged.connect(self.session_changed.emit)
    self.combo_videos.currentTextChanged.connect(self.video_changed.emit)
    self.btn_analysis.clicked.connect(self.handle_run_analysis)
_setup_ui()

Construct the visual layout of the sidebar.

Creates the main container, scroll area, and group boxes for organized control placement. It also configures the widget as a non-closable, left-aligned dock.

Returns:

Name Type Description
None None

Modifies the widget state in-place.

Source code in gui\widgets\sidebar.py
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
def _setup_ui(self) -> None:
    """
    Construct the visual layout of the sidebar.

    Creates the main container, scroll area, and group boxes for organized
    control placement. It also configures the widget as a non-closable,
    left-aligned dock.

    Returns:
        None (None): Modifies the widget state in-place.
    """
    self.setObjectName("Sidebar")
    self.setFixedWidth(320)

    # --- THE FIX: Create a central widget for the Dock ---
    self.main_container: QWidget = QWidget()
    self.setWidget(self.main_container)

    # All layouts now go inside 'self.main_container'
    main_layout: QVBoxLayout = QVBoxLayout(self.main_container)
    main_layout.setContentsMargins(0, 0, 0, 0)

    # 1. Create the Scroll Area
    self.scroll_area: QScrollArea = QScrollArea()
    self.scroll_area.setWidgetResizable(True)
    self.scroll_area.setFrameShape(QFrame.Shape.NoFrame)
    self.scroll_area.setHorizontalScrollBarPolicy(
        Qt.ScrollBarPolicy.ScrollBarAlwaysOff
    )

    # 2. Create a Container Widget for the scroll area
    self.container: QWidget = QWidget()
    self.container.setFixedWidth(300)
    self.container.setSizePolicy(
        QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Expanding
    )

    self.scroll_area.setWidget(self.container)

    # 3. The actual layout for your buttons
    layout: QVBoxLayout = QVBoxLayout(self.container)
    layout.setContentsMargins(10, 10, 10, 10)
    layout.setSpacing(15)

    # --- CAPTURE SECTION ---
    cap_group: QGroupBox = QGroupBox(self.tr("CAPTURE SOURCE"))
    cap_lay: QVBoxLayout = QVBoxLayout(cap_group)
    self.btn_fmc: QPushButton = QPushButton(self.tr("💀 RUN FREEMOCAP"))
    self.btn_fmc.setObjectName("FMCBtn")
    cap_lay.addWidget(self.btn_fmc)
    layout.addWidget(cap_group)

    # --- DATA MANAGEMENT ---
    data_group: QGroupBox = QGroupBox(self.tr("DATA MANAGEMENT"))
    data_lay: QVBoxLayout = QVBoxLayout(data_group)
    self.btn_select_root: QPushButton = QPushButton(
        self.tr("📂 SELECT FREEMOCAP ROOT")
    )
    self.btn_select_root.setToolTip(
        "Select the path of your 'freemocap_data' folder."
    )

    data_lay.addWidget(self.btn_select_root)
    self.lbl_session: QLabel = QLabel(self.tr("Select Recording Session:"))
    self.lbl_session.setObjectName("FieldLabel")
    data_lay.addWidget(self.lbl_session)
    self.combo_sessions: QComboBox = QComboBox()
    data_lay.addWidget(self.combo_sessions)
    layout.addWidget(data_group)

    # --- ANALYTICS ---
    analysis_group: QGroupBox = QGroupBox(self.tr("ANALYTICS"))
    analysis_lay: QVBoxLayout = QVBoxLayout(analysis_group)
    self.lbl_method: QLabel = QLabel(self.tr("Select Ergonomic Method:"))
    self.lbl_method.setObjectName("FieldLabel")
    analysis_lay.addWidget(self.lbl_method)
    self.combo_method: QComboBox = QComboBox()
    # self.combo_method.addItems(
    #     ["REBA", "RULA", "OCRA (Planned)", "NIOSH (Planned)"]
    # )
    self.combo_method.addItems(
        [
            self.tr("REBA"),
            self.tr("RULA"),
            # NOTE IF U RENAME THESE AND ADD THE CORRESPONDING TO THE ASSESSMENT METHOD ENUM \
            # IT WILL CAUSE BUGS IF THE CALCULATOR IS NOT IMPLEMENTED! KEEP THESE NAMES UNTIL THE END.
            self.tr("OCRA (Planned)"),
            self.tr("EWAS (Planned)"),
            self.tr("NIOSH (Planned)"),
            self.tr("SNOOK (Planned)"),
        ]
    )
    self.export_frames_checkbox = QCheckBox(text="Export Frames?")
    analysis_lay.addWidget(self.combo_method)
    analysis_lay.addWidget(self.export_frames_checkbox)
    self.btn_analysis: QPushButton = QPushButton(self.tr("🏃 RUN ANALYSIS"))
    self.btn_analysis.setObjectName("AnalyzeBtn")
    analysis_lay.addWidget(self.btn_analysis)
    layout.addWidget(analysis_group)

    # --- REPORTS ---
    report_group: QGroupBox = QGroupBox(self.tr("REPORTS"))
    report_lay: QVBoxLayout = QVBoxLayout(report_group)
    self.btn_report: QPushButton = QPushButton(self.tr("📊 OPEN REPORT DASHBOARD"))
    self.btn_report.setObjectName("ReportBtn")
    report_lay.addWidget(self.btn_report)
    layout.addWidget(report_group)

    # --- VIDEO VISUALIZER ---
    video_group: QGroupBox = QGroupBox(self.tr("VIDEO VISUALIZER"))
    video_lay: QVBoxLayout = QVBoxLayout(video_group)
    self.lbl_video_select: QLabel = QLabel(self.tr("Select Video:"))
    self.lbl_video_select.setObjectName("FieldLabel")
    video_lay.addWidget(self.lbl_video_select)

    self.combo_videos: QComboBox = QComboBox()
    video_lay.addWidget(self.combo_videos)

    self.btn_load_video: QPushButton = QPushButton(self.tr("🎞️ BROWSE OTHER VIDEO"))
    self.btn_play_video: QPushButton = QPushButton(self.tr("▶ PLAY / PAUSE"))
    self.btn_play_video.setEnabled(False)

    # New: Frame Control Layout
    frame_ctrl_lay = QHBoxLayout()
    self.btn_prev_frame = QPushButton("Back (←)")
    self.btn_next_frame = QPushButton("Fwd (→)")
    self.btn_prev_frame.setEnabled(False)
    self.btn_next_frame.setEnabled(False)

    frame_ctrl_lay.addWidget(self.btn_prev_frame)
    frame_ctrl_lay.addWidget(self.btn_next_frame)

    video_lay.addWidget(self.btn_load_video)
    video_lay.addWidget(self.btn_play_video)
    video_lay.addLayout(frame_ctrl_lay)

    layout.addWidget(video_group)

    layout.addStretch()

    # Inside ErgoSidebar._setup_ui, apply this to your buttons:
    self.btn_fmc.setFocusPolicy(Qt.FocusPolicy.NoFocus)
    self.btn_analysis.setFocusPolicy(Qt.FocusPolicy.NoFocus)
    self.btn_play_video.setFocusPolicy(Qt.FocusPolicy.NoFocus)
    self.btn_prev_frame.setFocusPolicy(Qt.FocusPolicy.NoFocus)
    self.btn_next_frame.setFocusPolicy(Qt.FocusPolicy.NoFocus)

    # --- STATUS BOX ---
    self.status_label: QTextEdit = QTextEdit()
    self.status_label.setReadOnly(True)
    self.status_label.setPlainText(self.tr("STATUS: READY"))

    self.status_label.setFrameStyle(QFrame.Shape.NoFrame)
    self.status_label.viewport().setAutoFillBackground(False)
    self.status_label.setMinimumHeight(120)
    self.status_label.setMaximumWidth(300)
    self.status_label.setSizePolicy(
        QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Expanding
    )

    self.scroll_area.setWidget(self.container)
    self.status_label.setAlignment(
        Qt.AlignmentFlag.AlignTop | Qt.AlignmentFlag.AlignLeft
    )
    layout.addWidget(self.status_label)

    # Remove the Docker default features
    self.setFeatures(QDockWidget.DockWidgetFeature.NoDockWidgetFeatures)

    # Optional: Prevents the user from accidentally dragging it out
    self.setAllowedAreas(Qt.DockWidgetArea.LeftDockWidgetArea)

    # 4. Finalize by adding the scroll area to the Sidebar's main layout
    main_layout.addWidget(self.scroll_area)
get_current_session()

Returns the currently selected session name.

Returns:

Name Type Description
str str

The text content of the active session combo box.

Source code in gui\widgets\sidebar.py
369
370
371
372
373
374
375
376
def get_current_session(self) -> str:
    """
    Returns the currently selected session name.

    Returns:
        str (str): The text content of the active session combo box.
    """
    return self.combo_sessions.currentText()
get_current_video()

Returns the currently selected video name.

Returns:

Name Type Description
str str

The text content of the active video combo box.

Source code in gui\widgets\sidebar.py
378
379
380
381
382
383
384
385
def get_current_video(self) -> str:
    """
    Returns the currently selected video name.

    Returns:
        str (str): The text content of the active video combo box.
    """
    return self.combo_videos.currentText()
get_selected_method()

Returns the currently selected analysis method.

Returns:

Name Type Description
str str

The selected method name (e.g., "REBA", "RULA").

Source code in gui\widgets\sidebar.py
387
388
389
390
391
392
393
394
395
def get_selected_method(self) -> str:
    """
    Returns the currently selected analysis method.

    Returns:
        str (str): The selected method name (e.g., "REBA", "RULA").
    """

    return self.combo_method.currentText()
handle_run_analysis()

Map selected GUI configurations to structured downstream events.

Constructs an instance of AnalysisRequest and triggers the run_analysis_clicked signal if parsing matches known AssessmentMethod mappings. This signal proxies the configuration payload directly to the application's frontend orchestrator slot run_analysis.

Returns:

Name Type Description
None None

Emits Qt signals or writes parsing faults to the local terminal view.

Source code in gui\widgets\sidebar.py
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
def handle_run_analysis(self):
    """
    Map selected GUI configurations to structured downstream events.

    Constructs an instance of [AnalysisRequest][gui.utils.models.AnalysisRequest]
    and triggers the `run_analysis_clicked` signal if parsing matches known
    [AssessmentMethod][gui.utils.constants.AssessmentMethod] mappings. This
    signal proxies the configuration payload directly to the application's
    frontend orchestrator slot [run_analysis][gui.frontend.MainWindow.run_analysis].

    Returns:
        None (None): Emits Qt signals or writes parsing faults to the local terminal view.
    """
    selected_method: str = self.get_selected_method().upper()
    try:
        selected_method: str = self.combo_method.currentText().upper()
        method: AssessmentMethod = AssessmentMethod[selected_method]
        self.run_analysis_clicked.emit(
            AnalysisRequest(
                method=method,
                export_frames=self.export_frames_checkbox.isChecked(),
            )
        )
    except KeyError:
        self.status_label.setText(f"{selected_method} is not implemented.")
set_status(text)

Update the status label text in the UI.

Parameters:

Name Type Description Default
text str

The status message to display.

required

Returns:

Name Type Description
None None

Updates the status_label widget text.

Source code in gui\widgets\sidebar.py
357
358
359
360
361
362
363
364
365
366
367
def set_status(self, text: str) -> None:
    """
    Update the status label text in the UI.

    Args:
        text (str): The status message to display.

    Returns:
        None (None): Updates the `status_label` widget text.
    """
    self.status_label.setText(f"STATUS: {text}")
update_sessions(sessions)

Refresh the session selection list.

Parameters:

Name Type Description Default
sessions list[str]

A list of session folder names found in the root directory.

required

Returns:

Name Type Description
None None

Updates the combo_sessions widget.

Source code in gui\widgets\sidebar.py
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
def update_sessions(self, sessions: list[str]) -> None:
    """
    Refresh the session selection list.

    Args:
        sessions (list[str]): A `list` of session folder names found in the root directory.

    Returns:
        None (None): Updates the `combo_sessions` widget.
    """
    self.combo_sessions.blockSignals(True)
    self.combo_sessions.clear()
    self.combo_sessions.addItems(sessions)
    self.combo_sessions.blockSignals(False)
    self.set_status(f"Found {len(sessions)} sessions.")
update_videos(videos)

Refresh the available video list and update playback controls.

Parameters:

Name Type Description Default
videos list[str]

A list of video file names associated with the current session.

required

Returns:

Name Type Description
None None

Updates the combo_videos widget and enables/disables the play button.

Source code in gui\widgets\sidebar.py
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
def update_videos(self, videos: list[str]) -> None:
    """
    Refresh the available video list and update playback controls.

    Args:
        videos (list[str]): A `list` of video file names associated with the current session.

    Returns:
        None (None): Updates the `combo_videos` widget and enables/disables the play button.
    """

    self.combo_videos.blockSignals(True)
    self.combo_videos.clear()
    self.combo_videos.addItems(videos)
    self.combo_videos.blockSignals(False)
    self.btn_play_video.setEnabled(len(videos) > 0)
    self.btn_next_frame.setEnabled(True)
    self.btn_prev_frame.setEnabled(True)

options: show_root_heading: true

gui.widgets.video_canvas

ErgoMoCap: Video Canvas

Visual rendering components for motion capture and ergonomic feedback.

This module provides the VideoCanvas class, a specialized QLabel designed to render synchronized video streams and skeletal overlays. It handles the real-time conversion of OpenCV frames to PySide6 graphics, applies dynamic scaling while maintaining aspect ratios, and overlays coordinate-based landmarks color-coded by ergonomic risk levels.

TODO refractor this code, it's still trying to paint while the annotation is done with freemocap!

Classes

Landmark

Mock class defining the structure of a pose landmark.

This class serves as a lightweight data structure to represent spatial coordinates and detection confidence without requiring the full MediaPipe dependency.

Attributes:

Name Type Description
x float

Normalized horizontal coordinate (0.0 to 1.0).

y float

Normalized vertical coordinate (0.0 to 1.0).

z float

Normalized depth coordinate.

visibility float

Confidence score of the landmark detection.

Source code in gui\widgets\video_canvas.py
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
class Landmark:
    """
    Mock class defining the structure of a pose landmark.

    This class serves as a lightweight data structure to represent spatial
    coordinates and detection confidence without requiring the full MediaPipe
    dependency.

    Attributes:
        x (float): Normalized horizontal coordinate (0.0 to 1.0).
        y (float): Normalized vertical coordinate (0.0 to 1.0).
        z (float): Normalized depth coordinate.
        visibility (float): Confidence score of the landmark detection.
    """

    x: float
    y: float
    z: float
    visibility: float

VideoCanvas

Bases: QLabel

A custom QLabel widget for rendering video frames and overlaying motion capture data.

This class handles the conversion of numpy.ndarray (OpenCV frames) to QPixmap, calculates centered scaling for display, and paints skeletal landmarks with dynamic color-coding based on RiskLevel.

Attributes:

Name Type Description
seek_requested Signal

Emits the target frame index (int).

toggle_requested Signal

Emits a request to play/pause.

final_pixmap QPixmap | None

The processed and scaled image ready for painting.

landmarks list[Any]

A list of detected pose landmarks for the current frame.

frame_num int

Current frame index for the overlay display.

total_frames int

Total frame count for scroller synchronization.

risk_color QColor

The color assigned to landmarks based on the ergonomic risk level.

risk_text str

String representation of the current risk level.

show_frame_overlay bool

Toggle for displaying frame metadata on screen.

color_map dict[RiskLevel, str]

Mapping of risk levels to hexadecimal color codes.

Methods:

Name Description
__init__

Initializes the VideoCanvas with default dimensions and styling.

set_frame_overlay

Toggle the visibility of the on-screen frame and risk metadata.

update_position

Update the internal frame counters for seeker rendering.

mousePressEvent

Handle mouse clicks for video seeking and playback toggling.

update_frame

Update the canvas with a new video frame and associated metadata.

paintEvent

Handles the rendering of the frame and the skeletal overlays.

_draw_seeker

Draw the interactive seeker bar at the bottom of the video.

_draw_overlay

Draw technical metadata onto the video surface.

Source code in gui\widgets\video_canvas.py
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
class VideoCanvas(QLabel):
    """
    A custom QLabel widget for rendering video frames and overlaying motion capture data.

    This class handles the conversion of `numpy.ndarray` (OpenCV frames) to `QPixmap`,
    calculates centered scaling for display, and paints skeletal landmarks with
    dynamic color-coding based on [RiskLevel][gui.utils.constants.RiskLevel].

    Attributes:
        seek_requested (Signal): Emits the target frame index (`int`).
        toggle_requested (Signal): Emits a request to play/pause.
        final_pixmap (QPixmap | None): The processed and scaled image ready for painting.
        landmarks (list[Any]): A `list` of detected pose landmarks for the current frame.
        frame_num (int): Current frame index for the overlay display.
        total_frames (int): Total frame count for scroller synchronization.
        risk_color (QColor): The color assigned to landmarks based on the ergonomic risk level.
        risk_text (str): String representation of the current risk level.
        show_frame_overlay (bool): Toggle for displaying frame metadata on screen.
        color_map (dict[RiskLevel, str]): Mapping of risk levels to hexadecimal color codes.

    Methods:
        __init__: Initializes the VideoCanvas with default dimensions and styling.
        set_frame_overlay: Toggle the visibility of the on-screen frame and risk metadata.
        update_position: Update the internal frame counters for seeker rendering.
        mousePressEvent: Handle mouse clicks for video seeking and playback toggling.
        update_frame: Update the canvas with a new video frame and associated metadata.
        paintEvent: Handles the rendering of the frame and the skeletal overlays.
        _draw_seeker: Draw the interactive seeker bar at the bottom of the video.
        _draw_overlay: Draw technical metadata onto the video surface.
    """

    seek_requested: Signal = Signal(
        int
    )  # Don't delete, connects to frontend -> backend -> video_worker
    toggle_requested: Signal = (
        Signal()
    )  # Don't delete, connects to frontend -> backend -> video_worker

    def __init__(self) -> None:
        """
        Initializes the VideoCanvas with default dimensions and styling.

        Returns:
            None (None): Initializer return.
        """
        super().__init__()
        self.setMinimumSize(854, 480)
        self.setAlignment(Qt.AlignmentFlag.AlignCenter)
        self.setObjectName("VideoCanvas")

        self.final_pixmap: QPixmap | None = None
        self.landmarks: list[Any] = []
        self.frame_num: int = 0
        self.total_frames: int = 0
        self.risk_color: QColor = QColor("#9ece6a")
        self.risk_text: str = ""
        self.show_frame_overlay: bool = True

        self.color_map: dict[RiskLevel, str] = {
            RiskLevel.VERY_HIGH: "#f7768e",  # Red
            RiskLevel.HIGH: "#ff9e64",  # Orange
            RiskLevel.MEDIUM: "#e0af68",  # Yellow/Gold
            RiskLevel.LOW: "#9ece6a",  # Green
            RiskLevel.NEGLIGIBLE: "#73daca",  # Cyan/Teal
        }

    def set_frame_overlay(self, show_frame_overlay: bool) -> None:
        """
        Toggle the visibility of the on-screen frame and risk metadata.

        Args:
            show_frame_overlay (bool): Whether to render the metadata text.

        Returns:
            None (None): Updates the overlay state.
        """
        self.show_frame_overlay = show_frame_overlay

    @Slot(VideoPosition)
    def update_position(self, video_position: VideoPosition) -> None:
        """
        Update the internal frame counters for seeker rendering.

        Args:
            video_position (VideoPosition): Structured data model containing indices
                mapped via [VideoPosition][gui.utils.models.VideoPosition].

        Returns:
            None (None): Updates internal state.
        """
        self.frame_num = video_position.current_frame
        self.total_frames = video_position.total_frames

    def mousePressEvent(self, event: QMouseEvent) -> None:
        """
        Handle mouse clicks for video seeking and playback toggling.

        Detects if a click lands within the bottom seeker boundary to dispatch frame skips
        via `seek_requested`, or hits the focal display canvas to toggle media playback.

        Args:
            event (QMouseEvent): Mouse event containing click coordinates.

        Returns:
            None (None): Emits control signals.
        """
        if not self.final_pixmap:
            return

        rect = self.contentsRect()
        # Video area dimensions
        vw, vh = self.final_pixmap.width(), self.final_pixmap.height()
        vx = rect.left() + (rect.width() - vw) // 2
        vy = rect.top() + (rect.height() - vh) // 2

        # Check if click is in the bottom seeker area (last 20px of video)
        if vy + vh - 20 <= event.position().y() <= vy + vh:
            relative_x = event.position().x() - vx
            if 0 <= relative_x <= vw:
                target_frame = int((relative_x / vw) * self.total_frames)
                self.seek_requested.emit(target_frame)
        else:
            self.toggle_requested.emit()

    @Slot(FrameData)
    def update_frame(self, frame_data: FrameData) -> None:
        """
        Update the canvas with a new video frame and associated metadata.

        Converts the raw image data to a scaled `QPixmap`, determines the
        visual color for landmarks based on the ergonomic risk level, and
        triggers a repaint.

        Args:
            frame_data (FrameData): Frame data capsule defined by
                [FrameData][gui.utils.models.FrameData], containing the underlying
                `numpy.ndarray` matrix image buffer and landmark listings.

        Returns:
            None (None): Emits an internal update request to trigger `paintEvent`.
        """
        if frame_data.image is None or frame_data.image.size == 0:
            return

        h, w, ch = frame_data.image.shape
        rgb_frame: MatLike = cv2.cvtColor(frame_data.image, cv2.COLOR_BGR2RGB)
        q_img: QImage = QImage(
            rgb_frame.data, w, h, ch * w, QImage.Format.Format_RGB888
        ).copy()
        pix: QPixmap = QPixmap.fromImage(q_img)

        safe_size: QSize = self.contentsRect().size()

        self.final_pixmap = pix.scaled(
            safe_size,
            Qt.AspectRatioMode.KeepAspectRatio,
            Qt.TransformationMode.SmoothTransformation,
        )
        self.landmarks = frame_data.landmarks

        risk_value = frame_data.risk if frame_data.risk else RiskLevel.NEGLIGIBLE
        risk_score = frame_data.score if frame_data.score else 0

        if isinstance(risk_value, str):
            self.risk_text = risk_value + " " + str(risk_score)
            self.risk_color = QColor(
                next(
                    (c for r, c in self.color_map.items() if r.value == risk_value),
                    "#73daca",
                )
            )
        else:
            self.risk_text = risk_value.value + " " + str(risk_score)
            self.risk_color = QColor(self.color_map.get(risk_value, "#73daca"))

        self.update()

    def paintEvent(self, event: QPaintEvent) -> None:
        """
        Handles the rendering of the frame and the skeletal overlays.

        Calculates the centered position of the scaled pixmap within the widget's
        available space and draws landmarks as ellipses if they meet the
        visibility threshold.

        Args:
            event (QPaintEvent): The paint event triggered by the system or `update()`.

        Returns:
            None (None): Draws directly to the widget's surface.
        """
        if not self.final_pixmap:
            super().paintEvent(event)
            return
        painter: QPainter = QPainter(self)

        rect: QRect = self.contentsRect()

        x: int = rect.left() + (rect.width() - self.final_pixmap.width()) // 2
        y: int = rect.top() + (rect.height() - self.final_pixmap.height()) // 2

        painter.drawPixmap(x, y, self.final_pixmap)

        if self.landmarks:
            painter.setRenderHint(QPainter.RenderHint.Antialiasing)
            painter.setPen(QPen(self.risk_color, 3))
            for lm in self.landmarks:
                if hasattr(lm, "visibility") and lm.visibility > 0.5:
                    px = x + (lm.x * self.final_pixmap.width())
                    py = y + (lm.y * self.final_pixmap.height())
                    painter.drawEllipse(QPointF(px, py), 4, 4)

        if self.show_frame_overlay:
            self._draw_overlay(painter, x, y)

        self._draw_seeker(
            painter, x, y, self.final_pixmap.width(), self.final_pixmap.height()
        )

    def _draw_seeker(self, painter: QPainter, x: int, y: int, w: int, h: int) -> None:
        """
        Draw the interactive seeker bar at the bottom of the video.

        Args:
            painter (QPainter): The active painting object.
            x (int): Video horizontal offset.
            y (int): Video vertical offset.
            w (int): Video width.
            h (int): Video height.

        Returns:
            None (None): Draws seeker bar.
        """
        if self.total_frames <= 0:
            return

        seeker_y = y + h - 10
        painter.setPen(Qt.PenStyle.NoPen)

        # Background bar
        painter.setBrush(QColor(100, 100, 100, 150))
        painter.drawRect(x, seeker_y, w, 5)

        # Progress bar
        progress_w = int((self.frame_num / self.total_frames) * w)
        painter.setBrush(self.risk_color)
        painter.drawRect(x, seeker_y, progress_w, 5)

    def _draw_overlay(self, painter: QPainter, x: int, y: int):
        """
        Draw technical metadata onto the video surface.

        Args:
            painter (QPainter): The active painting object.
            x (int): Horizontal offset of the scaled video.
            y (int): Vertical offset of the scaled video.

        Returns:
            None (None): Renders text using the active painter.
        """
        painter.setPen(QColor("#ff0000ff"))
        painter.setFont(self.font())
        painter.drawText(x + 10, y + 20, f"FRAME: #{self.frame_num:05d}")
        painter.setPen(QPen(self.risk_color, 2))
        painter.drawText(x + 10, y + 35, f"RISK: {self.risk_text.upper()}")
Functions
_draw_overlay(painter, x, y)

Draw technical metadata onto the video surface.

Parameters:

Name Type Description Default
painter QPainter

The active painting object.

required
x int

Horizontal offset of the scaled video.

required
y int

Vertical offset of the scaled video.

required

Returns:

Name Type Description
None None

Renders text using the active painter.

Source code in gui\widgets\video_canvas.py
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
def _draw_overlay(self, painter: QPainter, x: int, y: int):
    """
    Draw technical metadata onto the video surface.

    Args:
        painter (QPainter): The active painting object.
        x (int): Horizontal offset of the scaled video.
        y (int): Vertical offset of the scaled video.

    Returns:
        None (None): Renders text using the active painter.
    """
    painter.setPen(QColor("#ff0000ff"))
    painter.setFont(self.font())
    painter.drawText(x + 10, y + 20, f"FRAME: #{self.frame_num:05d}")
    painter.setPen(QPen(self.risk_color, 2))
    painter.drawText(x + 10, y + 35, f"RISK: {self.risk_text.upper()}")
_draw_seeker(painter, x, y, w, h)

Draw the interactive seeker bar at the bottom of the video.

Parameters:

Name Type Description Default
painter QPainter

The active painting object.

required
x int

Video horizontal offset.

required
y int

Video vertical offset.

required
w int

Video width.

required
h int

Video height.

required

Returns:

Name Type Description
None None

Draws seeker bar.

Source code in gui\widgets\video_canvas.py
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
def _draw_seeker(self, painter: QPainter, x: int, y: int, w: int, h: int) -> None:
    """
    Draw the interactive seeker bar at the bottom of the video.

    Args:
        painter (QPainter): The active painting object.
        x (int): Video horizontal offset.
        y (int): Video vertical offset.
        w (int): Video width.
        h (int): Video height.

    Returns:
        None (None): Draws seeker bar.
    """
    if self.total_frames <= 0:
        return

    seeker_y = y + h - 10
    painter.setPen(Qt.PenStyle.NoPen)

    # Background bar
    painter.setBrush(QColor(100, 100, 100, 150))
    painter.drawRect(x, seeker_y, w, 5)

    # Progress bar
    progress_w = int((self.frame_num / self.total_frames) * w)
    painter.setBrush(self.risk_color)
    painter.drawRect(x, seeker_y, progress_w, 5)
mousePressEvent(event)

Handle mouse clicks for video seeking and playback toggling.

Detects if a click lands within the bottom seeker boundary to dispatch frame skips via seek_requested, or hits the focal display canvas to toggle media playback.

Parameters:

Name Type Description Default
event QMouseEvent

Mouse event containing click coordinates.

required

Returns:

Name Type Description
None None

Emits control signals.

Source code in gui\widgets\video_canvas.py
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
def mousePressEvent(self, event: QMouseEvent) -> None:
    """
    Handle mouse clicks for video seeking and playback toggling.

    Detects if a click lands within the bottom seeker boundary to dispatch frame skips
    via `seek_requested`, or hits the focal display canvas to toggle media playback.

    Args:
        event (QMouseEvent): Mouse event containing click coordinates.

    Returns:
        None (None): Emits control signals.
    """
    if not self.final_pixmap:
        return

    rect = self.contentsRect()
    # Video area dimensions
    vw, vh = self.final_pixmap.width(), self.final_pixmap.height()
    vx = rect.left() + (rect.width() - vw) // 2
    vy = rect.top() + (rect.height() - vh) // 2

    # Check if click is in the bottom seeker area (last 20px of video)
    if vy + vh - 20 <= event.position().y() <= vy + vh:
        relative_x = event.position().x() - vx
        if 0 <= relative_x <= vw:
            target_frame = int((relative_x / vw) * self.total_frames)
            self.seek_requested.emit(target_frame)
    else:
        self.toggle_requested.emit()
paintEvent(event)

Handles the rendering of the frame and the skeletal overlays.

Calculates the centered position of the scaled pixmap within the widget's available space and draws landmarks as ellipses if they meet the visibility threshold.

Parameters:

Name Type Description Default
event QPaintEvent

The paint event triggered by the system or update().

required

Returns:

Name Type Description
None None

Draws directly to the widget's surface.

Source code in gui\widgets\video_canvas.py
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
def paintEvent(self, event: QPaintEvent) -> None:
    """
    Handles the rendering of the frame and the skeletal overlays.

    Calculates the centered position of the scaled pixmap within the widget's
    available space and draws landmarks as ellipses if they meet the
    visibility threshold.

    Args:
        event (QPaintEvent): The paint event triggered by the system or `update()`.

    Returns:
        None (None): Draws directly to the widget's surface.
    """
    if not self.final_pixmap:
        super().paintEvent(event)
        return
    painter: QPainter = QPainter(self)

    rect: QRect = self.contentsRect()

    x: int = rect.left() + (rect.width() - self.final_pixmap.width()) // 2
    y: int = rect.top() + (rect.height() - self.final_pixmap.height()) // 2

    painter.drawPixmap(x, y, self.final_pixmap)

    if self.landmarks:
        painter.setRenderHint(QPainter.RenderHint.Antialiasing)
        painter.setPen(QPen(self.risk_color, 3))
        for lm in self.landmarks:
            if hasattr(lm, "visibility") and lm.visibility > 0.5:
                px = x + (lm.x * self.final_pixmap.width())
                py = y + (lm.y * self.final_pixmap.height())
                painter.drawEllipse(QPointF(px, py), 4, 4)

    if self.show_frame_overlay:
        self._draw_overlay(painter, x, y)

    self._draw_seeker(
        painter, x, y, self.final_pixmap.width(), self.final_pixmap.height()
    )
set_frame_overlay(show_frame_overlay)

Toggle the visibility of the on-screen frame and risk metadata.

Parameters:

Name Type Description Default
show_frame_overlay bool

Whether to render the metadata text.

required

Returns:

Name Type Description
None None

Updates the overlay state.

Source code in gui\widgets\video_canvas.py
146
147
148
149
150
151
152
153
154
155
156
def set_frame_overlay(self, show_frame_overlay: bool) -> None:
    """
    Toggle the visibility of the on-screen frame and risk metadata.

    Args:
        show_frame_overlay (bool): Whether to render the metadata text.

    Returns:
        None (None): Updates the overlay state.
    """
    self.show_frame_overlay = show_frame_overlay
update_frame(frame_data)

Update the canvas with a new video frame and associated metadata.

Converts the raw image data to a scaled QPixmap, determines the visual color for landmarks based on the ergonomic risk level, and triggers a repaint.

Parameters:

Name Type Description Default
frame_data FrameData

Frame data capsule defined by FrameData, containing the underlying numpy.ndarray matrix image buffer and landmark listings.

required

Returns:

Name Type Description
None None

Emits an internal update request to trigger paintEvent.

Source code in gui\widgets\video_canvas.py
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
@Slot(FrameData)
def update_frame(self, frame_data: FrameData) -> None:
    """
    Update the canvas with a new video frame and associated metadata.

    Converts the raw image data to a scaled `QPixmap`, determines the
    visual color for landmarks based on the ergonomic risk level, and
    triggers a repaint.

    Args:
        frame_data (FrameData): Frame data capsule defined by
            [FrameData][gui.utils.models.FrameData], containing the underlying
            `numpy.ndarray` matrix image buffer and landmark listings.

    Returns:
        None (None): Emits an internal update request to trigger `paintEvent`.
    """
    if frame_data.image is None or frame_data.image.size == 0:
        return

    h, w, ch = frame_data.image.shape
    rgb_frame: MatLike = cv2.cvtColor(frame_data.image, cv2.COLOR_BGR2RGB)
    q_img: QImage = QImage(
        rgb_frame.data, w, h, ch * w, QImage.Format.Format_RGB888
    ).copy()
    pix: QPixmap = QPixmap.fromImage(q_img)

    safe_size: QSize = self.contentsRect().size()

    self.final_pixmap = pix.scaled(
        safe_size,
        Qt.AspectRatioMode.KeepAspectRatio,
        Qt.TransformationMode.SmoothTransformation,
    )
    self.landmarks = frame_data.landmarks

    risk_value = frame_data.risk if frame_data.risk else RiskLevel.NEGLIGIBLE
    risk_score = frame_data.score if frame_data.score else 0

    if isinstance(risk_value, str):
        self.risk_text = risk_value + " " + str(risk_score)
        self.risk_color = QColor(
            next(
                (c for r, c in self.color_map.items() if r.value == risk_value),
                "#73daca",
            )
        )
    else:
        self.risk_text = risk_value.value + " " + str(risk_score)
        self.risk_color = QColor(self.color_map.get(risk_value, "#73daca"))

    self.update()
update_position(video_position)

Update the internal frame counters for seeker rendering.

Parameters:

Name Type Description Default
video_position VideoPosition

Structured data model containing indices mapped via VideoPosition.

required

Returns:

Name Type Description
None None

Updates internal state.

Source code in gui\widgets\video_canvas.py
158
159
160
161
162
163
164
165
166
167
168
169
170
171
@Slot(VideoPosition)
def update_position(self, video_position: VideoPosition) -> None:
    """
    Update the internal frame counters for seeker rendering.

    Args:
        video_position (VideoPosition): Structured data model containing indices
            mapped via [VideoPosition][gui.utils.models.VideoPosition].

    Returns:
        None (None): Updates internal state.
    """
    self.frame_num = video_position.current_frame
    self.total_frames = video_position.total_frames

options: show_root_heading: true

gui.widgets.table_report_widget

ErgoMoCap: Table Report Widget

Standardized Tabular Visualization Component for Ergonomic Assessment Metrics.

This module provides the TableReportWidget, a high-level UI component used to display complex calculation results in a clean, professional table format. It leverages a Strategy-based architecture to handle different assessment protocols (RULA, REBA, etc.) while maintaining a consistent look and feel.

The widget is designed to integrate with a custom CSS framework by utilizing specific ObjectNames and UserRole data roles for dynamic styling.

Classes

TableReportWidget

Bases: QWidget

A professional UI component that renders data based on the provided strategy.

This widget provides a standardized interface for displaying ergonomic assessment results (such as RULA or REBA) within the ErgoMoCap GUI. It utilizes a strategy pattern to transform raw calculation data into formatted table rows and applies styling via ObjectNames for integration with the 'VOLKS-TYPO' CSS framework.

Attributes:

Name Type Description
strategy ReportStrategy

The active reporting protocol implementation.

title str

The title string used for the header label.

main_layout QVBoxLayout

The primary layout container.

title_lbl QLabel

The label displaying the assessment method name.

table QTableWidget

The internal table used to render metrics and values.

Methods:

Name Description
update_results

Execute the current strategy and update the UI table with new data.

update_strategy

Swap the current reporting strategy used by the widget.

_insert_row

Insert and format a new row in the internal QTableWidget based on ResultRow properties.

Source code in gui\widgets\table_report_widget.py
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
class TableReportWidget(QWidget):
    """
    A professional UI component that renders data based on the provided strategy.

    This widget provides a standardized interface for displaying ergonomic assessment
    results (such as RULA or REBA) within the ErgoMoCap GUI. It utilizes a strategy
    pattern to transform raw calculation data into formatted table rows and applies
    styling via `ObjectNames` for integration with the 'VOLKS-TYPO' CSS framework.

    Attributes:
        strategy (ReportStrategy): The active reporting protocol implementation.
        title (str): The title string used for the header label.
        main_layout (QVBoxLayout): The primary layout container.
        title_lbl (QLabel): The label displaying the assessment method name.
        table (QTableWidget): The internal table used to render metrics and values.

    Methods:
        update_results: Execute the current strategy and update the UI table with new data.
        update_strategy: Swap the current reporting strategy used by the widget.
        _insert_row: Insert and format a new row in the internal QTableWidget based on ResultRow properties.
    """

    def __init__(
        self, title: str, strategy: ReportStrategy, parent: QWidget | None = None
    ):
        """
        Initialize the analysis report widget.

        [ReportStrategy][gui.core.report_strategies.ReportStrategy]

        Args:
            title (str): The title string displayed in the header.
            strategy (ReportStrategy): An object implementing the strategy protocol.
            parent (QWidget | None): Optional parent widget. Defaults to `None`.

        Returns:
            None (None): Initializer return.
        """
        super().__init__(parent)
        self.strategy: ReportStrategy = strategy
        self.title: str = title

        self.main_layout: QVBoxLayout = QVBoxLayout(self)
        self.main_layout.setContentsMargins(0, 0, 0, 0)

        # Title with ObjectName for H3 styling
        self.title_lbl: QLabel = QLabel(f"// {title.upper()}")
        self.title_lbl.setObjectName("h3")
        self.main_layout.addWidget(self.title_lbl)

        # Table Setup
        self.table: QTableWidget = QTableWidget(0, 2)
        self.table.setObjectName("InfoTable")
        self.table.setHorizontalHeaderLabels(["METRIC", "VALUE"])
        self.table.verticalHeader().setVisible(False)
        # self.table.setAlternatingRowColors(True)
        self.table.setEditTriggers(QAbstractItemView.EditTrigger.NoEditTriggers)

        header: QHeaderView = self.table.horizontalHeader()
        if header:
            header.setSectionResizeMode(0, QHeaderView.ResizeMode.Stretch)
            header.setSectionResizeMode(1, QHeaderView.ResizeMode.Fixed)
            self.table.setColumnWidth(1, 100)

        self.main_layout.addWidget(self.table)

    def update_results(
        self,
        summary_data: dict[str, str],
    ) -> None:
        """
        Execute the current strategy and update the UI table with new data.

        Args:
            summary_data (dict[str, str]): A `dict` containing the raw metric keys and values
                retrieved from the calculators.

        Returns:
            None (None): Clears the existing rows and repopulates the table in-place.
        """

        self.title_lbl.setText(self.strategy.name)

        self.table.setRowCount(0)

        formatted_rows = self.strategy.format(summary_data)

        for row_data in formatted_rows:
            self._insert_row(row_data)

    def _insert_row(self, row_data: ResultRow) -> None:
        """
        Insert and format a new row in the internal `QTableWidget`.

        Handles logic for spans (headers), alignment, and conditional formatting based on the
        properties of the provided `ResultRow`.

        [ResultRow][gui.core.report_strategies.ResultRow]

        Args:
            row_data (ResultRow): The data object containing values and formatting flags.

        Returns:
            None (None): Modifies the internal `table` state by appending a formatted row.
        """
        row_idx: int = self.table.rowCount()
        self.table.insertRow(row_idx)

        # Label Item
        label_text: str = f"  {row_data.label}"
        label_item = QTableWidgetItem(label_text)

        # Value Item
        display_val = row_data.value
        if row_data.is_angle and isinstance(display_val, (int, float)):
            display_val = f"{display_val:.1f}°"
        value_item: QTableWidgetItem = QTableWidgetItem(str(display_val))
        value_item.setTextAlignment(Qt.AlignmentFlag.AlignCenter)

        # Apply Visual Logic via Data Roles (Safe for CSS)
        if row_data.is_header:
            label_item.setData(Qt.ItemDataRole.UserRole, "header")
            value_item.setData(Qt.ItemDataRole.UserRole, "header")
            self.table.setSpan(row_idx, 0, 1, 2)

        # if row_data.is_critical:
        #     value_item.setData(Qt.ItemDataRole.UserRole, "critical")
        #     # Inline highlight for total clarity
        #     value_item.setBackground(QColor("#ff3c00")) TODO delete this

        self.table.setItem(row_idx, 0, label_item)
        self.table.setItem(row_idx, 1, value_item)

    def update_strategy(self, strategy: ReportStrategy) -> None:
        """
        Swap the current reporting strategy used by the widget.

        [ReportStrategy][gui.core.report_strategies.ReportStrategy]

        Args:
            strategy (ReportStrategy): The new strategy to apply for future updates.

        Returns:
            None (None): Updates the internal reference and triggers a widget redraw.
        """
        self.strategy: ReportStrategy = strategy
        self.update()
Functions
_insert_row(row_data)

Insert and format a new row in the internal QTableWidget.

Handles logic for spans (headers), alignment, and conditional formatting based on the properties of the provided ResultRow.

ResultRow

Parameters:

Name Type Description Default
row_data ResultRow

The data object containing values and formatting flags.

required

Returns:

Name Type Description
None None

Modifies the internal table state by appending a formatted row.

Source code in gui\widgets\table_report_widget.py
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
def _insert_row(self, row_data: ResultRow) -> None:
    """
    Insert and format a new row in the internal `QTableWidget`.

    Handles logic for spans (headers), alignment, and conditional formatting based on the
    properties of the provided `ResultRow`.

    [ResultRow][gui.core.report_strategies.ResultRow]

    Args:
        row_data (ResultRow): The data object containing values and formatting flags.

    Returns:
        None (None): Modifies the internal `table` state by appending a formatted row.
    """
    row_idx: int = self.table.rowCount()
    self.table.insertRow(row_idx)

    # Label Item
    label_text: str = f"  {row_data.label}"
    label_item = QTableWidgetItem(label_text)

    # Value Item
    display_val = row_data.value
    if row_data.is_angle and isinstance(display_val, (int, float)):
        display_val = f"{display_val:.1f}°"
    value_item: QTableWidgetItem = QTableWidgetItem(str(display_val))
    value_item.setTextAlignment(Qt.AlignmentFlag.AlignCenter)

    # Apply Visual Logic via Data Roles (Safe for CSS)
    if row_data.is_header:
        label_item.setData(Qt.ItemDataRole.UserRole, "header")
        value_item.setData(Qt.ItemDataRole.UserRole, "header")
        self.table.setSpan(row_idx, 0, 1, 2)

    # if row_data.is_critical:
    #     value_item.setData(Qt.ItemDataRole.UserRole, "critical")
    #     # Inline highlight for total clarity
    #     value_item.setBackground(QColor("#ff3c00")) TODO delete this

    self.table.setItem(row_idx, 0, label_item)
    self.table.setItem(row_idx, 1, value_item)
update_results(summary_data)

Execute the current strategy and update the UI table with new data.

Parameters:

Name Type Description Default
summary_data dict[str, str]

A dict containing the raw metric keys and values retrieved from the calculators.

required

Returns:

Name Type Description
None None

Clears the existing rows and repopulates the table in-place.

Source code in gui\widgets\table_report_widget.py
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
def update_results(
    self,
    summary_data: dict[str, str],
) -> None:
    """
    Execute the current strategy and update the UI table with new data.

    Args:
        summary_data (dict[str, str]): A `dict` containing the raw metric keys and values
            retrieved from the calculators.

    Returns:
        None (None): Clears the existing rows and repopulates the table in-place.
    """

    self.title_lbl.setText(self.strategy.name)

    self.table.setRowCount(0)

    formatted_rows = self.strategy.format(summary_data)

    for row_data in formatted_rows:
        self._insert_row(row_data)
update_strategy(strategy)

Swap the current reporting strategy used by the widget.

ReportStrategy

Parameters:

Name Type Description Default
strategy ReportStrategy

The new strategy to apply for future updates.

required

Returns:

Name Type Description
None None

Updates the internal reference and triggers a widget redraw.

Source code in gui\widgets\table_report_widget.py
184
185
186
187
188
189
190
191
192
193
194
195
196
197
def update_strategy(self, strategy: ReportStrategy) -> None:
    """
    Swap the current reporting strategy used by the widget.

    [ReportStrategy][gui.core.report_strategies.ReportStrategy]

    Args:
        strategy (ReportStrategy): The new strategy to apply for future updates.

    Returns:
        None (None): Updates the internal reference and triggers a widget redraw.
    """
    self.strategy: ReportStrategy = strategy
    self.update()

options: show_root_heading: true

gui.widgets.chart_report_widget

ErgoMoCap: Chart Report Widget

Specialized Visualization Component for Ergonomic Analytics.

This module provides the ChartReportWidget, which encapsulates Matplotlib functionality within the PySide6 ecosystem. It is designed to handle the automated preprocessing and rendering of ergonomic risk and score distributions.

The widget integrates with the THEMES configuration to ensure visual consistency across the ErgoMoCap dashboard.

Classes

ChartReportWidget

Bases: QWidget

Encapsulated Matplotlib widget for ErgoMoCap.

Handles rendering of ergonomic distributions and provides utility methods for capturing figure states as raw byte streams for document generation.

Attributes:

Name Type Description
current_theme ErgoTheme

The active UI theme name (e.g., "dark", "light").

canvas FigureCanvasQTAgg

The Qt-compatible drawing surface for Matplotlib figures.

main_layout QVBoxLayout

The primary layout container.

Methods:

Name Description
update_chart

Hyper-abstracted update method for data visualization (preprocesses and renders pie charts).

get_image_bytes

Captures the current figure as a PNG-formatted byte stream for PDF/DOCX export.

Source code in gui\widgets\chart_report_widget.py
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
class ChartReportWidget(QWidget):
    """
    Encapsulated Matplotlib widget for ErgoMoCap.

    Handles rendering of ergonomic distributions and provides utility methods for
    capturing figure states as raw byte streams for document generation.

    Attributes:
        current_theme (ErgoTheme): The active UI theme name (e.g., "dark", "light").
        canvas (FigureCanvasQTAgg): The Qt-compatible drawing surface for Matplotlib figures.
        main_layout (QVBoxLayout): The primary layout container.

    Methods:
        update_chart: Hyper-abstracted update method for data visualization (preprocesses and renders pie charts).
        get_image_bytes: Captures the current figure as a PNG-formatted byte stream for PDF/DOCX export.
    """

    def __init__(self, current_theme: ErgoTheme, parent=None):
        """
        Initialize the Chart Report Widget.

        Args:
            current_theme (ErgoTheme): The initial theme identifier used for styling.
            parent (QWidget | None): The parent widget. Defaults to `None`.

        Returns:
            None (None): Initializer return.
        """
        super().__init__(parent)
        self.current_theme = current_theme
        self.canvas = FigureCanvasQTAgg(Figure(figsize=(4, 4), dpi=100))
        self.canvas.figure.patch.set_facecolor(THEMES[self.current_theme]["background"])

        self.main_layout = QVBoxLayout(self)
        self.main_layout.setContentsMargins(0, 0, 0, 0)
        self.main_layout.addWidget(self.canvas)

    def update_chart(
        self, data: Union[pd.DataFrame, pd.Series, dict], metric: MetricType
    ):
        """
        Hyper-abstracted update method for data visualization.

        This method handles DataFrame preprocessing, value counting, and binning
        automatically based on the provided [MetricType][gui.utils.constants.MetricType].
        It renders the results as a pie chart styled according to the project's color maps.

        [Figure][matplotlib.figure.Figure]

        Args:
            data (pd.DataFrame | pd.Series | dict): The source data to visualize.
                Can be a full project `DataFrame` or pre-summarized data.
            metric (MetricType): The type of ergonomic metric being plotted.

        Returns:
            None (None): Clears and redraws the `canvas`.
        """
        self.canvas.figure.clear()
        ax = self.canvas.figure.add_subplot(111)
        theme: dict[str, str] = THEMES[self.current_theme]
        bg_color: str = theme["background"]
        self.canvas.figure.patch.set_facecolor(bg_color)
        ax.set_facecolor(bg_color)
        # 1. Data Normalization
        if isinstance(data, pd.DataFrame):
            raw = data[metric.value]
            plot_data = (
                raw.value_counts()
                if metric == MetricType.RISK
                else pd.cut(
                    raw, bins=range(16), labels=[str(i) for i in range(1, 16)]
                ).value_counts()
            )
        else:
            plot_data = pd.Series(data)

        # 2. Rendering Logic
        if not plot_data.empty:
            # Unpack safely using a single variable to satisfy Pylance's Union type
            results = ax.pie(
                plot_data,
                labels=[str(i) for i in plot_data.index],
                autopct="%1.1f%%",
                colors=COLOR_MAP.get(metric, COLOR_MAP[MetricType.SCORE]),
                startangle=140,
                pctdistance=0.8,
            )

            # The type is tuple[list, list] | tuple[list, list, list]
            # We only iterate over what exists
            for text_list in results[1:]:  # Captures both 'texts' and 'autotexts'
                for t in text_list:
                    t.set_color(theme["text_primary"])
                    t.set_fontsize(9)

        # 3. Dynamic Metadata
        title = self.tr(f"{metric.name.replace('_', ' ')} DISTRIBUTION")
        ax.set_title(title, color=theme["accent"], fontweight="bold", pad=20)

        self.canvas.figure.tight_layout()

        self.canvas.draw()

    def get_image_bytes(self) -> bytes:
        """
        Captures the current figure for PDF/DOCX export.

        [Figure.savefig][matplotlib.figure.Figure.savefig]

        Returns:
            bytes (bytes): A PNG-formatted byte stream of the current Matplotlib figure.
        """
        buf = BytesIO()
        self.canvas.figure.savefig(buf, format="png")
        return buf.getvalue()
Functions
get_image_bytes()

Captures the current figure for PDF/DOCX export.

Figure.savefig

Returns:

Name Type Description
bytes bytes

A PNG-formatted byte stream of the current Matplotlib figure.

Source code in gui\widgets\chart_report_widget.py
163
164
165
166
167
168
169
170
171
172
173
174
def get_image_bytes(self) -> bytes:
    """
    Captures the current figure for PDF/DOCX export.

    [Figure.savefig][matplotlib.figure.Figure.savefig]

    Returns:
        bytes (bytes): A PNG-formatted byte stream of the current Matplotlib figure.
    """
    buf = BytesIO()
    self.canvas.figure.savefig(buf, format="png")
    return buf.getvalue()
update_chart(data, metric)

Hyper-abstracted update method for data visualization.

This method handles DataFrame preprocessing, value counting, and binning automatically based on the provided MetricType. It renders the results as a pie chart styled according to the project's color maps.

Figure

Parameters:

Name Type Description Default
data DataFrame | Series | dict

The source data to visualize. Can be a full project DataFrame or pre-summarized data.

required
metric MetricType

The type of ergonomic metric being plotted.

required

Returns:

Name Type Description
None None

Clears and redraws the canvas.

Source code in gui\widgets\chart_report_widget.py
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
def update_chart(
    self, data: Union[pd.DataFrame, pd.Series, dict], metric: MetricType
):
    """
    Hyper-abstracted update method for data visualization.

    This method handles DataFrame preprocessing, value counting, and binning
    automatically based on the provided [MetricType][gui.utils.constants.MetricType].
    It renders the results as a pie chart styled according to the project's color maps.

    [Figure][matplotlib.figure.Figure]

    Args:
        data (pd.DataFrame | pd.Series | dict): The source data to visualize.
            Can be a full project `DataFrame` or pre-summarized data.
        metric (MetricType): The type of ergonomic metric being plotted.

    Returns:
        None (None): Clears and redraws the `canvas`.
    """
    self.canvas.figure.clear()
    ax = self.canvas.figure.add_subplot(111)
    theme: dict[str, str] = THEMES[self.current_theme]
    bg_color: str = theme["background"]
    self.canvas.figure.patch.set_facecolor(bg_color)
    ax.set_facecolor(bg_color)
    # 1. Data Normalization
    if isinstance(data, pd.DataFrame):
        raw = data[metric.value]
        plot_data = (
            raw.value_counts()
            if metric == MetricType.RISK
            else pd.cut(
                raw, bins=range(16), labels=[str(i) for i in range(1, 16)]
            ).value_counts()
        )
    else:
        plot_data = pd.Series(data)

    # 2. Rendering Logic
    if not plot_data.empty:
        # Unpack safely using a single variable to satisfy Pylance's Union type
        results = ax.pie(
            plot_data,
            labels=[str(i) for i in plot_data.index],
            autopct="%1.1f%%",
            colors=COLOR_MAP.get(metric, COLOR_MAP[MetricType.SCORE]),
            startangle=140,
            pctdistance=0.8,
        )

        # The type is tuple[list, list] | tuple[list, list, list]
        # We only iterate over what exists
        for text_list in results[1:]:  # Captures both 'texts' and 'autotexts'
            for t in text_list:
                t.set_color(theme["text_primary"])
                t.set_fontsize(9)

    # 3. Dynamic Metadata
    title = self.tr(f"{metric.name.replace('_', ' ')} DISTRIBUTION")
    ax.set_title(title, color=theme["accent"], fontweight="bold", pad=20)

    self.canvas.figure.tight_layout()

    self.canvas.draw()

options: show_root_heading: true

gui.widgets.menu_bar

ErgoMoCap: Menu Bar

Custom Navigation and Command Interface for the Main Application.

This module implements the MenuBar class, which extends the standard QMenuBar to include specialized corner widgets for UI interaction. It integrates a hamburger-style sidebar toggle and a theme switcher directly into the menu bar real estate, providing a compact and modern navigation experience. The menus are populated using centralized actions defined in MenuActions.

Classes

MenuBar

Bases: QMenuBar

Custom menu bar implementation with integrated corner controls.

This class organizes the application's top-level navigation into logical categories (File, Controller, Settings, Help) while providing immediate access to global UI state toggles via QToolButton corner widgets.

Attributes:

Name Type Description
sidebar_btn QToolButton

A button positioned in the top-left corner used to toggle the navigation sidebar.

theme_btn QToolButton

A button positioned in the top-right corner used to switch between light and dark visual themes.

Source code in gui\widgets\menu_bar.py
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
class MenuBar(QMenuBar):
    """
    Custom menu bar implementation with integrated corner controls.

    This class organizes the application's top-level navigation into logical
    categories (File, Controller, Settings, Help) while providing immediate
    access to global UI state toggles via `QToolButton` corner widgets.

    Attributes:
        sidebar_btn (QToolButton): A button positioned in the top-left corner used
            to toggle the navigation sidebar.
        theme_btn (QToolButton): A button positioned in the top-right corner used
            to switch between light and dark visual themes.
    """

    def __init__(self, actions: MenuActions, parent):
        """
        Initialize the menu bar with structured menus and corner widgets.

        Sets up the visual layout by injecting the sidebar toggle and theme switcher
        into the bar's corners and populating the dropdown menus with actions
        provided by the [MenuActions][gui.widgets.menu_actions.MenuActions] container.

        Args:
            actions (MenuActions): The container holding pre-configured `QAction` objects.
            parent (QMainWindow): The parent [MainWindow][gui.frontend.MainWindow]
                instance. This parent must implement `toggle_sidebar()` and
                `toggle_theme()` slots.

        Returns:
            None (None): Initializer return.
        """

        super().__init__(parent)

        # --- THE SIDEBAR BUTTON ---
        self.sidebar_btn: QToolButton = QToolButton(self)
        self.sidebar_btn.setText("☰")
        self.sidebar_btn.setMinimumWidth(24)
        self.sidebar_btn.setSizePolicy(
            QSizePolicy.Policy.Minimum, QSizePolicy.Policy.Expanding
        )
        # Disable default focus rectangle and OS-level hover shadows
        self.sidebar_btn.setAttribute(Qt.WidgetAttribute.WA_NoSystemBackground, True)
        self.sidebar_btn.setCursor(Qt.CursorShape.PointingHandCursor)
        self.sidebar_btn.clicked.connect(parent.toggle_sidebar)

        # Inject the button into the TOP LEFT corner of the menu bar
        self.setCornerWidget(self.sidebar_btn, Qt.Corner.TopLeftCorner)

        # --- THE THEME BUTTON ---
        self.theme_btn: QToolButton = QToolButton(self)
        self.theme_btn.setText("☀️")
        self.theme_btn.setMinimumWidth(28)
        self.theme_btn.setSizePolicy(
            QSizePolicy.Policy.Minimum, QSizePolicy.Policy.Expanding
        )
        self.theme_btn.setCursor(Qt.CursorShape.PointingHandCursor)
        self.theme_btn.clicked.connect(parent.toggle_theme)

        # Inject the button into the TOP RIGHT corner of the menu bar
        self.setCornerWidget(self.theme_btn, Qt.Corner.TopRightCorner)

        # File
        file_menu: QMenu = self.addMenu(self.tr("File"))
        file_menu.addAction(actions.new_rec)
        file_menu.addAction(actions.load_rec)
        file_menu.addAction(actions.run_fmc)
        file_menu.addAction(actions.select_fmc_root)
        file_menu.addSeparator()
        file_menu.addAction(actions.exit_act)

        # Controller
        controller_menu: QMenu = self.addMenu(self.tr("Controller"))
        controller_menu.addAction(actions.kill_threads)
        controller_menu.addAction(actions.reboot_gui)

        # Settings
        settings_menu: QMenu = self.addMenu(self.tr("Settings"))
        settings_menu.addAction(actions.settings)

        # Help
        help_menu: QMenu = self.addMenu(self.tr("Help"))
        help_menu.addAction(actions.docs)
        help_menu.addAction(actions.tutorial)
        help_menu.addAction(actions.open_source)

options: show_root_heading: true

gui.widgets.menu_actions

ErgoMoCap: Menu Actions

Centralized Action Management for the ErgoMoCap Main Window.

This module defines the MenuActions class, which serves as a container for all QAction objects used in the application's menu bar and toolbars. By decoupling action definitions from the UI layout, it ensures that shortcuts, signals, and translations are managed in a single, testable location. It performs strict attribute validation to ensure the parent QMainWindow implements the necessary handler methods.

Classes

MenuActions

Factory class for creating and connecting GUI actions.

This class encapsulates the initialization of all user-triggerable actions within the ErgoMoCap interface. It maps keyboard shortcuts to specific logic handlers defined in the main window.

Attributes:

Name Type Description
new_rec QAction

Action to initiate a new motion capture recording.

load_rec QAction

Action to load an existing recording for analysis.

run_fmc QAction

Action to execute the FreeMoCap processing pipeline.

select_fmc_root QAction

Action to define the root directory for FreeMoCap data.

exit_act QAction

Action to safely close the application.

kill_threads QAction

Action to terminate all background processing threads.

reboot_gui QAction

Action to refresh/restart the GUI state.

settings QAction

Action to open the application configuration dialog.

docs QAction

Action to open the external project documentation.

tutorial QAction

Action to open the user tutorial.

open_source QAction

Action to open the project's source code repository.

Source code in gui\widgets\menu_actions.py
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
class MenuActions:
    """
    Factory class for creating and connecting GUI actions.

    This class encapsulates the initialization of all user-triggerable actions within
    the ErgoMoCap interface. It maps keyboard shortcuts to specific logic handlers
    defined in the main window.

    Attributes:
        new_rec (QAction): Action to initiate a new motion capture recording.
        load_rec (QAction): Action to load an existing recording for analysis.
        run_fmc (QAction): Action to execute the FreeMoCap processing pipeline.
        select_fmc_root (QAction): Action to define the root directory for FreeMoCap data.
        exit_act (QAction): Action to safely close the application.
        kill_threads (QAction): Action to terminate all background processing threads.
        reboot_gui (QAction): Action to refresh/restart the GUI state.
        settings (QAction): Action to open the application configuration dialog.
        docs (QAction): Action to open the external project documentation.
        tutorial (QAction): Action to open the user tutorial.
        open_source (QAction): Action to open the project's source code repository.
    """

    def __init__(self, main_win: QMainWindow):
        """
        Initialize and validate menu actions for the ErgoMoCap application.

        Performs a safety check to ensure the provided `main_win` contains all required
        callback methods. If validation passes, it initializes `QAction` objects with
        translations, standard shortcuts, and signal-slot connections.

        [MainWindow][gui.frontend.MainWindow]

        Args:
            main_win (QMainWindow): The target application window that implements the
                necessary handler slots for the frontend.

        Returns:
            None (None): Initializer return.

        Raises:
            AttributeError (AttributeError): If `main_win` is missing any required
                handler method (e.g., `handle_new_recording`, `kill_running_threads`, etc.).
        """

        if not hasattr(main_win, "handle_new_recording"):
            raise AttributeError("NO handle_new_recording")
        if not hasattr(main_win, "handle_load_recording"):
            raise AttributeError("NO handle_load_recording")

        if not hasattr(main_win, "handle_run_fmc"):
            raise AttributeError("NO handle_run_fmc")
        if not hasattr(main_win, "handle_select_root"):
            raise AttributeError("NO handle_select_root")

        if not hasattr(main_win, "kill_running_threads"):
            raise AttributeError("NO kill_running_threads")
        if not hasattr(main_win, "handle_reboot"):
            raise AttributeError("NO handle_reboot")
        if not hasattr(main_win, "open_settings"):
            raise AttributeError("NO open_settings")
        if not hasattr(main_win, "open_docs"):
            raise AttributeError("NO open_docs")
        if not hasattr(main_win, "open_tutorial"):
            raise AttributeError("NO open_tutorial")
        if not hasattr(main_win, "open_source"):
            raise AttributeError("NO open_source")
        if not hasattr(main_win, "safe_close"):
            raise AttributeError("NO safe_close")

        # --- File Section ---
        self.new_rec: QAction = QAction(main_win.tr("New Recording"), main_win)
        self.new_rec.setShortcut(QKeySequence.StandardKey.New)
        self.new_rec.triggered.connect(main_win.handle_new_recording)  # type: ignore Already Fixed using if gate

        self.load_rec: QAction = QAction(main_win.tr("Load Recording"), main_win)
        self.load_rec.setShortcut(QKeySequence.StandardKey.Open)
        self.load_rec.triggered.connect(main_win.handle_load_recording)  # type: ignore Already Fixed using if gate

        self.run_fmc: QAction = QAction(main_win.tr("Run FreeMoCap"), main_win)
        self.run_fmc.setShortcut(QKeySequence.StandardKey.Bold)
        self.run_fmc.triggered.connect(main_win.handle_run_fmc)  # type: ignore Already Fixed using if gate

        self.select_fmc_root: QAction = QAction(
            main_win.tr("Select FMC Folder"), main_win
        )
        self.select_fmc_root.setShortcut(QKeySequence.StandardKey.Italic)
        self.select_fmc_root.triggered.connect(main_win.handle_select_root)  # type: ignore Already Fixed using if gate

        self.exit_act: QAction = QAction(main_win.tr("Exit"), main_win)
        self.exit_act.setShortcut("Ctrl+Q")
        self.exit_act.triggered.connect(main_win.safe_close)  # type: ignore Already Fixed using if gate

        # --- Controller Section ---
        self.kill_threads: QAction = QAction(
            main_win.tr("Kill Threads and Processes"), main_win
        )
        self.kill_threads.setShortcut("Ctrl+K")
        self.kill_threads.triggered.connect(main_win.kill_running_threads)  # type: ignore Already Fixed using if gate

        self.reboot_gui: QAction = QAction(main_win.tr("Reboot GUI"), main_win)
        self.reboot_gui.setShortcut("Ctrl+R")
        self.reboot_gui.triggered.connect(main_win.handle_reboot)  # type: ignore Already Fixed using if gate

        # --- Settings Section ---
        self.settings: QAction = QAction(main_win.tr("Settings"), main_win)
        self.settings.setShortcut("Ctrl+,")
        self.settings.triggered.connect(main_win.open_settings)  # type: ignore Already Fixed using if gate

        # --- Help Section ---
        self.docs: QAction = QAction(main_win.tr("Documentation"), main_win)
        self.docs.triggered.connect(main_win.open_docs)  # type: ignore Already Fixed using if gate

        self.tutorial: QAction = QAction(main_win.tr("Tutorial"), main_win)
        self.tutorial.triggered.connect(main_win.open_tutorial)  # type: ignore Already Fixed using if gate

        self.open_source: QAction = QAction(main_win.tr("Source Code"), main_win)
        self.open_source.triggered.connect(main_win.open_source)  # type: ignore Already Fixed using if gate

options: show_root_heading: true


Utilities & Internationalization

gui.theme.style

ErgoMoCap: Volks-Typo Design System

Centralized styling and theme management for the ErgoMoCap application.

This module implements the "Volks-Typo" system, a design-token-driven architecture that manages color palettes, typography, and spatial grids. It generates dynamic Qt Style Sheets (QSS) to maintain a consistent visual language across the GUI while supporting seamless switching between light and dark modes.

Key components: - Design Tokens: Standardized GRID and THEMES dictionaries for layout and color. - Typography: Specialized font mappings for headings (Oswald, Roboto Condensed), body text (Work Sans), and technical data (JetBrains Mono). - Dynamic Theming: The get_stylesheet function injects theme tokens into a global QSS template.

Functions

get_stylesheet(mode=ErgoTheme.DARK)

Generates the Qt Style Sheet (QSS) based on the specified visual mode.

Maps the design tokens defined in THEMES and FONTS to a comprehensive QSS string. This includes base widget configuration, typography for custom label IDs (h1, h2, h3), and dynamic states for interactive components.

Parameters:

Name Type Description Default
mode ErgoTheme

The visual theme to retrieve. Must be one of ErgoTheme.LIGHT or ErgoTheme.DARK. Defaults to "ErgoTheme.DARK".

DARK

Returns:

Name Type Description
str str

A formatted QSS string ready to be applied via setStyleSheet().

Raises:

Type Description
KeyError

If an invalid mode is provided that does not exist in the THEMES dictionary.

Examples:

Applying the dark theme to a QApplication instance:

app = QApplication(sys.argv)
style_qss = get_stylesheet("dark")
app.setStyleSheet(style_qss)
Source code in gui\theme\style.py
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
def get_stylesheet(mode=ErgoTheme.DARK) -> str:
    """
    Generates the Qt Style Sheet (QSS) based on the specified visual mode.

    Maps the design tokens defined in [THEMES][gui.theme.style] and
    [FONTS][gui.theme.style] to a comprehensive QSS string. This includes
    base widget configuration, typography for custom label IDs (h1, h2, h3), and
    dynamic states for interactive components.

    Args:
        mode (ErgoTheme): The visual theme to retrieve. Must be one of `ErgoTheme.LIGHT` or `ErgoTheme.DARK`. Defaults to "ErgoTheme.DARK".

    Returns:
        str: A formatted QSS string ready to be applied via `setStyleSheet()`.

    Raises:
        KeyError: If an invalid `mode` is provided that does not exist in the [THEMES][gui.theme.style] dictionary.

    Examples:
        Applying the dark theme to a QApplication instance:
        ```python
        app = QApplication(sys.argv)
        style_qss = get_stylesheet("dark")
        app.setStyleSheet(style_qss)
        ```
    """
    c = THEMES[mode.value]

    return f"""
    /* BASE WIDGET CONFIGURATION */
    QWidget {{
        background-color: {c["background"]};
        color: {c["text_primary"]};
        font-family: "{FONTS["body"]}";
        font-size: 11px;
        outline: none;
    }}

    /* TYPOGRAPHY H1-H3 */
    QLabel#h1 {{
        font-family: "{FONTS["heading_primary"]}";
        font-size: 24px;
        font-weight: 700;
        color: {c["accent"]};
        letter-spacing: 2px;
        text-transform: uppercase;
        margin-bottom: {GRID * 2}px;
        padding: 0px;
    }}

    QLabel#h2 {{
        font-family: "{FONTS["heading_secondary"]}";
        font-size: 18px;
        font-weight: 700;
        color: {c["accent"]};
        letter-spacing: 1px;
        text-transform: uppercase;
        padding: {GRID}px 0px;
    }}

    QLabel#h3 {{
        font-family: "{FONTS["heading_secondary"]}";
        font-size: 14px;
        font-weight: 600;
        color: {c["accent"]};
        text-transform: uppercase;
        padding: {GRID // 2}px 0px;
    }}

    /* NAVIGATION COMPONENTS */
    QFrame#NavCard {{
        background-color: {c["surface"]};
        border: 2px solid {c["text_primary"]};
        padding: {GRID * 2}px;
    }}

    QFrame#NavCard:hover {{
        border-color: {c["accent"]};
        background-color: {c["accent"]};
    }}

    /* TOOLTIP SYSTEM */
    QToolTip {{
        background-color: {c["background"]};
        color: {c["text_primary"]};
        border: 1px solid {c["accent"]};
        border-radius: 4px;
        padding: 5px;
        font-family: {FONTS["mono"]};
        font-size: 40px;
    }}

    /* BUTTON SYSTEM */
    QPushButton {{
        font-family: "{FONTS["heading_primary"]}";
        text-transform: uppercase;
        font-weight: 800;
        letter-spacing: 0.5px;
        padding: {GRID * 2}px {GRID * 2}px;
        font-size: 11px;
        border: 1px solid {c["border"]};
        background-color: {c["btn_main"]};
        color: {c["btn_main_text"]};
    }}

    QPushButton:hover {{
        background-color: {c["btn_hover"]};
        color: #ffffff;
        border-color: {c["accent"]};
    }}

    QPushButton:pressed {{
        background-color: {c["btn_active"]};
        color: #000000;
    }}

    QPushButton#btnAction {{
        padding: {GRID * 2}px;
        font-size: 12px;
        background-color: {c["accent"]};
        color: #ffffff;
        border: 2px solid {c["text_primary"]};
    }}

    QPushButton#btnNega {{
        background-color: {c["text_primary"]};
        color: {c["background"]};
        padding: {GRID // 2}px {GRID * 2}px;
        font-family: "{FONTS["mono"]}";
        font-size: 9px;
    }}

    /* TOOLBAR SYSTEM */
    QFrame#Toolbar {{
        background-color: {c["surface"]};
        border-bottom: 2px solid {c["text_primary"]};
        min-height: 40px;
        padding: 0px {GRID * 2}px;
    }}

    /* FORM PANEL (LEFT SIDEBAR) */
    QFrame#FormPanel {{
        background-color: {c["background"]};
        border-right: 1px solid {c["text_primary"]};
        padding: {GRID * 2}px;
    }}

    /* INFO-MODAL SYSTEM */
    QDialog#InfoModal {{
        background-color: {c["background"]};
        border: 2px solid {c["text_primary"]};
    }}

    QFrame#ModalHeader {{
        background-color: {c["background"]};
        border-bottom: 1px solid {c["text_primary"]};
        min-height: 30px;
        padding: 0px {GRID}px;
    }}

    QLabel#ModalTitle {{
        font-family: "{FONTS["heading_primary"]}";
        text-transform: uppercase;
        letter-spacing: 1px;
        font-size: 11px;
        color: {c["text_primary"]};
    }}

    /* INFO-TABLE SYSTEM */
    QTableWidget#InfoTable {{
        background-color: {c["surface"]};
        gridline-color: {c["text_primary"]};
        border: 1px solid {c["text_primary"]};
        font-family: "{FONTS["body"]}";
        font-size: 10px;
        outline: none;
    }}

    QHeaderView::section {{
        background-color: {c["background"]};
        color: {c["accent"]};
        padding: {GRID // 2}px {GRID}px;
        font-family: "{FONTS["heading_secondary"]}";
        font-weight: 700;
        font-size: 9px;
        text-transform: uppercase;
        border: 1px solid {c["text_primary"]};
    }}

    QTableWidget::item {{
        background-color: {c["background"]};
        color: {c["text_primary"]};
        padding: {GRID // 2}px {GRID}px;
        border-bottom: 1px solid {c["text_primary"]};
    }}


    QTableWidget::item:hover{{
        background-color: {c["accent"]};
        color: {c["btn_main_text"]};
    }}

    /* GROUPBOX / FIELDSETS */
    QGroupBox {{
        font-family: "{FONTS["heading_secondary"]}";
        font-weight: 700;
        font-size: 12px;
        text-transform: uppercase;
        border: 1px solid {c["text_primary"]};
        margin-top: 6px;
        padding-top: 8px;
        background-color: {c["background"]};
    }}

    QGroupBox::title {{
        subcontrol-origin: margin;
        subcontrol-position: top left;
        padding: 0 {GRID}px;
        background-color: {c["background"]};
        left: {GRID}px;
        color: {c["accent"]};
        letter-spacing: 0.5px;
    }}

    /* FIELD LABELS (MICRO-TYPOGRAPHY) */
    QLabel#FieldLabel {{
        font-family: "{FONTS["heading_secondary"]}";
        font-weight: 700;
        text-transform: uppercase;
        font-size: 10px;
        color: {c["text_secondary"]};
        margin-bottom: 1px;
    }}

    /* INPUT FIELDS */
    QLineEdit, QTextEdit, QComboBox {{
        background-color: {c["surface"]};
        border: 1px solid {c["text_primary"]};
        padding: {GRID}px;
        font-family: "{FONTS["mono"]}";
        font-size: 12px;
        color: {c["text_primary"]};
    }}

    QLineEdit:focus, QComboBox:focus {{
        border: 1px solid {c["accent"]};
        background-color: {c["background"]};
    }}

    QComboBox::drop-down {{
        width: 18px;
        border-left: 1px solid {c["text_primary"]};
    }}

    /* THE CBOX ARROW ICON */
    QComboBox::down-arrow {{
         border-left: 1px solid {c["text_primary"]};
        border-bottom: 1px solid {c["text_primary"]};
        width: 5px;
        height: 5px;
        margin-top: -2px; /* Visual centering */
        margin-right: 2px;
    }}

    /* ARROW STATE WHEN OPEN */
    QComboBox::down-arrow:on {{
        border-top: none;
        border-bottom: 5px solid {c["accent"]};
    }}

    /* SCROLLBAR SYSTEM (SUPER THIN) */
    QScrollBar:vertical {{
        background: {c["surface"]};
        width: 6px;
        margin: 0px;
    }}

    QScrollBar::handle:vertical {{
        background: {c["text_primary"]};
        min-height: 15px;
    }}

    QScrollBar::handle:vertical:hover {{
        background: {c["accent"]};
    }}

    QScrollBar:horizontal {{
        background: {c["surface"]};
        height: 6px;
        margin: 0px;
    }}

    QScrollBar::handle:horizontal {{
        background: {c["text_primary"]};
        min-width: 15px;
    }}

    /* TAB WIDGET SYSTEM */
    QTabWidget::pane {{
        border: 1px solid {c["text_primary"]};
        background-color: {c["background"]};
        top: -1px;
    }}

    QTabBar::tab {{
        background-color: {c["surface"]};
        border: 1px solid {c["text_primary"]};
        font-family: "{FONTS["heading_secondary"]}";
        font-weight: 700;
        font-size: 9px;
        text-transform: uppercase;
        padding: {GRID}px {GRID * 2}px;
        margin-right: 1px;
        color: {c["text_secondary"]};
        letter-spacing: 0.5px;
    }}

    QTabBar::tab:selected {{
        background-color: {c["background"]};
        border-bottom-color: {c["background"]};
        color: {c["accent"]};
    }}

    QTabBar::tab:hover {{
        background-color: {c["accent"]};
        color: #ffffff;
    }}

    /* PROGRESS BAR (SLIM INDUSTRIAL) */
    QProgressBar {{
        border: 1px solid {c["text_primary"]};
        background-color: {c["surface"]};
        text-align: center;
        font-family: "{FONTS["mono"]}";
        font-size: 8px;
        font-weight: bold;
        color: {c["text_primary"]};
        height: 10px;
    }}

    QProgressBar::chunk {{
        background-color: {c["accent"]};
        width: 4px;
        margin: 0.5px;
    }}

    /* SLIDERS */
    QSlider::groove:horizontal {{
        border: 1px solid {c["text_primary"]};
        height: 2px;
        background: {c["surface"]};
    }}

    QSlider::handle:horizontal {{
        background: {c["text_primary"]};
        border: 1px solid {c["text_primary"]};
        width: 10px;
        height: 10px;
        margin: -4px 0;
    }}

    QSlider::handle:horizontal:hover {{
        background: {c["accent"]};
    }}

    /* STATUS BAR & TOOLTIPS */
    QStatusBar {{
        background-color: {c["text_primary"]};
        color: {c["background"]};
        font-family: "{FONTS["mono"]}";
        font-size: 9px;
        text-transform: uppercase;
        padding: 0px {GRID}px;
    }}

    QToolTip {{
        background-color: {c["text_primary"]};
        color: {c["background"]};
        border: none;
        font-family: "{FONTS["mono"]}";
        font-size: 9px;
        padding: {GRID // 2}px;
    }}

    /* VIDEO CANVAS & SPECIALTY BUTTONS */
    QLabel#VideoCanvas {{
        background-color: #000000;
        border: 2px solid {c["text_primary"]};
        border-radius: 4px;
        padding: {GRID * 3}px;
        margin: {GRID}px;
    }}

    QPushButton#btnInfoCircle {{
        background-color: {c["surface"]};
        border: 1px solid {c["text_primary"]};
        color: {c["text_primary"]};
        border-radius: 9px;
        max-width: 18px;
        max-height: 18px;
        font-size: 10px;
        font-weight: bold;
        padding: 4px;
    }}

    /* UTILITY CLASSES */
    .TextMuted {{
        color: {c["text_muted"]};
        font-size: 8px;
        font-family: "{FONTS["mono"]}";
    }}

    QFrame#hr {{
        background-color: {c["text_primary"]};
        max-height: 1px;
        min-height: 1px;
        margin: {GRID * 2}px 0px;
    }}

    QMenuBar {{
            background-color: {c["background"]};
            border-bottom: 2px solid {c["text_primary"]};
            font-family: "{FONTS["heading_secondary"]}";
            font-weight: 700;
            text-transform: uppercase;
            font-size: 10px;
            letter-spacing: 1px;
            padding: 0px;
        }}

    QMenuBar::item {{
        background: transparent;
        padding: {GRID * 2}px {GRID * 2}px;
        margin-right: {GRID}px;
        margin-left: {GRID}px;
        color: {c["text_primary"]};
    }}

    QMenuBar::item:selected {{
        background-color: {c["accent"]};
        color: #ffffff;
    }}

    QMenu {{
        background-color: {c["background"]};
        border: 2px solid {c["text_primary"]};
        padding: {GRID // 2}px;
    }}

    QMenu::item {{
        font-family: "{FONTS["body"]}";
        font-weight: 500;
        text-transform: uppercase;
        font-size: 10px;
        padding: {GRID * 2}px {GRID * 2}px;
        color: {c["text_primary"]};
        min-width: 150px;
    }}

    QMenu::item:selected {{
        background-color: {c["text_primary"]};
        color: {c["background"]};
    }}

    QMenu::separator {{
        height: 1px;
        background: {c["text_muted"]};
        margin: {GRID}px {GRID}px;
    }}

    QMenu::shortcut {{
        color: {c["text_muted"]};
        font-family: "{FONTS["mono"]}";
        font-size: 9px;
        padding-left: {GRID * 3}px;
    }}

    QMenu::indicator {{
        width: 10px;
        height: 10px;
        border: 1px solid {c["text_primary"]};
        margin-left: {GRID}px;
    }}

    QMenu::indicator:checked {{
        background-color: {c["accent"]};
    }}

    QToolButton {{
        font-family: "{FONTS["heading_primary"]}";
        font-weight: 800;
        font-size: 18px;
        border: none;
        background-color: transparent;
        color: {c["text_primary"]};

        /* Forced Geometry (Required for a circle) */
        width: 36px;
        height: 36px;
        padding: 0px;
        margin: 0px {GRID}px 0px {GRID * 2}px; /* Left padding as requested, centering the box */

        qproperty-toolButtonStyle: ToolButtonTextOnly;
        text-align: center;
    }}

    QToolButton:hover {{
        /* The Circle Highlight */
        color: {c["accent"]};
     }}

    QToolButton:pressed {{
        color: {c["accent"]};
    }}

    """

options: show_root_heading: true

gui.utils.utils

ErgoMoCap: Utility Module

Helper functions for session data management and report generation. (as of now)

This module provides utility functions to support the ergonomic analysis workflow, specifically focusing on the transformation of raw session data into actionable insights. It includes logic for:

  • Report Generation: Creating Markdown-formatted summaries of assessment sessions.
  • Metric Aggregation: Calculating dynamic averages from pandas.DataFrame objects to identify postural trends.
  • Naming Standardization: Constructing consistent column identifiers based on anatomical parts, metric types, and assessment methods (RULA/REBA).

These utilities ensure a unified data schema between the backend processing logic and the frontend reporting interface.

Classes

Functions

generate_markdown_report(report_path, all_data_records)

Generates a Markdown report summarizing the assessment session.

Parameters:

Name Type Description Default
report_path str | Path

Destination path for the .md file.

required
all_data_records list[dict[str, Any]]

A list of all frame dictionaries collected during the session.

required

Returns:

Name Type Description
None None

Writes the report to the file system.

Source code in gui\utils\utils.py
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
def generate_markdown_report(
    report_path: str | Path, all_data_records: list[dict[str, Any]]
) -> None:
    """
    Generates a Markdown report summarizing the assessment session.

    Args:
        report_path (str | Path): Destination path for the `.md` file.
        all_data_records (list[dict[str, Any]]): A `list` of all frame dictionaries collected during the session.

    Returns:
        None (None): Writes the report to the file system.
    """
    # TODO generate a table with the averages or remove this from backend
    pass

get_dynamic_metrics(df, metric_type, method)

Calculates session averages using standardized column naming.

Scans the pandas.DataFrame for columns ending in the standard score/method suffix and calculates their mean values to provide an overview of postural trends.

Parameters:

Name Type Description Default
df DataFrame

The full session data loaded into a pandas.DataFrame.

required
metric_type MetricType

The MetricType to filter by.

required
method AssessmentMethod

The AssessmentMethod used (e.g., RULA/REBA).

required

Returns:

Type Description
list[tuple[str, str]]

list[tuple[str, str]]: A list of (str, str) tuples containing (District_Name, Average_Score).

Source code in gui\utils\utils.py
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
def get_dynamic_metrics(
    df: pd.DataFrame, metric_type: MetricType, method: AssessmentMethod
) -> list[tuple[str, str]]:
    """
    Calculates session averages using standardized column naming.

    Scans the `pandas.DataFrame` for columns ending in the standard score/method suffix
    and calculates their mean values to provide an overview of postural trends.

    Args:
        df (pandas.DataFrame): The full session data loaded into a `pandas.DataFrame`.
        metric_type (MetricType): The [MetricType][gui.utils.constants.MetricType] to filter by.
        method (AssessmentMethod): The [AssessmentMethod][gui.utils.constants.AssessmentMethod] used (e.g., RULA/REBA).

    Returns:
        list[tuple[str, str]]: A `list` of (`str`, `str`) tuples containing (District_Name, Average_Score).
    """
    rows: list[tuple[str, str]] = []

    cleaned_df = df.drop(columns=[MetricType.RISK.value, MetricType.SCORE.value])

    for col in cleaned_df.columns:
        # display_name = col.replace("_", " ").title() TODO review this
        display_name = str(col)
        avg_value = df[col].mean()
        rows.append((display_name, f"{avg_value:.2f}"))

    # print(df.columns, df.head(2), f"GET DYNAMIC METRICS ROWS {rows}", "\n") TODO print_reactivate
    # print(df.columns, "SCORE COLS\n") TODO print_reactivate
    return rows

resolve_column_name(part, metric, method)

Constructs a standardized database/CSV column name.

Parameters:

Name Type Description Default
part BodyPart

The anatomical BodyPart Enum.

required
metric MetricType

The MetricType Enum (e.g., angle, score).

required
method AssessmentMethod

The AssessmentMethod Enum (e.g., reba).

required

Returns:

Name Type Description
str str

A lower_snake_case string in the format [part]_[metric]_[method].

Source code in gui\utils\utils.py
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
def resolve_column_name(
    part: BodyPart, metric: MetricType, method: AssessmentMethod
) -> str:
    """
    Constructs a standardized database/CSV column name.

    Args:
        part (BodyPart): The anatomical [BodyPart][gui.utils.constants.BodyPart] Enum.
        metric (MetricType): The [MetricType][gui.utils.constants.MetricType] Enum (e.g., angle, score).
        method (AssessmentMethod): The [AssessmentMethod][gui.utils.constants.AssessmentMethod] Enum (e.g., reba).

    Returns:
        str (str): A `lower_snake_case` string in the format `[part]_[metric]_[method]`.
    """
    return f"{part.value}_{metric.value}_{method.value}"

options: show_root_heading: true

gui.utils.app_paths

ErgoMoCap: Application Path Management

Centralized Path Resolution for Internal Assets and External Data.

This module provides the ErgoPaths class and supporting utility functions to standardize how the application accesses the file system. It specifically addresses the challenges of path resolution in "frozen" environments (e.g., executables bundled with PyInstaller) versus standard development environments.

By centralizing all "magic strings" related to directory names and file locations, this module ensures that changes to the project structure only need to be reflected in a single location.

Classes

ErgoPaths

Centralized registry for all folder names and file locations in ErgoMoCap.

This class serves as the single source of truth for the project's file system hierarchy. It differentiates between internal read-only application layers (APP_CODE) and external writeable structures (USER_DATA).

Attributes:

Name Type Description
USER_DATA Path

Root path for persistent user data and tracking sessions.

SESSIONS Path

Directory containing recording session data folders.

APP_CODE Path

Root path for the core application source and asset resources.

ASSETS Path

Directory containing UI images, icons, and static graphics.

TEMPLATES Path

Directory containing Jinja2/HTML visual report templates.

OUTPUT_FOLDER Path

Standardized directory for generated ergonomic analysis results.

DATA_FOLDER_NAME str

Static directory string identifying subfolders holding raw metric data.

VIDEO_FOLDER_NAME str

Static directory string identifying subfolders holding annotated video streams.

LOCAL_SITE str

Static directory string pointing to local web assets or report packages.

LOGO Path

Absolute filesystem locator path to the primary application logo graphic.

Methods:

Name Description
update_user_root

Updates the USER_DATA path and all constant paths at a class level.

session_folder

Constructs the absolute path to a specific recording session directory.

data_folder

Constructs the absolute path to the data subfolder of a session.

video_folder

Constructs the absolute path to the video subfolder of a session.

frames_folder

Constructs the absolute path to the video frames subfolder of a video, creating it if needed.

output_folder

Resolves the global output directory, ensuring its safe creation on disk.

analysis_output

Returns the standardized target path for the primary analysis CSV export sheet.

get_local_site_url

Converts an absolute systemic path string into a valid QUrl resource location.

Source code in gui\utils\app_paths.py
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
class ErgoPaths:
    """
    Centralized registry for all folder names and file locations in ErgoMoCap.

    This class serves as the single source of truth for the project's file system
    hierarchy. It differentiates between internal read-only application layers (`APP_CODE`)
    and external writeable structures (`USER_DATA`).

    Attributes:
        USER_DATA (Path): Root path for persistent user data and tracking sessions.
        SESSIONS (Path): Directory containing recording session data folders.
        APP_CODE (Path): Root path for the core application source and asset resources.
        ASSETS (Path): Directory containing UI images, icons, and static graphics.
        TEMPLATES (Path): Directory containing Jinja2/HTML visual report templates.
        OUTPUT_FOLDER (Path): Standardized directory for generated ergonomic analysis results.
        DATA_FOLDER_NAME (str): Static directory string identifying subfolders holding raw metric data.
        VIDEO_FOLDER_NAME (str): Static directory string identifying subfolders holding annotated video streams.
        LOCAL_SITE (str): Static directory string pointing to local web assets or report packages.
        LOGO (Path): Absolute filesystem locator path to the primary application logo graphic.

    Methods:
        update_user_root: Updates the USER_DATA path and all constant paths at a class level.
        session_folder: Constructs the absolute path to a specific recording session directory.
        data_folder: Constructs the absolute path to the data subfolder of a session.
        video_folder: Constructs the absolute path to the video subfolder of a session.
        frames_folder: Constructs the absolute path to the video frames subfolder of a video, creating it if needed.
        output_folder: Resolves the global output directory, ensuring its safe creation on disk.
        analysis_output: Returns the standardized target path for the primary analysis CSV export sheet.
        get_local_site_url: Converts an absolute systemic path string into a valid QUrl resource location.
    """

    # --- The Big Roots ---
    # Where user data lives (External)
    SESSIONS_FOLDER_NAME = "recording_sessions"
    USER_DATA = get_external_root() / "freemocap_data"
    SESSIONS = USER_DATA / SESSIONS_FOLDER_NAME

    # Where the app code/assets live (Internal)
    APP_CODE = get_internal_root()
    ASSETS = APP_CODE / "assets"
    TEMPLATES = APP_CODE / "gui" / "templates"

    OUTPUT_FOLDER = APP_CODE / "ergomocap_data"

    # --- Specific Folder Names ---
    # These are the "Magic Strings" we are killing
    DATA_FOLDER_NAME = "output_data"
    VIDEO_FOLDER_NAME = "annotated_videos"
    LOCAL_SITE = "site"

    # --- Common Files ---
    LOGO = ASSETS / "ergomocap_logo_dark.png"

    @classmethod
    def update_user_root(cls, new_root: Path) -> None:
        """
        Dynamically updates the base path location when a user selects
        a custom root folder from the interface.


        Args:
            new_root (Path): The unique identifier/folder name of the session.

        Returns:
            None (None): Simply Updated the class.
        """
        if new_root.name == cls.SESSIONS_FOLDER_NAME:
            # If they picked 'recording_sessions', go one step up to find freemocap_data
            cls.USER_DATA = new_root.parent
            cls.SESSIONS = new_root
        elif (
            new_root / cls.SESSIONS_FOLDER_NAME
        ).exists() or new_root.name == "freemocap_data":
            # If they picked 'freemocap_data' or a directory containing 'recording_sessions'
            cls.USER_DATA = new_root
            cls.SESSIONS = new_root / cls.SESSIONS_FOLDER_NAME
        else:
            # Fallback treat whatever they picked as the directory containing session folders
            cls.USER_DATA = new_root.parent
            cls.SESSIONS = new_root

    @staticmethod
    def session_folder(session_name: str) -> Path:
        """
        Constructs the absolute path to a specific recording session.

        Args:
            session_name (str): The unique identifier/folder name of the session.

        Returns:
            Path (Path): Absolute path to the session directory.
        """
        return ErgoPaths.SESSIONS / session_name

    @staticmethod
    def data_folder(session_name: str) -> Path:
        """
        Constructs the absolute path to the data subfolder of a session.

        Args:
            session_name (str): The name of the target session.

        Returns:
            Path (Path): Path to the session's 'output_data' directory.
        """
        return ErgoPaths.session_folder(session_name) / ErgoPaths.DATA_FOLDER_NAME

    @staticmethod
    def video_folder(session_name: str) -> Path:
        """
        Constructs the absolute path to the video subfolder of a session.

        Args:
            session_name (str): The name of the target session.

        Returns:
            Path (Path): Path to the session's 'annotated_videos' directory.
        """
        return ErgoPaths.session_folder(session_name) / ErgoPaths.VIDEO_FOLDER_NAME

    @staticmethod
    def frames_folder(session_name: str, video_name: str) -> Path:
        """
        Constructs the absolute path to the video frames subfolder of a video.

        Args:
            session_name (str): The name of the target session.

        Returns:
            Path (Path): Path to the video's 'frames' directory.
        """

        frames_dir = ErgoPaths.output_folder() / session_name / video_name / "frames"
        frames_dir.mkdir(parents=True, exist_ok=True)
        return frames_dir

    @staticmethod
    def output_folder() -> Path:
        """
        Resolves the global output folder, creating it if it does not exist.

        Returns:
            Path (Path): The verified directory for ergonomic data output.
        """
        ErgoPaths.OUTPUT_FOLDER.mkdir(parents=True, exist_ok=True)
        return ErgoPaths.OUTPUT_FOLDER

    @staticmethod
    def analysis_output() -> Path:
        """
        Returns the standardized path for the primary analysis CSV.

        Ensures that analysis results are consistently stored in the recognized
        output directory.

        Returns:
            Path (Path): Absolute path to 'ergo_analysis.csv'.
        """
        # TODO do something better like return ErgoPaths.output_folder() / f"{method.lower()}_analysis.csv"
        return ErgoPaths.output_folder() / "ergo_analysis.csv"

    @staticmethod
    def get_local_site_url(page_name: str) -> QUrl:
        """
        Helper method to construct a safe local file URL.

        Resolves internal application page assets into validated uniform resource locator
        structures compatible with PySide6 web engine components.

        Args:
            page_name (str): Relative filename string pointing to the targeted web asset or page.

        Returns:
            QUrl (QUrl): A validated local file system pointer scheme (`file:///...`) targeting the component.

        Raises:
            ValueError (ValueError): If the target file resource does not exist at the resolved location path.
        """

        local_path = ErgoPaths.APP_CODE / ErgoPaths.LOCAL_SITE / page_name

        if not local_path.exists():
            raise ValueError("Url not found")

        # Convert the absolute system path into a valid QUrl (file:///...)
        return QUrl.fromLocalFile(local_path)
Functions
analysis_output() staticmethod

Returns the standardized path for the primary analysis CSV.

Ensures that analysis results are consistently stored in the recognized output directory.

Returns:

Name Type Description
Path Path

Absolute path to 'ergo_analysis.csv'.

Source code in gui\utils\app_paths.py
232
233
234
235
236
237
238
239
240
241
242
243
244
@staticmethod
def analysis_output() -> Path:
    """
    Returns the standardized path for the primary analysis CSV.

    Ensures that analysis results are consistently stored in the recognized
    output directory.

    Returns:
        Path (Path): Absolute path to 'ergo_analysis.csv'.
    """
    # TODO do something better like return ErgoPaths.output_folder() / f"{method.lower()}_analysis.csv"
    return ErgoPaths.output_folder() / "ergo_analysis.csv"
data_folder(session_name) staticmethod

Constructs the absolute path to the data subfolder of a session.

Parameters:

Name Type Description Default
session_name str

The name of the target session.

required

Returns:

Name Type Description
Path Path

Path to the session's 'output_data' directory.

Source code in gui\utils\app_paths.py
179
180
181
182
183
184
185
186
187
188
189
190
@staticmethod
def data_folder(session_name: str) -> Path:
    """
    Constructs the absolute path to the data subfolder of a session.

    Args:
        session_name (str): The name of the target session.

    Returns:
        Path (Path): Path to the session's 'output_data' directory.
    """
    return ErgoPaths.session_folder(session_name) / ErgoPaths.DATA_FOLDER_NAME
frames_folder(session_name, video_name) staticmethod

Constructs the absolute path to the video frames subfolder of a video.

Parameters:

Name Type Description Default
session_name str

The name of the target session.

required

Returns:

Name Type Description
Path Path

Path to the video's 'frames' directory.

Source code in gui\utils\app_paths.py
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
@staticmethod
def frames_folder(session_name: str, video_name: str) -> Path:
    """
    Constructs the absolute path to the video frames subfolder of a video.

    Args:
        session_name (str): The name of the target session.

    Returns:
        Path (Path): Path to the video's 'frames' directory.
    """

    frames_dir = ErgoPaths.output_folder() / session_name / video_name / "frames"
    frames_dir.mkdir(parents=True, exist_ok=True)
    return frames_dir
get_local_site_url(page_name) staticmethod

Helper method to construct a safe local file URL.

Resolves internal application page assets into validated uniform resource locator structures compatible with PySide6 web engine components.

Parameters:

Name Type Description Default
page_name str

Relative filename string pointing to the targeted web asset or page.

required

Returns:

Name Type Description
QUrl QUrl

A validated local file system pointer scheme (file:///...) targeting the component.

Raises:

Type Description
ValueError(ValueError)

If the target file resource does not exist at the resolved location path.

Source code in gui\utils\app_paths.py
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
@staticmethod
def get_local_site_url(page_name: str) -> QUrl:
    """
    Helper method to construct a safe local file URL.

    Resolves internal application page assets into validated uniform resource locator
    structures compatible with PySide6 web engine components.

    Args:
        page_name (str): Relative filename string pointing to the targeted web asset or page.

    Returns:
        QUrl (QUrl): A validated local file system pointer scheme (`file:///...`) targeting the component.

    Raises:
        ValueError (ValueError): If the target file resource does not exist at the resolved location path.
    """

    local_path = ErgoPaths.APP_CODE / ErgoPaths.LOCAL_SITE / page_name

    if not local_path.exists():
        raise ValueError("Url not found")

    # Convert the absolute system path into a valid QUrl (file:///...)
    return QUrl.fromLocalFile(local_path)
output_folder() staticmethod

Resolves the global output folder, creating it if it does not exist.

Returns:

Name Type Description
Path Path

The verified directory for ergonomic data output.

Source code in gui\utils\app_paths.py
221
222
223
224
225
226
227
228
229
230
@staticmethod
def output_folder() -> Path:
    """
    Resolves the global output folder, creating it if it does not exist.

    Returns:
        Path (Path): The verified directory for ergonomic data output.
    """
    ErgoPaths.OUTPUT_FOLDER.mkdir(parents=True, exist_ok=True)
    return ErgoPaths.OUTPUT_FOLDER
session_folder(session_name) staticmethod

Constructs the absolute path to a specific recording session.

Parameters:

Name Type Description Default
session_name str

The unique identifier/folder name of the session.

required

Returns:

Name Type Description
Path Path

Absolute path to the session directory.

Source code in gui\utils\app_paths.py
166
167
168
169
170
171
172
173
174
175
176
177
@staticmethod
def session_folder(session_name: str) -> Path:
    """
    Constructs the absolute path to a specific recording session.

    Args:
        session_name (str): The unique identifier/folder name of the session.

    Returns:
        Path (Path): Absolute path to the session directory.
    """
    return ErgoPaths.SESSIONS / session_name
update_user_root(new_root) classmethod

Dynamically updates the base path location when a user selects a custom root folder from the interface.

Parameters:

Name Type Description Default
new_root Path

The unique identifier/folder name of the session.

required

Returns:

Name Type Description
None None

Simply Updated the class.

Source code in gui\utils\app_paths.py
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
@classmethod
def update_user_root(cls, new_root: Path) -> None:
    """
    Dynamically updates the base path location when a user selects
    a custom root folder from the interface.


    Args:
        new_root (Path): The unique identifier/folder name of the session.

    Returns:
        None (None): Simply Updated the class.
    """
    if new_root.name == cls.SESSIONS_FOLDER_NAME:
        # If they picked 'recording_sessions', go one step up to find freemocap_data
        cls.USER_DATA = new_root.parent
        cls.SESSIONS = new_root
    elif (
        new_root / cls.SESSIONS_FOLDER_NAME
    ).exists() or new_root.name == "freemocap_data":
        # If they picked 'freemocap_data' or a directory containing 'recording_sessions'
        cls.USER_DATA = new_root
        cls.SESSIONS = new_root / cls.SESSIONS_FOLDER_NAME
    else:
        # Fallback treat whatever they picked as the directory containing session folders
        cls.USER_DATA = new_root.parent
        cls.SESSIONS = new_root
video_folder(session_name) staticmethod

Constructs the absolute path to the video subfolder of a session.

Parameters:

Name Type Description Default
session_name str

The name of the target session.

required

Returns:

Name Type Description
Path Path

Path to the session's 'annotated_videos' directory.

Source code in gui\utils\app_paths.py
192
193
194
195
196
197
198
199
200
201
202
203
@staticmethod
def video_folder(session_name: str) -> Path:
    """
    Constructs the absolute path to the video subfolder of a session.

    Args:
        session_name (str): The name of the target session.

    Returns:
        Path (Path): Path to the session's 'annotated_videos' directory.
    """
    return ErgoPaths.session_folder(session_name) / ErgoPaths.VIDEO_FOLDER_NAME

Functions

get_external_root()

Resolves the root directory for external user data.

Ensures that output files (videos, CSVs) are saved relative to the user's executable environment in a 'frozen' state, preventing data from being written to temporary system folders.

Returns:

Name Type Description
Path Path

The absolute path to the persistent external application environment.

Source code in gui\utils\app_paths.py
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
def get_external_root() -> Path:
    """
    Resolves the root directory for external user data.

    Ensures that output files (videos, CSVs) are saved relative to the user's
    executable environment in a 'frozen' state, preventing data from being
    written to temporary system folders.

    Returns:
        Path (Path): The absolute path to the persistent external
            application environment.
    """
    if getattr(sys, "frozen", False):
        external_root = Path(sys.executable).resolve().parent.parent.parent
    else:
        external_root = Path(__file__).resolve().parent.parent.parent

    return external_root

get_internal_root()

Resolves the root directory for internal assets.

Handles the path shift that occurs when the application is bundled using PyInstaller (_MEIPASS) versus running as a raw script. This is used for read-only assets like icons, templates, and core application code. Path

Returns:

Name Type Description
Path Path

The absolute path to the bundled internal assets or the project source root.

Source code in gui\utils\app_paths.py
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
def get_internal_root() -> Path:
    """
    Resolves the root directory for internal assets.

    Handles the path shift that occurs when the application is bundled using
    PyInstaller (`_MEIPASS`) versus running as a raw script. This is used for
    read-only assets like icons, templates, and core application code.
    `Path`

    Returns:
        Path (Path): The absolute path to the bundled internal assets or
            the project source root.
    """
    if hasattr(sys, "_MEIPASS"):
        # Running as internal bundle
        internal_root = Path(sys._MEIPASS)  # type: ignore
    else:
        # Running as normal script
        internal_root = Path(__file__).resolve().parent.parent.parent

    return internal_root

options: show_root_heading: true

gui.utils.constants

ErgoMoCap: Project Constants and Enumerations

Centralized Definitions for Domain Entities, Metrics, and Data Indices.

This module serves as the single source of truth for constants used across the ErgoMoCap ecosystem. It defines structured enumerations for biological segments, assessment methodologies, and risk levels to ensure type safety and logic consistency between the /calculators and /gui modules.

A critical component of this module is the synchronization with the FreeMoCap (FMC) nomenclature. It maps specific biomechanical degrees of freedom to their respective indices and column names used in the underlying data arrays and DataFrames.

Key Enumerations
  • BodyPart: Anatomical segments targeted by ergonomic assessments.
  • MetricType: Classification of data points (Score, Angle, or Risk).
  • AssessmentMethod: Supported ergonomic protocols (REBA, RULA).
  • RiskLevel: Qualitative descriptors for calculated ergonomic risks.
  • DegsIndexes: Integer mapping for raw biomechanical degree-of-freedom arrays.
Data Schemas
  • FMC_ANGLE_COLUMNS: Standardized list of strings for DataFrame column indexing.
  • ANGLE_LABELS: Mapping of BodyPart to project-specific FMC metric strings.

Classes

AssessmentMethod

Bases: Enum

Supported ergonomic assessment protocols.

Attributes:

Name Type Description
REBA str

Rapid Entire Body Assessment.

RULA str

Rapid Upper Limb Assessment.

Source code in gui\utils\constants.py
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
class AssessmentMethod(Enum):
    """
    Supported ergonomic assessment protocols.

    Attributes:
        REBA (str): Rapid Entire Body Assessment.
        RULA (str): Rapid Upper Limb Assessment.
    """

    REBA = "reba"
    RULA = "rula"  # TODO uncomment when implemented pdf/docx and scores_list in video canvas for rula too

BodyPart

Bases: Enum

Enumeration of anatomical segments targeted by ergonomic assessments.

Attributes:

Name Type Description
NECK str

The cervical spine region.

TRUNK str

The main torso/spine region.

LEGS str

Lower extremities including knees and hips.

UPPER_ARM str

Humerus region (shoulder to elbow).

LOWER_ARM str

Forearm region (elbow to wrist).

WRIST str

Carpal region.

SHOULDERS str

Bi-lateral shoulder alignment.

HIPS str

Bi-lateral pelvic alignment.

Source code in gui\utils\constants.py
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
class BodyPart(Enum):
    """
    Enumeration of anatomical segments targeted by ergonomic assessments.

    Attributes:
        NECK (str): The cervical spine region.
        TRUNK (str): The main torso/spine region.
        LEGS (str): Lower extremities including knees and hips.
        UPPER_ARM (str): Humerus region (shoulder to elbow).
        LOWER_ARM (str): Forearm region (elbow to wrist).
        WRIST (str): Carpal region.
        SHOULDERS (str): Bi-lateral shoulder alignment.
        HIPS (str): Bi-lateral pelvic alignment.
    """

    NECK = "neck"
    TRUNK = "trunk"
    LEGS = "legs"
    UPPER_ARM = "upper_arm"
    LOWER_ARM = "lower_arm"
    WRIST = "wrist"
    SHOULDERS = "shoulders"
    HIPS = "hips"

DegsIndexes

Bases: IntEnum

Standardized indices mapping to FreeMoCap (FMC) biomechanical degree-of-freedom arrays.

These indices are used to slice raw data arrays based on the [bodypart][metric][method/subgroup] nomenclature.

Attributes:

Name Type Description
RIGHT_KNEE_EXTENSION_FLEXION int

Index 0.

LEFT_KNEE_EXTENSION_FLEXION int

Index 1.

SPINE_EXTENSION_FLEXION int

Index 2.

SPINE_LATERAL_FLEXION int

Index 3.

SPINE_ROTATION_TORSION int

Index 4.

NECK_EXTENSION_FLEXION int

Index 5.

NECK_LATERAL_FLEXION int

Index 6.

NECK_ROTATION int

Index 7.

RIGHT_SHOULDER_EXTENSION_FLEXION int

Index 8.

LEFT_SHOULDER_EXTENSION_FLEXION int

Index 9.

RIGHT_SHOULDER_ABDUCTION_ADDUCTION int

Index 10.

LEFT_SHOULDER_ABDUCTION_ADDUCTION int

Index 11.

RIGHT_SHOULDER_RISE int

Index 12.

LEFT_SHOULDER_RISE int

Index 13.

RIGHT_ELBOW_EXTENSION_FLEXION int

Index 14.

LEFT_ELBOW_EXTENSION_FLEXION int

Index 15.

RIGHT_HAND_EXTENSION_FLEXION int

Index 16.

LEFT_HAND_EXTENSION_FLEXION int

Index 17.

RIGHT_HAND_LATERAL_SIDE int

Index 18.

LEFT_HAND_LATERAL_SIDE int

Index 19.

RIGHT_HAND_TWIST int

Index 20.

LEFT_HAND_TWIST int

Index 21.

Source code in gui\utils\constants.py
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
class DegsIndexes(IntEnum):
    """
    Standardized indices mapping to FreeMoCap (FMC) biomechanical degree-of-freedom arrays.

    These indices are used to slice raw data arrays based on the
    [bodypart]_[metric]_[method/subgroup] nomenclature.

    Attributes:
        RIGHT_KNEE_EXTENSION_FLEXION (int): Index 0.
        LEFT_KNEE_EXTENSION_FLEXION (int): Index 1.
        SPINE_EXTENSION_FLEXION (int): Index 2.
        SPINE_LATERAL_FLEXION (int): Index 3.
        SPINE_ROTATION_TORSION (int): Index 4.
        NECK_EXTENSION_FLEXION (int): Index 5.
        NECK_LATERAL_FLEXION (int): Index 6.
        NECK_ROTATION (int): Index 7.
        RIGHT_SHOULDER_EXTENSION_FLEXION (int): Index 8.
        LEFT_SHOULDER_EXTENSION_FLEXION (int): Index 9.
        RIGHT_SHOULDER_ABDUCTION_ADDUCTION (int): Index 10.
        LEFT_SHOULDER_ABDUCTION_ADDUCTION (int): Index 11.
        RIGHT_SHOULDER_RISE (int): Index 12.
        LEFT_SHOULDER_RISE (int): Index 13.
        RIGHT_ELBOW_EXTENSION_FLEXION (int): Index 14.
        LEFT_ELBOW_EXTENSION_FLEXION (int): Index 15.
        RIGHT_HAND_EXTENSION_FLEXION (int): Index 16.
        LEFT_HAND_EXTENSION_FLEXION (int): Index 17.
        RIGHT_HAND_LATERAL_SIDE (int): Index 18.
        LEFT_HAND_LATERAL_SIDE (int): Index 19.
        RIGHT_HAND_TWIST (int): Index 20.
        LEFT_HAND_TWIST (int): Index 21.
    """

    # 1. LEGS
    RIGHT_KNEE_EXTENSION_FLEXION = 0
    LEFT_KNEE_EXTENSION_FLEXION = 1

    # 2. TRUNK
    SPINE_EXTENSION_FLEXION = 2
    SPINE_LATERAL_FLEXION = 3
    SPINE_ROTATION_TORSION = 4

    # 3. NECK
    NECK_EXTENSION_FLEXION = 5
    NECK_LATERAL_FLEXION = 6
    NECK_ROTATION = 7

    # 4. UPPER ARM
    RIGHT_SHOULDER_EXTENSION_FLEXION = 8
    LEFT_SHOULDER_EXTENSION_FLEXION = 9
    RIGHT_SHOULDER_ABDUCTION_ADDUCTION = 10
    LEFT_SHOULDER_ABDUCTION_ADDUCTION = 11
    RIGHT_SHOULDER_RISE = 12
    LEFT_SHOULDER_RISE = 13

    # 5. LOWER ARM
    RIGHT_ELBOW_EXTENSION_FLEXION = 14
    LEFT_ELBOW_EXTENSION_FLEXION = 15

    # 6. WRIST
    RIGHT_HAND_EXTENSION_FLEXION = 16
    LEFT_HAND_EXTENSION_FLEXION = 17
    RIGHT_HAND_LATERAL_SIDE = 18
    LEFT_HAND_LATERAL_SIDE = 19
    RIGHT_HAND_TWIST = 20
    LEFT_HAND_TWIST = 21

MetricType

Bases: Enum

Classification of ergonomic data points and calculation results.

Attributes:

Name Type Description
SCORE str

Numerical assessment value (e.g., REBA final score).

ANGLE str

Biomechanical joint angle in degrees.

RISK str

Qualitative risk classification.

Source code in gui\utils\constants.py
76
77
78
79
80
81
82
83
84
85
86
87
88
class MetricType(Enum):
    """
    Classification of ergonomic data points and calculation results.

    Attributes:
        SCORE (str): Numerical assessment value (e.g., REBA final score).
        ANGLE (str): Biomechanical joint angle in degrees.
        RISK (str): Qualitative risk classification.
    """

    SCORE = "score"
    ANGLE = "angle"
    RISK = "risk"

RiskLevel

Bases: Enum

Qualitative descriptors for calculated ergonomic risk levels.

Attributes:

Name Type Description
NEGLIGIBLE str

No action required.

LOW str

Further investigation may be needed.

MEDIUM str

Further investigation and changes soon.

HIGH str

Investigation and changes required immediately.

VERY_HIGH str

Urgent changes required.

Source code in gui\utils\constants.py
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
class RiskLevel(Enum):
    """
    Qualitative descriptors for calculated ergonomic risk levels.

    Attributes:
        NEGLIGIBLE (str): No action required.
        LOW (str): Further investigation may be needed.
        MEDIUM (str): Further investigation and changes soon.
        HIGH (str): Investigation and changes required immediately.
        VERY_HIGH (str): Urgent changes required.
    """

    NEGLIGIBLE = "negligible"
    LOW = "low"
    MEDIUM = "medium"
    HIGH = "high"
    VERY_HIGH = "very_high"

options: show_root_heading: true

gui.utils.models

ErgoMoCap: GUI - Backend Communication Models

Typed Data Contracts and Structural Communication Interfaces.

This module provides immutably frozen, memory-optimized (__slots__) data models to standardize communications between asynchronous backend processes, workers, and user interface threads.

By enforcing strict type boundaries across signals and slots, this architecture prevents thread-boundary race mutations and standardizes API contracts across the application lifecycle.

Classes

AnalysisRequest dataclass

Typed command container parameters payload requested to run ergonomic calculations.

Attributes:

Name Type Description
method AssessmentMethod

Target metric evaluation system mapping layout configuration selection.

export_frames bool

Flag determining whether single annotated frame matrices are generated to disk. Defaults to False.

data_ref ndarray | Path | None

File tracking pointer reference indicating where tracking elements reside. Defaults to None.

Source code in gui\utils\models.py
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
@dataclass(frozen=True, slots=True)
class AnalysisRequest:
    """
    Typed command container parameters payload requested to run ergonomic calculations.

    Attributes:
        method (AssessmentMethod): Target metric evaluation system mapping layout configuration selection.
        export_frames (bool): Flag determining whether single annotated frame matrices are generated to disk. Defaults to False.
        data_ref (np.ndarray | Path | None): File tracking pointer reference indicating where tracking elements reside. Defaults to None.
    """

    method: AssessmentMethod
    export_frames: bool = False
    data_ref: np.ndarray | Path | None = None

AnalysisResult dataclass

Typed compilation results structural receipt dispatched back by the calculation engines.

Attributes:

Name Type Description
success bool

Operational completion assertion status flag tracking validation success.

message str

Explanatory execution text trace reporting diagnostics details logs metadata.

output_path Path | None

Systemic disk tracking target pointer path containing CSV sheets, or None. Defaults to None.

scores Sequence[int]

Sequential collection of the frame-by-frame computed results score integers array. Defaults to empty list.

stats dict[str, int]

Evaluative qualitative summary frequency grouping metadata calculation table. Defaults to empty dict.

Source code in gui\utils\models.py
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
@dataclass(frozen=True, slots=True)
class AnalysisResult:
    """
    Typed compilation results structural receipt dispatched back by the calculation engines.

    Attributes:
        success (bool): Operational completion assertion status flag tracking validation success.
        message (str): Explanatory execution text trace reporting diagnostics details logs metadata.
        output_path (Path | None): Systemic disk tracking target pointer path containing CSV sheets, or None. Defaults to None.
        scores (Sequence[int]): Sequential collection of the frame-by-frame computed results score integers array. Defaults to empty list.
        stats (dict[str, int]): Evaluative qualitative summary frequency grouping metadata calculation table. Defaults to empty dict.
    """

    success: bool
    message: str
    output_path: Path | None = None
    scores: Sequence[int] = field(default_factory=list)
    stats: dict[str, int] = field(default_factory=dict)

ErrorInfo dataclass

Simplified structured telemetry error container packet optimized for cross-thread exception warning widgets deployment.

Attributes:

Name Type Description
title str

Bold notification dialogue header summary label classification metric context text string.

message str

Core execution stack exceptions context message descriptive explanation logs parameters.

Source code in gui\utils\models.py
312
313
314
315
316
317
318
319
320
321
322
323
@dataclass(frozen=True, slots=True)
class ErrorInfo:
    """
    Simplified structured telemetry error container packet optimized for cross-thread exception warning widgets deployment.

    Attributes:
        title (str): Bold notification dialogue header summary label classification metric context text string.
        message (str): Core execution stack exceptions context message descriptive explanation logs parameters.
    """

    title: str
    message: str

FrameData dataclass

Typed contract for sequential video frame emission pipelines.

Encapsulates raw frame visual matrix blocks alongside contextual calculations and skeletal landmark parameters processed during asynchronous visual workers ticks.

Attributes:

Name Type Description
image ndarray

The raw multi-channel image matrix array matching OpenCV formats (BGR).

frame_idx int

The absolute temporal timeline sequential element integer frame index identifier.

landmarks list

Collection array structure holding multi-dimensional coordinate mapping elements. Defaults to empty list.

score int | None

The specific calculated ergonomic evaluation integer score, or None if skipped. Defaults to None.

risk RiskLevel | None

The qualitative risk ranking assignment context enum classification tracking value. Defaults to None.

Source code in gui\utils\models.py
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
@dataclass(frozen=True, slots=True)
class FrameData:
    """
    Typed contract for sequential video frame emission pipelines.

    Encapsulates raw frame visual matrix blocks alongside contextual calculations
    and skeletal landmark parameters processed during asynchronous visual workers ticks.

    Attributes:
        image (np.ndarray): The raw multi-channel image matrix array matching OpenCV formats (BGR).
        frame_idx (int): The absolute temporal timeline sequential element integer frame index identifier.
        landmarks (list): Collection array structure holding multi-dimensional coordinate mapping elements. Defaults to empty list.
        score (int | None): The specific calculated ergonomic evaluation integer score, or None if skipped. Defaults to None.
        risk (RiskLevel | None): The qualitative risk ranking assignment context enum classification tracking value. Defaults to None.
    """

    image: np.ndarray
    frame_idx: int
    landmarks: list = field(default_factory=list)
    score: int | None = None
    risk: RiskLevel | None = None

    def to_dict(self) -> dict:
        """
        Helper for backward compatibility with dict-based consumer tracking slots.

        Flattens the structured fields data layouts directly into primitive structural
        lookup dictionaries for legacy tracking elements components.

        Returns:
            dict (dict): Composed metric values keys structure mapping frame information configurations.
        """
        return {
            "frame_idx": self.frame_idx,
            MetricType.SCORE.value: self.score if self.score else None,
            MetricType.RISK.value: self.risk.value if self.risk else None,
        }
Functions
to_dict()

Helper for backward compatibility with dict-based consumer tracking slots.

Flattens the structured fields data layouts directly into primitive structural lookup dictionaries for legacy tracking elements components.

Returns:

Name Type Description
dict dict

Composed metric values keys structure mapping frame information configurations.

Source code in gui\utils\models.py
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
def to_dict(self) -> dict:
    """
    Helper for backward compatibility with dict-based consumer tracking slots.

    Flattens the structured fields data layouts directly into primitive structural
    lookup dictionaries for legacy tracking elements components.

    Returns:
        dict (dict): Composed metric values keys structure mapping frame information configurations.
    """
    return {
        "frame_idx": self.frame_idx,
        MetricType.SCORE.value: self.score if self.score else None,
        MetricType.RISK.value: self.risk.value if self.risk else None,
    }

FramesExportResult dataclass

Typed tracking receipt mapping operational metrics recording asynchronous batch frame processing operations.

Attributes:

Name Type Description
success bool

Execution tracking parameters completion validation monitoring status flag.

message str

Text specification string details log tracing diagnostic parameters notes outputs.

frames_paths str

Absolute file target path string tracking localized assembly folders directories layouts. Defaults to empty string.

Source code in gui\utils\models.py
266
267
268
269
270
271
272
273
274
275
276
277
278
279
@dataclass(frozen=True, slots=True)
class FramesExportResult:
    """
    Typed tracking receipt mapping operational metrics recording asynchronous batch frame processing operations.

    Attributes:
        success (bool): Execution tracking parameters completion validation monitoring status flag.
        message (str): Text specification string details log tracing diagnostic parameters notes outputs.
        frames_paths (str): Absolute file target path string tracking localized assembly folders directories layouts. Defaults to empty string.
    """

    success: bool
    message: str
    frames_paths: str = field(default_factory=str)

PlaybackState

Bases: Enum

Tracks whether the video engine ticker is active or paused.

Attributes:

Name Type Description
PLAYING bool

The background worker processing timer tick loop is enabled.

PAUSED bool

The background worker parsing pipeline is halted.

Source code in gui\utils\models.py
47
48
49
50
51
52
53
54
55
56
57
class PlaybackState(Enum):
    """
    Tracks whether the video engine ticker is active or paused.

    Attributes:
        PLAYING (bool): The background worker processing timer tick loop is enabled.
        PAUSED (bool): The background worker parsing pipeline is halted.
    """

    PLAYING = True
    PAUSED = False

ReportData dataclass

Data payload model for generating standalone visual report templates sheets.

Attributes:

Name Type Description
df DataFrame

Dataframe container compiling synchronized evaluation data layers parameters metrics.

file_path Path

System absolute path target pinpointing localized assets configurations metrics logs.

total_frames int

Length magnitude configuration metrics representing overall timeline size scope counts.

average_score float

Computed cumulative global standard score tracking mean floating-point calculations.

summary_dict dict

Consolidated diagnostic lookup grouping keys defining assessment distribution metadata profiles.

Source code in gui\utils\models.py
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
@dataclass(frozen=True, slots=True)
class ReportData:
    """
    Data payload model for generating standalone visual report templates sheets.

    Attributes:
        df (pd.DataFrame): Dataframe container compiling synchronized evaluation data layers parameters metrics.
        file_path (Path): System absolute path target pinpointing localized assets configurations metrics logs.
        total_frames (int): Length magnitude configuration metrics representing overall timeline size scope counts.
        average_score (float): Computed cumulative global standard score tracking mean floating-point calculations.
        summary_dict (dict): Consolidated diagnostic lookup grouping keys defining assessment distribution metadata profiles.
    """

    df: pd.DataFrame
    file_path: Path
    total_frames: int
    average_score: float
    summary_dict: dict

ReportExportRequest dataclass

Unified command request container compiling raw visualization elements to generate printable document formats.

Attributes:

Name Type Description
save_path Path

Absolute system destination output file locator target route specifications tracking.

chart_data bytes

Byte array buffer container streams processing embedded static graphics layouts parameters.

Source code in gui\utils\models.py
282
283
284
285
286
287
288
289
290
291
292
293
@dataclass(frozen=True, slots=True)
class ReportExportRequest:
    """
    Unified command request container compiling raw visualization elements to generate printable document formats.

    Attributes:
        save_path (Path): Absolute system destination output file locator target route specifications tracking.
        chart_data (bytes): Byte array buffer container streams processing embedded static graphics layouts parameters.
    """

    save_path: Path
    chart_data: bytes

ReportExportResult dataclass

Typed tracking response envelope returning results from background document generation sub-workers.

Attributes:

Name Type Description
success bool

Transaction completion state verification parameters flag tracking status indicator.

message str

Technical context text trace layout providing detailed internal log strings configurations.

report_path str

Final target localized storage path string tracking generated report elements on disk. Defaults to empty string.

Source code in gui\utils\models.py
296
297
298
299
300
301
302
303
304
305
306
307
308
309
@dataclass(frozen=True, slots=True)
class ReportExportResult:
    """
    Typed tracking response envelope returning results from background document generation sub-workers.

    Attributes:
        success (bool): Transaction completion state verification parameters flag tracking status indicator.
        message (str): Technical context text trace layout providing detailed internal log strings configurations.
        report_path (str): Final target localized storage path string tracking generated report elements on disk. Defaults to empty string.
    """

    success: bool
    message: str
    report_path: str = field(default_factory=str)

SessionData dataclass

Typed session parsing and directory mapping validation meta context tracking model.

Attributes:

Name Type Description
name str

Label string identifying unique target folder recording session items profiles.

success bool

Operational status assertion flag verifying disk structure lookup results mappings.

message str

Log tracing diagnostic notification description text parameters.

csv_path Path | None

Absolute system folder mapping target identifying joint coordinate files, or None. Defaults to None.

video_paths list[str]

Sequential listing array containing absolute string links paths pointing to media files. Defaults to empty list.

loaded bool

Initialization evaluation track status monitoring flag component. Defaults to False.

Source code in gui\utils\models.py
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
@dataclass(frozen=True, slots=True)
class SessionData:
    """
    Typed session parsing and directory mapping validation meta context tracking model.

    Attributes:
        name (str): Label string identifying unique target folder recording session items profiles.
        success (bool): Operational status assertion flag verifying disk structure lookup results mappings.
        message (str): Log tracing diagnostic notification description text parameters.
        csv_path (Path | None): Absolute system folder mapping target identifying joint coordinate files, or None. Defaults to None.
        video_paths (list[str]): Sequential listing array containing absolute string links paths pointing to media files. Defaults to empty list.
        loaded (bool): Initialization evaluation track status monitoring flag component. Defaults to False.
    """

    name: str
    success: bool
    message: str
    csv_path: Path | None = None
    video_paths: list[str] = field(default_factory=list)
    loaded: bool = False

    @property
    def is_ready(self) -> bool:
        """
        Convenience checking query interface supporting transactional interface interactive states enablement.

        Returns:
            bool (bool): True if session paths resolution criteria parameters components matches healthy targets.
        """
        return self.loaded and self.csv_path is not None
Attributes
is_ready property

Convenience checking query interface supporting transactional interface interactive states enablement.

Returns:

Name Type Description
bool bool

True if session paths resolution criteria parameters components matches healthy targets.

VideoCommand

Bases: Enum

Transport action command primitive indicators navigating background worker media rendering buffers indices.

Attributes:

Name Type Description
TOGGLE auto

Reverses running execution playback state metrics tracking switches.

STEP_FORWARD auto

Shifts background rendering arrays pointers ahead by exactly one index unit frame.

STEP_BACKWARD auto

Regresses active frame buffers lookups markers back by exactly one frame iteration unit.

SEEK auto

Forces operational media decoders directly onto target localized coordinate position bounds.

Source code in gui\utils\models.py
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
class VideoCommand(Enum):
    """
    Transport action command primitive indicators navigating background worker media rendering buffers indices.

    Attributes:
        TOGGLE (auto): Reverses running execution playback state metrics tracking switches.
        STEP_FORWARD (auto): Shifts background rendering arrays pointers ahead by exactly one index unit frame.
        STEP_BACKWARD (auto): Regresses active frame buffers lookups markers back by exactly one frame iteration unit.
        SEEK (auto): Forces operational media decoders directly onto target localized coordinate position bounds.
    """

    TOGGLE = auto()
    STEP_FORWARD = auto()
    STEP_BACKWARD = auto()
    SEEK = auto()

VideoControl dataclass

Unified execution envelope routing atomic layout command structures directly down into background transport streams.

Attributes:

Name Type Description
command VideoCommand

Action type designation key identifier tracking intent options mappings.

target_frame int | None

Direct specific sequence coordinate framework positional index location parameters, or None. Defaults to None.

Source code in gui\utils\models.py
252
253
254
255
256
257
258
259
260
261
262
263
@dataclass(frozen=True, slots=True)
class VideoControl:
    """
    Unified execution envelope routing atomic layout command structures directly down into background transport streams.

    Attributes:
        command (VideoCommand): Action type designation key identifier tracking intent options mappings.
        target_frame (int | None): Direct specific sequence coordinate framework positional index location parameters, or None. Defaults to None.
    """

    command: VideoCommand
    target_frame: int | None = None

VideoLoadRequest dataclass

Typed parameter targets initialization packet dispatched to establish background video engine playback context.

Attributes:

Name Type Description
path Path

Absolute system directory file target locator tracking media recording structures source assets.

scores list[int]

Sequential array indices carrying calculated timeline point score information markers. Defaults to empty list.

thresholds list[tuple[int, RiskLevel]]

Structural intervals matrix tracking score-to-level translation limits. Defaults to empty list.

Source code in gui\utils\models.py
187
188
189
190
191
192
193
194
195
196
197
198
199
200
@dataclass(frozen=True, slots=True)
class VideoLoadRequest:
    """
    Typed parameter targets initialization packet dispatched to establish background video engine playback context.

    Attributes:
        path (Path): Absolute system directory file target locator tracking media recording structures source assets.
        scores (list[int]): Sequential array indices carrying calculated timeline point score information markers. Defaults to empty list.
        thresholds (list[tuple[int, RiskLevel]]): Structural intervals matrix tracking score-to-level translation limits. Defaults to empty list.
    """

    path: Path
    scores: list[int] = field(default_factory=list)
    thresholds: list[tuple[int, RiskLevel]] = field(default_factory=list)

VideoLoadResult dataclass

Typed structural verification feedback payload reporting media worker initialization context success parameters.

Attributes:

Name Type Description
success bool

Verification parameter monitoring success of media asset loading pipelines.

message str

Logging trace update text string specifying context setup diagnostics parameters.

video_paths list[str]

Discovered system target files paths listings array tracks structure. Defaults to empty list.

loaded bool

State indicator monitoring internal setup verification completeness metrics. Defaults to False.

Source code in gui\utils\models.py
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
@dataclass(frozen=True, slots=True)
class VideoLoadResult:
    """
    Typed structural verification feedback payload reporting media worker initialization context success parameters.

    Attributes:
        success (bool): Verification parameter monitoring success of media asset loading pipelines.
        message (str): Logging trace update text string specifying context setup diagnostics parameters.
        video_paths (list[str]): Discovered system target files paths listings array tracks structure. Defaults to empty list.
        loaded (bool): State indicator monitoring internal setup verification completeness metrics. Defaults to False.
    """

    success: bool
    message: str
    video_paths: list[str] = field(default_factory=list)
    loaded: bool = False

VideoPosition dataclass

Typed real-time positional coordinate update tracking payload enabling cross-thread synchronization widgets interfaces.

Attributes:

Name Type Description
current_frame int

Absolute playback timeline frame positional index pointer integer position.

total_frames int

Total bounded capacity layout limit tracking metric representing files stream dimension length.

Source code in gui\utils\models.py
221
222
223
224
225
226
227
228
229
230
231
232
@dataclass(frozen=True, slots=True)
class VideoPosition:
    """
    Typed real-time positional coordinate update tracking payload enabling cross-thread synchronization widgets interfaces.

    Attributes:
        current_frame (int): Absolute playback timeline frame positional index pointer integer position.
        total_frames (int): Total bounded capacity layout limit tracking metric representing files stream dimension length.
    """

    current_frame: int
    total_frames: int

options: show_root_heading: true

intl.update_intl

ErgoMoCap: Internationalization (i18n) Manager

Localization workflow automation for Qt-based strings.

This utility streamlines the process of updating and compiling translation files for the ErgoMoCap interface. It automates the extraction of translatable strings from the /gui and /calculators directories and manages the conversion between Qt XML source files (.ts) and binary runtime files (.qm).

Workflow: 1. Extraction: Scans Python source files using pyside6-lupdate. 2. Aggregation: Updates the regional translation source files. 3. Compilation: Converts human-readable translations into high-performance binary formats via pyside6-lrelease.

Functions

run_intl()

intl.update_intl.run_intl

Orchestrates the extraction and compilation of translatable strings for the ErgoMoCap project.

This function automates the Qt localization workflow by: 1. Identifying all Python source files within the /gui and /calculators directories. 2. Executing pyside6-lupdate to synchronize translatable strings into an XML-based .ts file. 3. Prompting the user to compile the updated source into a high-performance binary .qm file using pyside6-lrelease.

The generated files are stored in the intl/generated directory, which is utilized by the main application entry point for runtime translation.

Returns:

Name Type Description
None None

The return value is always None.

Raises:

Type Description
CalledProcessError

If the external Qt tools (lupdate or lrelease) fail during execution.

OSError

If there are issues creating the generated directory or accessing source files.

Examples:

To update project translations from the terminal:

python -m intl.update_intl
Source code in intl\update_intl.py
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
def run_intl() -> None:
    """
    [intl.update_intl.run_intl][]

    Orchestrates the extraction and compilation of translatable strings for the ErgoMoCap project.

    This function automates the Qt localization workflow by:
    1.  Identifying all Python source files within the `/gui` and `/calculators` directories.
    2.  Executing `pyside6-lupdate` to synchronize translatable strings into an XML-based `.ts` file.
    3.  Prompting the user to compile the updated source into a high-performance binary `.qm` file
        using `pyside6-lrelease`.

    The generated files are stored in the `intl/generated` directory, which is utilized by
    the [main][main.main] application entry point for runtime translation.

    Returns:
        None: The return value is always None.

    Raises:
        subprocess.CalledProcessError: If the external Qt tools (`lupdate` or `lrelease`) fail
            during execution.
        OSError: If there are issues creating the `generated` directory or accessing source files.

    Examples:
        To update project translations from the terminal:
        ```bash
        python -m intl.update_intl
        ```
    """
    # 1. Path Setup
    # Script is in /intl, so root is one level up
    intl_dir = Path(__file__).parent
    root_dir = intl_dir.parent

    # Target directory for generated files
    try:
        gen_dir = intl_dir / "generated"
        gen_dir.mkdir(parents=True, exist_ok=True)
    except OSError as e:
        logger.error(f"❌ Failed to create generated directory: {e}")
        raise

    folders_to_scan = ["gui", "calculators"]
    ts_file = gen_dir / "strings_it.ts"
    qm_file = gen_dir / "strings_it.qm"

    # 2. Manually collect all .py files for a deep scan
    py_files = []
    try:
        for folder in folders_to_scan:
            target_path = root_dir / folder
            if target_path.exists():
                # rglob finds everything in subdirectories too
                found = [str(f) for f in target_path.rglob("*.py")]
                py_files.extend(found)
    except OSError as e:
        logger.error(f"❌ Failed to scan directories: {e}")
        raise

    if not py_files:
        logger.info(f"❌ No .py files found in: {', '.join(folders_to_scan)}")
        return

    logger.info(f"Scanning {len(py_files)} files across {folders_to_scan}...")

    # 3. Extract strings (lupdate)
    cmd_update = ["pyside6-lupdate"] + py_files + ["-ts", str(ts_file)]

    try:
        subprocess.run(cmd_update, check=True)
        logger.info(f"✅ TS file updated at: {ts_file}")
    except subprocess.CalledProcessError as e:
        logger.error(f"❌ lupdate failed: {e}")
        raise

    # 4. Compile strings (lrelease)
    logger.info(
        f"\nIf you have pasted the LLM translations into {ts_file.name}, press 'y' to compile."
    )
    confirm = input("Compile to binary .qm? (y/n): ").lower()

    if confirm == "y":
        cmd_release = ["pyside6-lrelease", str(ts_file), "-qm", str(qm_file)]
        try:
            subprocess.run(cmd_release, check=True)
            logger.info(f"✅ Binary ready at: {qm_file}")
        except subprocess.CalledProcessError as e:
            logger.error(f"❌ lrelease failed: {e}")
            raise

options: show_root_heading: true


Main Entry Point

main

ErgoMoCap: Application Entry Point

Main execution script for the ErgoMoCap ergonomic analysis suite.

This module initializes the high-level application environment, including the Qt event loop, localization (i18n), visual theming, and the primary window instantiation. It serves as the bridge between the system environment and the MainWindow component.

Key Initialization Steps: 1. Stream Fallbacks: Safeguards standard I/O streams by intercepting detached or missing system environments and overriding sys.stdout and sys.stderr with in-memory io.StringIO buffers. 2. Multiprocessing Support: Invokes multiprocessing.freeze_support() to ensure proper process spawning behaviors within PyInstaller frozen execution runtimes. 3. Subprocess Redispatching: Evaluates CLI arguments early to catch specific internal routing directives (e.g., --run-freemocap-gui), dynamically self-modifying Python's sys.path and working directories to isolate bundled background operations seamlessly. 4. Environment Setup: Configures the QApplication instance with native system arguments. 5. Localization: Detects system QLocale and attempts to load corresponding .qm translation files from the intl/generated directory. 6. Theming: Applies the "Fusion" style and the project's custom dark-mode stylesheet via get_stylesheet. 7. Path Management: Utilizes ErgoPaths to locate resources like the application icon. 8. Window Management: Launches the main GUI and handles the clean exit of the process.

Classes

Functions

main()

Primary entry point for the ErgoMoCap application.

Initializes the QApplication instance, configures the localization (i18n) settings based on the user's system locale, applies the global visual theme via get_stylesheet, and instantiates the MainWindow.

The function orchestrates the following bootstrap sequence: 1. Handles PyInstaller multiprocessing requirements via freeze_support(). 2. Intercepts custom CLI routing flags (e.g., --run-freemocap-gui) to run bundled background modules dynamically from the frozen environment, safely altering the working directory to _internal if present to guarantee proper asset resolution. 3. Creates the QApplication and handles CLI arguments. 4. Searches for and loads .qm translation files from the intl/generated directory. 5. Sets the application style to 'Fusion' and applies the custom dark theme. 6. Configures the application-wide icon using ErgoPaths. 7. Enters the Qt main event loop.

Returns:

Name Type Description
None None

This function terminates the process by calling sys.exit().

Examples:

To start the application from the command line:

python main.py
Source code in main.py
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
def main() -> None:
    """Primary entry point for the ErgoMoCap application.

    Initializes the `QApplication` instance, configures the localization (i18n)
    settings based on the user's system locale, applies the global visual theme
    via [get_stylesheet][gui.theme.style], and instantiates the
    [MainWindow][gui.frontend.MainWindow].

    The function orchestrates the following bootstrap sequence:
    1.  Handles PyInstaller multiprocessing requirements via freeze_support().
    2.  Intercepts custom CLI routing flags (e.g., `--run-freemocap-gui`) to
        run bundled background modules dynamically from the frozen environment,
        safely altering the working directory to `_internal` if present to guarantee
        proper asset resolution.
    3.  Creates the `QApplication` and handles CLI arguments.
    4.  Searches for and loads `.qm` translation files from the `intl/generated`
        directory.
    5.  Sets the application style to 'Fusion' and applies the custom dark
        theme.
    6.  Configures the application-wide icon using
        [ErgoPaths][gui.utils.app_paths.ErgoPaths].
    7.  Enters the Qt main event loop.

    Returns:
        None: This function terminates the process by calling `sys.exit()`.

    Examples:
        To start the application from the command line:
        ```bash
        python main.py
        ```
    """

    multiprocessing.freeze_support()

    ### PYINSTALLER REDISPATCH LOGIC ROUTE ###
    # Intercept custom command-line flags before any ErgoMoCap modules initialize
    if "--run-freemocap-gui" in sys.argv:
        if getattr(sys, "frozen", False):
            # Tell the embedded environment where to search for compiled modules
            base_path = getattr(sys, "_MEIPASS")
            sys.path.insert(0, base_path)

            internal_path = Path(base_path) / "_internal"
            if internal_path.exists():
                os.chdir(internal_path)
            else:
                os.chdir(base_path)

        try:
            # Enclose the import here so it only evaluates inside the subprocess
            from freemocap.gui.qt.freemocap_main import qt_gui_main

            qt_gui_main()
            sys.exit(0)
        except Exception as e:
            print(f"Failed to route FreeMoCap subprocess execution: {e}")
            sys.exit(1)

    ##########################################

    app = QApplication(sys.argv)

    ### TRANSLATOR SECTION ###
    # TODO implement this

    translator = QTranslator()
    short_locale = QLocale.system().name()[:2]
    intl_dir = Path(__file__).parent / "intl" / "generated"
    translation_file = intl_dir / f"strings_{short_locale}.qm"

    if translation_file.exists():
        if translator.load(str(translation_file)):
            app.installTranslator(translator)

    ##########################

    app.setStyle("Fusion")

    app.setStyleSheet(get_stylesheet())

    icon_path = ErgoPaths.LOGO

    if icon_path.exists():
        app.setWindowIcon(QIcon(str(icon_path)))

    win = MainWindow()
    win.show()
    sys.exit(app.exec())

options: show_root_heading: true


© 2026 medlav. Distributed under the AGPL-3.0 License.