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
« prev ^ index » next coverage.py v7.17.0, created at 2026-11-02 04:50 +0000
1"""Brush stabilizer (pointer smoothing) for :class:`PaintCanvas`.
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
12log = get_logger(__name__)
15class BrushStabilizerMixin(CanvasMembers):
16 def set_brush_stabilizer(self, value):
17 self.brush_stabilizer_strength = max(
18 0, min(300, int(value))
19 )
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))))
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 = []
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))
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)
65 if strength <= 0:
66 self._brush_stabilizer_history = [QPointF(raw)]
67 self._stabilized_canvas = QPointF(raw)
68 return QPointF(raw)
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]
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 )
86 if self._stabilized_canvas is None:
87 self._stabilized_canvas = QPointF(target)
88 return QPointF(target)
90 current = QPointF(self._stabilized_canvas)
91 dx = target.x() - current.x()
92 dy = target.y() - current.y()
93 distance = math.hypot(dx, dy)
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)
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 )
115 self._stabilized_canvas = QPointF(filtered)
116 return filtered
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)
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
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 )
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)
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()
182 def _stop_brush_follow_timer(self):
183 self._brush_follow_timer.stop()
184 self._brush_follow_settle = 0.0
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
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
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
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)
239 def _finish_stabilized_brush(self, raw_point, pressure):
240 """ペンを離したとき、補正点を終点へ滑らかに収束させる。"""
241 raw = QPointF(raw_point)
242 self._draw_stabilized_brush_to(raw, pressure)
244 strength = max(
245 0, min(300, int(self.brush_stabilizer_strength))
246 )
247 if strength <= 0:
248 return
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)
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)