Coverage for paintmaskanimator/canvas_brush_stabilizer.py: 96%

126 statements  

« prev     ^ index     » next       coverage.py v7.17.0, created at 2026-11-02 04:50 +0000

1"""Brush stabilizer (pointer smoothing) for :class:`PaintCanvas`. 

2 

3Split out of ``canvas.py`` as a mixin. These methods smooth raw pointer input 

4into a settled brush position/pressure, drive the follow-up settle timer, and 

5finish the stabilized stroke. They run against a live ``PaintCanvas`` instance. 

6""" 

7import math 

8from PySide6.QtCore import QPointF 

9from ._canvas_members import CanvasMembers 

10from .logging_setup import get_logger 

11 

12log = get_logger(__name__) 

13 

14 

15class BrushStabilizerMixin(CanvasMembers): 

16 def set_brush_stabilizer(self, value): 

17 self.brush_stabilizer_strength = max( 

18 0, min(300, int(value)) 

19 ) 

20 

21 def _brush_stabilizer_window(self): 

22 strength = max( 

23 0, min(300, int(self.brush_stabilizer_strength)) 

24 ) 

25 if strength <= 0: 

26 return 1 

27 return max(2, min(64, 2 + int(round(strength * 0.20)))) 

28 

29 def _reset_brush_stabilizer(self, point=None): 

30 initial = QPointF(point) if point is not None else None 

31 self._stabilized_canvas = ( 

32 QPointF(initial) if initial is not None else None 

33 ) 

34 self._last_raw_canvas = ( 

35 QPointF(initial) if initial is not None else None 

36 ) 

37 self._brush_stabilizer_history = ( 

38 [QPointF(initial)] if initial is not None else [] 

39 ) 

40 self._last_brush_pressure = 1.0 

41 self._pressure_input_history = [] 

42 

43 def _smooth_brush_pressure(self, pressure): 

44 """直近の筆圧を重み付き平均し、サイズ変化の段差を抑える。""" 

45 value = max(0.001, min(1.0, float(pressure))) 

46 history = self._pressure_input_history 

47 history.append(value) 

48 if len(history) > 7: 

49 del history[:-7] 

50 weights = list(range(1, len(history) + 1)) 

51 return sum( 

52 sample * weight 

53 for sample, weight in zip(history, weights) 

54 ) / float(sum(weights)) 

55 

56 def _stabilized_brush_point(self, raw_point, settle=0.0): 

57 """移動平均+遅延半径で、実際に描画する座標を返す。""" 

58 raw = QPointF(raw_point) 

59 strength = max( 

60 0, min(300, int(self.brush_stabilizer_strength)) 

61 ) 

62 settle = max(0.0, min(1.0, float(settle))) 

63 self._last_raw_canvas = QPointF(raw) 

64 

65 if strength <= 0: 

66 self._brush_stabilizer_history = [QPointF(raw)] 

67 self._stabilized_canvas = QPointF(raw) 

68 return QPointF(raw) 

69 

70 history = self._brush_stabilizer_history 

71 history.append(QPointF(raw)) 

72 window = self._brush_stabilizer_window() 

73 if len(history) > window: 

74 del history[:-window] 

75 

76 # 新しい点を少し強くしつつ、直近の細かな揺れを平均化する。 

77 weights = list(range(1, len(history) + 1)) 

78 weight_sum = float(sum(weights)) 

79 target = QPointF( 

80 sum(point.x() * weight for point, weight in zip(history, weights)) 

81 / weight_sum, 

82 sum(point.y() * weight for point, weight in zip(history, weights)) 

83 / weight_sum, 

84 ) 

85 

86 if self._stabilized_canvas is None: 

87 self._stabilized_canvas = QPointF(target) 

88 return QPointF(target) 

89 

90 current = QPointF(self._stabilized_canvas) 

91 dx = target.x() - current.x() 

92 dy = target.y() - current.y() 

93 distance = math.hypot(dx, dy) 

94 

95 # 画面上の約0~20pxを補正半径として使う。 

96 # 強度が大きいほど、小さな手振れでは描画点が動かない。 

97 radius_screen = ( 

98 60.0 * math.pow(strength / 300.0, 1.35) 

99 ) 

100 radius_canvas = ( 

101 radius_screen / max(0.05, float(self.zoom)) 

102 ) 

103 radius_canvas *= (1.0 - settle) 

104 

105 if distance <= radius_canvas or distance <= 1e-9: 

106 filtered = current 

107 else: 

108 move_distance = distance - radius_canvas 

109 ratio = move_distance / distance 

110 filtered = QPointF( 

111 current.x() + dx * ratio, 

112 current.y() + dy * ratio, 

113 ) 

114 

115 self._stabilized_canvas = QPointF(filtered) 

116 return filtered 

117 

118 def _draw_stabilized_brush_to(self, raw_point, pressure): 

119 """入力イベント間も補間し、補正後の軌跡を連続描画する。""" 

120 raw = QPointF(raw_point) 

121 self._brush_follow_settle = 0.0 

122 self._start_brush_follow_timer() 

123 if self.last_canvas is None: 123 ↛ 124line 123 didn't jump to line 124 because the condition on line 123 was never true

124 self.last_canvas = QPointF(raw) 

125 

126 strength = max( 

127 0, min(300, int(self.brush_stabilizer_strength)) 

128 ) 

