Use bundled digit templates by default

This commit is contained in:
scootz 2026-05-01 13:51:43 +01:00
parent 53d1077a22
commit 7787c9f247
24 changed files with 150 additions and 23 deletions

View file

@ -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

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

View file

@ -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:

File diff suppressed because one or more lines are too long