Skip to content
Merged
9 changes: 8 additions & 1 deletion README.ja.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,12 @@ v0.1.0を公開済みです。

- PySide6によるデスクトップGUI
- JSONによるルール保存・読み込み
- `rules/*.json` によるルールセット作成・切り替え
- ルールの追加・編集・削除
- ルールの複製・並び替え
- ルール一覧での最終状態・スコア表示
- 検知画像プレビュー
- スクリーンショットからの検知画像切り出し
- 画面上での探索範囲選択
- 検知画像上でのクリック位置指定
- マルチディスプレイ対応
Expand Down Expand Up @@ -145,7 +150,9 @@ GitHub Releasesでは、Windows向けzipを配布しています。
8. ルールを保存する。
9. `Test Detection`でクリックなしの検知確認を行う。
10. `Start`で実行する。
11. `Stop`で停止する。
11. `Stop`または`Esc`で停止する。

メイン画面上部の`Rule Set`からルールセットを切り替えられます。`New`で空のルールセットを`rules`フォルダ内に作成できます。

## ルール例

Expand Down
9 changes: 8 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,12 @@ Current implementation includes:

- PySide6 desktop GUI
- JSON rule loading and saving
- Rule set creation and switching from `rules/*.json`
- Rule add, edit, and delete
- Rule duplication and reordering
- Last status and score display in the rule list
- Detection image preview
- Detection image capture from screenshots
- Screen region selection
- Click position selection on the template image
- Multi-monitor region support
Expand Down Expand Up @@ -149,7 +154,9 @@ GitHub Releases can be created from tags such as `v0.1.0-alpha.1`. The release w
8. Save the rule.
9. Use `Test Detection` to verify matching without clicking.
10. Click `Start` to run the macro loop.
11. Click `Stop` to stop execution.
11. Click `Stop` or press `Esc` to stop execution.

Use the `Rule Set` dropdown in the main window to switch rule sets. Click `New` to create an empty rule set under the `rules` directory.

## Rule Example

Expand Down
11 changes: 8 additions & 3 deletions app/detector.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,14 @@ def __init__(self, base_dir: str | Path = ".") -> None:

def detect(self, screenshot: np.ndarray, rule: Rule) -> MatchResult | None:
"""Return the best match when it meets the rule confidence."""
match = self.find_best_match(screenshot, rule)
if match is None or match.score < rule.confidence:
return None

return match

def find_best_match(self, screenshot: np.ndarray, rule: Rule) -> MatchResult | None:
"""Return the best match even when it is below the rule confidence."""
origin_x = 0
origin_y = 0
if isinstance(screenshot, CapturedScreenshot):
Expand All @@ -67,9 +75,6 @@ def detect(self, screenshot: np.ndarray, rule: Rule) -> MatchResult | None:
result = self._match_template(region_image, template)
_, max_score, _, max_location = cv2.minMaxLoc(result)

if max_score < rule.confidence:
return None

