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
This commit is contained in:
2026-10-08 00:20:48 +08:00
parent 412237afe4
commit be82070c04
2 changed files with 369 additions and 1 deletions
+87 -1
View File
@@ -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()