﻿#!/usr/bin/env python3
"""On-screen camera calibration: ChArUco board on a second monitor, live capture,
KB4 (Kannala-Brandt 'kb4') + pinhole-radtan calibration.

Pick which monitor shows the pattern with --screen; its geometry (resolution,
position, physical size) is read automatically (Win32 API on Windows, `xrandr`
on Linux/X11). List screens first:

    python3 calibrate_camera_windows.py --list-screens
    python3 calibrate_camera_windows.py --screen HDMI-0 --device /dev/video0 --width 1280 --height 800

On Windows, cameras are opened by index instead of a device path:

    python calibrate_camera_windows.py --screen 1 --device 0 --width 1280 --height 800

If screen detection is unavailable (headless/Wayland) or reports the wrong
physical size, set the pattern-monitor geometry manually (overrides --screen):

    python3 calibrate_camera_windows.py \
        --device /dev/video0 --width 1280 --height 800 \
        --monitor-x 1920 --monitor-px-w 2560 --monitor-px-h 1440 --monitor-mm-w 697

Workflow:
  1. The pattern window opens fullscreen on the second monitor; the live camera
     preview opens on the primary monitor.
  2. Point the camera at the pattern from 1.5-2.5 m (lens is focused at infinity;
     farther = sharper corners). Fill different parts of the image, tilt the
     camera up to ~35 deg in all directions, and get the board into the corners
     of the FOV. Samples auto-capture when the view is sharp, well-detected, and
     different from previous samples.
  3. Collect 30+ samples with good coverage, press 'c' to calibrate.

Keys (camera window focused):
  SPACE  force-capture current frame
  c      run calibration with collected samples
  u      drop the last sample
  r      reset all samples
  q/ESC  quit

Re-run calibration offline from saved frames:
    python3 calibrate_camera_windows.py --from-dir calibration_results/<ts>
"""

import argparse
import json
import math
import platform
import re
import subprocess
import sys
import time
from datetime import datetime
from pathlib import Path

import cv2
import numpy as np

IS_WINDOWS = platform.system() == "Windows"

# ---------------------------------------------------------------------------
# Monitors / screen selection
# ---------------------------------------------------------------------------

# default pattern-monitor geometry, used when neither --screen nor --monitor-*
# flags are given (px width, px height, x offset, physical width in mm)
DEFAULT_GEOMETRY = {"monitor_px_w": 2560, "monitor_px_h": 1440,
                    "monitor_x": 1920, "monitor_mm_w": 697.0}

_XRANDR_MON = re.compile(
    r"^\s*(\d+):\s+\+(\*?)(\S+)\s+(\d+)/(\d+)x(\d+)/(\d+)\+(\d+)\+(\d+)")


def _list_monitors_windows():
    """Enumerate monitors via the Win32 API (geometry + physical size in mm)."""
    import ctypes
    import ctypes.wintypes as wt

    user32 = ctypes.windll.user32
    gdi32 = ctypes.windll.gdi32
    # per-monitor DPI aware, so monitor rects are in real pixels
    try:
        ctypes.windll.shcore.SetProcessDpiAwareness(2)
    except (AttributeError, OSError):
        user32.SetProcessDPIAware()

    class MONITORINFOEXW(ctypes.Structure):
        _fields_ = [("cbSize", wt.DWORD),
                    ("rcMonitor", wt.RECT),
                    ("rcWork", wt.RECT),
                    ("dwFlags", wt.DWORD),
                    ("szDevice", wt.WCHAR * 32)]

    HORZSIZE, VERTSIZE = 4, 6  # GetDeviceCaps: physical size in mm
    monitors = []

    MonitorEnumProc = ctypes.WINFUNCTYPE(
        wt.BOOL, wt.HMONITOR, wt.HDC, ctypes.POINTER(wt.RECT), wt.LPARAM)

    def cb(hmon, hdc, lprect, lparam):
        info = MONITORINFOEXW()
        info.cbSize = ctypes.sizeof(MONITORINFOEXW)
        if not user32.GetMonitorInfoW(hmon, ctypes.byref(info)):
            return True
        r = info.rcMonitor
        mm_w = mm_h = 0.0
        dc = gdi32.CreateDCW(info.szDevice, None, None, None)
        if dc:
            mm_w = float(gdi32.GetDeviceCaps(dc, HORZSIZE))
            mm_h = float(gdi32.GetDeviceCaps(dc, VERTSIZE))
            gdi32.DeleteDC(dc)
        monitors.append({
            "index": len(monitors), "name": info.szDevice,
            "primary": bool(info.dwFlags & 1),  # MONITORINFOF_PRIMARY
            "px_w": r.right - r.left, "px_h": r.bottom - r.top,
            "mm_w": mm_w, "mm_h": mm_h,
            "x": r.left, "y": r.top,
        })
        return True

    user32.EnumDisplayMonitors(None, None, MonitorEnumProc(cb), 0)
    return monitors


