Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,18 @@ Decks are saved in Arcana Land's [Tarot Deck format](https://github.com/arcanala
- Access to a 3D printer to create jigs that will hold cards in your scanner.
- A slab of dark material to place on top of the cards while scanning, such as black polyurethane foam (Optional)

## Usage

```
deckle init my-deck # create a project
deckle rectify scan.png # process a single scan image
deckle assign # name staged masters from labels (dry run)
deckle assign --apply
deckle unassign m2 w7 # back to staging/, labelled, to relabel and assign again
deckle status
deckle emit # write the output deck directory
```

## Technical Details

Detecting a card in an image:
Expand Down
102 changes: 98 additions & 4 deletions src/deckle/assign.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,20 @@
from __future__ import annotations

import os
import re
import shutil
from dataclasses import dataclass
from pathlib import Path
from dataclasses import dataclass, replace
from pathlib import Path, PurePosixPath
from typing import Any

import cv2

from .ids import CardId, check_custom_name, resolve_ref
from .project import CARD_BACKS_DIR, Project
from .staging import provenance_for, read_staging_index, write_staging_index
from .labels import ROTATE_MARK, Label, LabelError, format_label, parse_label, write_label
from .project import CARD_BACKS_DIR, PROJECT_FILE, Project
from .staging import MEASURED_KEYS, provenance_for, read_staging_index, write_staging_index

_SLOT = re.compile(r"r[0-9]+c[0-9]+")


