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:
+87
-1
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user