feat(calibrate): full interactive Tk wizard
Replace skeletal wizard (window title only) with complete click-to-sample flow: - Screenshot target window via mss+pygetwindow (Windows) or --screenshot PNG - Single-canvas Tk wizard with scaled display + back/skip/save controls - Stepwise clicks collect: dot_roi, all 7 dot colours (+ optional background), chart_roi, 2 y-axis reference points with prices, canary region - Auto-compute canary baseline phash at save time - Pull Discord/Telegram creds from ATM_DISCORD_URL/ATM_TG_TOKEN/ATM_TG_CHAT env - Add --screenshot flag to 'atm calibrate' for non-Windows dev testing Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +1,8 @@
|
|||||||
"""Calibration wizard for chart window — Tk-based, safe to import headlessly."""
|
"""Calibration wizard for chart window — Tk-based, safe to import headlessly."""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import time
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
@@ -30,7 +32,6 @@ def _toml_scalar(v: object) -> str:
|
|||||||
|
|
||||||
|
|
||||||
def _emit_table(lines: list[str], data: dict, prefix: str) -> None:
|
def _emit_table(lines: list[str], data: dict, prefix: str) -> None:
|
||||||
"""Emit scalar key-value pairs then recurse into sub-tables."""
|
|
||||||
for k, v in data.items():
|
for k, v in data.items():
|
||||||
if not isinstance(v, dict):
|
if not isinstance(v, dict):
|
||||||
lines.append(f"{k} = {_toml_scalar(v)}")
|
lines.append(f"{k} = {_toml_scalar(v)}")
|
||||||
@@ -48,77 +49,351 @@ def _dict_to_toml(data: dict) -> str:
|
|||||||
return "\n".join(lines) + "\n"
|
return "\n".join(lines) + "\n"
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Pure helper — testable without Tk
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
def write_config(data: dict, out_dir: Path) -> Path:
|
def write_config(data: dict, out_dir: Path) -> Path:
|
||||||
"""Serialise *data* to a timestamped TOML file in *out_dir* and update current.txt.
|
"""Serialise *data* to a timestamped TOML file in *out_dir* and update current.txt."""
|
||||||
|
|
||||||
Returns the path of the newly written config file.
|
|
||||||
"""
|
|
||||||
out_dir = Path(out_dir)
|
out_dir = Path(out_dir)
|
||||||
out_dir.mkdir(parents=True, exist_ok=True)
|
out_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
ts = datetime.now(timezone.utc).strftime("%Y-%m-%d-%H%M")
|
ts = datetime.now(timezone.utc).strftime("%Y-%m-%d-%H%M")
|
||||||
filename = f"{ts}.toml"
|
filename = f"{ts}.toml"
|
||||||
config_path = out_dir / filename
|
config_path = out_dir / filename
|
||||||
|
|
||||||
config_path.write_text(_dict_to_toml(data), encoding="utf-8")
|
config_path.write_text(_dict_to_toml(data), encoding="utf-8")
|
||||||
(out_dir / "current.txt").write_text(filename, encoding="utf-8")
|
(out_dir / "current.txt").write_text(filename, encoding="utf-8")
|
||||||
|
|
||||||
return config_path
|
return config_path
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Interactive wizard — Tk imported only at runtime
|
# Window capture (Windows live) / screenshot fallback
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
def run_calibration(out_dir: Path) -> Path:
|
def _capture_window(title_substr: str):
|
||||||
"""Launch the guided calibration wizard and return the saved config path."""
|
"""Screenshot the first window matching *title_substr*. Windows-only (mss+pygetwindow).
|
||||||
|
|
||||||
|
Returns a PIL RGB Image.
|
||||||
|
"""
|
||||||
|
import mss # type: ignore[import-untyped]
|
||||||
|
import pygetwindow as gw # type: ignore[import-untyped]
|
||||||
|
from PIL import Image
|
||||||
|
|
||||||
|
wins = gw.getWindowsWithTitle(title_substr)
|
||||||
|
if not wins:
|
||||||
|
raise RuntimeError(f"No window matching title substring: {title_substr!r}")
|
||||||
|
win = wins[0]
|
||||||
|
try:
|
||||||
|
win.activate()
|
||||||
|
except Exception:
|
||||||
|
pass # activate may fail on some windows; continue — mss still captures by region
|
||||||
|
time.sleep(0.3)
|
||||||
|
|
||||||
|
with mss.mss() as sct:
|
||||||
|
mon = {
|
||||||
|
"top": int(win.top), "left": int(win.left),
|
||||||
|
"width": int(win.width), "height": int(win.height),
|
||||||
|
}
|
||||||
|
shot = sct.grab(mon)
|
||||||
|
return Image.frombytes("RGB", shot.size, shot.rgb)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Wizard
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
_COLOR_ORDER = [
|
||||||
|
"turquoise", "yellow", "dark_green", "dark_red",
|
||||||
|
"light_green", "light_red", "gray", "background",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def run_calibration(out_dir: Path, screenshot: Path | None = None) -> Path:
|
||||||
|
"""Launch the guided calibration wizard and return the saved config path.
|
||||||
|
|
||||||
|
If *screenshot* is provided, use that image instead of live window capture
|
||||||
|
(useful for dev/testing on non-Windows hosts).
|
||||||
|
"""
|
||||||
import tkinter as tk
|
import tkinter as tk
|
||||||
from tkinter import simpledialog
|
from tkinter import simpledialog
|
||||||
|
from PIL import Image
|
||||||
|
|
||||||
out_dir = Path(out_dir)
|
out_dir = Path(out_dir)
|
||||||
configs_dir = out_dir / "configs"
|
configs_dir = out_dir / "configs" if out_dir.name != "configs" else out_dir
|
||||||
configs_dir.mkdir(parents=True, exist_ok=True)
|
configs_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
root = tk.Tk()
|
# ------------------------------------------------------------------
|
||||||
root.withdraw()
|
# 1. Acquire image + window title
|
||||||
|
# ------------------------------------------------------------------
|
||||||
# Step 1: window title
|
pil_img: "Image.Image"
|
||||||
|
if screenshot is not None:
|
||||||
|
pil_img = Image.open(screenshot).convert("RGB")
|
||||||
|
root0 = tk.Tk(); root0.withdraw()
|
||||||
window_title = simpledialog.askstring(
|
window_title = simpledialog.askstring(
|
||||||
"Step 1 — Window title",
|
"Window title",
|
||||||
"Enter the exact title of the chart window:",
|
"Window title substring used by 'atm run' to locate the chart:",
|
||||||
parent=root,
|
parent=root0,
|
||||||
|
initialvalue=screenshot.stem,
|
||||||
|
) or screenshot.stem
|
||||||
|
root0.destroy()
|
||||||
|
else:
|
||||||
|
root0 = tk.Tk(); root0.withdraw()
|
||||||
|
window_title = simpledialog.askstring(
|
||||||
|
"Window title",
|
||||||
|
"Substring of the chart window title (e.g. 'DIA' or 'TradeStation'):",
|
||||||
|
parent=root0,
|
||||||
) or ""
|
) or ""
|
||||||
|
root0.destroy()
|
||||||
if not window_title:
|
if not window_title:
|
||||||
root.destroy()
|
|
||||||
raise ValueError("Window title is required to proceed.")
|
raise ValueError("Window title is required to proceed.")
|
||||||
|
pil_img = _capture_window(window_title)
|
||||||
|
|
||||||
# Steps 2-5 require a visible window; skeleton data used until full wizard is built.
|
# ------------------------------------------------------------------
|
||||||
data: dict = {
|
# 2. Launch interactive wizard on the image
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
wizard = _CalibrationWizard(pil_img, window_title)
|
||||||
|
data = wizard.run()
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# 3. Inject notifier creds (env → placeholders otherwise)
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
data["discord"] = {
|
||||||
|
"webhook_url": os.environ.get(
|
||||||
|
"ATM_DISCORD_URL",
|
||||||
|
"https://discord.com/api/webhooks/REPLACE_ME",
|
||||||
|
),
|
||||||
|
}
|
||||||
|
data["telegram"] = {
|
||||||
|
"bot_token": os.environ.get("ATM_TG_TOKEN", "REPLACE_ME"),
|
||||||
|
"chat_id": os.environ.get("ATM_TG_CHAT", "0"),
|
||||||
|
}
|
||||||
|
data.setdefault("options", {})
|
||||||
|
|
||||||
|
return write_config(data, configs_dir)
|
||||||
|
|
||||||
|
|
||||||
|
class _CalibrationWizard:
|
||||||
|
"""Single-window Tk wizard with step-by-step click instructions."""
|
||||||
|
|
||||||
|
def __init__(self, pil_img, window_title: str) -> None:
|
||||||
|
import tkinter as tk
|
||||||
|
from PIL import ImageTk
|
||||||
|
|
||||||
|
self._pil_img = pil_img
|
||||||
|
self._window_title = window_title
|
||||||
|
|
||||||
|
self._root = tk.Tk()
|
||||||
|
self._root.title("ATM calibration wizard")
|
||||||
|
self._result: dict | None = None
|
||||||
|
|
||||||
|
# --- build step plan ---
|
||||||
|
self._steps: list[tuple[str, str]] = [
|
||||||
|
("dot_roi_tl", "M2D MAPS strip: click TOP-LEFT corner"),
|
||||||
|
("dot_roi_br", "M2D MAPS strip: click BOTTOM-RIGHT corner"),
|
||||||
|
]
|
||||||
|
for c in _COLOR_ORDER:
|
||||||
|
if c == "background":
|
||||||
|
self._steps.append(("color_background", "Click on the chart BACKGROUND (empty area)"))
|
||||||
|
else:
|
||||||
|
self._steps.append((f"color_{c}", f"Click on a {c.replace('_',' ').upper()} dot"))
|
||||||
|
self._steps += [
|
||||||
|
("chart_roi_tl", "Chart area: click TOP-LEFT (where SL/TP lines will appear)"),
|
||||||
|
("chart_roi_br", "Chart area: click BOTTOM-RIGHT"),
|
||||||
|
("y1", "Click on a known price level (any horizontal grid line) — reference 1"),
|
||||||
|
("y2", "Click on ANOTHER price level — reference 2"),
|
||||||
|
("canary_tl", "Canary region: click TOP-LEFT of a stable UI element (axis label, logo)"),
|
||||||
|
("canary_br", "Canary region: click BOTTOM-RIGHT"),
|
||||||
|
("done", "Calibration complete — click Save"),
|
||||||
|
]
|
||||||
|
self._idx = 0
|
||||||
|
|
||||||
|
self._data: dict = {
|
||||||
"window_title": window_title,
|
"window_title": window_title,
|
||||||
"dot_roi": {"x": 0, "y": 0, "w": 120, "h": 120},
|
"dot_roi": None,
|
||||||
"chart_roi": {"x": 0, "y": 0, "w": 800, "h": 600},
|
"chart_roi": None,
|
||||||
"colors": {
|
"colors": {},
|
||||||
"turquoise": {"rgb": [0, 200, 200], "tolerance": 30.0},
|
"y_axis": {},
|
||||||
"yellow": {"rgb": [255, 255, 0], "tolerance": 30.0},
|
"canary_roi": None,
|
||||||
"dark_green": {"rgb": [0, 128, 0], "tolerance": 30.0},
|
}
|
||||||
"dark_red": {"rgb": [139, 0, 0], "tolerance": 30.0},
|
self._tmp: dict = {} # first-click holders
|
||||||
"light_green": {"rgb": [144, 238, 144], "tolerance": 30.0},
|
|
||||||
"light_red": {"rgb": [255, 182, 193], "tolerance": 30.0},
|
# --- layout ---
|
||||||
"gray": {"rgb": [128, 128, 128], "tolerance": 30.0},
|
screen_w = self._root.winfo_screenwidth() - 100
|
||||||
},
|
screen_h = self._root.winfo_screenheight() - 200
|
||||||
"y_axis": {"p1_y": 0, "p1_price": 0.0, "p2_y": 1, "p2_price": 1.0},
|
orig_w, orig_h = pil_img.size
|
||||||
"canary": {
|
self._scale = min(screen_w / orig_w, screen_h / orig_h, 1.0)
|
||||||
"roi": {"x": 0, "y": 0, "w": 50, "h": 50},
|
disp_w = int(orig_w * self._scale)
|
||||||
"baseline_phash": "",
|
disp_h = int(orig_h * self._scale)
|
||||||
"drift_threshold": 8,
|
|
||||||
},
|
scaled = pil_img.resize((disp_w, disp_h))
|
||||||
"discord": {"webhook_url": "http://placeholder"},
|
self._tkimg = ImageTk.PhotoImage(scaled)
|
||||||
"telegram": {"bot_token": "placeholder", "chat_id": "0"},
|
|
||||||
|
self._status = tk.Label(
|
||||||
|
self._root, text="", font=("Arial", 13), fg="white", bg="#2b7de9",
|
||||||
|
padx=8, pady=6, anchor="w",
|
||||||
|
)
|
||||||
|
self._status.pack(fill="x")
|
||||||
|
|
||||||
|
bar = tk.Frame(self._root)
|
||||||
|
bar.pack(fill="x")
|
||||||
|
self._back_btn = tk.Button(bar, text="← Back", command=self._back)
|
||||||
|
self._back_btn.pack(side="left", padx=4, pady=4)
|
||||||
|
self._skip_btn = tk.Button(bar, text="Skip (can't find)", command=self._skip)
|
||||||
|
self._skip_btn.pack(side="left", padx=4, pady=4)
|
||||||
|
self._save_btn = tk.Button(bar, text="💾 Save", command=self._save, state="disabled")
|
||||||
|
self._save_btn.pack(side="right", padx=4, pady=4)
|
||||||
|
|
||||||
|
self._canvas = tk.Canvas(
|
||||||
|
self._root, width=disp_w, height=disp_h, bg="#222", cursor="crosshair",
|
||||||
|
highlightthickness=0,
|
||||||
|
)
|
||||||
|
self._canvas.pack()
|
||||||
|
self._canvas.create_image(0, 0, anchor="nw", image=self._tkimg)
|
||||||
|
self._canvas.bind("<Button-1>", self._on_click)
|
||||||
|
|
||||||
|
self._markers: list[int] = []
|
||||||
|
self._refresh()
|
||||||
|
|
||||||
|
# -------- state control --------
|
||||||
|
def _refresh(self) -> None:
|
||||||
|
name, prompt = self._steps[self._idx]
|
||||||
|
self._status.config(text=f"Step {self._idx + 1}/{len(self._steps)} — {prompt}")
|
||||||
|
self._back_btn.config(state=("normal" if self._idx > 0 else "disabled"))
|
||||||
|
self._skip_btn.config(state=("normal" if name.startswith("color_") else "disabled"))
|
||||||
|
self._save_btn.config(state=("normal" if name == "done" else "disabled"))
|
||||||
|
|
||||||
|
def _advance(self) -> None:
|
||||||
|
self._idx = min(self._idx + 1, len(self._steps) - 1)
|
||||||
|
self._refresh()
|
||||||
|
|
||||||
|
def _back(self) -> None:
|
||||||
|
if self._idx == 0:
|
||||||
|
return
|
||||||
|
self._idx -= 1
|
||||||
|
if self._markers:
|
||||||
|
self._canvas.delete(self._markers.pop())
|
||||||
|
self._refresh()
|
||||||
|
|
||||||
|
def _skip(self) -> None:
|
||||||
|
name, _ = self._steps[self._idx]
|
||||||
|
if name.startswith("color_"):
|
||||||
|
cname = name.split("_", 1)[1]
|
||||||
|
self._data["colors"][cname] = {"rgb": [128, 128, 128], "tolerance": 30.0}
|
||||||
|
self._advance()
|
||||||
|
|
||||||
|
# -------- click handler --------
|
||||||
|
def _on_click(self, event) -> None:
|
||||||
|
name, _ = self._steps[self._idx]
|
||||||
|
if name == "done":
|
||||||
|
return
|
||||||
|
ox = int(event.x / self._scale)
|
||||||
|
oy = int(event.y / self._scale)
|
||||||
|
|
||||||
|
# draw marker
|
||||||
|
r = 8
|
||||||
|
mk = self._canvas.create_oval(
|
||||||
|
event.x - r, event.y - r, event.x + r, event.y + r,
|
||||||
|
outline="#ff3838", width=3,
|
||||||
|
)
|
||||||
|
self._markers.append(mk)
|
||||||
|
|
||||||
|
if name == "dot_roi_tl":
|
||||||
|
self._tmp["dot_tl"] = (ox, oy)
|
||||||
|
elif name == "dot_roi_br":
|
||||||
|
x0, y0 = self._tmp["dot_tl"]
|
||||||
|
self._data["dot_roi"] = {
|
||||||
|
"x": min(ox, x0), "y": min(oy, y0),
|
||||||
|
"w": abs(ox - x0), "h": abs(oy - y0),
|
||||||
|
}
|
||||||
|
elif name.startswith("color_"):
|
||||||
|
cname = name.split("_", 1)[1]
|
||||||
|
rgb = self._sample_rgb(ox, oy)
|
||||||
|
tol = 15.0 if cname == "background" else 30.0
|
||||||
|
self._data["colors"][cname] = {"rgb": list(rgb), "tolerance": tol}
|
||||||
|
elif name == "chart_roi_tl":
|
||||||
|
self._tmp["chart_tl"] = (ox, oy)
|
||||||
|
elif name == "chart_roi_br":
|
||||||
|
x0, y0 = self._tmp["chart_tl"]
|
||||||
|
self._data["chart_roi"] = {
|
||||||
|
"x": min(ox, x0), "y": min(oy, y0),
|
||||||
|
"w": abs(ox - x0), "h": abs(oy - y0),
|
||||||
|
}
|
||||||
|
elif name in ("y1", "y2"):
|
||||||
|
from tkinter import simpledialog
|
||||||
|
price = simpledialog.askfloat(
|
||||||
|
"Price", f"Enter the price at this y-coordinate (pixel y={oy}):",
|
||||||
|
parent=self._root,
|
||||||
|
)
|
||||||
|
if price is None:
|
||||||
|
self._canvas.delete(self._markers.pop())
|
||||||
|
return
|
||||||
|
if name == "y1":
|
||||||
|
self._data["y_axis"]["p1_y"] = oy
|
||||||
|
self._data["y_axis"]["p1_price"] = price
|
||||||
|
else:
|
||||||
|
if oy == self._data["y_axis"].get("p1_y"):
|
||||||
|
from tkinter import messagebox
|
||||||
|
messagebox.showerror("Invalid", "Reference 2 must have a DIFFERENT y-coordinate.")
|
||||||
|
self._canvas.delete(self._markers.pop())
|
||||||
|
return
|
||||||
|
self._data["y_axis"]["p2_y"] = oy
|
||||||
|
self._data["y_axis"]["p2_price"] = price
|
||||||
|
elif name == "canary_tl":
|
||||||
|
self._tmp["canary_tl"] = (ox, oy)
|
||||||
|
elif name == "canary_br":
|
||||||
|
x0, y0 = self._tmp["canary_tl"]
|
||||||
|
self._data["canary_roi"] = {
|
||||||
|
"x": min(ox, x0), "y": min(oy, y0),
|
||||||
|
"w": abs(ox - x0), "h": abs(oy - y0),
|
||||||
}
|
}
|
||||||
|
|
||||||
root.destroy()
|
self._advance()
|
||||||
return write_config(data, configs_dir)
|
|
||||||
|
# -------- helpers --------
|
||||||
|
def _sample_rgb(self, x: int, y: int) -> tuple[int, int, int]:
|
||||||
|
"""Average RGB of a 7x7 patch centered on (x,y) in the original image."""
|
||||||
|
box = (max(0, x - 3), max(0, y - 3), x + 4, y + 4)
|
||||||
|
pixels = list(self._pil_img.crop(box).getdata())
|
||||||
|
n = len(pixels) or 1
|
||||||
|
r = sum(p[0] for p in pixels) // n
|
||||||
|
g = sum(p[1] for p in pixels) // n
|
||||||
|
b = sum(p[2] for p in pixels) // n
|
||||||
|
return (r, g, b)
|
||||||
|
|
||||||
|
def _save(self) -> None:
|
||||||
|
from tkinter import messagebox
|
||||||
|
missing = [k for k in ("dot_roi", "chart_roi", "canary_roi") if not self._data.get(k)]
|
||||||
|
if missing:
|
||||||
|
messagebox.showerror("Incomplete", f"Missing: {', '.join(missing)}")
|
||||||
|
return
|
||||||
|
if len(self._data["colors"]) < 7:
|
||||||
|
messagebox.showerror("Incomplete", "All dot colours are required (background optional).")
|
||||||
|
return
|
||||||
|
if "p1_y" not in self._data["y_axis"] or "p2_y" not in self._data["y_axis"]:
|
||||||
|
messagebox.showerror("Incomplete", "Both y-axis reference points are required.")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Compute canary baseline phash
|
||||||
|
import numpy as np
|
||||||
|
from .vision import phash, crop_roi
|
||||||
|
from .config import ROI
|
||||||
|
arr = np.array(self._pil_img)[:, :, ::-1] # RGB → BGR
|
||||||
|
roi = ROI(**self._data["canary_roi"])
|
||||||
|
ph = phash(crop_roi(arr, roi))
|
||||||
|
|
||||||
|
out = {
|
||||||
|
"window_title": self._window_title,
|
||||||
|
"dot_roi": self._data["dot_roi"],
|
||||||
|
"chart_roi": self._data["chart_roi"],
|
||||||
|
"colors": self._data["colors"],
|
||||||
|
"y_axis": self._data["y_axis"],
|
||||||
|
"canary": {
|
||||||
|
"roi": self._data["canary_roi"],
|
||||||
|
"baseline_phash": ph,
|
||||||
|
"drift_threshold": 8,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
self._result = out
|
||||||
|
self._root.destroy()
|
||||||
|
|
||||||
|
def run(self) -> dict:
|
||||||
|
self._root.mainloop()
|
||||||
|
if self._result is None:
|
||||||
|
raise ValueError("Calibration cancelled (window closed before save)")
|
||||||
|
return self._result
|
||||||
|
|||||||
@@ -30,7 +30,11 @@ def main(argv=None) -> None:
|
|||||||
sub.required = True
|
sub.required = True
|
||||||
|
|
||||||
# calibrate
|
# calibrate
|
||||||
sub.add_parser("calibrate", help="Launch guided calibration wizard (Tk)")
|
p_cal = sub.add_parser("calibrate", help="Launch guided calibration wizard (Tk)")
|
||||||
|
p_cal.add_argument(
|
||||||
|
"--screenshot", type=Path, default=None, metavar="PATH",
|
||||||
|
help="Use a saved screenshot instead of live window capture (dev/testing)",
|
||||||
|
)
|
||||||
|
|
||||||
# label
|
# label
|
||||||
p_label = sub.add_parser("label", help="Label dot-colour samples (Tk)")
|
p_label = sub.add_parser("label", help="Label dot-colour samples (Tk)")
|
||||||
@@ -91,7 +95,7 @@ def _cmd_calibrate(args) -> None:
|
|||||||
from atm.calibrate import run_calibration
|
from atm.calibrate import run_calibration
|
||||||
except ImportError as exc:
|
except ImportError as exc:
|
||||||
sys.exit(f"calibrate module not available: {exc}")
|
sys.exit(f"calibrate module not available: {exc}")
|
||||||
run_calibration(Path("configs"))
|
run_calibration(Path("configs"), screenshot=args.screenshot)
|
||||||
|
|
||||||
|
|
||||||
def _cmd_label(args) -> None:
|
def _cmd_label(args) -> None:
|
||||||
|
|||||||
Reference in New Issue
Block a user