def list_monitors():
    """Monitor dicts via Win32 (Windows) or `xrandr` (X11); [] if unavailable."""
    if IS_WINDOWS:
        return _list_monitors_windows()
    try:
        out = subprocess.run(["xrandr", "--listmonitors"],
                             capture_output=True, text=True, check=True).stdout
    except (FileNotFoundError, subprocess.CalledProcessError):
        return []
    monitors = []
    for line in out.splitlines():
        m = _XRANDR_MON.match(line)
        if not m:
            continue
        idx, primary, name, pw, mw, ph, mh, x, y = m.groups()
        monitors.append({
            "index": int(idx), "name": name, "primary": bool(primary),
            "px_w": int(pw), "px_h": int(ph),
            "mm_w": float(mw), "mm_h": float(mh),
            "x": int(x), "y": int(y),
        })
    return monitors


def resolve_screen(token, monitors):
    """Find a monitor by xrandr index or output name (exact, then substring)."""
    if token.isdigit():
        for m in monitors:
            if m["index"] == int(token):
                return m
    for m in monitors:
        if m["name"] == token:
            return m
    for m in monitors:
        if token.lower() in m["name"].lower():
            return m
    return None


def describe_monitor(m):
    star = " *primary" if m["primary"] else ""
    return (f"{m['index']}: {m['name']:<12s} {m['px_w']}x{m['px_h']} "
            f"@ +{m['x']}+{m['y']}  {m['mm_w']:.0f}x{m['mm_h']:.0f} mm{star}")


def resolve_geometry(args):
    """Fill args.monitor_* from --screen (xrandr) and/or explicit overrides."""
    monitors = list_monitors()
    geo = dict(DEFAULT_GEOMETRY)

    if args.screen is not None:
        if not monitors:
            sys.exit("--screen requested but no monitors detected; "
                     "pass --monitor-x/-px-w/-px-h/-mm-w manually instead")
        mon = resolve_screen(args.screen, monitors)
        if mon is None:
            avail = ", ".join(f"{m['index']}:{m['name']}" for m in monitors)
            sys.exit(f"screen '{args.screen}' not found; available: {avail}")
        if mon["mm_w"] <= 0 and args.monitor_mm_w is None:
            sys.exit(f"screen '{mon['name']}' reports 0 mm physical width; "
                     "pass --monitor-mm-w <mm> explicitly")
        geo.update(monitor_x=mon["x"], monitor_px_w=mon["px_w"],
                   monitor_px_h=mon["px_h"], monitor_mm_w=float(mon["mm_w"]))
        print(f"using screen {mon['index']}:{mon['name']} {mon['px_w']}x{mon['px_h']} "
              f"@ +{mon['x']} ({mon['mm_w']:.0f} mm wide)")
    elif len(monitors) > 1:
        print("tip: multiple screens detected; pick one with --list-screens / --screen")

    # explicit CLI flags win over both defaults and --screen
    for key in ("monitor_x", "monitor_px_w", "monitor_px_h", "monitor_mm_w"):
        override = getattr(args, key)
        if override is not None:
            geo[key] = override
        setattr(args, key, geo[key])


# ---------------------------------------------------------------------------
# Board / pattern
# ---------------------------------------------------------------------------


