Use bundled digit templates by default
24
README.md
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
Queue-position reader for Arma Reforger on Linux.
|
||||
|
||||
The tool captures the Reforger queue screen, crops the queue-number region, and reads the orange UI digits with a small built-in matcher. It does not use Tesseract, OpenCV, or Pillow.
|
||||
The tool captures the Reforger queue screen, crops the queue-number region, and reads the orange UI digits with bundled real-game digit templates. It does not use Tesseract, OpenCV, or Pillow.
|
||||
|
||||
## Install
|
||||
|
||||
|
|
@ -27,9 +27,7 @@ Build the local helpers:
|
|||
make
|
||||
```
|
||||
|
||||
No sudo install is required for the default Portal/PipeWire capture path.
|
||||
|
||||
The digit matcher uses ImageMagick to render reference digits. By default it auto-selects an installed font, preferring Roboto Condensed when present and falling back to common system fonts such as DejaVu Sans.
|
||||
No sudo install is required for the default Portal/PipeWire capture path. No specific font package is required for normal use.
|
||||
|
||||
## Use
|
||||
|
||||
|
|
@ -63,10 +61,10 @@ Save the captured input image while debugging:
|
|||
uv run reforger_queue_read.py --portal-window --save-input /tmp/apt-portal-window.png --show-crop --debug
|
||||
```
|
||||
|
||||
If font matching is weird on a specific machine, pass an installed ImageMagick font explicitly:
|
||||
The default matcher uses `templates/reforger_digits.json`. Font mode is only for debugging:
|
||||
|
||||
```bash
|
||||
uv run reforger_queue_read.py --portal-window --font DejaVu-Sans-Condensed --show-crop --debug
|
||||
uv run reforger_queue_read.py --portal-window --font Roboto-Condensed --show-crop --debug
|
||||
```
|
||||
|
||||
## Optional KDE/KWin Mode
|
||||
|
|
@ -123,7 +121,19 @@ Use an OBS scene screenshot:
|
|||
uv run reforger_queue_read.py --obs-scene APT --show-crop --debug
|
||||
```
|
||||
|
||||
Validate the included dataset:
|
||||
Validate the regression set:
|
||||
|
||||
```bash
|
||||
uv run reforger_queue_read.py --dataset datasets/regression-test-set --expect-filenames
|
||||
```
|
||||
|
||||
Expected current result:
|
||||
|
||||
```text
|
||||
summary: 21 passed, 0 failed
|
||||
```
|
||||
|
||||
Validate the smaller included dataset:
|
||||
|
||||
```bash
|
||||
uv run reforger_queue_read.py --dataset datasets/scootz-dataset --show-crop
|
||||
|
|
|
|||
BIN
datasets/regression-test-set/10.png
Normal file
|
After Width: | Height: | Size: 1.2 MiB |
BIN
datasets/regression-test-set/11.png
Normal file
|
After Width: | Height: | Size: 1.2 MiB |
BIN
datasets/regression-test-set/12.png
Normal file
|
After Width: | Height: | Size: 1.2 MiB |
BIN
datasets/regression-test-set/13.png
Normal file
|
After Width: | Height: | Size: 1.2 MiB |
BIN
datasets/regression-test-set/14.png
Normal file
|
After Width: | Height: | Size: 1.2 MiB |
BIN
datasets/regression-test-set/15.png
Normal file
|
After Width: | Height: | Size: 1.2 MiB |
BIN
datasets/regression-test-set/16.png
Normal file
|
After Width: | Height: | Size: 1.2 MiB |
BIN
datasets/regression-test-set/17.png
Normal file
|
After Width: | Height: | Size: 1.2 MiB |
BIN
datasets/regression-test-set/18.png
Normal file
|
After Width: | Height: | Size: 1.2 MiB |
BIN
datasets/regression-test-set/19.png
Normal file
|
After Width: | Height: | Size: 1.2 MiB |
BIN
datasets/regression-test-set/2.png
Normal file
|
After Width: | Height: | Size: 1.2 MiB |
BIN
datasets/regression-test-set/21.png
Normal file
|
After Width: | Height: | Size: 1.2 MiB |
BIN
datasets/regression-test-set/22.png
Normal file
|
After Width: | Height: | Size: 1.2 MiB |
BIN
datasets/regression-test-set/23.png
Normal file
|
After Width: | Height: | Size: 1.2 MiB |
BIN
datasets/regression-test-set/24.png
Normal file
|
After Width: | Height: | Size: 1.2 MiB |
BIN
datasets/regression-test-set/25.png
Normal file
|
After Width: | Height: | Size: 1.2 MiB |
BIN
datasets/regression-test-set/3.png
Normal file
|
After Width: | Height: | Size: 1.2 MiB |
BIN
datasets/regression-test-set/6.png
Normal file
|
After Width: | Height: | Size: 1.2 MiB |
BIN
datasets/regression-test-set/7.png
Normal file
|
After Width: | Height: | Size: 1.2 MiB |
BIN
datasets/regression-test-set/8.png
Normal file
|
After Width: | Height: | Size: 1.2 MiB |
BIN
datasets/regression-test-set/9.png
Normal file
|
After Width: | Height: | Size: 1.2 MiB |
|
|
@ -16,19 +16,15 @@ import uuid
|
|||
|
||||
DEFAULT_REFERENCE_SIZE = (2560, 1440)
|
||||
DEFAULT_REFERENCE_CROP = (1050, 620, 100, 60)
|
||||
DEFAULT_FONT = "auto"
|
||||
DEFAULT_TEMPLATE_SET = os.path.join(os.path.dirname(os.path.abspath(__file__)), "templates", "reforger_digits.json")
|
||||
DEFAULT_POINTSIZE = 43
|
||||
AUTO_FONT_CANDIDATES = (
|
||||
"Roboto-Condensed",
|
||||
"RobotoCondensed-Regular",
|
||||
"Roboto-Condensed-Regular",
|
||||
"Noto-Sans-Condensed",
|
||||
"NotoSans-Condensed",
|
||||
"Liberation-Sans-Narrow",
|
||||
"DejaVu-Sans-Condensed",
|
||||
"DejaVu-Sans",
|
||||
"Arial",
|
||||
"Helvetica",
|
||||
"Roboto",
|
||||
"Adwaita-Sans",
|
||||
"Adwaita-Sans-Bold",
|
||||
)
|
||||
SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||
SYSTEM_KWIN_HELPER = "/usr/local/bin/reforger-kwin-capture"
|
||||
|
|
@ -748,12 +744,101 @@ def build_templates(font, pointsize):
|
|||
)
|
||||
|
||||
|
||||
def validate_template_grid(grid, digit, sample_index, path):
|
||||
if not isinstance(grid, list) or len(grid) != 36:
|
||||
sys.exit(f"invalid template grid for digit {digit} sample {sample_index} in {path}: expected 36 rows")
|
||||
for row in grid:
|
||||
if not isinstance(row, list) or len(row) != 24 or any(not isinstance(value, bool) for value in row):
|
||||
sys.exit(
|
||||
f"invalid template grid for digit {digit} sample {sample_index} in {path}: "
|
||||
"expected 24 boolean columns per row"
|
||||
)
|
||||
|
||||
|
||||
def load_template_set(path):
|
||||
try:
|
||||
with open(path, "r", encoding="utf-8") as handle:
|
||||
payload = json.load(handle)
|
||||
except FileNotFoundError:
|
||||
sys.exit(f"missing digit template set {path}; regenerate it from the regression dataset")
|
||||
except json.JSONDecodeError as exc:
|
||||
sys.exit(f"failed to parse digit template set {path}: {exc}")
|
||||
|
||||
digits = payload.get("digits") if isinstance(payload, dict) else None
|
||||
if not isinstance(digits, dict):
|
||||
sys.exit(f"invalid digit template set {path}: missing digits object")
|
||||
|
||||
templates = {}
|
||||
for digit in "0123456789":
|
||||
samples = digits.get(digit)
|
||||
if not isinstance(samples, list) or not samples:
|
||||
sys.exit(f"invalid digit template set {path}: missing samples for digit {digit}")
|
||||
templates[digit] = []
|
||||
for index, grid in enumerate(samples):
|
||||
validate_template_grid(grid, digit, index, path)
|
||||
templates[digit].append(grid)
|
||||
|
||||
return templates
|
||||
|
||||
|
||||
def templates_from_dataset(paths, reference_crop, reference_size, scale_mode, cropped):
|
||||
templates = {str(digit): [] for digit in range(10)}
|
||||
for path in paths:
|
||||
expected = os.path.splitext(os.path.basename(path))[0]
|
||||
if not expected.isdigit():
|
||||
sys.exit(f"template source filename must be numeric: {path}")
|
||||
|
||||
crop = resolve_crop(path, None, reference_crop, reference_size, scale_mode, cropped)
|
||||
width, height, raw = load_queue_image(path, crop, cropped)
|
||||
components = connected_components(orange_digit_mask(width, height, raw))
|
||||
if len(components) != len(expected):
|
||||
sys.exit(
|
||||
f"template source {path} produced {len(components)} digit components, "
|
||||
f"but filename expects {len(expected)}"
|
||||
)
|
||||
|
||||
for digit, component in zip(expected, components):
|
||||
templates[digit].append(normalize(component))
|
||||
|
||||
missing = [digit for digit, samples in templates.items() if not samples]
|
||||
if missing:
|
||||
sys.exit(f"template dataset has no samples for digits: {', '.join(missing)}")
|
||||
return templates
|
||||
|
||||
|
||||
def write_template_set(output, templates, source_paths):
|
||||
payload = {
|
||||
"version": 1,
|
||||
"normalize_size": [24, 36],
|
||||
"source": "regression digit crops",
|
||||
"source_files": [os.path.basename(path) for path in source_paths],
|
||||
"digits": templates,
|
||||
}
|
||||
directory = os.path.dirname(output)
|
||||
if directory:
|
||||
os.makedirs(directory, exist_ok=True)
|
||||
temp_path = f"{output}.tmp"
|
||||
with open(temp_path, "w", encoding="utf-8") as handle:
|
||||
json.dump(payload, handle, separators=(",", ":"))
|
||||
handle.write("\n")
|
||||
os.replace(temp_path, output)
|
||||
|
||||
|
||||
def resolve_templates(template_set, font, pointsize):
|
||||
if font:
|
||||
return build_templates(font, pointsize)
|
||||
return load_template_set(template_set)
|
||||
|
||||
|
||||
def classify(component, templates):
|
||||
sample = normalize(component)
|
||||
ranked = []
|
||||
for digit, template in templates.items():
|
||||
hamming, iou = template_distance(sample, template)
|
||||
ranked.append((hamming, -iou, digit, iou))
|
||||
for digit, digit_templates in templates.items():
|
||||
if digit_templates and isinstance(digit_templates[0][0], bool):
|
||||
digit_templates = [digit_templates]
|
||||
for template in digit_templates:
|
||||
hamming, iou = template_distance(sample, template)
|
||||
ranked.append((hamming, -iou, digit, iou))
|
||||
ranked.sort()
|
||||
hamming, negative_iou, digit, iou = ranked[0]
|
||||
return digit, hamming, iou
|
||||
|
|
@ -1213,10 +1298,10 @@ def load_queue_image(image_path, crop, already_cropped=False):
|
|||
return ppm_from_magick([image_path, "+repage", "-crop", f"{width}x{height}+{x}+{y}", "+repage"])
|
||||
|
||||
|
||||
def read_queue_number(image_path, crop, font, pointsize, already_cropped=False):
|
||||
def read_queue_number(image_path, crop, template_set, font, pointsize, already_cropped=False):
|
||||
width, height, raw = load_queue_image(image_path, crop, already_cropped)
|
||||
components = connected_components(orange_digit_mask(width, height, raw))
|
||||
templates = build_templates(font, pointsize)
|
||||
templates = resolve_templates(template_set, font, pointsize)
|
||||
|
||||
digits = []
|
||||
details = []
|
||||
|
|
@ -1282,6 +1367,16 @@ def main():
|
|||
parser = argparse.ArgumentParser(description="Read Arma Reforger queue number from a fixed screen crop.")
|
||||
parser.add_argument("--image", help="Read from an existing image.")
|
||||
parser.add_argument("--dataset", help="Read every image in a dataset directory.")
|
||||
parser.add_argument(
|
||||
"--build-template-set",
|
||||
metavar="DATASET",
|
||||
help=argparse.SUPPRESS,
|
||||
)
|
||||
parser.add_argument(
|
||||
"--template-output",
|
||||
default=DEFAULT_TEMPLATE_SET,
|
||||
help=argparse.SUPPRESS,
|
||||
)
|
||||
parser.add_argument("--list-screens", action="store_true", help="List KWin output names for use with --screen.")
|
||||
parser.add_argument(
|
||||
"--list-kwin-windows",
|
||||
|
|
@ -1373,7 +1468,12 @@ def main():
|
|||
default="width",
|
||||
help="How to scale --reference-crop. Width mode preserves a 16:9 UI layout and is the default.",
|
||||
)
|
||||
parser.add_argument("--font", default=DEFAULT_FONT, help="Font used for synthetic digit templates.")
|
||||
parser.add_argument(
|
||||
"--template-set",
|
||||
default=DEFAULT_TEMPLATE_SET,
|
||||
help="Digit template JSON used by default recognition.",
|
||||
)
|
||||
parser.add_argument("--font", help="Render synthetic digit templates from an ImageMagick font instead of using --template-set.")
|
||||
parser.add_argument("--pointsize", type=int, default=DEFAULT_POINTSIZE, help="Point size used for templates.")
|
||||
parser.add_argument("--debug", action="store_true", help="Print component and match details.")
|
||||
parser.add_argument("--show-crop", action="store_true", help="Print the resolved crop used for each full-size image.")
|
||||
|
|
@ -1406,6 +1506,7 @@ def main():
|
|||
for value in (
|
||||
args.image,
|
||||
args.dataset,
|
||||
args.build_template_set,
|
||||
args.capture,
|
||||
args.list_screens,
|
||||
args.list_kwin_windows,
|
||||
|
|
@ -1427,6 +1528,21 @@ def main():
|
|||
print_kwin_windows(kwin_windows())
|
||||
return 0
|
||||
|
||||
if args.build_template_set:
|
||||
extensions = {".png", ".jpg", ".jpeg", ".webp", ".bmp"}
|
||||
paths = [
|
||||
os.path.join(args.build_template_set, name)
|
||||
for name in sorted(os.listdir(args.build_template_set))
|
||||
if os.path.splitext(name)[1].lower() in extensions
|
||||
]
|
||||
if not paths:
|
||||
print("no template source images found")
|
||||
return 2
|
||||
templates = templates_from_dataset(paths, args.reference_crop, args.reference_size, args.scale_mode, args.cropped)
|
||||
write_template_set(args.template_output, templates, paths)
|
||||
print(args.template_output)
|
||||
return 0
|
||||
|
||||
if args.dataset:
|
||||
extensions = {".png", ".jpg", ".jpeg", ".webp", ".bmp"}
|
||||
paths = [
|
||||
|
|
@ -1445,7 +1561,7 @@ def main():
|
|||
crop = resolve_crop(path, args.crop, args.reference_crop, args.reference_size, args.scale_mode, args.cropped)
|
||||
if args.show_crop and crop:
|
||||
print(f"{path}\tcrop={crop[0]},{crop[1]},{crop[2]},{crop[3]}")
|
||||
number, details = read_queue_number(path, crop, args.font, args.pointsize, args.cropped)
|
||||
number, details = read_queue_number(path, crop, args.template_set, args.font, args.pointsize, args.cropped)
|
||||
if not number:
|
||||
status = 2
|
||||
failed += 1
|
||||
|
|
@ -1529,7 +1645,7 @@ def main():
|
|||
crop = resolve_crop(image_path, args.crop, args.reference_crop, args.reference_size, args.scale_mode, args.cropped)
|
||||
if args.show_crop and crop:
|
||||
print(f"crop={crop[0]},{crop[1]},{crop[2]},{crop[3]}")
|
||||
number, details = read_queue_number(image_path, crop, args.font, args.pointsize, args.cropped)
|
||||
number, details = read_queue_number(image_path, crop, args.template_set, args.font, args.pointsize, args.cropped)
|
||||
finally:
|
||||
if temp_path:
|
||||
try:
|
||||
|
|
|
|||