From be82070c04310217d8f159fd9d59981d68b65e39 Mon Sep 17 00:00:00 2001 From: Jens Tirsvad Nielsen Date: Thu, 8 Oct 2026 00:20:48 +0800 Subject: [PATCH] Draw the Hirst painting: pen, dot positions, dots, click wait and speed MIL-002 tasks 1 to 6. - Add create_pen, which hides the turtle, lifts the pen and switches the screen to 0 to 255 colours. - Add dot_positions: 100 dot centres in 10 rows of 10, 50 apart, centred on the origin so the painting fits the default window. - Add draw_dots, which draws a size-20 dot at every position in a colour chosen from the palette, and raises ValueError for an empty palette. - Add main: draw with the palette of the reference image, then wait for a click with exitonclick. Animation is off while drawing and the screen updates once, which takes the drawing from 21.1 to 0.35 seconds (SC6 allows 30). - Add 22 tests: pen setup, positions and spacing, dots on a recording fake pen and on a real turtle, no trail, and the order of main's steps. All tests share one hidden Tk window, because starting Tk per test fails now and then on Windows. Closes #7 Closes #8 Closes #9 Closes #10 Closes #11 Closes #12 Task: MIL-002#1 Task: MIL-002#2 Task: MIL-002#3 Task: MIL-002#4 Task: MIL-002#5 Task: MIL-002#6 --- src/hirst_painting.py | 88 ++++++++++- tests/test_hirst_painting.py | 282 +++++++++++++++++++++++++++++++++++ 2 files changed, 369 insertions(+), 1 deletion(-) diff --git a/src/hirst_painting.py b/src/hirst_painting.py index 387ba57..ee26d9a 100644 --- a/src/hirst_painting.py +++ b/src/hirst_painting.py @@ -1,13 +1,19 @@ """Hirst-style spot painting drawn with turtle.""" -from collections.abc import Iterable +import random +import turtle +from collections.abc import Callable, Iterable, Sequence from pathlib import Path +from typing import Protocol import colorgram type Colour = tuple[int, int, int] """A colour as red, green and blue values from 0 to 255.""" +type Position = tuple[int, int] +"""A point on the turtle screen as x and y, in screen units.""" + REFERENCE_IMAGE_PATH = ( Path(__file__).resolve().parent.parent / "assets" / "20260524_132700.jpg" ) @@ -16,6 +22,14 @@ REFERENCE_IMAGE_PATH = ( # has fewer, so the palette is never padded. EXTRACTED_COLOUR_COUNT = 30 +GRID_ROWS = 10 +GRID_COLUMNS = 10 + +DOT_SIZE = 20 + +# Distance between the centres of neighbouring dots, along rows and columns. +DOT_SPACING = 50 + # A colour counts as a white shade when red, green and blue are all at or above # this value: such dots are invisible on the white background (SC3 of BC-001). WHITE_THRESHOLD = 240 @@ -41,3 +55,75 @@ def extract_palette( extracted = colorgram.extract(str(image_path), colour_count) colours = [(found.rgb.r, found.rgb.g, found.rgb.b) for found in extracted] return remove_white_shades(colours) + + +class DotPen(Protocol): + """The two turtle operations that drawing dots needs.""" + + def goto(self, position: Position, /) -> None: + """Move to the position.""" + ... + + def dot(self, size: int, colour: Colour, /) -> None: + """Draw a dot of the size and colour at the current position.""" + ... + + +def create_pen(screen: turtle.TurtleScreen) -> turtle.RawTurtle: + """Return a hidden turtle with its pen up on a screen that takes RGB colours. + + The pen is up and the turtle is hidden so that the painting shows no trail + and no cursor; the screen takes colours as 0 to 255 values, as `Colour` does. + """ + screen.colormode(255) + pen = turtle.RawTurtle(screen) + pen.hideturtle() + pen.penup() + return pen + + +def dot_positions() -> list[Position]: + """Return the centre of every dot, rows from the bottom up, left to right. + + The grid is centred on the origin, the middle of the turtle window, so the + whole painting fits in a window of the default size. + """ + left = -DOT_SPACING * (GRID_COLUMNS - 1) // 2 + bottom = -DOT_SPACING * (GRID_ROWS - 1) // 2 + return [ + (left + column * DOT_SPACING, bottom + row * DOT_SPACING) + for row in range(GRID_ROWS) + for column in range(GRID_COLUMNS) + ] + + +def draw_dots( + pen: DotPen, + palette: Sequence[Colour], + choose_colour: Callable[[Sequence[Colour]], Colour] = random.choice, +) -> None: + """Draw a dot at every dot position, each in a colour chosen from the palette. + + Raises ValueError when the palette has no colours. + """ + if not palette: + raise ValueError("The palette has no colours to draw with.") + for position in dot_positions(): + pen.goto(position) + pen.dot(DOT_SIZE, choose_colour(palette)) + + +def main() -> None: + """Draw the painting with the reference image's palette, then wait for a click.""" + screen = turtle.Screen() + # Animating the 100 moves takes about 20 seconds on screen, against the 30 + # allowed (SC6 of BC-001), so draw unseen and show the whole painting at once. + screen.tracer(0) + pen = create_pen(screen) + draw_dots(pen, extract_palette(REFERENCE_IMAGE_PATH)) + screen.update() + screen.exitonclick() + + +if __name__ == "__main__": + main() diff --git a/tests/test_hirst_painting.py b/tests/test_hirst_painting.py index f89fa64..dab827c 100644 --- a/tests/test_hirst_painting.py +++ b/tests/test_hirst_painting.py @@ -1,15 +1,27 @@ """Tests for the colour palette helpers of the Hirst painting program.""" +import tkinter +import turtle +from collections import Counter +from collections.abc import Iterator, Sequence +from dataclasses import dataclass, field from pathlib import Path import pytest from PIL import Image +import hirst_painting from hirst_painting import ( REFERENCE_IMAGE_PATH, Colour, + DotPen, + Position, + create_pen, + dot_positions, + draw_dots, extract_palette, is_white_shade, + main, remove_white_shades, ) @@ -118,3 +130,273 @@ def test_reference_image_palette_has_two_colours_and_no_white_shade() -> None: assert len(palette) >= 2 assert not any(is_white_shade(colour) for colour in palette) + + +@pytest.fixture(scope="session") +def tk_root() -> Iterator[tkinter.Tk]: + """Create one hidden Tk window for the whole run. + + Starting Tk again and again in one process fails now and then on Windows + (Tk cannot read its own library files), so every test shares this one. + """ + root = tkinter.Tk() + root.withdraw() + yield root + root.destroy() + + +@pytest.fixture +def canvas(tk_root: tkinter.Tk) -> Iterator[tkinter.Canvas]: + """Yield a canvas on the hidden window, so no window appears.""" + canvas = tkinter.Canvas(tk_root) + yield canvas + canvas.destroy() + + +@pytest.fixture +def screen(canvas: tkinter.Canvas) -> turtle.TurtleScreen: + """Return a turtle screen that draws on the hidden canvas.""" + return turtle.TurtleScreen(canvas) + + +def test_create_pen_hides_the_turtle(screen: turtle.TurtleScreen) -> None: + assert not create_pen(screen).isvisible() + + +def test_create_pen_lifts_the_pen(screen: turtle.TurtleScreen) -> None: + assert not create_pen(screen).isdown() + + +def test_create_pen_switches_the_screen_to_255_level_colours( + screen: turtle.TurtleScreen, +) -> None: + create_pen(screen) + + assert screen.colormode() == 255 + + +def test_create_pen_draws_on_the_given_screen(screen: turtle.TurtleScreen) -> None: + assert create_pen(screen).getscreen() is screen + + +def test_dot_positions_returns_100_positions() -> None: + assert len(dot_positions()) == 100 + + +def test_dot_positions_are_all_different() -> None: + positions = dot_positions() + + assert len(set(positions)) == len(positions) + + +def test_dot_positions_have_10_heights_with_10_dots_each() -> None: + dots_per_height = Counter(y for _, y in dot_positions()) + + assert list(dots_per_height.values()) == [10] * 10 + + +def test_dot_positions_are_50_apart_along_each_row() -> None: + positions = dot_positions() + + for start in range(0, 100, 10): + row = positions[start : start + 10] + assert [x for x, _ in row] == list(range(-225, 226, 50)) + assert len({y for _, y in row}) == 1 + + +def test_dot_positions_are_50_apart_between_rows() -> None: + heights = sorted({y for _, y in dot_positions()}) + + assert heights == list(range(-225, 226, 50)) + + +def test_dot_positions_start_bottom_left_and_fill_rows_upward() -> None: + positions = dot_positions() + + assert positions[0] == (-225, -225) + assert positions[1] == (-175, -225) + assert positions[10] == (-225, -175) + assert positions[-1] == (225, 225) + + +def test_dot_positions_are_centred_on_the_origin() -> None: + positions = dot_positions() + + assert min(x for x, _ in positions) == -max(x for x, _ in positions) + assert min(y for _, y in positions) == -max(y for _, y in positions) + + +class RecordingPen: + """A pen that records the dots it was asked to draw and where.""" + + def __init__(self) -> None: + """Start at the origin with no dots drawn.""" + self.position: Position = (0, 0) + self.dots: list[tuple[Position, int, Colour]] = [] + + def goto(self, position: Position, /) -> None: + """Remember the position the next dot is drawn at.""" + self.position = position + + def dot(self, size: int, colour: Colour, /) -> None: + """Record a dot at the current position.""" + self.dots.append((self.position, size, colour)) + + +PALETTE: list[Colour] = [(200, 30, 40), (30, 90, 160), (20, 120, 60)] + + +def test_draw_dots_draws_a_dot_at_every_position_in_order() -> None: + pen = RecordingPen() + + draw_dots(pen, PALETTE) + + assert [position for position, _, _ in pen.dots] == dot_positions() + + +def test_draw_dots_draws_dots_of_size_20() -> None: + pen = RecordingPen() + + draw_dots(pen, PALETTE) + + assert {size for _, size, _ in pen.dots} == {20} + + +def test_draw_dots_colours_every_dot_from_the_palette() -> None: + pen = RecordingPen() + + draw_dots(pen, PALETTE) + + assert {colour for _, _, colour in pen.dots} <= set(PALETTE) + + +def test_draw_dots_asks_the_chooser_for_the_colour_of_each_dot() -> None: + pen = RecordingPen() + asked: list[Sequence[Colour]] = [] + + def choose_last(colours: Sequence[Colour]) -> Colour: + asked.append(colours) + return colours[-1] + + draw_dots(pen, PALETTE, choose_colour=choose_last) + + assert len(asked) == 100 + assert {colour for _, _, colour in pen.dots} == {(20, 120, 60)} + + +def test_draw_dots_raises_when_the_palette_is_empty() -> None: + with pytest.raises(ValueError, match="no colours"): + draw_dots(RecordingPen(), []) + + +def _item_option(canvas: tkinter.Canvas, item: int, option: str) -> str: + """Return one option of a canvas item, such as its width or fill colour.""" + # typeshed leaves Canvas.itemcget untyped, which strict mypy refuses to call. + return str(canvas.itemcget(item, option)) # type: ignore[no-untyped-call] + + +def _drawn_dots(canvas: tkinter.Canvas) -> list[tuple[Position, str]]: + """Return the position and fill colour of every dot on the canvas. + + Turtle draws a dot as a round-capped line whose width is the dot size. + """ + dots: list[tuple[Position, str]] = [] + for item in canvas.find_all(): + is_dot = ( + str(canvas.type(item)) == "line" + and _item_option(canvas, item, "width") == "20.0" + ) + if is_dot: + x, y = canvas.coords(item)[:2] + dots.append(((round(x), -round(y)), _item_option(canvas, item, "fill"))) + return dots + + +def _other_lines(canvas: tkinter.Canvas) -> int: + """Count the line items that are not dots, such as a trail behind the pen.""" + lines = [item for item in canvas.find_all() if str(canvas.type(item)) == "line"] + return len(lines) - len(_drawn_dots(canvas)) + + +def test_draw_dots_draws_100_dots_at_the_dot_positions_on_a_real_turtle( + screen: turtle.TurtleScreen, canvas: tkinter.Canvas +) -> None: + screen.tracer(0) + pen = create_pen(screen) + + draw_dots(pen, PALETTE) + + positions = [position for position, _ in _drawn_dots(canvas)] + assert sorted(positions) == sorted(dot_positions()) + + +def test_draw_dots_colours_the_dots_of_a_real_turtle_from_the_palette( + screen: turtle.TurtleScreen, canvas: tkinter.Canvas +) -> None: + screen.tracer(0) + pen = create_pen(screen) + + draw_dots(pen, PALETTE) + + palette_fills = {f"#{red:02x}{green:02x}{blue:02x}" for red, green, blue in PALETTE} + assert {fill for _, fill in _drawn_dots(canvas)} <= palette_fills + + +def test_draw_dots_draws_no_trail_between_dots( + screen: turtle.TurtleScreen, canvas: tkinter.Canvas +) -> None: + screen.tracer(0) + pen = create_pen(screen) + other_lines_before = _other_lines(canvas) + + draw_dots(pen, PALETTE) + + assert _other_lines(canvas) == other_lines_before + + +@dataclass +class MainRun: + """What happened during one run of main() on a fake screen.""" + + events: list[str] = field(default_factory=list) + palettes: list[Sequence[Colour]] = field(default_factory=list) + animation_while_drawing: list[int] = field(default_factory=list) + + +@pytest.fixture +def main_run(monkeypatch: pytest.MonkeyPatch, canvas: tkinter.Canvas) -> MainRun: + """Run main() once with drawing and the click wait replaced by recorders.""" + run = MainRun() + + class RecordingScreen(turtle.TurtleScreen): + def update(self) -> None: + run.events.append("update") + + def exitonclick(self) -> None: + run.events.append("exitonclick") + + screen = RecordingScreen(canvas) + + def record_draw(pen: DotPen, palette: Sequence[Colour]) -> None: + run.events.append("draw_dots") + run.palettes.append(palette) + run.animation_while_drawing.append(screen.tracer()) + + monkeypatch.setattr(turtle, "Screen", lambda: screen) + monkeypatch.setattr(hirst_painting, "draw_dots", record_draw) + main() + return run + + +def test_main_draws_with_the_palette_of_the_reference_image( + main_run: MainRun, +) -> None: + assert main_run.palettes == [extract_palette(REFERENCE_IMAGE_PATH)] + + +def test_main_turns_the_animation_off_while_drawing(main_run: MainRun) -> None: + assert main_run.animation_while_drawing == [0] + + +def test_main_shows_the_painting_then_waits_for_a_click(main_run: MainRun) -> None: + assert main_run.events == ["draw_dots", "update", "exitonclick"]