Use bundled digit templates by default
24
README.md
|
|
@ -2,7 +2,7 @@
|
||||||
|
|
||||||
Queue-position reader for Arma Reforger on Linux.
|
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
|
## Install
|
||||||
|
|
||||||
|
|
@ -27,9 +27,7 @@ Build the local helpers:
|
||||||
make
|
make
|
||||||
```
|
```
|
||||||
|
|
||||||
No sudo install is required for the default Portal/PipeWire capture path.
|
No sudo install is required for the default Portal/PipeWire capture path. No specific font package is required for normal use.
|
||||||
|
|
||||||
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.
|
|
||||||
|
|
||||||
## 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
|
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
|
```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
|
## 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
|
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
|
```bash
|
||||||
uv run reforger_queue_read.py --dataset datasets/scootz-dataset --show-crop
|
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_SIZE = (2560, 1440)
|
||||||
DEFAULT_REFERENCE_CROP = (1050, 620, 100, 60)
|
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
|
DEFAULT_POINTSIZE = 43
|
||||||
AUTO_FONT_CANDIDATES = (
|
AUTO_FONT_CANDIDATES = (
|
||||||
"Roboto-Condensed",
|
"Roboto-Condensed",
|
||||||
"RobotoCondensed-Regular",
|
"RobotoCondensed-Regular",
|
||||||
"Roboto-Condensed-Regular",
|
"Roboto-Condensed-Regular",
|
||||||
"Noto-Sans-Condensed",
|
"Roboto",
|
||||||
"NotoSans-Condensed",
|
"Adwaita-Sans",
|
||||||
"Liberation-Sans-Narrow",
|
"Adwaita-Sans-Bold",
|
||||||
"DejaVu-Sans-Condensed",
|
|
||||||
"DejaVu-Sans",
|
|
||||||
"Arial",
|
|
||||||
"Helvetica",
|
|
||||||
)
|
)
|
||||||
SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
|
SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||||
SYSTEM_KWIN_HELPER = "/usr/local/bin/reforger-kwin-capture"
|
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):
|
def classify(component, templates):
|
||||||
sample = normalize(component)
|
sample = normalize(component)
|
||||||
ranked = []
|
ranked = []
|
||||||
for digit, template in templates.items():
|
for digit, digit_templates in templates.items():
|
||||||
hamming, iou = template_distance(sample, template)
|
if digit_templates and isinstance(digit_templates[0][0], bool):
|
||||||
ranked.append((hamming, -iou, digit, iou))
|
digit_templates = [digit_templates]
|
||||||
|
for template in digit_templates:
|
||||||
|
hamming, iou = template_distance(sample, template)
|
||||||
|
ranked.append((hamming, -iou, digit, iou))
|
||||||
ranked.sort()
|
ranked.sort()
|
||||||
hamming, negative_iou, digit, iou = ranked[0]
|
hamming, negative_iou, digit, iou = ranked[0]
|
||||||
return digit, hamming, iou
|
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"])
|
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)
|
width, height, raw = load_queue_image(image_path, crop, already_cropped)
|
||||||
components = connected_components(orange_digit_mask(width, height, raw))
|
components = connected_components(orange_digit_mask(width, height, raw))
|
||||||
templates = build_templates(font, pointsize)
|
templates = resolve_templates(template_set, font, pointsize)
|
||||||
|
|
||||||
digits = []
|
digits = []
|
||||||
details = []
|
details = []
|
||||||
|
|
@ -1282,6 +1367,16 @@ def main():
|
||||||
parser = argparse.ArgumentParser(description="Read Arma Reforger queue number from a fixed screen crop.")
|
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("--image", help="Read from an existing image.")
|
||||||
parser.add_argument("--dataset", help="Read every image in a dataset directory.")
|
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-screens", action="store_true", help="List KWin output names for use with --screen.")
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--list-kwin-windows",
|
"--list-kwin-windows",
|
||||||
|
|
@ -1373,7 +1468,12 @@ def main():
|
||||||
default="width",
|
default="width",
|
||||||
help="How to scale --reference-crop. Width mode preserves a 16:9 UI layout and is the default.",
|
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("--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("--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.")
|
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 (
|
for value in (
|
||||||
args.image,
|
args.image,
|
||||||
args.dataset,
|
args.dataset,
|
||||||
|
args.build_template_set,
|
||||||
args.capture,
|
args.capture,
|
||||||
args.list_screens,
|
args.list_screens,
|
||||||
args.list_kwin_windows,
|
args.list_kwin_windows,
|
||||||
|
|
@ -1427,6 +1528,21 @@ def main():
|
||||||
print_kwin_windows(kwin_windows())
|
print_kwin_windows(kwin_windows())
|
||||||
return 0
|
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:
|
if args.dataset:
|
||||||
extensions = {".png", ".jpg", ".jpeg", ".webp", ".bmp"}
|
extensions = {".png", ".jpg", ".jpeg", ".webp", ".bmp"}
|
||||||
paths = [
|
paths = [
|
||||||
|
|
@ -1445,7 +1561,7 @@ def main():
|
||||||
crop = resolve_crop(path, args.crop, args.reference_crop, args.reference_size, args.scale_mode, args.cropped)
|
crop = resolve_crop(path, args.crop, args.reference_crop, args.reference_size, args.scale_mode, args.cropped)
|
||||||
if args.show_crop and crop:
|
if args.show_crop and crop:
|
||||||
print(f"{path}\tcrop={crop[0]},{crop[1]},{crop[2]},{crop[3]}")
|
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:
|
if not number:
|
||||||
status = 2
|
status = 2
|
||||||
failed += 1
|
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)
|
crop = resolve_crop(image_path, args.crop, args.reference_crop, args.reference_size, args.scale_mode, args.cropped)
|
||||||
if args.show_crop and crop:
|
if args.show_crop and crop:
|
||||||
print(f"crop={crop[0]},{crop[1]},{crop[2]},{crop[3]}")
|
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:
|
finally:
|
||||||
if temp_path:
|
if temp_path:
|
||||||
try:
|
try:
|
||||||
|
|
|
||||||