def make_board(args):
    dictionary = cv2.aruco.getPredefinedDictionary(cv2.aruco.DICT_4X4_100)
    px_per_mm = args.monitor_px_w / args.monitor_mm_w
    margin_px = 60
    square_px = min((args.monitor_px_w - 2 * margin_px) // args.squares_x,
                    (args.monitor_px_h - 2 * margin_px) // args.squares_y)
    square_mm = square_px / px_per_mm
    square_m = square_mm * 1e-3
    board = cv2.aruco.CharucoBoard((args.squares_x, args.squares_y),
                                   square_m, square_m * 0.75, dictionary)
    img_w = square_px * args.squares_x + 2 * margin_px
    img_h = square_px * args.squares_y + 2 * margin_px
    board_img = board.generateImage((img_w, img_h), marginSize=margin_px)
    canvas = np.full((args.monitor_px_h, args.monitor_px_w), 255, np.uint8)
    y0 = (args.monitor_px_h - img_h) // 2
    x0 = (args.monitor_px_w - img_w) // 2
    canvas[y0:y0 + img_h, x0:x0 + img_w] = board_img
    print(f"board: {args.squares_x}x{args.squares_y} squares, "
          f"{square_px}px = {square_mm:.2f}mm per square")
    return board, canvas


# ---------------------------------------------------------------------------
# Camera
# ---------------------------------------------------------------------------


def open_camera(args):
    if IS_WINDOWS:
        # accept an index ("0"), or a /dev/videoN path for cross-platform docs
        m = re.match(r"/dev/video(\d+)$", str(args.device))
        index = int(m.group(1)) if m else (
            int(args.device) if str(args.device).isdigit() else 0)
        cap = cv2.VideoCapture(index, cv2.CAP_DSHOW)
        if not cap.isOpened():
            cap = cv2.VideoCapture(index, cv2.CAP_MSMF)
    else:
        cap = cv2.VideoCapture(args.device, cv2.CAP_V4L2)
    if not cap.isOpened():
        sys.exit(f"cannot open {args.device}")
    for fourcc in (args.fourcc, "MJPG", "GREY", "YUYV"):
        if not fourcc:
            continue
        cap.set(cv2.CAP_PROP_FOURCC, cv2.VideoWriter_fourcc(*fourcc))
        cap.set(cv2.CAP_PROP_FRAME_WIDTH, args.width)
        cap.set(cv2.CAP_PROP_FRAME_HEIGHT, args.height)
        ok, frame = cap.read()
        if ok and frame is not None:
            w = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH))
            h = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT))
            print(f"camera: {args.device} {w}x{h} fourcc={fourcc} "
                  f"fps={cap.get(cv2.CAP_PROP_FPS):.0f}")
            if (w, h) != (args.width, args.height):
                print(f"WARNING: negotiated {w}x{h} != requested "
                      f"{args.width}x{args.height} - calibration applies to {w}x{h}")
            return cap
    sys.exit("camera opened but no frames received with any pixel format")


def to_gray(frame):
    return frame if frame.ndim == 2 else cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY)


# ---------------------------------------------------------------------------
# Calibration
# ---------------------------------------------------------------------------


def calibrate(samples, image_size, board):
    """samples: list of (charuco_corners Nx1x2 float32, charuco_ids Nx1 int32)."""
    all_obj = board.getChessboardCorners().astype(np.float64)  # (N,3)
    obj_pts, img_pts = [], []
    for corners, ids in samples:
        ids = ids.ravel()
        if len(ids) < 8:
            continue
        obj_pts.append(all_obj[ids].reshape(-1, 1, 3))
        img_pts.append(corners.astype(np.float64).reshape(-1, 1, 2))
    if len(obj_pts) < 6:
        print(f"not enough usable views ({len(obj_pts)}), need >= 6")
        return None

    out = {"views_used": len(obj_pts), "image_size": list(image_size)}

    # --- pinhole + radtan (robust reference) ---
    rms, K, dist, _, _ = cv2.calibrateCamera(
        [o.astype(np.float32) for o in obj_pts],
        [i.astype(np.float32) for i in img_pts],
        image_size, None, None)
    out["pinhole_radtan"] = {
        "rms_px": float(rms),
        "fx": float(K[0, 0]), "fy": float(K[1, 1]),
        "cx": float(K[0, 2]), "cy": float(K[1, 2]),
        "dist": [float(d) for d in dist.ravel()],
    }

    # --- KB4 fisheye with bad-view pruning ---
    flags = (cv2.fisheye.CALIB_RECOMPUTE_EXTRINSIC |
             cv2.fisheye.CALIB_FIX_SKEW |
             cv2.fisheye.CALIB_CHECK_COND)
    obj_f = [o.reshape(1, -1, 3) for o in obj_pts]
    img_f = [i.reshape(1, -1, 2) for i in img_pts]
    kb4 = None
    for _ in range(15):
        if len(obj_f) < 6:
            break
        try:
            Kf = np.eye(3)
            Df = np.zeros((4, 1))
            rms_f, Kf, Df, _, _ = cv2.fisheye.calibrate(
                obj_f, img_f, image_size, Kf, Df, flags=flags,
                criteria=(cv2.TERM_CRITERIA_EPS + cv2.TERM_CRITERIA_MAX_ITER, 200, 1e-8))
            ks = np.asarray(Df).ravel()
            kb4 = {
                "rms_px": float(rms_f),
                "fx": float(Kf[0, 0]), "fy": float(Kf[1, 1]),
                "cx": float(Kf[0, 2]), "cy": float(Kf[1, 2]),
                "k1": float(ks[0]), "k2": float(ks[1]),
                "k3": float(ks[2]), "k4": float(ks[3]),
                "views_used": len(obj_f),
            }
            break
        except cv2.error as e:
            msg = str(e)
            idx = None
            if "input array " in msg:  # CALIB_CHECK_COND names the offending view
                try:
                    idx = int(msg.split("input array ")[1].split()[0])
                except (ValueError, IndexError):
                    idx = None
            if idx is None or idx >= len(obj_f):
                # fall back: drop CHECK_COND and accept the fit
                flags &= ~cv2.fisheye.CALIB_CHECK_COND
                continue
            del obj_f[idx], img_f[idx]
    out["kb4"] = kb4
    return out