class AssignError(RuntimeError):
Expand Down Expand Up @@ -96,3 +101,92 @@ def assign(
write_staging_index(project, index)

return Assignment(ref=ref_out, source=source, dest=dest, replaced=replaced, rotated=rotate_180)


@dataclass(frozen=True)
class Unassignment:
ref: str
master: Path
restaged: Path
label: str
labelled: bool # False when the filesystem refused the label xattr


@dataclass(frozen=True)
class _Undo:
label: Label
master: Path
restaged: Path
row: dict[str, Any]


def _restaged_name(label: Label, record: dict[str, Any]) -> str:
"""The name rectify gave the file, so a later assign finds its index row again."""
scan, slot = record.get("source_scan"), record.get("slot")
if scan and slot and _SLOT.fullmatch(slot):
return f"{PurePosixPath(scan).stem}_{slot}.png"
return f"{label.target.replace(':', '.')}.png"


def unassign(project: Project, refs: list[str]) -> list[Unassignment]:
"""Put masters back in staging/ as they were before assign: name, index row, label."""
if project.degraded:
raise AssignError(
f"cannot unassign: {project.degraded_reason}. Fix or remove the state file first."
)

back_default = project.doc.get("project", {}).get("card_back_default")
undo: list[_Undo] = []
problems: list[str] = []
for text in refs:
try:
label = parse_label(text, card_back_default=back_default)
except LabelError as exc:
problems.append(str(exc))
continue
if label.rotate_180:
problems.append(f"{text!r}: {ROTATE_MARK} has no meaning here; unassign restores it")
continue
record = project.cards.get(label.target)
if record is None:
problems.append(f"{label.target} has no record in {PROJECT_FILE}")
continue
master = project.root / record["master"] if record.get("master") else None
if master is None or not master.is_file():
problems.append(f"{label.target}: its master {record.get('master')!r} is missing")
continue
rotated = bool(record.get("rotate_180"))
restaged = project.staging_dir / _restaged_name(label, record)
undo.append(_Undo(replace(label, rotate_180=rotated), master, restaged, record))

seen: dict[Path, str] = {}
for u in undo:
if u.restaged.exists():
problems.append(f"{u.label.target}: {u.restaged.name} is already in staging/")
elif u.restaged in seen:
problems.append(
f"{u.label.target} and {seen[u.restaged]} both restage as {u.restaged.name}"
)
seen[u.restaged] = u.label.target
if problems:
raise AssignError("nothing was unassigned:\n " + "\n ".join(problems))

index = read_staging_index(project)
out: list[Unassignment] = []
for u in undo:
_move(u.master, u.restaged, rotate_180=u.label.rotate_180)
text = format_label(u.label)
try:
write_label(u.restaged, text)
labelled = True
except OSError:
labelled = False
if _SLOT.fullmatch(u.row.get("slot") or ""):
index[u.restaged.name] = {
k: u.row[k] for k in ("source_scan", "slot", *MEASURED_KEYS) if k in u.row
}
write_staging_index(project, index)
project.forget_card(u.label.target)
project.save()
out.append(Unassignment(u.label.target, u.master, u.restaged, text, labelled))
return out
96 changes: 92 additions & 4 deletions src/deckle/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,12 @@
import numpy as np

from . import build_id
from .assign import AssignError, assign
from .assign import AssignError, assign, unassign
from .debug import write_bundle
from .detect import Card, CardSpec, DetectionError, detect_report
from .emit import EmitError, emit
from .ids import IdError
from .labels import plan_staging
from .project import Config, Project, ProjectError, find_project, init
from .rectify import rectify_all
from .render import report, status_report
Expand Down Expand Up @@ -230,6 +231,17 @@ def _cmd_status(args) -> int:


def _cmd_assign(args) -> int:
if args.file is None:
stray = [f for f, on in _FILE_ONLY_FLAGS.items() if on(args)]
if stray:
args.usage(f"{', '.join(stray)} need a file; without one, assign reads labels")
return _assign_from_labels(
Project.discover(args.project), apply=args.apply, force=args.force
)
stray = [f for f, on in _LABEL_ONLY_FLAGS.items() if on(args)]
if stray:
args.usage(f"{', '.join(stray)} only apply without a file, when assign reads labels")

project = Project.discover(args.project)
ref = args.id
if ref and args.variant:
Expand All @@ -242,6 +254,65 @@ def _cmd_assign(args) -> int:
return 0


def _cmd_unassign(args) -> int:
project = Project.discover(args.project)
for u in unassign(project, args.refs):
print(f"{u.ref} -> {u.restaged.relative_to(project.root)} (label {u.label})")
if not u.labelled:
print(
f"deckle: warning: could not write the label onto {u.restaged.name}; "
"label it by hand",
file=sys.stderr,
)
return 0


_FILE_ONLY_FLAGS = {
"--variant": lambda a: a.variant is not None,
"--card-back": lambda a: a.card_back is not None,
"--rotate-180": lambda a: a.rotate_180,
}
_LABEL_ONLY_FLAGS = {"--apply": lambda a: a.apply, "--force": lambda a: a.force}


def _assign_from_labels(project: Project, *, apply: bool, force: bool) -> int:
plan = plan_staging(project, force=force)

for row in plan.rows:
print(row.describe(project.root))
for w in plan.warnings:
print(f"warning: {w}")
for p in plan.problems:
print(f"problem: {p}")
print(f"staging: {plan.summary()}")

if plan.problems:
if apply:
print("deckle: nothing was assigned; fix the problems above first", file=sys.stderr)
return 1
if not apply:
return 0

for done, row in enumerate(plan.rows):
try:
assign(
project,
row.source,
row.label.ref,
card_back=row.label.card_back,
rotate_180=row.label.rotate_180,
)
except (AssignError, ProjectError, IdError, OSError) as exc:
print(f"deckle: {row.source.name}: {exc}", file=sys.stderr)
print(
f"deckle: {done} of {len(plan.rows)} assigned before this; run again to continue",
file=sys.stderr,
)
return 1
print(f"assigned {len(plan.rows)} file(s)")
return 0


def _cmd_emit(args) -> int:
result = emit(Project.discover(args.project), args.deck_dir)

Expand Down Expand Up @@ -287,14 +358,31 @@ def _parser() -> argparse.ArgumentParser:
_project_arg(s)
s.set_defaults(func=_cmd_status)

a = sub.add_parser("assign", help="move a staged master into masters/ under a card ID")
a.add_argument("file", type=Path, help="the staged master to assign")
a = sub.add_parser(
"assign",
help="move staged masters into masters/ under card IDs",
description="With a file, assign it to the given card ID. Without one, read every "
"label in staging/, print the plan, and with --apply carry it out.",
)
a.add_argument("file", type=Path, nargs="?", default=None, help="the staged master to assign")
a.add_argument("id", nargs="?", default=None, help="card ID, e.g. minor_arcana.cups.king")
_project_arg(a)
a.add_argument("--variant", default=None, help="variant key, e.g. two_women")
a.add_argument("--card-back", default=None, help="assign as a card back design instead")
a.add_argument("--rotate-180", action="store_true", help="the card was placed upside down")
a.set_defaults(func=_cmd_assign)
a.add_argument("--apply", action="store_true", help="carry out the plan read from labels")
a.add_argument(
"--force", action="store_true", help="let the plan replace masters that already exist"
)
a.set_defaults(func=_cmd_assign, usage=a.error)

u = sub.add_parser(
"unassign",
help="put masters back in staging/, labelled, to relabel and assign again",
)
u.add_argument("refs", nargs="+", metavar="ref", help="a card ID or label, e.g. m2:alt")
_project_arg(u)
u.set_defaults(func=_cmd_unassign)

e = sub.add_parser("emit", help="generate the deck directory from masters/")
_project_arg(e)
Expand Down
Loading
Loading