--- title: "Fields: dot screens, stipple and engraving" output: rmarkdown::html_vignette vignette: > %\VignetteIndexEntry{Fields: dot screens, stipple and engraving} %\VignetteEngine{knitr::rmarkdown} %\VignetteEncoding{UTF-8} --- ```{r setup, include = FALSE} knitr::opts_chunk$set(collapse = TRUE, comment = "#>", dev = "ragg_png", dpi = 300, fig.retina = 1, fig.width = 89 / 25.4, fig.height = 75 / 25.4, out.width = "55%", fig.align = "center", fig.process = if (requireNamespace("magick", quietly = TRUE)) function(f) { magick::image_write(magick::image_quantize(magick::image_read(f), 48, dither = FALSE), f); f }) # halftone PNGs compress poorly; quantise library(ggplot2); library(gghalftone) vol <- data.frame(expand.grid(x = seq_len(ncol(volcano)), y = seq_len(nrow(volcano))), z = as.vector(t(volcano))) ``` `geom_halftone()` takes a gridded field (`x`, `y` and a value) and screens it directly. The examples use the Maunga Whau elevation grid from `datasets::volcano`. The area of each dot is the tone at that point. Colour scales apply to the field, and the dots inherit the colour. ## Continuous tone ```{r field} ggplot(vol, aes(x, y, z = z)) + geom_halftone(aes(colour = z), angle = 15, grid = "hex", gamma = 1.5, dot_max = 1.0) + with_halo(geom_contour(colour = "black", linewidth = 0.25, bins = 8)) + coord_equal(expand = FALSE) + labs(x = NULL, y = NULL, colour = "Elevation (m)") + theme_bw() + theme_classic(base_size = 8) + theme_halftone() + theme(axis.text = element_blank(), axis.ticks = element_blank()) ``` Two settings carry this figure. `gamma` above 1 keeps the low ground light. Below 1 it lifts the whole field toward the top of the register: the plain prints as dark as the flanks and the dots stop changing size, leaving colour to carry the elevation alone. `dot_max = 1` gives the summit enough ink to separate from the flanks. The 45 degree square lattice is the conventional screen for maps and photographs in print. On a continuous field its axes read through as a criss-cross, so the hex lattice is used here. ## Engraving A line screen draws strips along the lattice rows. Strip width follows tone. Set the halo under the contours to 0.15 mm, because a 0.09 mm halo is invisible against hatching. Where tone falls below the printable minimum, the strips break into dashes instead of getting thinner. ```{r engraving} ggplot(vol, aes(x, y, z = z)) + geom_halftone(shape = "line", colour = "black", angle = 30, gamma = 1.4) + with_halo(geom_contour(colour = "black", linewidth = 0.2, bins = 8), width = 0.15) + coord_equal(expand = FALSE) + labs(x = NULL, y = NULL) + theme_bw() + theme_classic(base_size = 8) + theme_halftone() + theme(axis.text = element_blank(), axis.ticks = element_blank()) ``` ## Illuminated contours `with_relief()` implements illuminated contours (Tanaka 1950). Each contour segment is lit or shaded according to the direction of its slope relative to a light source, by default from the upper left. Lit segments are drawn in paper colour and shaded segments in ink. Both widen where the slope faces the light directly. Over an engraving, the paper segments cut through the hatching. The wrapper infers which side of each contour is uphill from the neighbouring contours. `light` sets the light azimuth in degrees. ```{r relief} ggplot(vol, aes(x, y, z = z)) + geom_halftone(shape = "line", colour = "black", angle = 30, gamma = 1.4) + with_relief(geom_contour(bins = 10), light = 315) + coord_equal(expand = FALSE) + labs(x = NULL, y = NULL) + theme_bw() + theme_classic(base_size = 8) + theme_halftone() + theme(axis.text = element_blank(), axis.ticks = element_blank()) ``` Draw relief over a screened field. Lit segments are paper-coloured, so on bare paper only the shaded half of each contour appears. A line over a confidence band takes `with_halo()`, whose halo is symmetric because an interval has no lit side. Contours over a surface take `with_relief()`. ## Stipple `levels = 1` with blue-noise dithering (Ulichney 1993) gives a binary stipple. The other algorithms are ordered dithering (Bayer 1973) and error diffusion (Floyd and Steinberg 1976). Every dot has the same size, and dot density carries the value. Tone is capped at 55% by default, so the densest region remains a stipple rather than a filled lattice. ```{r stipple, fig.height = 62 / 25.4} kd <- MASS::kde2d(faithful$eruptions, faithful$waiting, n = 120, lims = c(1.3, 5.6, 40, 100)) dens <- data.frame(expand.grid(x = kd$x, y = kd$y), z = as.vector(kd$z)) ggplot(dens, aes(x, y, z = z)) + geom_halftone(levels = 1, algorithm = "blue_noise", colour = "black") + with_halo(geom_contour(colour = "black", linewidth = 0.35, bins = 3)) + labs(x = "Eruption (min)", y = "Waiting (min)") + theme_bw() + theme_classic(base_size = 8) + theme_halftone() ``` ## Photographs `halftone_raster()` reads an image into a field. One screen per process ink gives four-colour process, with the rosette that comes from the classic screen angles. Error diffusion on the luminance in one ink gives the newspaper screen. ```{r photo, fig.width = 183 / 25.4, fig.height = 62 / 25.4, out.width = "100%", eval = requireNamespace("magick", quietly = TRUE)} f <- halftone_raster(magick::image_read("rose:"), max_px = 150) process <- ggplot() + geom_halftone_cmyk(f, pitch = 0.6, levels = 6) + coord_equal(expand = FALSE) + theme_void(base_size = 8) + theme_halftone() news <- ggplot(f, aes(x, y, z = z)) + geom_halftone(levels = 2, algorithm = "floyd_steinberg", colour = "black", pitch = 0.4) + coord_equal(expand = FALSE) + theme_void(base_size = 8) + theme_halftone() patchwork::wrap_plots(process, news, nrow = 1) ``` ## Maps `with_halftone()` on a polygon layer prints each region at the tone of its fill colour. The default continuous ramp runs from paper through ochre and red to near-black. ```{r map, fig.height = 58 / 25.4, eval = requireNamespace("maps", quietly = TRUE) && requireNamespace("mapproj", quietly = TRUE)} val <- setNames(USArrests$Murder, tolower(rownames(USArrests))) st <- ggplot2::map_data("state"); st$murder <- val[st$region] ggplot(st, aes(long, lat, group = group, fill = murder)) + with_halftone(geom_polygon(colour = "black", linewidth = 0.2), angle = 45, grid = "square") + coord_map("albers", lat0 = 30, lat1 = 45) + labs(x = NULL, y = NULL, fill = "Murders\nper 100k") + theme_minimal() + theme_classic(base_size = 8) + theme_halftone() + theme(axis.ticks = element_blank()) + theme(axis.text = element_blank()) ``` ## Pitch The default pitch is 0.35 mm, or 73 lines per inch. The comparison below shows why: a 0.6 mm screen reads as dots, while 0.25 mm collapses into a flat tint and takes about 1.8 times as long to draw. ```{r ladder, fig.width = 110 / 25.4, fig.height = 180 / 25.4, out.width = "72%"} d <- data.frame(x = seq(0, 10, length.out = 60)); d$y <- sin(d$x); d$lo <- d$y - 0.5 - 0.02 * d$x; d$hi <- d$y + 0.5 + 0.02 * d$x one <- function(p) ggplot(d, aes(x)) + with_halftone(geom_ribbon(aes(ymin = lo, ymax = hi), fill = "black"), pitch = p) + with_halo(geom_line(aes(y = y), linewidth = 0.35)) + labs(x = NULL, y = NULL, title = sprintf("%.2f mm", p)) + theme_classic(base_size = 8) + theme_halftone() + theme(plot.title = element_text(size = 11, face = "bold", margin = margin(b = 2))) patchwork::wrap_plots(lapply(c(0.6, 0.45, 0.35, 0.25), one), ncol = 1) ``` ## Press artefacts `with_press()` draws a screen as a press puts it on paper, not as the plate describes it. It takes a layer, a plot or a patchwork; given more than one layer, each is pressed as its own plate with its own offset. Everything it does makes a figure less faithful to its data, so it is off by default and outside the journal register. Use it for a plate, a poster or a cover. Stacked rather than set side by side, and narrow to suit a portrait subject. Three panels across a page leave each one too small to show the screen. ```{r press, fig.width = 78 / 25.4, fig.height = 228 / 25.4, out.width = "58%"} one <- function(title, ...) { lay <- geom_halftone(shape = "line", colour = "black", angle = 30, gamma = 1.4, pitch = 0.6) args <- list(...) if (length(args)) lay <- do.call(with_press, c(list(lay), args)) ggplot(vol, aes(x, y, z = z)) + lay + coord_equal(expand = FALSE) + labs(x = NULL, y = NULL, title = title) + theme_void(base_size = 8) + theme_halftone() + theme(plot.title = element_text(size = 11, face = "bold", hjust = 0, margin = margin(b = 3))) } patchwork::wrap_plots(one("as prepared"), one("dot gain only", gain = 0.3, seed = 4), one("the whole press", gain = 0.3, slur = 0.08, fillet = 0.05, mottle = 0.16, seed = 4), ncol = 1) ``` `gain` is the trade's tone value increase at a 50% screen, about 0.15 for offset on coated stock and 0.35 on newsprint. It applies to coverage, the fraction of paper the screen inks, which is what a densitometer reads and is not the tone the screen was asked for. Coverage stops at the sheet, and that is what fills a shadow in. Dot gain alone moves ink and nothing else, so on a flat field it is close to what raising `dot_max` would give. The settings that no plain figure reproduces are `mottle`, the slow drift in ink density across a sheet; `slur`, the smear of a sheet moving under the plate; `fillet`, the ink bridge surface tension pulls where two dots meet; and `registration`, one plate landing off another. `fillet` acts only where dots meet, so it needs `gain` above about 0.2 to give it something to bridge, and it needs the polyclip package. ## References - Bayer, B. E. (1973). An optimum method for two-level rendition of continuous-tone pictures. *IEEE International Conference on Communications*, 26, 11-15. - Floyd, R. W., and Steinberg, L. (1976). An adaptive algorithm for spatial greyscale. *Proceedings of the Society for Information Display*, 17(2), 75-77. - Tanaka, K. (1950). The relief contour method of representing topography on maps. *Geographical Review*, 40(3), 444-456. - Ulichney, R. (1993). The void-and-cluster method for dither array generation. *Proceedings of SPIE*, 1913, 332-343. - Maunga Whau elevation data: R `datasets::volcano`, digitised by Ross Ihaka from a topographic map of Auckland.