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
13 changes: 13 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,19 @@ actually care about. Set `render.qr_base_url` to a short redirector and encode a
a full URL. The table is pinned in `tests/test_scannability.py`, so if layout changes the
test fails and this table gets revisited.

A scheme in the base (`https://sngn.top/i/` rather than `SNGN.TOP/I/`) is worth having,
since phones then offer to open the code instead of showing it as text. It costs one QR
version, though, and in the 12mm presets the QR shares its row with the text. Pay for it
with `render.qr_quiet_zone`:

| Base on the `part` preset, 12mm | quiet zone 4 | 3 | 2 |
|---|:--:|:--:|:--:|
| `SNGN.TOP/I/` | ✅ | ✅ | ✅ |
| `https://sngn.top/i/` | ❌ | ❌ | ✅ |

That is the degraded read again. At 2 the prefixed code is back to 3px per module and
physically larger than the bare one at 4. Pinned in the same test file.

## Layout

| Package | What |
Expand Down
4 changes: 4 additions & 0 deletions deploy/agent.toml.example
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,10 @@ separator_mm = 2.0
[render]
# A short redirector buys back QR module size; encode a token, not a full URL.
qr_base_url = ""
# QR quiet zone in modules; the spec asks for 4. A base with a scheme
# (https://...) makes the code one version bigger, and on 12mm tape that only
# survives a phone-camera read at 2. Leave it at 4 for a bare base.
qr_quiet_zone = 4
threshold = 128

[spool]
Expand Down
6 changes: 6 additions & 0 deletions src/labelfab/agent/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,12 @@ class StripSection(BaseModel):

class RenderSection(BaseModel):
qr_base_url: str = ""
#: QR quiet zone in modules. The spec asks for 4, and 4 is right for a bare code.
#: A base URL with a scheme (``https://...``) is what lowering it pays for: that
#: payload is one QR version larger, and in the 12mm presets the QR shares its row
#: with the text, so at 4 it drops to 2px per module. 3 still lands on 2px; 2 brings
#: it back to 3px, with the code physically larger than a bare one at 4.
qr_quiet_zone: int = Field(default=4, ge=1, le=8)
threshold: int = Field(default=128, ge=0, le=255)


