Coverage for main/components/crosshair_graphic_view.py: 95%

66 statements  

« prev     ^ index     » next       coverage.py v7.15.0, created at 2026-07-03 15:51 +0000

1from PyQt6.QtCore import pyqtSignal, Qt 

2from PyQt6.QtGui import QPainter, QPen, QColor, QMouseEvent, QWheelEvent 

3from PyQt6.QtWidgets import QGraphicsView 

4 

5 

6class CrosshairGraphicsView(QGraphicsView): 

7 """Custom QGraphicsView subclass that supports: 

8 - Live crosshair display following mouse movements 

9 - Coordinate tracking and emission via Qt signals 

10 - Integration with a parent viewer for synchronized slice updates 

11 """ 

12 

13 # Signal emitted whenever the mouse moves — sends (view_idx, x, y). 

14 coordinate_changed = pyqtSignal(int, int, int) 

15 """**Signal(int, int, int):** Emitted whenever the mouse moves within a view.  

16 Parameters represent: 

17 - `view_idx`: The index of the active view (e.g., axial, coronal, sagittal).  

18 - `x`, `y`: The current mouse coordinates within that view. 

19 """ 

20 

21 def __init__(self, view_idx, parent=None): 

22 """ 

23 Initialize the crosshair view. 

24 

25 Args: 

26 view_idx (int): Index of the view (e.g. axial=0, coronal=1, sagittal=2) 

27 parent (QWidget): Parent viewer widget (usually manages multiple CrosshairGraphicsViews) 

28 """ 

29 super().__init__(parent) 

30 self.view_idx = view_idx 

31 self.parent_viewer = parent 

32 

33 # Enable continuous mouse tracking (even when no button pressed) 

34 self.setMouseTracking(True) 

35 

36 # Disable dragging mode (only used for coordinate interaction) 

37 self.setDragMode(QGraphicsView.DragMode.NoDrag) 

38 

39 # Crosshair graphics items (horizontal and vertical lines) 

40 self.crosshair_h = None 

41 self.crosshair_v = None 

42 self.crosshair_visible = False 

43 

44 # Enable anti-aliasing and smooth scaling for better visual quality 

45 self.setRenderHints( 

46 QPainter.RenderHint.Antialiasing | 

47 QPainter.RenderHint.SmoothPixmapTransform 

48 ) 

49 

50 # ------------------------------------------------------------------------- 

51 # Crosshair setup and management 

52 # ------------------------------------------------------------------------- 

53 def setup_crosshairs(self): 

54 """ 

55 Initialize the crosshair lines once the QGraphicsScene is available. 

56 Adds two semi-transparent yellow lines to the scene (horizontal + vertical). 

57 """ 

58 if self.scene(): 

59 # Yellow, semi-transparent pen for crosshair 

60 pen = QPen(QColor(255, 255, 0, 180), 1) 

61 

62 # Create horizontal and vertical crosshair lines in the scene 

63 self.crosshair_h = self.scene().addLine(0, 0, 0, 0, pen) 

64 self.crosshair_v = self.scene().addLine(0, 0, 0, 0, pen) 

65 

66 # Initially invisible 

67 self.crosshair_h.setVisible(False) 

68 self.crosshair_v.setVisible(False) 

69 

70 # ------------------------------------------------------------------------- 

71 # Mouse movement: update coordinates and crosshair position 

72 # ------------------------------------------------------------------------- 

73 def mouseMoveEvent(self, event: QMouseEvent): 

74 """ 

75 Handle mouse movement: 

76 - Maps cursor position to scene coordinates 

77 - Checks bounds 

78 - Updates crosshair lines 

79 - Emits coordinate_changed signal 

80 """ 

81 if self.scene() and self.parent_viewer and self.parent_viewer.img_data is not None: 

82 # Map mouse position from view to scene coordinates 

83 pos = self.mapToScene(event.pos()) 

84 x, y = int(pos.x()), int(pos.y()) 

85 

86 # Get scene dimensions for bounds checking 

87 scene_rect = self.scene().sceneRect() 

88 

89 # Only process if cursor is inside the image area 

90 if (0 <= x < scene_rect.width() and 0 <= y < scene_rect.height()): 

91 # Move crosshair to current position 

92 self.update_crosshairs(x, y) 

93 