129 if strength <= 0: 

130 self.draw_line(self.last_canvas, raw, pressure) 

131 self.last_canvas = QPointF(raw) 

132 self._last_raw_canvas = QPointF(raw) 

133 return 

134 

135 start_raw = ( 

136 QPointF(self._last_raw_canvas) 

137 if self._last_raw_canvas is not None 

138 else QPointF(raw) 

139 ) 

140 distance = math.hypot( 

141 raw.x() - start_raw.x(), 

142 raw.y() - start_raw.y(), 

143 ) 

144 step_canvas = max( 

145 0.5, 

146 min( 

147 2.5, 

148 1.5 / max(0.05, float(self.zoom)), 

149 ), 

150 ) 

151 steps = max( 

152 1, 

153 min(96, int(math.ceil(distance / step_canvas))), 

154 ) 

155 

156 for index in range(1, steps + 1): 

157 ratio = index / float(steps) 

158 intermediate = QPointF( 

159 start_raw.x() + (raw.x() - start_raw.x()) * ratio, 

160 start_raw.y() + (raw.y() - start_raw.y()) * ratio, 

161 ) 

162 filtered = self._stabilized_brush_point(intermediate) 

163 if ( 

164 abs(filtered.x() - self.last_canvas.x()) > 0.005 

165 or abs(filtered.y() - self.last_canvas.y()) > 0.005 

166 ): 

167 self.draw_line( 

168 self.last_canvas, 

169 filtered, 

170 pressure, 

171 ) 

172 self.last_canvas = QPointF(filtered) 

173 

174 def _start_brush_follow_timer(self): 

175 self._brush_follow_settle = 0.0 

176 if ( 

177 int(self.brush_stabilizer_strength) > 0 

178 and not self._brush_follow_timer.isActive() 

179 ): 

180 self._brush_follow_timer.start() 

181 

182 def _stop_brush_follow_timer(self): 

183 self._brush_follow_timer.stop() 

184 self._brush_follow_settle = 0.0 

185 

186 def _advance_stabilized_brush(self): 

187 """入力が止まっていても、描画点をカーソル終点へ追従させる。""" 

188 if ( 

189 not self.drawing 

190 or self.effective_tool() != "brush" 

191 or self._last_raw_canvas is None 

192 or self.last_canvas is None 

193 ): 

194 self._stop_brush_follow_timer() 

195 return 

196 

197 raw = QPointF(self._last_raw_canvas) 

198 distance = math.hypot( 

199 raw.x() - self.last_canvas.x(), 

200 raw.y() - self.last_canvas.y(), 

201 ) 

202 if distance <= 0.01: 

203 return 

204 

205 strength = max( 

206 0, 

207 min(300, int(self.brush_stabilizer_strength)), 

208 ) 

209 if strength <= 0: 

210 self.draw_line( 

211 self.last_canvas, 

212 raw, 

213 self._last_brush_pressure, 

214 ) 

215 self.last_canvas = QPointF(raw) 

216 return 

217 

218 self._brush_follow_settle = min( 

219 1.0, 

220 self._brush_follow_settle 

221 + 0.035 

222 + 0.055 * (strength / 300.0), 

223 ) 

224 filtered = self._stabilized_brush_point( 

225 raw, 

226 settle=self._brush_follow_settle, 

227 ) 

228 if ( 228 ↛ exitline 228 didn't return from function '_advance_stabilized_brush' because the condition on line 228 was always true

229 abs(filtered.x() - self.last_canvas.x()) > 0.005 

230 or abs(filtered.y() - self.last_canvas.y()) > 0.005 

231 ): 

232 self.draw_line( 

233 self.last_canvas, 

234 filtered, 

235 self._last_brush_pressure, 

236 ) 

237 self.last_canvas = QPointF(filtered) 

238 

239 def _finish_stabilized_brush(self, raw_point, pressure): 

240 """ペンを離したとき、補正点を終点へ滑らかに収束させる。""" 

241 raw = QPointF(raw_point) 

242 self._draw_stabilized_brush_to(raw, pressure) 

243 

244 strength = max( 

245 0, min(300, int(self.brush_stabilizer_strength)) 

246 ) 

247 if strength <= 0: 

248 return 

249 

250 settle_steps = self._brush_stabilizer_window() + 8 

251 for index in range(settle_steps): 

252 settle = (index + 1) / float(settle_steps) 

253 filtered = self._stabilized_brush_point( 

254 raw, 

255 settle=settle, 

256 ) 

257 if ( 257 ↛ 251line 257 didn't jump to line 251 because the condition on line 257 was always true

258 abs(filtered.x() - self.last_canvas.x()) > 0.005 

259 or abs(filtered.y() - self.last_canvas.y()) > 0.005 

260 ): 

261 self.draw_line( 

262 self.last_canvas, 

263 filtered, 

264 pressure, 

265 ) 

266 self.last_canvas = QPointF(filtered) 

267 

268 # 数値誤差だけが残った場合も、終点を確実に一致させる。 

269 if ( 269 ↛ 273line 269 didn't jump to line 273 because the condition on line 269 was never true

270 abs(raw.x() - self.last_canvas.x()) > 0.01 

271 or abs(raw.y() - self.last_canvas.y()) > 0.01 

272 ): 

273 self.draw_line(self.last_canvas, raw, pressure) 

274 self.last_canvas = QPointF(raw)