Expand Down
1 change: 1 addition & 0 deletions src/labelfab/agent/worker.py
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,7 @@ def __init__(
def _render_cfg(self) -> RenderConfig:
return RenderConfig(
qr_base_url=self.config.render.qr_base_url,
qr_quiet_zone=self.config.render.qr_quiet_zone,
threshold=self.config.render.threshold,
rotation=self.config.tape.rotation,
mirror=self.config.tape.mirror,
Expand Down
9 changes: 9 additions & 0 deletions src/labelfab/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ def _load_job(path: str) -> PrintJob:
def _config(args) -> RenderConfig:
return RenderConfig(
qr_base_url=args.qr_base_url,
qr_quiet_zone=args.qr_quiet_zone,
threshold=args.threshold,
rotation=args.rotation,
mirror=args.mirror,
Expand Down Expand Up @@ -277,6 +278,14 @@ def cmd_probe(args) -> int:
def _add_render_flags(p: argparse.ArgumentParser) -> None:
p.add_argument("job", help="path to a job JSON file, or - for stdin")
p.add_argument("--qr-base-url", default="", help="prefix codes with a short-link base")
p.add_argument(
"--qr-quiet-zone",
type=int,
default=4,
choices=range(1, 9),
metavar="{1..8}",
help="QR quiet zone in modules (spec: 4)",
)
p.add_argument("--threshold", type=int, default=128, help="1-bit cutoff (0-255)")
p.add_argument("--rotation", type=int, default=270, choices=[0, 90, 180, 270])
p.add_argument("--mirror", action="store_true")
Expand Down
3 changes: 2 additions & 1 deletion src/labelfab/render/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ class RenderConfig:
"""Render-side knobs. Everything here is hardware- or taste-dependent."""

qr_base_url: str = ""
qr_quiet_zone: int = 4
threshold: int = 128
rotation: int = 270
mirror: bool = False
Expand All @@ -51,7 +52,7 @@ class RenderConfig:

def _tree(label: LabelSpec, cfg: RenderConfig) -> Box:
if label.preset is not None:
ctx = _presets.PresetContext(qr_base_url=cfg.qr_base_url)
ctx = _presets.PresetContext(qr_base_url=cfg.qr_base_url, qr_quiet_zone=cfg.qr_quiet_zone)
return _presets.get(label.preset)(label.vars, ctx)
children = list(label.elements or [])
if len(children) == 1 and isinstance(children[0], Box):
Expand Down
7 changes: 4 additions & 3 deletions src/labelfab/render/presets.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,9 @@
class PresetContext:
"""Render-side configuration a preset may consult."""

def __init__(self, qr_base_url: str = "") -> None:
def __init__(self, qr_base_url: str = "", qr_quiet_zone: int = 4) -> None:
self.qr_base_url = qr_base_url
self.qr_quiet_zone = qr_quiet_zone

def qr_value(self, code: str) -> str:
"""Prefer a short redirector: QR module size, not capacity, is the limit.
Expand Down Expand Up @@ -54,7 +55,7 @@ def qr_caption(v: Mapping[str, str], ctx: PresetContext) -> Box:
direction="row",
gap_mm=1.0,
children=[
QrElement(value=ctx.qr_value(_g(v, "code", "pk"))),
QrElement(value=ctx.qr_value(_g(v, "code", "pk")), quiet_zone=ctx.qr_quiet_zone),
Box(
direction="col",
align="start",
Expand Down Expand Up @@ -115,7 +116,7 @@ def location(v: Mapping[str, str], ctx: PresetContext) -> Box:
),
],
),
QrElement(value=ctx.qr_value(_g(v, "code", "pk"))),
QrElement(value=ctx.qr_value(_g(v, "code", "pk")), quiet_zone=ctx.qr_quiet_zone),
],
)

Expand Down
25 changes: 25 additions & 0 deletions tests/test_agent_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -64,3 +64,28 @@ def test_an_unknown_density_is_rejected_at_load():

with pytest.raises(ValidationError, match="not one of"):
DeviceSection(density=3)


def test_qr_quiet_zone_defaults_to_the_spec_and_reaches_the_renderer(tmp_path, harness):
"""A value that stopped at the config section would still print at 4, so check it
arrives in the RenderConfig the worker hands to the renderer."""
assert Config().render.qr_quiet_zone == 4

toml = tmp_path / "agent.toml"
toml.write_text("[render]\nqr_quiet_zone = 2\n")
assert load(toml).render.qr_quiet_zone == 2

h = harness()
h.config.render.qr_quiet_zone = 2
assert h.worker._render_cfg().qr_quiet_zone == 2


def test_a_quiet_zone_outside_the_contract_is_rejected_at_load():
import pytest
from pydantic import ValidationError

from labelfab.agent.config import RenderSection

for bad in (0, 9):
with pytest.raises(ValidationError):
RenderSection(qr_quiet_zone=bad)
9 changes: 9 additions & 0 deletions tests/test_render.py
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,15 @@ def test_qr_base_url_is_applied_by_presets():
assert plain.tobytes() != prefixed.tobytes()


def test_qr_quiet_zone_is_applied_by_presets():
spec = LabelSpec(preset="part", vars={"code": "PA39", "title": "t"}, length_mm=30)
spec_default = render_label(spec, TAPE_12, RenderConfig(qr_base_url="https://sngn.top/i/"))
tighter = render_label(
spec, TAPE_12, RenderConfig(qr_base_url="https://sngn.top/i/", qr_quiet_zone=2)
)
assert spec_default.tobytes() != tighter.tobytes()


# --------------------------------------------------------------------------- #
# Barcode
# --------------------------------------------------------------------------- #
Expand Down
26 changes: 26 additions & 0 deletions tests/test_scannability.py
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,32 @@ def test_qr_still_decodes_at_phone_camera_resolution(payload):
]


#: A scheme in the short-link base is paid for in quiet zone. Measured on the part
#: preset at 12mm, where the QR shares its row with the text: the prefixed payload is
#: one QR version bigger, so at the spec's 4 modules it drops to 2px per module and
#: fails a degraded read. 3 does not help. 2 brings it back to 3px. The bare base
#: survives at any setting, which is why 4 stays the default.
PRESET_QUIET_ZONE = [
("SNGN.TOP/I/", 4, True),
("https://sngn.top/i/", 4, False),
("https://sngn.top/i/", 3, False),
("https://sngn.top/i/", 2, True),
]


@pytest.mark.parametrize(("base", "quiet_zone", "survives_degraded"), PRESET_QUIET_ZONE)
def test_a_scheme_in_the_base_costs_two_quiet_zone_modules(base, quiet_zone, survives_degraded):
cfg = RenderConfig(qr_base_url=base, qr_quiet_zone=quiet_zone)
label = LabelSpec(
preset="part",
vars={"code": "PA39", "title": "INV-PA39 · A1533", "sub": "iPhone 5s"},
length_mm=30,
)
img = _through_the_wire(label, TAPE_12, cfg)
assert _decode(img) == f"{base}PA39", "should always decode at 1:1"
assert (_decode(_as_phone_camera(img)) == f"{base}PA39") is survives_degraded


@pytest.mark.parametrize(("tape_mm", "payload", "survives_degraded"), DENSITY_CLIFF)
def test_density_cliff_is_where_we_think_it_is(tape_mm, payload, survives_degraded):
"""Pin the payload lengths that survive a degraded scan on each tape width.
Expand Down
Loading