/ core / attention / eye_tracking.py
eye_tracking.py
  1  """
  2  Eye Tracking Integration (Tobii via Talon)
  3  
  4  Integration point for hardware eye tracking on macOS.
  5  
  6  Architecture:
  7      Tobii 5 → Talon (Mac driver) → Unix Socket → TalonIntegration → AttentionTracker
  8  
  9  Since Tobii doesn't officially support macOS, we use Talon as the
 10  hardware interface. Talon provides:
 11  - Mac-compatible Tobii driver
 12  - Raw gaze data streaming
 13  - Calibration UI
 14  
 15  This module provides:
 16  1. TalonIntegration - connects to Talon's gaze bridge
 17  2. Fixation detection using I-DT algorithm
 18  3. Semantic mapping (screen regions to concepts)
 19  4. AttentionEvent generation for the attention tracker
 20  
 21  The hierarchy of attention signals (from inferred to direct):
 22  
 23      INFERRED                                      DIRECT
 24      ◄────────────────────────────────────────────────►
 25  
 26      Session topics    Podcast plays    Highlights    Eye gaze
 27      (weak signal)     (medium)         (strong)      (ground truth)
 28  
 29  Eye tracking provides:
 30  - Fixation duration → depth of attention
 31  - Saccade patterns → scanning vs reading
 32  - Pupil dilation → cognitive load / interest
 33  - Gaze velocity → searching vs focused
 34  
 35  Integration points:
 36  - AttentionEvent.modality = 'gaze'
 37  - AttentionEvent.intensity = fixation_duration / baseline
 38  - Real-time trajectory updates (sub-second)
 39  
 40  "Attention is all you need" - both the transformer insight and literally true.
 41  """
 42  
 43  from dataclasses import dataclass, field
 44  from datetime import datetime
 45  from typing import Optional, List, Dict, Tuple, Callable, Union
 46  from enum import Enum
 47  from pathlib import Path
 48  import json
 49  import socket
 50  import threading
 51  import time
 52  
 53  
 54  class GazeEventType(Enum):
 55      """Types of gaze events."""
 56      FIXATION = "fixation"      # Sustained gaze on target
 57      SACCADE = "saccade"        # Rapid movement between targets
 58      SMOOTH_PURSUIT = "smooth"  # Tracking moving target
 59      BLINK = "blink"            # Eyes closed
 60  
 61  
 62  @dataclass
 63  class GazePoint:
 64      """A single gaze sample from the eye tracker."""
 65      timestamp: datetime
 66      x: float  # Screen x coordinate (0-1 normalized)
 67      y: float  # Screen y coordinate (0-1 normalized)
 68      left_valid: bool = True
 69      right_valid: bool = True
 70      pupil_diameter: Optional[float] = None  # mm, indicates cognitive load
 71  
 72  
 73  @dataclass
 74  class Fixation:
 75      """A fixation event - sustained attention on a point."""
 76      start_time: datetime
 77      end_time: datetime
 78      x: float  # Centroid x
 79      y: float  # Centroid y
 80      duration_ms: float
 81      dispersion: float  # How spread out the gaze points were
 82      avg_pupil: Optional[float] = None
 83  
 84      @property
 85      def intensity(self) -> float:
 86          """
 87          Compute attention intensity from fixation properties.
 88  
 89          Longer fixations with smaller dispersion = higher intensity.
 90          Larger pupil = higher cognitive engagement.
 91          """
 92          # Base intensity from duration (saturates around 1 second)
 93          duration_factor = min(1.0, self.duration_ms / 1000.0)
 94  
 95          # Dispersion penalty (tight gaze = more focused)
 96          dispersion_factor = max(0.5, 1.0 - self.dispersion * 2)
 97  
 98          # Pupil boost (if available)
 99          pupil_factor = 1.0
