Last Updated on 2026年6月30日 by num46
マウスカーソルの先が常に動いている方向を向く(傾く)ようにするにはどうしたらいい? Pythonを使って記述してほしい
とAgentモードでCursorのAIに聞いたら自動で作ってくれた。
AgentモードだとCursorのIncluded Usage(プラン枠内の使用量?)がすぐ貯まる。今回の使用量が4%だった。画像も作ってもらったからかもしれない。
対して以前ソフトを作った際、Askモードで出力されたコードをコピペしながらCursorAIを使った場合にはあまり貯まらないことが分かった。(結構使ったが1~2%くらい。画像は特に作ってもらっていない。)
プランは$20/月のプロプランのヤツ。
環境はWindows 10 Pro(64bit)
AIにポン出ししてもらってあまり精査していないので自己責任で利用してください。
コード
directional_cursor_png.py
"""
Windows: directional mouse cursor using Win32 API (PNG + tip hotspot).
PNG image loader version. Press Esc to exit.
"""
from __future__ import annotations
import argparse
import ctypes
import math
import sys
from ctypes import wintypes
from pathlib import Path
from PIL import Image
user32 = ctypes.windll.user32
gdi32 = ctypes.windll.gdi32
kernel32 = ctypes.windll.kernel32
SCRIPT_DIR = Path(__file__).parent
DEFAULT_CURSOR_PNG = SCRIPT_DIR / "cursor_sample.png"
SOURCE_SIZE = 48
SOURCE_ROTATION_CENTER = (24, 24)
SOURCE_TIP = (46, 24)
class ICONINFO(ctypes.Structure):
_fields_ = [
("fIcon", wintypes.BOOL),
("xHotspot", wintypes.DWORD),
("yHotspot", wintypes.DWORD),
("hbmMask", wintypes.HBITMAP),
("hbmColor", wintypes.HBITMAP),
]
class BITMAPINFOHEADER(ctypes.Structure):
_fields_ = [
("biSize", wintypes.DWORD),
("biWidth", wintypes.LONG),
("biHeight", wintypes.LONG),
("biPlanes", wintypes.WORD),
("biBitCount", wintypes.WORD),
("biCompression", wintypes.DWORD),
("biSizeImage", wintypes.DWORD),
("biXPelsPerMeter", wintypes.LONG),
("biYPelsPerMeter", wintypes.LONG),
("biClrUsed", wintypes.DWORD),
("biClrImportant", wintypes.DWORD),
]
class BITMAPINFO(ctypes.Structure):
_fields_ = [("bmiHeader", BITMAPINFOHEADER), ("bmiColors", wintypes.DWORD * 3)]
class POINT(ctypes.Structure):
_fields_ = [("x", wintypes.LONG), ("y", wintypes.LONG)]
class MSLLHOOKSTRUCT(ctypes.Structure):
_fields_ = [
("pt", POINT),
("mouseData", wintypes.DWORD),
("flags", wintypes.DWORD),
("time", wintypes.DWORD),
("dwExtraInfo", ctypes.c_size_t),
]
WH_MOUSE_LL = 14
WM_MOUSEMOVE = 0x0200
PM_REMOVE = 0x0001
if ctypes.sizeof(ctypes.c_void_p) == 8:
LRESULT = ctypes.c_longlong
else:
LRESULT = ctypes.c_long
HOOKPROC = ctypes.WINFUNCTYPE(
LRESULT,
ctypes.c_int,
wintypes.WPARAM,
wintypes.LPARAM,
)
def _configure_win32_api() -> None:
"""64-bit Windows で LPARAM/WPARAM のオーバーフローを防ぐ。"""
user32.CallNextHookEx.argtypes = [
wintypes.HHOOK,
ctypes.c_int,
wintypes.WPARAM,
wintypes.LPARAM,
]
user32.CallNextHookEx.restype = LRESULT
user32.SetWindowsHookExW.argtypes = [
ctypes.c_int,
HOOKPROC,
wintypes.HINSTANCE,
wintypes.DWORD,
]
user32.SetWindowsHookExW.restype = wintypes.HHOOK
user32.UnhookWindowsHookEx.argtypes = [wintypes.HHOOK]
user32.UnhookWindowsHookEx.restype = wintypes.BOOL
_configure_win32_api()
OCR_NORMAL = 32512
SPI_SETCURSORS = 0x0057
def get_cursor_size() -> int:
"""DPI に合わせたカーソル画像サイズ(96 DPI 基準で 32px)。"""
try:
dpi = user32.GetDpiForSystem()
except (AttributeError, OSError):
hdc = user32.GetDC(None)
try:
dpi = gdi32.GetDeviceCaps(hdc, 88) # LOGPIXELSX
finally:
user32.ReleaseDC(None, hdc)
return max(32, round(32 * dpi / 96))
def normalize_angle(angle: float) -> float:
"""角度を -180〜180 度に正規化する。"""
angle = (angle + 180) % 360 - 180
return angle
def angle_for_cache(angle: float, step: float) -> int:
"""キャッシュ用に 0〜359 度へ量子化する。"""
positive = angle % 360
if positive < 0:
positive += 360
return int(round(positive / step) * step) % 360
def scale_point(point: tuple[int, int], from_size: int, to_size: int) -> tuple[int, int]:
scale = to_size / from_size
return (int(round(point[0] * scale)), int(round(point[1] * scale)))
def load_cursor_image(
png_path: Path,
target_size: int,
source_size: int = SOURCE_SIZE,
source_rotation_center: tuple[int, int] = SOURCE_ROTATION_CENTER,
source_tip: tuple[int, int] = SOURCE_TIP,
) -> tuple[Image.Image, tuple[int, int], tuple[int, int]]:
if not png_path.is_file():
raise FileNotFoundError(f"Cursor PNG not found: {png_path}")
img = Image.open(png_path).convert("RGBA")
if img.width != img.height:
raise ValueError(f"Cursor PNG must be square: {png_path}")
src = source_size if source_size > 0 else img.width
if img.width != target_size:
img = img.resize((target_size, target_size), Image.Resampling.LANCZOS)
rotation_center = scale_point(source_rotation_center, src, target_size)
tip_pt = scale_point(source_tip, src, target_size)
return img, rotation_center, tip_pt
def rotate_arrow(
base: Image.Image, angle: float, rotation_center: tuple[int, int]
) -> Image.Image:
"""進行方向 angle(度)に矢印を向ける。expand 後に中央へ配置。"""
size = base.size[0]
rotated = base.rotate(
-angle,
resample=Image.BICUBIC,
center=rotation_center,
expand=True,
)
canvas = Image.new("RGBA", (size, size), (0, 0, 0, 0))
offset_x = (size - rotated.width) // 2
offset_y = (size - rotated.height) // 2
canvas.paste(rotated, (offset_x, offset_y), rotated)
return canvas
def hotspot_for_angle(
angle_deg: int,
rotation_center: tuple[int, int],
tip: tuple[int, int],
) -> tuple[int, int]:
"""回転後の矢印先端をホットスポット座標として計算する。"""
cx, cy = rotation_center
tx, ty = tip
rad = math.radians(angle_deg)
ox, oy = tx - cx, ty - cy
hx = cx + ox * math.cos(rad) - oy * math.sin(rad)
hy = cy + ox * math.sin(rad) + oy * math.cos(rad)
return (int(round(hx)), int(round(hy)))
def pil_to_hcursor(pil_image: Image.Image, hotspot: tuple[int, int]) -> int:
"""Create an HCURSOR from a PIL RGBA image."""
width, height = pil_image.size
rgba = pil_image.convert("RGBA")
pixels = bytearray()
for y in range(height - 1, -1, -1):
for x in range(width):
r, g, b, a = rgba.getpixel((x, y))
pixels.extend((b, g, r, a))
bmi = BITMAPINFO()
bmi.bmiHeader.biSize = ctypes.sizeof(BITMAPINFOHEADER)
bmi.bmiHeader.biWidth = width
bmi.bmiHeader.biHeight = height
bmi.bmiHeader.biPlanes = 1
bmi.bmiHeader.biBitCount = 32
bmi.bmiHeader.biCompression = 0
color_bits = ctypes.c_void_p()
hbm_color = gdi32.CreateDIBSection(
None,
ctypes.byref(bmi),
0,
ctypes.byref(color_bits),
None,
0,
)
if not hbm_color:
raise OSError("CreateDIBSection (color) failed")
color_buf = (ctypes.c_ubyte * len(pixels)).from_buffer_copy(pixels)
ctypes.memmove(color_bits, color_buf, len(pixels))
and_row_bytes = ((width + 31) // 32) * 4
and_mask = bytes(and_row_bytes * height)
bmi_and = BITMAPINFO()
bmi_and.bmiHeader.biSize = ctypes.sizeof(BITMAPINFOHEADER)
bmi_and.bmiHeader.biWidth = width
bmi_and.bmiHeader.biHeight = height
bmi_and.bmiHeader.biPlanes = 1
bmi_and.bmiHeader.biBitCount = 1
bmi_and.bmiHeader.biCompression = 0
and_bits = ctypes.c_void_p()
hbm_and = gdi32.CreateDIBSection(
None,
ctypes.byref(bmi_and),
0,
ctypes.byref(and_bits),
None,
0,
)
if not hbm_and:
gdi32.DeleteObject(hbm_color)
raise OSError("CreateDIBSection (mask) failed")
and_buf = (ctypes.c_ubyte * len(and_mask)).from_buffer_copy(and_mask)
ctypes.memmove(and_bits, and_buf, len(and_mask))
icon_info = ICONINFO()
icon_info.fIcon = False
icon_info.xHotspot = hotspot[0]
icon_info.yHotspot = hotspot[1]
icon_info.hbmMask = hbm_and
icon_info.hbmColor = hbm_color
hcursor = user32.CreateIconIndirect(ctypes.byref(icon_info))
gdi32.DeleteObject(hbm_color)
gdi32.DeleteObject(hbm_and)
if not hcursor:
raise OSError("CreateIconIndirect failed")
return hcursor
def destroy_hcursor(hcursor: int) -> None:
if hcursor:
user32.DestroyIcon(hcursor)
class CursorCache:
"""Cache HCURSOR handles per quantized angle (tip hotspot)."""
def __init__(
self,
base_image: Image.Image,
rotation_center: tuple[int, int],
tip: tuple[int, int],
step_degrees: float = 5.0,
) -> None:
self._base = base_image
self._rotation_center = rotation_center
self._tip = tip
self._step = step_degrees
self._cache: dict[int, int] = {}
def _quantize(self, angle: float) -> int:
return angle_for_cache(angle, self._step)
def get(self, angle: float) -> int:
key = self._quantize(angle)
if key not in self._cache:
rotated = rotate_arrow(self._base, key, self._rotation_center)
hotspot = hotspot_for_angle(key, self._rotation_center, self._tip)
self._cache[key] = pil_to_hcursor(rotated, hotspot)
return self._cache[key]
def destroy_all(self) -> None:
for handle in self._cache.values():
destroy_hcursor(handle)
self._cache.clear()
def restore_default_cursor() -> None:
"""レジストリのポインタースキームから標準カーソルを再読み込みする。"""
user32.SystemParametersInfoW(SPI_SETCURSORS, 0, None, 0)
def apply_system_cursor(hcursor: int) -> None:
"""システム全体の標準カーソルを差し替える(全アプリで有効)。"""
copy = user32.CopyIcon(hcursor)
if copy:
user32.SetSystemCursor(copy, OCR_NORMAL)
def is_escape_pressed() -> bool:
VK_ESCAPE = 0x1B
return bool(user32.GetAsyncKeyState(VK_ESCAPE) & 0x8000)
class DirectionalCursorApp:
"""マウス移動を監視し、進行方向に合わせてシステムカーソルを回転させる。"""
def __init__(
self,
png_path: Path,
cursor_size: int | None = None,
source_size: int = SOURCE_SIZE,
source_rotation_center: tuple[int, int] = SOURCE_ROTATION_CENTER,
source_tip: tuple[int, int] = SOURCE_TIP,
smooth: float = 0.18,
angle_step: float = 2.0,
move_threshold: float = 1.0,
velocity_smooth: float = 0.35,
) -> None:
size = cursor_size if cursor_size is not None else get_cursor_size()
self.cursor_size = size
self.png_path = png_path
base, rotation_center, tip = load_cursor_image(
png_path, size, source_size, source_rotation_center, source_tip
)
self.cache = CursorCache(
base, rotation_center, tip, step_degrees=angle_step
)
self.smooth = smooth
self.move_threshold = move_threshold
self.velocity_smooth = velocity_smooth
self.target_angle = 0.0
self.display_angle = 0.0
self.velocity_x = 0.0
self.velocity_y = 0.0
self.current_hcursor: int = 0
self._applied_hcursor: int = 0
self.prev = POINT()
user32.GetCursorPos(ctypes.byref(self.prev))
self.current_hcursor = self.cache.get(self.display_angle)
self._hook = HOOKPROC(self._mouse_hook_proc)
self._hook_handle = None
def _apply_cursor_if_changed(self) -> None:
if self.current_hcursor != self._applied_hcursor:
apply_system_cursor(self.current_hcursor)
self._applied_hcursor = self.current_hcursor
def _smooth_toward_target(self) -> None:
diff = normalize_angle(self.target_angle - self.display_angle)
if abs(diff) < 0.05:
return
step = diff * self.smooth
# 1 フレームあたりの最大回転量を制限してガクつきを抑える
max_step = 12.0
if step > max_step:
step = max_step
elif step < -max_step:
step = -max_step
self.display_angle = normalize_angle(self.display_angle + step)
self.current_hcursor = self.cache.get(self.display_angle)
def _update_motion(self, pt: POINT) -> None:
dx = pt.x - self.prev.x
dy = pt.y - self.prev.y
distance = math.hypot(dx, dy)
if distance >= self.move_threshold:
alpha = self.velocity_smooth
self.velocity_x = self.velocity_x * (1 - alpha) + dx * alpha
self.velocity_y = self.velocity_y * (1 - alpha) + dy * alpha
speed = math.hypot(self.velocity_x, self.velocity_y)
if speed >= self.move_threshold * 0.5:
self.target_angle = math.degrees(
math.atan2(self.velocity_y, self.velocity_x)
)
self.prev = pt
self._smooth_toward_target()
def _mouse_hook_proc(
self,
n_code: int,
w_param: wintypes.WPARAM,
l_param: wintypes.LPARAM,
) -> LRESULT:
# 角度更新はメインループに一本化(二重更新による向きのブレを防ぐ)
return user32.CallNextHookEx(self._hook_handle, n_code, w_param, l_param)
def _install_hook(self) -> bool:
self._hook_handle = user32.SetWindowsHookExW(
WH_MOUSE_LL,
self._hook,
None,
0,
)
if self._hook_handle:
return True
error = kernel32.GetLastError()
print(
f"Warning: SetWindowsHookExW failed (error {error}). "
"Falling back to polling mode.",
file=sys.stderr,
)
return False
def _main_loop(self) -> None:
while not is_escape_pressed():
pt = POINT()
user32.GetCursorPos(ctypes.byref(pt))
self._update_motion(pt)
self._apply_cursor_if_changed()
kernel32.Sleep(4)
msg = wintypes.MSG()
while user32.PeekMessageW(ctypes.byref(msg), None, 0, 0, PM_REMOVE):
user32.TranslateMessage(ctypes.byref(msg))
user32.DispatchMessageW(ctypes.byref(msg))
def run(self) -> None:
self._install_hook()
self._apply_cursor_if_changed()
print(
f"Directional cursor (PNG) started\n"
f" image : {self.png_path}\n"
f" size : {self.cursor_size}px\n"
f"Press Esc to exit."
)
try:
self._main_loop()
finally:
if self._hook_handle:
user32.UnhookWindowsHookEx(self._hook_handle)
restore_default_cursor()
self.cache.destroy_all()
print("Default cursor restored. Exiting.")
def run(
png_path: Path | None = None,
cursor_size: int | None = None,
smooth: float = 0.18,
angle_step: float = 2.0,
move_threshold: float = 1.0,
velocity_smooth: float = 0.35,
) -> None:
if sys.platform != "win32":
print("Windows only.", file=sys.stderr)
sys.exit(1)
DirectionalCursorApp(
png_path=png_path or DEFAULT_CURSOR_PNG,
cursor_size=cursor_size,
smooth=smooth,
angle_step=angle_step,
move_threshold=move_threshold,
velocity_smooth=velocity_smooth,
).run()
def main() -> None:
parser = argparse.ArgumentParser(description="Directional cursor (PNG version)")
parser.add_argument(
"--png",
type=Path,
default=DEFAULT_CURSOR_PNG,
help=f"Cursor PNG path (default: {DEFAULT_CURSOR_PNG.name})",
)
args = parser.parse_args()
run(png_path=args.png)
if __name__ == "__main__":
main()
画像


cursor_sample.pngはどんな感じにするか相談するでもなくAIがなんか勝手に作ってくれた。Cursor5.pngは自作。Windows風のカーソルを作って、とAIに頼んだら以下のようなカーソルが作られた。

やり方
C:\Python\directional_cursorのようにフォルダを作る。Pythonをインストールしてcmd開いて作ったフォルダに移動して
py -m venv venv
で仮想環境作成。
venv\Scripts\activate
で仮想環境に入って
pip install pillow
でPillow(PIL)をインストール。
C:\Python\directional_cursorの中にdirectional_cursor_png.py、cursor_sample.png、Cursor5.pngを入れる。PNG画像は48×48で、右向きである必要がある。
py directional_cursor_png.py
で起動。Escキーで終了。
py directional_cursor_png.py --png cursor5.png
で別の画像(cursor5.png)をカーソルに出来る。
その他
色々試行錯誤してる途中でポインターの表示がおかしくなった時にAIに聞いた対処法
コンパネ→マウス→マウスのプロパティのポインター→デザイン変更して再度元に戻す。これで小さくなったポインターが元に戻った。
もしくは再起動とのこと。