| """Gripper segmenter: map frames to subgoals using gripper state signal.""" |
|
|
| from __future__ import annotations |
|
|
| import logging |
| import warnings |
| from dataclasses import dataclass |
| from typing import List, Optional, Tuple |
|
|
| import numpy as np |
|
|
| from phaseaware_data_construction.instruction_parser import Subgoal |
|
|
| logger = logging.getLogger(__name__) |
|
|
| |
| MANIP_PICK_TYPE = "pick" |
| MANIP_PLACE_TYPE = "place" |
| |
| STANDALONE_TYPES = {"open", "close", "turn_on", "turn_off", "push"} |
|
|
|
|
| @dataclass |
| class SegmentationResult: |
| frame_subgoals: List[str] |
| frame_progress: List[str] |
| n_cycles_detected: int |
| n_cycles_expected: int |
| warnings: List[str] |
|
|
|
|
| def segment_episode( |
| gripper_signal: np.ndarray, |
| subgoals: List[Subgoal], |
| signal_source: str = "action", |
| threshold: float = 0.02, |
| median_filter_size: int = 5, |
| min_cycle_frames: int = 10, |
| ) -> SegmentationResult: |
| """Segment an episode into subgoal phases using gripper signal. |
| |
| Args: |
| gripper_signal: [T] array. |
| If signal_source='action': action[:, 6], binary 1=open 0=closed. |
| If signal_source='state': observation.state[:, 7], continuous ~-0.04(open) to ~-0.005(closed). |
| subgoals: ordered subgoal list from instruction_parser. |
| signal_source: 'action' (default, more reliable) or 'state'. |
| threshold: binarization threshold (only used for 'state' source). |
| median_filter_size: kernel size for median filtering. |
| min_cycle_frames: minimum cycle duration in frames; shorter cycles are filtered as noise. |
| |
| Returns: |
| SegmentationResult with per-frame labels. |
| """ |
| T = len(gripper_signal) |
| warn_msgs: List[str] = [] |
|
|
| |
| g_bin = _binarize(gripper_signal, signal_source, threshold, median_filter_size) |
| close_events, open_events = _detect_events(g_bin) |
| cycles = _pair_cycles(close_events, open_events) |
|
|
| |
| |
| paired_closes = {c for c, _ in cycles} |
| unpaired = [c for c in close_events if c not in paired_closes] |
| if unpaired and g_bin[-1] == 1: |
| |
| cycles.append((unpaired[-1], T)) |
|
|
| |
| if min_cycle_frames > 0: |
| cycles = [(c, o) for c, o in cycles if (o - c) >= min_cycle_frames] |
|
|
| |
| pre_manip, manip_pairs, post_manip = _classify_subgoals(subgoals) |
| n_expected = len(manip_pairs) |
| n_detected = len(cycles) |
|
|
| if n_detected != n_expected: |
| warn_msgs.append( |
| f"Cycle mismatch: detected {n_detected}, expected {n_expected}" |
| ) |
|
|
| |
| if n_expected == 0 and len(pre_manip) == 0 and len(post_manip) == 0: |
| |
| |
| frame_subgoals = [subgoals[0].label()] * T if subgoals else ["unknown"] * T |
| elif n_expected == 0: |
| |
| all_standalone = pre_manip + post_manip |
| frame_subgoals = _assign_frames_evenly(T, all_standalone, 0, T) |
| else: |
| frame_subgoals = _assign_with_cycles( |
| T, pre_manip, manip_pairs, post_manip, cycles, n_expected, warn_msgs |
| ) |
|
|
| |
| frame_progress = _compute_progress(frame_subgoals, T) |
|
|
| return SegmentationResult( |
| frame_subgoals=frame_subgoals, |
| frame_progress=frame_progress, |
| n_cycles_detected=n_detected, |
| n_cycles_expected=n_expected, |
| warnings=warn_msgs, |
| ) |
|
|
|
|
| def _binarize( |
| gripper_signal: np.ndarray, signal_source: str, threshold: float, median_size: int |
| ) -> np.ndarray: |
| """Binarize gripper signal: 1=closed, 0=open. |
| |
| For 'action': action[:, 6] where 1=open, 0=closed → invert. |
| For 'state': observation.state[:, 7] where ~-0.04=open, ~-0.005=closed → threshold. |
| """ |
| if signal_source == "action": |
| |
| g_bin = (gripper_signal < 0.5).astype(np.int32) |
| else: |
| |
| g_bin = (gripper_signal > -threshold).astype(np.int32) |
| if median_size > 1: |
| g_bin = _median_filter_1d(g_bin, median_size) |
| return g_bin |
|
|
|
|
| def _median_filter_1d(arr: np.ndarray, kernel: int) -> np.ndarray: |
| """Simple 1D median filter (no scipy dependency).""" |
| out = arr.copy() |
| half = kernel // 2 |
| for i in range(half, len(arr) - half): |
| out[i] = int(np.median(arr[i - half : i + half + 1])) |
| return out |
|
|
|
|
| def _detect_events(g_bin: np.ndarray) -> Tuple[List[int], List[int]]: |
| """Detect close (0→1) and open (1→0) transition indices.""" |
| close_events = [] |
| open_events = [] |
| for t in range(1, len(g_bin)): |
| if g_bin[t - 1] == 0 and g_bin[t] == 1: |
| close_events.append(t) |
| elif g_bin[t - 1] == 1 and g_bin[t] == 0: |
| open_events.append(t) |
| return close_events, open_events |
|
|
|
|
| def _pair_cycles( |
| close_events: List[int], open_events: List[int] |
| ) -> List[Tuple[int, int]]: |
| """Pair each close event with its nearest subsequent open event.""" |
| cycles = [] |
| open_idx = 0 |
| for ci in close_events: |
| |
| while open_idx < len(open_events) and open_events[open_idx] <= ci: |
| open_idx += 1 |
| if open_idx < len(open_events): |
| cycles.append((ci, open_events[open_idx])) |
| open_idx += 1 |
| return cycles |
|
|
|
|
| def _classify_subgoals( |
| subgoals: List[Subgoal], |
| ) -> Tuple[List[Subgoal], List[Tuple[Subgoal, Subgoal]], List[Subgoal]]: |
| """Split subgoals into pre_manip, manip_pairs (pick+place), post_manip.""" |
| pre_manip: List[Subgoal] = [] |
| manip_pairs: List[Tuple[Subgoal, Subgoal]] = [] |
| post_manip: List[Subgoal] = [] |
|
|
| i = 0 |
| |
| while i < len(subgoals) and subgoals[i].type in STANDALONE_TYPES: |
| pre_manip.append(subgoals[i]) |
| i += 1 |
|
|
| |
| while i < len(subgoals): |
| if subgoals[i].type == MANIP_PICK_TYPE: |
| pick_sg = subgoals[i] |
| if i + 1 < len(subgoals) and subgoals[i + 1].type == MANIP_PLACE_TYPE: |
| place_sg = subgoals[i + 1] |
| manip_pairs.append((pick_sg, place_sg)) |
| i += 2 |
| else: |
| |
| manip_pairs.append((pick_sg, pick_sg)) |
| i += 1 |
| elif subgoals[i].type in STANDALONE_TYPES: |
| post_manip.append(subgoals[i]) |
| i += 1 |
| else: |
| |
| post_manip.append(subgoals[i]) |
| i += 1 |
|
|
| return pre_manip, manip_pairs, post_manip |
|
|
|
|
| def _assign_with_cycles( |
| T: int, |
| pre_manip: List[Subgoal], |
| manip_pairs: List[Tuple[Subgoal, Subgoal]], |
| post_manip: List[Subgoal], |
| cycles: List[Tuple[int, int]], |
| n_expected: int, |
| warn_msgs: List[str], |
| ) -> List[str]: |
| """Assign frames to subgoals using detected gripper cycles.""" |
| frame_subgoals = [""] * T |
|
|
| |
| |
| |
| if len(cycles) > n_expected and n_expected > 0: |
| selected_cycles = cycles[-n_expected:] |
| discarded_cycles = cycles[:-n_expected] |
| else: |
| selected_cycles = cycles[:min(len(cycles), n_expected)] |
| discarded_cycles = [] |
| n_usable = len(selected_cycles) |
|
|
| |
| if n_usable > 0: |
| first_close = selected_cycles[0][0] |
| else: |
| |
| all_sgs = ( |
| pre_manip |
| + [sg for pair in manip_pairs for sg in pair] |
| + post_manip |
| ) |
| return _assign_frames_evenly(T, all_sgs, 0, T) |
|
|
| |
| |
| |
| |
| if discarded_cycles and pre_manip: |
| pre_end = discarded_cycles[-1][1] |
| elif pre_manip: |
| |
| |
| n_pre = len(pre_manip) |
| pre_end = first_close * n_pre // (n_pre + 1) |
| else: |
| pre_end = 0 |
|
|
| if pre_manip and pre_end > 0: |
| pre_labels = _assign_frames_evenly(T, pre_manip, 0, pre_end) |
| for t in range(0, pre_end): |
| frame_subgoals[t] = pre_labels[t] |
| manip_start = pre_end |
|
|
| |
| prev_boundary = manip_start |
| for pair_idx in range(n_expected): |
| pick_sg, place_sg = manip_pairs[pair_idx] |
| if pair_idx < n_usable: |
| close_t, open_t = selected_cycles[pair_idx] |
| |
| for t in range(prev_boundary, close_t): |
| frame_subgoals[t] = pick_sg.label() |
| |
| for t in range(close_t, open_t): |
| frame_subgoals[t] = place_sg.label() |
| prev_boundary = open_t |
| else: |
| |
| break |
|
|
| |
| post_start = prev_boundary |
|
|
| |
| remaining_sgs: List[Subgoal] = [] |
| for pair_idx in range(n_usable, n_expected): |
| pick_sg, place_sg = manip_pairs[pair_idx] |
| remaining_sgs.extend([pick_sg, place_sg]) |
| remaining_sgs.extend(post_manip) |
|
|
| if remaining_sgs and post_start < T: |
| post_labels = _assign_frames_evenly(T, remaining_sgs, post_start, T) |
| for t in range(post_start, T): |
| frame_subgoals[t] = post_labels[t] |
| elif post_start < T: |
| |
| last_label = frame_subgoals[post_start - 1] if post_start > 0 else "unknown" |
| for t in range(post_start, T): |
| frame_subgoals[t] = last_label |
|
|
| |
| for t in range(T): |
| if not frame_subgoals[t]: |
| frame_subgoals[t] = "unknown" |
|
|
| return frame_subgoals |
|
|
|
|
| def _assign_frames_evenly( |
| T: int, subgoals: List[Subgoal], start: int, end: int |
| ) -> List[str]: |
| """Evenly distribute frame range [start, end) among subgoals.""" |
| labels = [""] * T |
| n = len(subgoals) |
| if n == 0 or start >= end: |
| return labels |
| span = end - start |
| per_sg = max(1, span // n) |
| for i, sg in enumerate(subgoals): |
| sg_start = start + i * per_sg |
| sg_end = start + (i + 1) * per_sg if i < n - 1 else end |
| for t in range(sg_start, min(sg_end, end)): |
| labels[t] = sg.label() |
| return labels |
|
|
|
|
| def _compute_progress(frame_subgoals: List[str], T: int) -> List[str]: |
| """Assign 'early'/'late' progress to each frame within its subgoal span.""" |
| progress = ["early"] * T |
| if T == 0: |
| return progress |
|
|
| |
| spans: List[Tuple[int, int, str]] = [] |
| current_label = frame_subgoals[0] |
| span_start = 0 |
| for t in range(1, T): |
| if frame_subgoals[t] != current_label: |
| spans.append((span_start, t, current_label)) |
| current_label = frame_subgoals[t] |
| span_start = t |
| spans.append((span_start, T, current_label)) |
|
|
| for s, e, _ in spans: |
| mid = (s + e) // 2 |
| for t in range(s, mid): |
| progress[t] = "early" |
| for t in range(mid, e): |
| progress[t] = "late" |
|
|
| return progress |
|
|