100          if self.avg_pupil:
101              # Assume baseline ~4mm, larger = more engaged
102              pupil_factor = min(1.2, self.avg_pupil / 4.0)
103  
104          return duration_factor * dispersion_factor * pupil_factor
105  
106  
107  @dataclass
108  class ScreenRegion:
109      """A region of the screen mapped to a semantic target."""
110      name: str
111      x_min: float
112      y_min: float
113      x_max: float
114      y_max: float
115      target_id: Optional[str] = None  # UUID if mapped to a bullet/concept
116      target_type: Optional[str] = None  # 'bullet', 'code', 'ui_element'
117  
118      def contains(self, x: float, y: float) -> bool:
119          """Check if a point is in this region."""
120          return (self.x_min <= x <= self.x_max and
121                  self.y_min <= y <= self.y_max)
122  
123  
124  class TobiiIntegration:
125      """
126      Integration with Tobii eye tracker.
127  
128      This is a placeholder for when the hardware arrives.
129      The actual implementation will use the Tobii SDK.
130  
131      Usage (future):
132          tobii = TobiiIntegration()
133          tobii.connect()
134  
135          # Register screen regions
136          tobii.register_region(ScreenRegion(
137              name="editor",
138              x_min=0.0, y_min=0.0,
139              x_max=0.7, y_max=1.0,
140              target_type="code"
141          ))
142  
143          # Start tracking
144          tobii.start()
145  
146          # Get fixations
147          for fixation in tobii.get_fixations():
148              print(f"Looked at {fixation.x}, {fixation.y} for {fixation.duration_ms}ms")
149      """
150  
151      def __init__(
152          self,
153          fixation_threshold_ms: float = 100,  # Minimum duration for fixation
154          dispersion_threshold: float = 0.03,  # Maximum dispersion (normalized)
155      ):
156          self.fixation_threshold_ms = fixation_threshold_ms
157          self.dispersion_threshold = dispersion_threshold
158  
159          # State
160          self._connected = False
161          self._tracking = False
162          self._gaze_buffer: List[GazePoint] = []
163          self._fixations: List[Fixation] = []
164          self._regions: List[ScreenRegion] = []
165  
166          # Callbacks
167          self._on_fixation: List[Callable[[Fixation], None]] = []
168          self._on_region_enter: List[Callable[[ScreenRegion, Fixation], None]] = []
169  
170      def connect(self) -> bool:
171          """
172          Connect to the Tobii eye tracker.
173  
174          Returns True if connected successfully.
175          """
176          # TODO: Implement with Tobii SDK
177          # from tobii_research import find_all_eyetrackers
178          # eyetrackers = find_all_eyetrackers()
179          # if eyetrackers:
180          #     self._tracker = eyetrackers[0]
181          #     self._connected = True
182          #     return True
183  
184          print("[TobiiIntegration] Hardware not connected (placeholder mode)")
185          self._connected = False
186          return False
187  
188      def start(self) -> None:
189          """Start tracking gaze."""
190          if not self._connected:
191              print("[TobiiIntegration] Not connected, cannot start")
192              return
193  
194          # TODO: Subscribe to gaze data
195          # self._tracker.subscribe_to(tr.EYETRACKER_GAZE_DATA, self._gaze_callback)
196          self._tracking = True
197  
198      def stop(self) -> None:
199          """Stop tracking gaze."""
200          # TODO: Unsubscribe from gaze data
201          self._tracking = False
202  
203      def register_region(self, region: ScreenRegion) -> None:
204          """Register a screen region for semantic mapping."""
205          self._regions.append(region)
206  
207      def on_fixation(self, callback: Callable[[Fixation], None]) -> None:
208          """Register callback for fixation events."""
209          self._on_fixation.append(callback)
210  
211      def on_region_enter(
212          self,
213          callback: Callable[[ScreenRegion, Fixation], None]
214      ) -> None:
215          """Register callback for when gaze enters a region."""
216          self._on_region_enter.append(callback)
217  
218      def get_fixations(self, since: datetime = None) -> List[Fixation]:
219          """Get recent fixations."""
220          if since:
221              return [f for f in self._fixations if f.start_time >= since]
222          return self._fixations
223  
224      def get_attention_heatmap(
225          self,
226          width: int = 100,
227          height: int = 100
228      ) -> List[List[float]]:
229          """
230          Generate an attention heatmap from recent fixations.
231  
232          Returns a 2D grid of attention intensity values.
233          """
234          heatmap = [[0.0] * width for _ in range(height)]
235  
236          for fixation in self._fixations[-100:]:  # Last 100 fixations
237              x_idx = int(fixation.x * (width - 1))
238              y_idx = int(fixation.y * (height - 1))
239  
240              # Add intensity with gaussian spread
241              for dx in range(-3, 4):
242                  for dy in range(-3, 4):
243                      nx, ny = x_idx + dx, y_idx + dy
244                      if 0 <= nx < width and 0 <= ny < height:
245                          dist = (dx**2 + dy**2) ** 0.5
246                          spread = fixation.intensity * max(0, 1 - dist / 3)
247                          heatmap[ny][nx] += spread
248  
249          return heatmap
250  
251      def _gaze_callback(self, gaze_data) -> None:
252          """
253          Callback for raw gaze data from Tobii SDK.
254  
255          Converts raw samples to fixations using I-DT algorithm
256          (dispersion-threshold identification).
257          """
258          # TODO: Implement with real Tobii data structure
259          # point = GazePoint(
260          #     timestamp=datetime.now(),
261          #     x=gaze_data['left_gaze_point_on_display_area'][0],
262          #     y=gaze_data['left_gaze_point_on_display_area'][1],
263          #     left_valid=gaze_data['left_gaze_point_validity'],
264          #     right_valid=gaze_data['right_gaze_point_validity'],
265          #     pupil_diameter=gaze_data['left_pupil_diameter']
266          # )
267          # self._gaze_buffer.append(point)
268          # self._process_buffer()
269          pass
270  
271      def _process_buffer(self) -> None:
272          """
273          Process gaze buffer to detect fixations.
274  
275          Uses I-DT (dispersion-threshold) algorithm:
276          1. Maintain a sliding window of gaze points
277          2. If dispersion < threshold and duration > threshold, it's a fixation
278          3. Emit fixation event
279          """
280          if len(self._gaze_buffer) < 3:
281              return
282  
283          # Check if recent points form a fixation
284          window = self._gaze_buffer[-10:]  # Last 10 samples
285          if not window:
286              return
287  
288          # Calculate dispersion (max distance from centroid)
289          xs = [p.x for p in window if p.left_valid or p.right_valid]
290          ys = [p.y for p in window if p.left_valid or p.right_valid]
291  
292          if not xs:
293              return
294  
295          cx, cy = sum(xs) / len(xs), sum(ys) / len(ys)
296          dispersion = max(
297              max(abs(x - cx) for x in xs),
298              max(abs(y - cy) for y in ys)
299          )
300  
301          # Check if this is a fixation
302          if dispersion < self.dispersion_threshold:
303              duration_ms = (
304                  window[-1].timestamp - window[0].timestamp
305              ).total_seconds() * 1000
306  
307              if duration_ms >= self.fixation_threshold_ms:
308                  # Create fixation
309                  avg_pupil = None
310                  pupils = [p.pupil_diameter for p in window if p.pupil_diameter]
311                  if pupils:
312                      avg_pupil = sum(pupils) / len(pupils)
313  
314                  fixation = Fixation(
315                      start_time=window[0].timestamp,
316                      end_time=window[-1].timestamp,
317                      x=cx,
318                      y=cy,
319                      duration_ms=duration_ms,
320                      dispersion=dispersion,
321                      avg_pupil=avg_pupil
322                  )
323  
324                  self._fixations.append(fixation)
325  
326                  # Trigger callbacks
327                  for callback in self._on_fixation:
328                      callback(fixation)
329  
330                  # Check region entry
331                  for region in self._regions:
332                      if region.contains(cx, cy):
333                          for callback in self._on_region_enter:
334                              callback(region, fixation)
335  
336                  # Clear buffer
337                  self._gaze_buffer = self._gaze_buffer[-3:]
338  
339  
340  class TalonIntegration:
341      """
342      Integration with Tobii eye tracker via Talon on macOS.
343  
344      Talon provides the Mac driver for Tobii 5. This class connects
345      to Talon's gaze bridge (sovereign_gaze_bridge.py) via Unix socket
346      and processes the raw gaze stream.
347  
348      Usage:
349          talon = TalonIntegration()
350          talon.connect()
351  
352          # Register screen regions
353          talon.register_region(ScreenRegion(
354              name="editor",
355              x_min=0.0, y_min=0.0,
356              x_max=0.7, y_max=1.0,
357              target_type="code"
358          ))
359  
360          # Start tracking (runs in background thread)
361          talon.start()
362  
363          # Get fixations
364          for fixation in talon.get_fixations():
365              print(f"Looked at {fixation.x}, {fixation.y} for {fixation.duration_ms}ms")
366  
367          # Cleanup
368          talon.stop()
369      """
370  
371      SOCKET_PATH = Path.home() / ".sovereign" / "gaze.sock"
372      FALLBACK_PORT = 9876
373  
374      def __init__(
375          self,
376          fixation_threshold_ms: float = 100,
377          dispersion_threshold: float = 0.03,
378      ):
379          self.fixation_threshold_ms = fixation_threshold_ms
380          self.dispersion_threshold = dispersion_threshold
381  
382          # State
383          self._connected = False
384          self._tracking = False
385          self._socket: Optional[socket.socket] = None
386          self._receiver_thread: Optional[threading.Thread] = None
387          self._stop_event = threading.Event()
388  
389          # Data buffers
390          self._gaze_buffer: List[GazePoint] = []
391          self._fixations: List[Fixation] = []
392          self._regions: List[ScreenRegion] = []
393          self._buffer_lock = threading.Lock()
394  
395          # Callbacks
396          self._on_fixation: List[Callable[[Fixation], None]] = []
397          self._on_region_enter: List[Callable[[ScreenRegion, Fixation], None]] = []
398          self._on_gaze_point: List[Callable[[GazePoint], None]] = []
399  
400      def connect(self) -> bool:
401          """
402          Connect to Talon's gaze bridge.
403  
404          Returns True if connected successfully.
405          """
406          # Try Unix socket first
407          if self.SOCKET_PATH.exists():
408              try:
409                  self._socket = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
410                  self._socket.connect(str(self.SOCKET_PATH))
411                  self._socket.setblocking(False)
412                  self._connected = True
413                  print(f"[TalonIntegration] Connected via Unix socket")
414                  return True
415              except Exception as e:
416                  print(f"[TalonIntegration] Unix socket failed: {e}")
417  
418          # Fall back to TCP
419          try:
420              self._socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
421              self._socket.connect(('127.0.0.1', self.FALLBACK_PORT))
422              self._socket.setblocking(False)
423              self._connected = True
424              print(f"[TalonIntegration] Connected via TCP port {self.FALLBACK_PORT}")
425              return True
426          except Exception as e:
427              print(f"[TalonIntegration] TCP connection failed: {e}")
428              print("[TalonIntegration] Is Talon running with sovereign_gaze_bridge?")
429              self._connected = False
430              return False
431  
432      def start(self) -> None:
433          """Start receiving gaze data in background thread."""
434          if not self._connected:
435              print("[TalonIntegration] Not connected, cannot start")
436              return
437  
438          if self._tracking:
439              return
440  
441          self._tracking = True
442          self._stop_event.clear()
443          self._receiver_thread = threading.Thread(
444              target=self._receive_loop,
445              daemon=True,
446              name="TalonGazeReceiver"
447          )
448          self._receiver_thread.start()
449          print("[TalonIntegration] Started gaze tracking")
450  
451      def stop(self) -> None:
452          """Stop receiving gaze data."""
453          if not self._tracking:
454              return
455  
456          self._tracking = False
457          self._stop_event.set()
458  
459          if self._receiver_thread:
460              self._receiver_thread.join(timeout=1.0)
461              self._receiver_thread = None
462  
463          if self._socket:
464              try:
465                  self._socket.close()
466              except:
467                  pass
468              self._socket = None
469  
470          self._connected = False
471          print("[TalonIntegration] Stopped gaze tracking")
472  
473      def register_region(self, region: ScreenRegion) -> None:
474          """Register a screen region for semantic mapping."""
475          self._regions.append(region)
476  
477      def on_fixation(self, callback: Callable[[Fixation], None]) -> None:
478          """Register callback for fixation events."""
479          self._on_fixation.append(callback)
480  
481      def on_region_enter(
482          self,
483          callback: Callable[[ScreenRegion, Fixation], None]
484      ) -> None:
485          """Register callback for when gaze enters a region."""
486          self._on_region_enter.append(callback)
487  
488      def on_gaze_point(self, callback: Callable[[GazePoint], None]) -> None:
489          """Register callback for raw gaze points."""
490          self._on_gaze_point.append(callback)
491  
492      def get_fixations(self, since: datetime = None) -> List[Fixation]:
493          """Get recent fixations."""
494          with self._buffer_lock:
495              if since:
496                  return [f for f in self._fixations if f.start_time >= since]
497              return list(self._fixations)
498  
499      def get_attention_heatmap(
500          self,
501          width: int = 100,
502          height: int = 100
503      ) -> List[List[float]]:
504          """Generate an attention heatmap from recent fixations."""
505          heatmap = [[0.0] * width for _ in range(height)]
506  
507          with self._buffer_lock:
508              fixations = self._fixations[-100:]
509  
510          for fixation in fixations:
511              x_idx = int(fixation.x * (width - 1))
512              y_idx = int(fixation.y * (height - 1))
513  
514              for dx in range(-3, 4):
515                  for dy in range(-3, 4):
516                      nx, ny = x_idx + dx, y_idx + dy
517                      if 0 <= nx < width and 0 <= ny < height:
518                          dist = (dx**2 + dy**2) ** 0.5
519                          spread = fixation.intensity * max(0, 1 - dist / 3)
520                          heatmap[ny][nx] += spread
521  
522          return heatmap
523  
524      def _receive_loop(self) -> None:
525          """Background thread that receives gaze data from Talon."""
526          buffer = ""
527  
528          while not self._stop_event.is_set():
529              try:
530                  data = self._socket.recv(4096).decode('utf-8')
531                  if not data:
532                      time.sleep(0.01)
533                      continue
534  
535                  buffer += data
536  
537                  # Process complete JSON lines
538                  while '\n' in buffer:
539                      line, buffer = buffer.split('\n', 1)
540                      if line.strip():
541                          self._process_message(line)
542  
543              except BlockingIOError:
544                  time.sleep(0.01)
545              except Exception as e:
546                  if not self._stop_event.is_set():
547                      print(f"[TalonIntegration] Receive error: {e}")
548                  break
549  
550      def _process_message(self, message: str) -> None:
551          """Process a JSON message from Talon."""
552          try:
553              data = json.loads(message)
554  
555              if data.get("type") == "gaze_point":
556                  point = GazePoint(
557                      timestamp=datetime.fromisoformat(data["timestamp"]),
558                      x=data["x"],
559                      y=data["y"],
560                      left_valid=data.get("left_valid", True),
561                      right_valid=data.get("right_valid", True),
562                      pupil_diameter=data.get("pupil_diameter"),
563                  )
564  
565                  # Notify raw gaze callbacks
566                  for callback in self._on_gaze_point:
567                      try:
568                          callback(point)
569                      except Exception as e:
570                          print(f"[TalonIntegration] Gaze callback error: {e}")
571  
572                  # Add to buffer and process fixations
573                  with self._buffer_lock:
574                      self._gaze_buffer.append(point)
575                      # Keep buffer bounded
576                      if len(self._gaze_buffer) > 200:
577                          self._gaze_buffer = self._gaze_buffer[-100:]
578  
579                  self._process_buffer()
580  
581          except json.JSONDecodeError as e:
582              print(f"[TalonIntegration] JSON parse error: {e}")
583          except Exception as e:
584              print(f"[TalonIntegration] Message processing error: {e}")
585  
586      def _process_buffer(self) -> None:
587          """
588          Process gaze buffer to detect fixations using I-DT algorithm.
589  
590          I-DT (dispersion-threshold identification):
591          1. Maintain a sliding window of gaze points
592          2. If dispersion < threshold and duration > threshold, it's a fixation
593          3. Emit fixation event and clear the window
594          """
595          with self._buffer_lock:
596              if len(self._gaze_buffer) < 3:
597                  return
598  
599              window = self._gaze_buffer[-10:]
600              if not window:
601                  return
602  
603              # Calculate dispersion (max distance from centroid)
604              xs = [p.x for p in window if p.left_valid or p.right_valid]
605              ys = [p.y for p in window if p.left_valid or p.right_valid]
606  
607              if not xs:
608                  return
609  
610              cx, cy = sum(xs) / len(xs), sum(ys) / len(ys)
611              dispersion = max(
612                  max(abs(x - cx) for x in xs),
613                  max(abs(y - cy) for y in ys)
614              )
615  
616              # Check if this is a fixation
617              if dispersion < self.dispersion_threshold:
618                  duration_ms = (
619                      window[-1].timestamp - window[0].timestamp
620                  ).total_seconds() * 1000
621  
622                  if duration_ms >= self.fixation_threshold_ms:
623                      avg_pupil = None
624                      pupils = [p.pupil_diameter for p in window if p.pupil_diameter]
625                      if pupils:
626                          avg_pupil = sum(pupils) / len(pupils)
627  
628                      fixation = Fixation(
629                          start_time=window[0].timestamp,
630                          end_time=window[-1].timestamp,
631                          x=cx,
632                          y=cy,
633                          duration_ms=duration_ms,
634                          dispersion=dispersion,
635                          avg_pupil=avg_pupil
636                      )
637  
638                      self._fixations.append(fixation)
639                      # Keep fixations bounded
640                      if len(self._fixations) > 1000:
641                          self._fixations = self._fixations[-500:]
642  
643                      # Clear processed points, keep last few for continuity
644                      self._gaze_buffer = self._gaze_buffer[-3:]
645  
646          # Trigger callbacks outside the lock
647          if 'fixation' in dir():
648              for callback in self._on_fixation:
649                  try:
650                      callback(fixation)
651                  except Exception as e:
652                      print(f"[TalonIntegration] Fixation callback error: {e}")
653  
654              # Check region entry
655              for region in self._regions:
656                  if region.contains(fixation.x, fixation.y):
657                      for callback in self._on_region_enter:
658                          try:
659                              callback(region, fixation)
660                          except Exception as e:
661                              print(f"[TalonIntegration] Region callback error: {e}")
662  
663  
664  class EyeTrackingAttentionBridge:
665      """
666      Bridges eye tracking data to the attention system.
667  
668      Converts fixations into AttentionEvents that feed
669      the main attention tracker.
670  
671      Works with either TalonIntegration (Mac) or TobiiIntegration (Windows/Linux).
672      """
673  
674      def __init__(self, tracker: Union[TalonIntegration, TobiiIntegration]):
675          self.tracker = tracker
676          self._screen_to_target: Dict[Tuple[float, float], str] = {}
677  
678      def register_target(
679          self,
680          region: ScreenRegion,
681          target_id: str,
682          target_type: str
683      ) -> None:
684          """Register a screen region as a semantic target."""
685          region.target_id = target_id
686          region.target_type = target_type
687          self.tracker.register_region(region)
688  
689      def fixation_to_attention_event(self, fixation: Fixation):
690          """
691          Convert a fixation to an AttentionEvent.
692  
693          Returns an AttentionEvent or None if the fixation
694          doesn't map to a known target.
695          """
696          from .tracker import AttentionEvent
697  
698          # Find which region this fixation is in
699          for region in self.tracker._regions:
700              if region.contains(fixation.x, fixation.y):
701                  if region.target_id:
702                      source = 'talon' if isinstance(self.tracker, TalonIntegration) else 'tobii'
703                      return AttentionEvent(
704                          timestamp=fixation.start_time,
705                          target_id=region.target_id,
706                          target_type=region.target_type or 'unknown',
707                          modality='gaze',
708                          duration_seconds=fixation.duration_ms / 1000.0,
709                          intensity=fixation.intensity,
710                          source=source
711                      )
712  
713          return None
714  
715  
716  def create_eye_tracking_system(prefer_talon: bool = True):
717      """
718      Create the eye tracking system.
719  
720      On macOS, uses Talon as the Tobii driver.
721      On Windows/Linux, can use direct Tobii SDK.
722  
723      Args:
724          prefer_talon: If True, try Talon first (recommended for Mac)
725  
726      Returns:
727          (tracker, bridge) tuple, or (None, None) if unavailable
728      """
729      import platform
730  
731      # On Mac, always use Talon
732      if platform.system() == "Darwin" or prefer_talon:
733          talon = TalonIntegration()
734          if talon.connect():
735              bridge = EyeTrackingAttentionBridge(talon)
736              return talon, bridge
737          print("[EyeTracking] Talon bridge not available")
738          print("  1. Install Talon from talonvoice.com")
739          print("  2. Copy sovereign_gaze_bridge.py to ~/.talon/user/")
740          print("  3. Connect your Tobii 5 and run calibration")
741  
742      # Fall back to direct Tobii (Windows/Linux)
743      tobii = TobiiIntegration()
744      if tobii.connect():
745          bridge = EyeTrackingAttentionBridge(tobii)
746          return tobii, bridge
747  
748      print("[EyeTracking] No eye tracking available")
749      return None, None
750  
751  
752  if __name__ == "__main__":
753      import platform
754  
755      print("=== Eye Tracking Module ===\n")
756      print(f"Platform: {platform.system()}")
757  
758      if platform.system() == "Darwin":
759          print("\nMac detected - using Talon integration")
760          print("\nSetup steps:")
761          print("  1. Install Talon from https://talonvoice.com")
762          print("  2. Copy these files to ~/.talon/user/:")
763          print("     - integrations/talon/sovereign_gaze_bridge.py")
764          print("     - integrations/talon/sovereign_gaze.talon")
765          print("  3. Connect Tobii Eye Tracker 5")
766          print("  4. In Talon, run calibration: 'run calibration'")
767          print("  5. Start this script to receive gaze data")
768      else:
769          print("\nWindows/Linux detected - can use direct Tobii SDK")
770          print("  pip install tobii-research")
771  
772      print("\nArchitecture:")
773      print("  Tobii 5 → Talon → Unix Socket → TalonIntegration → AttentionTracker")
774      print("\nData flow:")
775      print("  GazePoint (raw) → Fixation (I-DT) → AttentionEvent → Trajectory")
776      print("\n'Attention is all you need'")