def report(result):
    print("\n" + "=" * 64)
    ph = result["pinhole_radtan"]
    w, h = result["image_size"]
    print(f"views: {result['views_used']}  image: {w}x{h}")
    print(f"pinhole+radtan: rms={ph['rms_px']:.3f}px  "
          f"fx={ph['fx']:.2f} fy={ph['fy']:.2f} cx={ph['cx']:.1f} cy={ph['cy']:.1f}")
    if result.get("kb4"):
        kb = result["kb4"]
        print(f"kb4:            rms={kb['rms_px']:.3f}px  "
              f"fx={kb['fx']:.2f} fy={kb['fy']:.2f} cx={kb['cx']:.1f} cy={kb['cy']:.1f}")
        print(f"                k1={kb['k1']:.6f} k2={kb['k2']:.6f} "
              f"k3={kb['k3']:.6f} k4={kb['k4']:.6f}")
        fx = kb["fx"]
    else:
        print("kb4 fit failed - using pinhole fx for HFOV estimate")
        fx = ph["fx"]
    hfov = 2 * math.degrees(math.atan((w / 2) / fx))
    print(f"HFOV ~= {hfov:.1f} deg")
    print("=" * 64)


def save_results(out_dir, result, board_meta):
    out_dir.mkdir(parents=True, exist_ok=True)
    result = dict(result, board=board_meta,
                  timestamp=datetime.now().isoformat(timespec="seconds"))
    with open(out_dir / "calibration.json", "w") as f:
        json.dump(result, f, indent=2)
    if result.get("kb4"):
        kb = result["kb4"]
        w, h = result["image_size"]
        yaml_snippet = (
            "# KB4 intrinsics measured with calibrate_camera_onscreen.py\n"
            f"# {result['timestamp']}  rms={kb['rms_px']:.3f}px  views={kb['views_used']}\n"
            "intrinsics:\n"
            f"  fx: {kb['fx']:.4f}\n"
            f"  fy: {kb['fy']:.4f}\n"
            f"  cx: {kb['cx']:.4f}\n"
            f"  cy: {kb['cy']:.4f}\n"
            f"  k1: {kb['k1']:.8f}\n"
            f"  k2: {kb['k2']:.8f}\n"
            f"  k3: {kb['k3']:.8f}\n"
            f"  k4: {kb['k4']:.8f}\n"
            "resolution:\n"
            f"  width: {w}\n"
            f"  height: {h}\n")
        (out_dir / "camera_params_snippet.yaml").write_text(yaml_snippet)
    print(f"saved -> {out_dir}/calibration.json")


# ---------------------------------------------------------------------------
# Live capture loop
# ---------------------------------------------------------------------------

GRID_X, GRID_Y = 6, 4


def coverage_cells(corners, w, h):
    cells = set()
    for c in corners.reshape(-1, 2):
        cells.add((min(GRID_X - 1, int(c[0] / w * GRID_X)),
                   min(GRID_Y - 1, int(c[1] / h * GRID_Y))))
    return cells