match_x = rule.region.x + max_location[0]
match_y = rule.region.y + max_location[1]
return MatchResult(
Expand Down
54 changes: 54 additions & 0 deletions app/rule_operations.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

from __future__ import annotations

from dataclasses import replace
from pathlib import Path

from app.models import Rule, RuleSet
Expand Down Expand Up @@ -36,6 +37,59 @@ def remove_rule(rule_set: RuleSet, index: int) -> RuleSet:
return RuleSet(version=rule_set.version, rules=rules)


def duplicate_rule(rule_set: RuleSet, index: int) -> RuleSet:
"""Return a new RuleSet with the selected rule copied after itself."""
if index < 0 or index >= len(rule_set.rules):
raise RuleOperationError("rule index is out of range")

rules = list(rule_set.rules)
original = rules[index]
duplicated = replace(
original,
name=_copy_rule_name(original.name, {rule.name for rule in rules}),
)
rules.insert(index + 1, duplicated)
return RuleSet(version=rule_set.version, rules=rules)


def move_rule(rule_set: RuleSet, index: int, target_index: int) -> RuleSet:
"""Return a new RuleSet with one rule moved to target_index."""
if index < 0 or index >= len(rule_set.rules):
raise RuleOperationError("rule index is out of range")
if target_index < 0 or target_index >= len(rule_set.rules):
raise RuleOperationError("target rule index is out of range")

rules = list(rule_set.rules)
rule = rules.pop(index)
rules.insert(target_index, rule)
return RuleSet(version=rule_set.version, rules=rules)


def reorder_rules(rule_set: RuleSet, order: list[int]) -> RuleSet:
"""Return a new RuleSet reordered by a list of original indices."""
if len(order) != len(rule_set.rules):
raise RuleOperationError("rule order length does not match rule count")
if sorted(order) != list(range(len(rule_set.rules))):
raise RuleOperationError("rule order must contain each rule index exactly once")

return RuleSet(
version=rule_set.version,
rules=[rule_set.rules[index] for index in order],
)


def _copy_rule_name(name: str, existing_names: set[str]) -> str:
base_name = f"{name} copy"
if base_name not in existing_names:
return base_name

suffix = 2
while f"{base_name} {suffix}" in existing_names:
suffix += 1

return f"{base_name} {suffix}"


def make_image_path_relative(rule: Rule, base_dir: str | Path) -> Rule:
"""Return a copy of rule with image path relative to base_dir when possible."""
image_path = Path(rule.image)
Expand Down
19 changes: 18 additions & 1 deletion app/runner.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ class RuleRunResult:
matched: bool = False
triggered: bool = False
skipped_cooldown: bool = False
score: float | None = None
match: MatchResult | None = None
click_target: ClickTarget | None = None
error: str | None = None
Expand Down Expand Up @@ -108,7 +109,7 @@ def test_once(self, screenshot: Any) -> RunnerCycleResult:
continue

try:
match = self.detector.detect(screenshot, rule)
match = self._find_best_match_for_test(screenshot, rule)
except DetectionError as error:
results.append(
RuleRunResult(
Expand All @@ -120,11 +121,19 @@ def test_once(self, screenshot: Any) -> RunnerCycleResult:

if match is None:
results.append(RuleRunResult(rule_name=rule.name))
elif match.score < rule.confidence:
results.append(
RuleRunResult(
rule_name=rule.name,
score=match.score,
)
)
else:
results.append(
RuleRunResult(
rule_name=rule.name,
matched=True,
score=match.score,
match=match,
)
)
Expand Down Expand Up @@ -162,6 +171,7 @@ def run_once(self, screenshot: Any) -> RunnerCycleResult:
rule_name=rule.name,
matched=True,
triggered=True,
score=match.score,
match=match,
click_target=click_target,
)
Expand Down Expand Up @@ -190,3 +200,10 @@ def _capture_screenshot(self, screenshot_provider: ScreenshotProvider) -> Any:
return capture_frame()

return screenshot_provider.capture()

def _find_best_match_for_test(self, screenshot: Any, rule: Rule) -> MatchResult | None:
find_best_match = getattr(self.detector, "find_best_match", None)
if callable(find_best_match):
return find_best_match(screenshot, rule)

return self.detector.detect(screenshot, rule)
63 changes: 63 additions & 0 deletions app/storage.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@
from __future__ import annotations

import json
import re
from dataclasses import dataclass
from pathlib import Path
from typing import Any

Expand All @@ -13,6 +15,67 @@ class RuleStorageError(RuntimeError):
"""Raised when a rule file cannot be loaded or saved."""


@dataclass(frozen=True)
class RuleProfile:
title: str
path: Path


def list_rule_profiles(base_dir: str | Path = ".") -> list[RuleProfile]:
"""List the root rules.json and rule JSON files from the rules directory."""
root_dir = Path(base_dir)
profile_paths = []

legacy_rules_path = root_dir / "rules.json"
if legacy_rules_path.is_file():
profile_paths.append(legacy_rules_path)

profiles_dir = root_dir / "rules"
if profiles_dir.exists() and profiles_dir.is_dir():
profile_paths.extend(
path
for path in sorted(profiles_dir.glob("*.json"), key=lambda item: item.stem.lower())
if path.is_file()
)

return _title_rule_profiles(profile_paths)


def _title_rule_profiles(paths: list[Path]) -> list[RuleProfile]:
title_counts: dict[str, int] = {}
profiles = []
for path in paths:
base_title = path.stem
count = title_counts.get(base_title, 0)
title_counts[base_title] = count + 1
title = base_title if count == 0 else f"{base_title} ({count})"
profiles.append(RuleProfile(title=title, path=path))

return profiles


def safe_rule_profile_stem(name: str) -> str:
"""Return a filesystem-friendly rule profile filename stem."""
stem = re.sub(r"[^0-9A-Za-z_-]+", "_", name.strip()).strip("_")
return stem or "rules"


def new_rule_profile_path(base_dir: str | Path, name: str) -> Path:
"""Return a non-existing path for a new rule profile under rules/."""
profiles_dir = Path(base_dir) / "rules"
stem = safe_rule_profile_stem(name)
path = profiles_dir / f"{stem}.json"
if not path.exists():
return path

suffix = 1
while True:
candidate = profiles_dir / f"{stem}_{suffix}.json"
if not candidate.exists():
return candidate
suffix += 1


def load_rules(path: str | Path) -> RuleSet:
"""Load a rule set from JSON.

Expand Down
Loading
Loading