94 # Notify parent about the new coordinates 

95 self.coordinate_changed.emit(self.view_idx, x, y) 

96 

97 # Pass event to base class for default behavior 

98 super().mouseMoveEvent(event) 

99 

100 # ------------------------------------------------------------------------- 

101 # Mouse scroll: change slices 

102 # ------------------------------------------------------------------------- 

103 

104 def wheelEvent(self, event: QWheelEvent): 

105 delta = int(event.angleDelta().y()/120) 

106 self.parent_viewer.handle_scroll(self.view_idx, delta) 

107 event.accept() 

108 

109 # ------------------------------------------------------------------------- 

110 # Mouse click: trigger coordinate selection or slice updates 

111 # ------------------------------------------------------------------------- 

112 def mousePressEvent(self, event: QMouseEvent): 

113 """ 

114 Handle mouse clicks: 

115 - On left-click, compute image coordinates 

116 - Notify parent viewer for cross-view synchronization or slice update 

117 """ 

118 if (event.button() == Qt.MouseButton.LeftButton and 

119 self.scene() and self.parent_viewer and 

120 self.parent_viewer.img_data is not None): 

121 

122 # Convert click position to scene coordinates 

123 pos = self.mapToScene(event.pos()) 

124 x, y = int(pos.x()), int(pos.y()) 

125 

126 # Verify position is inside the scene 

127 scene_rect = self.scene().sceneRect() 

128 if 0 <= x < scene_rect.width() and 0 <= y < scene_rect.height(): 

129 # Delegate handling to parent viewer (e.g., update other views) 

130 self.parent_viewer.handle_click_coordinates(self.view_idx, x, y) 

131 

132 super().mousePressEvent(event) 

133 

134 # ------------------------------------------------------------------------- 

135 # Update crosshair position visually 

136 # ------------------------------------------------------------------------- 

137 def update_crosshairs(self, x, y): 

138 """ 

139 Move crosshair lines to a new position (x, y). 

140 Makes them visible if hidden. 

141 """ 

142 if self.crosshair_h and self.crosshair_v and self.scene(): 

143 scene_rect = self.scene().sceneRect() 

144 

145 # Horizontal line across the scene 

146 self.crosshair_h.setLine(0, y, scene_rect.width(), y) 

147 

148 # Vertical line across the scene 

149 self.crosshair_v.setLine(x, 0, x, scene_rect.height()) 

150 

151 # If not visible yet, make them appear 

152 if not self.crosshair_visible: 

153 self.crosshair_h.setVisible(True) 

154 self.crosshair_v.setVisible(True) 

155 self.crosshair_visible = True 

156 

157 # ------------------------------------------------------------------------- 

158 # External setter for crosshair position (used by parent viewer) 

159 # ------------------------------------------------------------------------- 

160 def set_crosshair_position(self, x, y): 

161 """ 

162 Allows external components (e.g. linked views) to set the crosshair. 

163 Ensures it's within bounds before updating. 

164 """ 

165 if self.crosshair_h and self.crosshair_v and self.scene(): 

166 scene_rect = self.scene().sceneRect() 

167 if (0 <= x < scene_rect.width() and 0 <= y < scene_rect.height()): 

168 self.crosshair_h.setLine(0, y, scene_rect.width(), y) 

169 self.crosshair_v.setLine(x, 0, x, scene_rect.height()) 

170 self.crosshair_h.setVisible(True) 

171 self.crosshair_v.setVisible(True) 

172 self.crosshair_visible = True 

173 

174 # ------------------------------------------------------------------------- 

175 # Handle when the mouse leaves the widget area 

176 # ------------------------------------------------------------------------- 

177 def leaveEvent(self, event): 

178 """ 

179 Hide or update crosshairs when the mouse leaves the view area. 

180 Currently, it calls the parent viewer’s update function to 

181 synchronize crosshairs across multiple views. 

182 """ 

183 if self.crosshair_h and self.crosshair_v: 

184 # Previous behavior (hidden when leaving): 

185 # self.crosshair_h.setVisible(False) 

186 # self.crosshair_v.setVisible(False) 

187 # self.crosshair_visible = False 

188 

189 # New behavior: ask parent to refresh all cross-view lines 

190 self.parent_viewer.update_cross_view_lines() 

191 

192 super().leaveEvent(event)