def run_live(args):
    board, pattern_img = make_board(args)
    detector = cv2.aruco.CharucoDetector(board)

    # order matters for most window managers: show, pump events, move onto the
    # target monitor, pump events, then go fullscreen
    cv2.namedWindow("CALIBRATION PATTERN", cv2.WINDOW_NORMAL)
    cv2.imshow("CALIBRATION PATTERN", pattern_img)
    cv2.waitKey(100)
    cv2.moveWindow("CALIBRATION PATTERN", args.monitor_x + 100, 100)
    cv2.waitKey(100)
    cv2.setWindowProperty("CALIBRATION PATTERN", cv2.WND_PROP_FULLSCREEN,
                          cv2.WINDOW_FULLSCREEN)
    cv2.waitKey(100)
    cv2.namedWindow("camera", cv2.WINDOW_NORMAL)
    cv2.moveWindow("camera", 100, 100)

    cap = open_camera(args)
    out_dir = (Path(__file__).parent / "calibration_results" /
               datetime.now().strftime("%Y%m%d_%H%M%S"))
    frames_dir = out_dir / "frames"
    frames_dir.mkdir(parents=True, exist_ok=True)

    samples = []          # (charuco_corners, charuco_ids)
    sample_centroids = []
    covered = set()
    last_capture_t = 0.0
    last_result = None
    image_size = None
    board_meta = {"squares_x": args.squares_x, "squares_y": args.squares_y}

    print("\ncollect 30+ samples (tilt the camera, cover all FOV regions), "
          "then press 'c'\n")
    while True:
        ok, frame = cap.read()
        if not ok:
            time.sleep(0.05)
            continue
        gray = to_gray(frame)
        h, w = gray.shape
        image_size = (w, h)
        vis = cv2.cvtColor(gray, cv2.COLOR_GRAY2BGR)

        ch_corners, ch_ids, mk_corners, _ = detector.detectBoard(gray)
        n_det = 0 if ch_ids is None else len(ch_ids)
        sharp = cv2.Laplacian(gray, cv2.CV_64F).var()

        auto_ok = False
        if n_det >= args.min_corners and sharp >= args.min_sharpness:
            cv2.aruco.drawDetectedCornersCharuco(vis, ch_corners, ch_ids,
                                                 (0, 255, 0))
            centroid = ch_corners.reshape(-1, 2).mean(axis=0)
            moved = (not sample_centroids or
                     min(np.linalg.norm(centroid - c) for c in sample_centroids)
                     > args.min_move_px)
            if moved and time.time() - last_capture_t > 0.4:
                auto_ok = True

        key = cv2.waitKey(1) & 0xFF
        force = key == ord(' ')
        if (auto_ok or (force and n_det >= 8)):
            samples.append((ch_corners.copy(), ch_ids.copy()))
            sample_centroids.append(ch_corners.reshape(-1, 2).mean(axis=0))
            covered |= coverage_cells(ch_corners, w, h)
            last_capture_t = time.time()
            cv2.imwrite(str(frames_dir / f"{len(samples):03d}.png"), gray)
            print(f"sample {len(samples):3d}: corners={n_det} sharp={sharp:.0f} "
                  f"coverage={len(covered)}/{GRID_X*GRID_Y}")

        # HUD
        for gx in range(GRID_X):
            for gy in range(GRID_Y):
                color = (0, 160, 0) if (gx, gy) in covered else (0, 0, 180)
                cv2.rectangle(vis, (int(gx * w / GRID_X), int(gy * h / GRID_Y)),
                              (int((gx + 1) * w / GRID_X) - 2,
                               int((gy + 1) * h / GRID_Y) - 2), color, 1)
        flash = (time.time() - last_capture_t) < 0.15
        hud = [
            f"samples {len(samples)}  coverage {len(covered)}/{GRID_X*GRID_Y}  "
            f"corners {n_det}  sharp {sharp:.0f}"
            + ("  [CAPTURE]" if flash else ""),
            "SPACE capture | c calibrate | u undo | r reset | q quit",
        ]
        if last_result and last_result.get("kb4"):
            kb = last_result["kb4"]
            hud.append(f"kb4 fx={kb['fx']:.1f} fy={kb['fy']:.1f} "
                       f"rms={kb['rms_px']:.2f}px")
        for i, line in enumerate(hud):
            cv2.putText(vis, line, (10, 28 + 26 * i), cv2.FONT_HERSHEY_SIMPLEX,
                        0.62, (0, 0, 0), 3, cv2.LINE_AA)
            cv2.putText(vis, line, (10, 28 + 26 * i), cv2.FONT_HERSHEY_SIMPLEX,
                        0.62, (60, 220, 255), 1, cv2.LINE_AA)
        cv2.imshow("camera", vis)

        if key in (ord('q'), 27):
            break
        elif key == ord('u') and samples:
            samples.pop()
            sample_centroids.pop()
            print(f"dropped last sample ({len(samples)} left)")
        elif key == ord('r'):
            samples.clear()
            sample_centroids.clear()
            covered.clear()
            print("samples reset")
        elif key == ord('c') and samples:
            print(f"\ncalibrating with {len(samples)} samples...")
            result = calibrate(samples, image_size, board)
            if result:
                report(result)
                save_results(out_dir, result, board_meta)
                last_result = result

    cap.release()
    cv2.destroyAllWindows()


