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 CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,16 @@ versions follow [Semantic Versioning](https://semver.org/).
## [Unreleased]

### Added
- Darkroom marks: a black guide line round the image and registration marks (a crosshair target
on each side, crop marks at the corners), each a checkbox. On negatives they go in the safe edge
in black ink, which shows under a red safelight (red and orange negatives all but vanish there)
and blocks UV like the safe edge, so nothing prints; saved setups remember them. Prints get them
in a 0.25 in white border. The blocking test and process charts offer them too. Command line:
`--guide` and `--registration` on `negative` and `target --kind blocking|chart`.
- Test sheets take a preferred size: the blocking test and process chart page can be enlarged or
shrunk to fit within W x H (turned to match, safe edge included, never past the page's margins);
the patches keep their shape, so scans read as before. Remembered between sessions. Command
line: `openquad target --kind blocking --size 8x10`.
- Tabs: the Mac app is one window with five tabs along the toolbar (⌘1–⌘5): Print, Negative,
Contact Sheet, Printer Calibration and Process Calibration (blocking colour, then process curve).
Each tab keeps its own work, so one printer can be calibrating while another prints; the Print
Expand Down Expand Up @@ -44,6 +54,9 @@ versions follow [Semantic Versioning](https://semver.org/).
it. App: "Outline in black" with its width under the safe edge; command line:
`openquad negative --outline 0.1`.

### Changed
- Stacked process charts sit closer together: 0.1 in of film between them instead of 0.3 in.

## [0.1.0-beta.3] - 2026-10-27

### Fixed
Expand Down
13 changes: 13 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,9 @@ opens on first launch (and later from **Help** or the **?** button).
contact print (carbon, or another UV process).
- **Contact sheets:** the Contact Sheet tab (⌘3) puts up to 16 photos on one sheet, to print or
to make a negative of.
- **Darkroom marks:** the guide line and registration marks (Darkroom marks, in the Print and
Negative tabs and both calibration wizards). Can you see and line up a red or orange negative
under your safelight?
- **Tabs:** Print, Negative, Contact Sheet, Printer Calibration and Process Calibration each keep
their own work. Does moving between them while working feel natural?
- **The guide:** is anything unclear or missing?
Expand Down Expand Up @@ -150,6 +153,16 @@ openquad negative photo.tif -o neg.tif --colour green --fit Letter # 2. a nega
The orientation **F** in the safe edge prints in the carbon print: if it reads backwards, flip
`--mirror`.

Red and orange negatives are hard to see under a red safelight: `--guide` adds a black line round
the image and `--registration` adds targets and corner crop marks, all in black ink in the safe
edge (it blocks UV like the safe edge, so they don't print). Test sheets take `--size WxH` to fit
your tissue:

```bash
openquad negative photo.tif -o neg.tif --colour orange --fit Letter --guide --registration
openquad target --kind blocking --size 5x7 --guide -o blocking.tif
```

Then calibrate the process from a ChartThrob-style chart (after Kevin Bjorke's ChartThrob):

```bash
Expand Down
69 changes: 59 additions & 10 deletions scripts/export_swift_vectors.py
Original file line number Diff line number Diff line change
Expand Up @@ -178,19 +178,37 @@ def negative_vectors() -> dict[str, Any]:

photo = np.tile(np.rint(np.linspace(0, 65535, 12)).astype(np.uint16), (10, 1))
photo[3, 4] = 1234
cases: list[tuple[str, bool, float, bool, float, float]] = [
("black", False, 0.0, True, 100.0, 0.0),
("green", False, 0.2, True, 100.0, 0.0),
("orange", True, 0.2, True, 100.0, 0.0),
("red", False, 0.05, False, 100.0, 0.0),
("green", True, 0.2, True, 100.0, 0.05), # with a black outline
("cyan", False, 0.0, False, 100.0, 0.03), # an outline but no safe edge
cases: list[tuple[str, bool, float, bool, float, float, bool, bool]] = [
("black", False, 0.0, True, 100.0, 0.0, False, False),
("green", False, 0.2, True, 100.0, 0.0, False, False),
("orange", True, 0.2, True, 100.0, 0.0, False, False),
("red", False, 0.05, False, 100.0, 0.0, False, False),
("green", True, 0.2, True, 100.0, 0.05, False, False), # with a black outline
("cyan", False, 0.0, False, 100.0, 0.03, False, False), # an outline but no safe edge
("orange", True, 0.2, True, 100.0, 0.05, True, True), # darkroom marks
("red", False, 0.3, True, 100.0, 0.0, True, False),
("yellow", False, 0.1, False, 100.0, 0.0, False, True),
("orange", False, 0.0, True, 100.0, 0.0, True, True), # marks but no safe edge
]
coverage = np.linspace(0.0, 1.0, 101)
from openquad.marks import add_print_marks, mark_mask

masks = []
for width, height, band, dpi, guide, registration in [
(60, 50, 10, 100.0, True, False), (61, 47, 13, 300.0, False, True),
(200, 160, 40, 300.0, True, True), (30, 30, 2, 300.0, True, True),
(180, 220, 75, 300.0, True, True),
]: # fmt: skip
mask = mark_mask(width, height, band, dpi, guide, registration)
masks.append({"width": width, "height": height, "band": band, "dpi": dpi,
"guide": guide, "registration": registration,
"on": _ints(np.flatnonzero(mask))}) # fmt: skip
printed = add_print_marks(np.stack([photo] * 3, -1), 40.0, True, True)
rendered = []
for colour, mirror, edge, mark, dpi, outline in cases:
for colour, mirror, edge, mark, dpi, outline, guide, registration in cases:
settings = NegativeSettings(colour=colour, mirror=mirror, safe_edge=edge, mark=mark,
outline=outline) # fmt: skip
outline=outline, guide=guide,
registration=registration) # fmt: skip
negative = make_negative(photo, settings, dpi)
rendered.append(
{
Expand All @@ -200,6 +218,8 @@ def negative_vectors() -> dict[str, Any]:
"mark": mark,
"dpi": dpi,
"outline": outline,
"guide": guide,
"registration": registration,
"shape": list(negative.shape),
"rgb": _ints(negative),
}
Expand All @@ -212,6 +232,8 @@ def negative_vectors() -> dict[str, Any]:
},
"photo": {"height": 10, "width": 12, "pixels": _ints(photo)},
"cases": rendered,
"masks": masks,
"print_marks": {"dpi": 40.0, "shape": list(printed.shape), "rgb": _ints(printed)},
}


Expand Down Expand Up @@ -839,6 +861,12 @@ def save(name: str, image: Any, **kwargs: Any) -> None:
{"photo": "photo-grey16.tif", "mode": "negative", "max_size": [4.0, 5.0],
"safe_edge": 0.25, "outline": 0.0625, "units": "mm"},
{"photo": "photo-grey16.tif", "fit": False, "ppi": 7.0, "units": "mm"},
{"photo": "photo-grey16.tif", "fit": False, "ppi": 20.0, "guide": True,
"registration": True},
{"photo": "photo-grey16.tif", "registration": True, "max_size": [3.0, 2.0]},
{"photo": "photo-grey16.tif", "mode": "negative", "fit": False, "ppi": 20.0,
"negative_colour": "orange", "safe_edge": 0.3, "outline": 0.05, "guide": True,
"registration": True},
] # fmt: skip
sessions = []
for config in configurations:
Expand All @@ -859,6 +887,8 @@ def save(name: str, image: Any, **kwargs: Any) -> None:
session.safe_edge = config.get("safe_edge", 0.25)
session.max_size = tuple(config["max_size"]) if "max_size" in config else None
session.outline = config.get("outline", 0.0)
session.guide = config.get("guide", False)
session.registration = config.get("registration", False)
session.set_units(config.get("units", "in"))
session.printer = "Canon_MG3600_series"
rendered = session.render(session._photo().pixels)
Expand Down Expand Up @@ -911,7 +941,23 @@ def wizards_vectors(directory: Path) -> dict[str, Any]:
"chart_photo_compact_2": _digest(chart_photo(ChartLayout.compact(), 50, index=2)),
"chart_negative": _digest(chart_negative("green", green, mirror=True, dpi=50)),
"chart_page": _digest(chart_page(["green", "orange"], [None, darker], dpi=60)),
}
"chart_page_marked": _digest(chart_page(["green", "orange"], [None, darker], True, 60,
0.8, guide=True, registration=True)),
"chart_negative_marked": _digest(chart_negative("orange", None, False, 50 * 1.3,
safe_edge=0.25 / 1.3, guide=True,
registration=True)),
} # fmt: skip
from openquad.blocking import blocking_scale
from openquad.chart import chart_scale, page_scale

scales = {
"blocking": [[*box, blocking_scale(box)] for box in ((8.0, 10.0), (4.0, 6.0), (5.0, 3.0))],
"chart": [[*box, chart_scale(box)] for box in ((8.0, 10.0), (4.0, 4.0))],
"page": [[count, *box, page_scale(count, box)] for count, box in
((1, (8.0, 10.0)), (2, (8.0, 10.0)), (3, (4.0, 6.0)))],
"page_unturned": [[count, *box, page_scale(count, box, turn=False)] for count, box in
((1, (7.5, 10.0)), (2, (10.0, 7.5)))],
} # fmt: skip

assessments = []
for session, prefix in (("2026-10-20", "m4"), ("2026-10-20", "m6"), ("2026-10-22", "m8")):
Expand Down Expand Up @@ -953,6 +999,8 @@ def wizards_vectors(directory: Path) -> dict[str, Any]:
)
mixtures = blocking.BlockingLayout(colours=MIXTURE_COLOURS)
renders["blocking_sheet_mixtures"] = _digest(blocking.blocking_sheet(mixtures, dpi=50))
marked = blocking.blocking_sheet(dpi=50, scale=1.4, guide=True, registration=True)
renders["blocking_sheet_marked"] = _digest(marked)
finally:
blocking._draw_labels = labels
return {
Expand All @@ -961,6 +1009,7 @@ def wizards_vectors(directory: Path) -> dict[str, Any]:
"identity_levels": _ints(identity.levels),
"identity_curves": _ints(identity.curves),
"renders": renders,
"scales": scales,
"assessments": assessments,
"chart_results": results,
}
Expand Down
27 changes: 25 additions & 2 deletions src/openquad/blocking.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
STANDARD_COLOURS,
NegativeSettings,
add_safe_edge,
fit_scale,
ink_to_rgb,
)
from openquad.quadfile import MAX_VALUE
Expand Down Expand Up @@ -113,9 +114,17 @@ def blocking_sheet(
dpi: float = 300.0,
mirror: bool = False,
safe_edge: float = 0.25,
scale: float = 1.0,
guide: bool = False,
registration: bool = False,
) -> NDArray[np.uint16]:
"""The test sheet as a negative: (H, W, 3) uint16 sRGB, clear film = 65535."""
"""The test sheet as a negative: (H, W, 3) uint16 sRGB, clear film = 65535.

`scale` enlarges or shrinks the sheet (not its safe edge) to fit a preferred size;
see `blocking_scale`. `guide` and `registration` add darkroom marks.
"""
layout = layout or BlockingLayout()
dpi, safe_edge = dpi * scale, safe_edge / scale # the same edge in inches
for name in layout.colours:
if name not in NEGATIVE_COLOURS:
raise BlockingError(f"unknown colour {name!r}")
Expand All @@ -134,10 +143,24 @@ def px(box: tuple[float, float, float, float]) -> tuple[slice, slice]:
coverage = np.array([1.0 - step / (STEPS - 1)]) # 100% next to the full patch
sheet[px(layout.step_box(row, step))] = ink_to_rgb(coverage, colour)[0]
_draw_labels(sheet, layout, dpi)
settings = NegativeSettings(mirror=mirror, safe_edge=safe_edge)
settings = NegativeSettings(
mirror=mirror, safe_edge=safe_edge, guide=guide, registration=registration
)
return add_safe_edge(sheet, settings, dpi)


def blocking_scale(
box: tuple[float, float],
layout: BlockingLayout | None = None,
safe_edge: float = 0.25,
turn: bool = True,
) -> float:
"""The `scale` that makes the sheet, safe edge included, fit within `box` inches
(turned to match unless `turn` is False)."""
layout = layout or BlockingLayout()
return fit_scale((layout.width, layout.height), (2 * safe_edge, 2 * safe_edge), box, turn)


def _draw_labels(sheet: NDArray[np.uint16], layout: BlockingLayout, dpi: float) -> None:
"""Colour names in black ink in the label column (they print paper white)."""
height, width = sheet.shape[:2]
Expand Down
36 changes: 32 additions & 4 deletions src/openquad/chart.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@
)
from openquad.colour import linear_rgb_to_lab, srgb_to_linear
from openquad.linearize import target_lightness
from openquad.negative import NEGATIVE_COLOURS, NegativeSettings, make_negative
from openquad.negative import NEGATIVE_COLOURS, NegativeSettings, fit_scale, make_negative
from openquad.quadfile import MAX_VALUE

PATCHES = 101 # 0% .. 100% in 1% steps
Expand Down Expand Up @@ -164,35 +164,63 @@ def chart_negative(
dpi: float = 300.0,
layout: ChartLayout | None = None,
index: int = 0,
safe_edge: float = 0.25,
guide: bool = False,
registration: bool = False,
) -> NDArray[np.uint16]:
"""The chart printed as a negative, through `process` if given (to check or refine it)."""
photo = chart_photo(layout, dpi, index)
if process is not None:
photo = process.apply(photo)
return make_negative(photo, NegativeSettings(colour=colour, mirror=mirror), dpi)
settings = NegativeSettings(
colour=colour, mirror=mirror, safe_edge=safe_edge, guide=guide, registration=registration
)
return make_negative(photo, settings, dpi)


PAGE_GAP = 0.1 # inches of clear film between stacked charts
PAGE_EDGE = 0.25 # each chart's safe edge


PAGE_GAP = 0.3 # inches of clear film between stacked charts
def chart_scale(box: tuple[float, float], turn: bool = True) -> float:
"""The scale (of `dpi`, with the safe edge divided by it) that makes one full-size
chart fit within `box` inches (turned to match unless `turn` is False)."""
layout = ChartLayout()
return fit_scale((layout.width, layout.height), (2 * PAGE_EDGE, 2 * PAGE_EDGE), box, turn)


def page_scale(count: int, box: tuple[float, float], turn: bool = True) -> float:
"""The `scale` that makes a page of `count` stacked charts fit within `box` inches
(turned to match unless `turn` is False)."""
layout = ChartLayout.compact()
fixed_h = count * 2 * PAGE_EDGE + (count - 1) * PAGE_GAP
return fit_scale((layout.width, count * layout.height), (2 * PAGE_EDGE, fixed_h), box, turn)


def chart_page(
colours: list[str],
processes: list[ProcessCurve | None] | None = None,
mirror: bool = False,
dpi: float = 300.0,
scale: float = 1.0,
guide: bool = False,
registration: bool = False,
) -> NDArray[np.uint16]:
"""Several compact charts stacked on one page, one per colour, top to bottom.

Chart k carries k identification squares, so a scan of the page says which is which.
`scale` enlarges or shrinks the charts (not their safe edges or the gaps) to fit a
preferred size; see `page_scale`. `guide` and `registration` add darkroom marks.
"""
if not 1 <= len(colours) <= MAX_ID + 1:
raise ValueError(f"a page holds 1-{MAX_ID + 1} charts, got {len(colours)}")
processes = processes or [None] * len(colours)
if len(processes) != len(colours):
raise ValueError("give one process curve (or none) per colour")
layout = ChartLayout.compact()
marks = {"guide": guide, "registration": registration}
charts = [
chart_negative(c, pr, mirror, dpi, layout, index=k)
chart_negative(c, pr, mirror, dpi * scale, layout, k, PAGE_EDGE / scale, **marks)
for k, (c, pr) in enumerate(zip(colours, processes, strict=True))
]
gap = round(PAGE_GAP * dpi)
Expand Down
Loading
Loading