def run_from_dir(args):
    board, _ = make_board(args)
    detector = cv2.aruco.CharucoDetector(board)
    frames = sorted(Path(args.from_dir).glob("frames/*.png"))
    if not frames:
        sys.exit(f"no frames in {args.from_dir}/frames")
    samples, image_size = [], None
    for p in frames:
        gray = cv2.imread(str(p), cv2.IMREAD_GRAYSCALE)
        image_size = (gray.shape[1], gray.shape[0])
        ch_corners, ch_ids, _, _ = detector.detectBoard(gray)
        if ch_ids is not None and len(ch_ids) >= 8:
            samples.append((ch_corners, ch_ids))
    print(f"re-detected {len(samples)}/{len(frames)} frames")
    result = calibrate(samples, image_size, board)
    if result:
        report(result)
        save_results(Path(args.from_dir), result,
                     {"squares_x": args.squares_x, "squares_y": args.squares_y})


def main():
    p = argparse.ArgumentParser(description=__doc__,
                                formatter_class=argparse.RawDescriptionHelpFormatter)
    p.add_argument("--device", default="0" if IS_WINDOWS else "/dev/video0",
                   help="camera: index on Windows (e.g. 0), device path on Linux")
    p.add_argument("--width", type=int, default=1280)
    p.add_argument("--height", type=int, default=800)
    p.add_argument("--fourcc", default="")
    # pattern monitor: pick by --screen (auto-read from xrandr), or set geometry
    # manually with the --monitor-* overrides below
    p.add_argument("--screen", default=None,
                   help="monitor to show the pattern on, by xrandr index or name "
                        "(e.g. --screen 1 or --screen HDMI-0); fills geometry")
    p.add_argument("--list-screens", action="store_true",
                   help="list monitors detected via xrandr and exit")
    p.add_argument("--monitor-x", type=int, default=None,
                   help="override: x offset of the pattern monitor (px)")
    p.add_argument("--monitor-px-w", type=int, default=None,
                   help="override: pattern monitor width (px)")
    p.add_argument("--monitor-px-h", type=int, default=None,
                   help="override: pattern monitor height (px)")
    p.add_argument("--monitor-mm-w", type=float, default=None,
                   help="override: physical width of the pattern monitor (mm)")
    p.add_argument("--squares-x", type=int, default=10)
    p.add_argument("--squares-y", type=int, default=7)
    p.add_argument("--min-corners", type=int, default=15,
                   help="min charuco corners for auto-capture")
    p.add_argument("--min-sharpness", type=float, default=60.0,
                   help="min Laplacian variance for auto-capture")
    p.add_argument("--min-move-px", type=float, default=40.0,
                   help="min board centroid movement between auto-captures")
    p.add_argument("--from-dir", default=None,
                   help="recalibrate from saved frames instead of live capture")
    args = p.parse_args()

    if args.list_screens:
        monitors = list_monitors()
        if not monitors:
            sys.exit("no monitors detected (Win32 API on Windows, xrandr/X11 "
                     "elsewhere)")
        print("detected screens (use --screen <index|name>):")
        for m in monitors:
            print("  " + describe_monitor(m))
        return

    resolve_geometry(args)

    if args.from_dir:
        run_from_dir(args)
    else:
        run_live(args)


if __name__ == "__main__":
    main()
