Add the colour palette: white-shade filter and palette extraction
MIL-001 tasks 1 to 6. - Pin the toolchain and tool configuration in pyproject.toml: Python 3.13, colorgram.py 1.2.0, and ruff, mypy (strict) and pytest as dev tools. - Add is_white_shade and remove_white_shades (threshold 240) with tests that pin the boundary at 239 and 240. - Add extract_palette, which reads the reference image with colorgram and drops white shades, with tests on synthetic images and on the reference image. - Add the reference image assets/20260524_132700.jpg and a note on it. - Record the code review against QC-PY-001 as RC-005 (verdict Go). Closes #1 Closes #2 Closes #3 Closes #4 Closes #5 Closes #6 Task: MIL-001#1 Task: MIL-001#2 Task: MIL-001#3 Task: MIL-001#4 Task: MIL-001#5 Task: MIL-001#6
This commit is contained in:
Binary file not shown.
|
After Width: | Height: | Size: 169 KiB |
@@ -0,0 +1,5 @@
|
|||||||
|
# Assets
|
||||||
|
|
||||||
|
`20260524_132700.jpg` is the reference image the colour palette is extracted
|
||||||
|
from (JPEG, 523 by 930 pixels, 172,725 bytes, supplied by S01 on 2026-10-07).
|
||||||
|
`REFERENCE_IMAGE_PATH` in `src/hirst_painting.py` points at it.
|
||||||
@@ -15,7 +15,7 @@ document of a type. `Primary File` may contain a glob (e.g.
|
|||||||
| SA | Stakeholder Analysis | docs/stakeholder-analysis.md | 002 |
|
| SA | Stakeholder Analysis | docs/stakeholder-analysis.md | 002 |
|
||||||
| PP | Project Plan | docs/project-plan.md | 002 |
|
| PP | Project Plan | docs/project-plan.md | 002 |
|
||||||
| MIL | Milestone / Gateway | docs/milestones/*.md | 003 |
|
| MIL | Milestone / Gateway | docs/milestones/*.md | 003 |
|
||||||
| RC | SQA Review Record | docs/sqa/reviews/rc-*.md | 005 |
|
| RC | SQA Review Record | docs/sqa/reviews/rc-*.md | 006 |
|
||||||
|
|
||||||
## Languages
|
## Languages
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,73 @@
|
|||||||
|
# Review Record: Code of MIL-001 (Colour Palette)
|
||||||
|
|
||||||
|
## Metadata
|
||||||
|
| Key | Value |
|
||||||
|
| --- | --- |
|
||||||
|
| ID | RC-005 |
|
||||||
|
| CrossReference | [MIL-001], [BC-001], [QC-PY-001] |
|
||||||
|
|
||||||
|
## Version History
|
||||||
|
| Date | Status | Author | Reviewer | Change | Commit |
|
||||||
|
| --- | --- | --- | --- | --- | --- |
|
||||||
|
| 2026-10-07 | Accepted | Jens Tirsvad Nielsen | S01 | Initial version (draft prepared by the assistant for S01 to confirm)<br>Verdict `Go` confirmed by S01 | pending |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Artifact Under Review
|
||||||
|
|
||||||
|
- Instance reviewed: the code of [MIL-001] tasks 2, 3 and 5 (issues #2, #3 and #5): `src/hirst_painting.py` (`is_white_shade`, `remove_white_shades`, `extract_palette`), `tests/test_hirst_painting.py` (18 tests), and the tool configuration in `pyproject.toml`
|
||||||
|
- Checklist used: [QC-PY-001]
|
||||||
|
- Scope: full review
|
||||||
|
- Language and domain: n/a (source code is a technical type, always IT Professional English)
|
||||||
|
- Language reviewer: none. S01 is also the author of record; S01 accepted a documented self-review on 2026-10-07 (see the review record of BC-001, Action Item 1)
|
||||||
|
|
||||||
|
**Status of this record:** final. The assistant read the code against every
|
||||||
|
criterion and ran the checks named below; S01 confirmed the verdict `Go` on
|
||||||
|
2026-10-07.
|
||||||
|
|
||||||
|
Evidence gathered on 2026-10-07 with Python 3.13.14, `ruff` 0.16.10, `mypy`
|
||||||
|
2.4.0 and `pytest` 9.1.1:
|
||||||
|
|
||||||
|
- `ruff format --check .` reports no changes and `ruff check .` reports "All checks passed!"
|
||||||
|
- `mypy` (strict, set in `pyproject.toml`) reports "no issues found in 2 source files"
|
||||||
|
- `pytest` reports 18 passed; the same 18 pass in reverse order and each passes when run alone
|
||||||
|
- A search of `src/` and `tests/` for `print(`, `logging`, `except`, `noqa`, `type: ignore` and `nosec` finds nothing
|
||||||
|
- An abstract-syntax-tree scan of both files finds no shadowed builtin
|
||||||
|
|
||||||
|
## Checklist Results
|
||||||
|
|
||||||
|
| # | Criterion | Status | Evidence/Notes |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| 1 | Packages, modules, functions, variables, classes and constants follow PEP 8 casing (`snake_case`, `PascalCase`, `UPPER_SNAKE`) | Pass | Module `hirst_painting`; functions `is_white_shade`, `remove_white_shades`, `extract_palette`; constants `WHITE_THRESHOLD`, `EXTRACTED_COLOUR_COUNT`, `REFERENCE_IMAGE_PATH`, `STRIPE_WIDTH`; type alias `Colour`; private test helper `_write_stripes`; `ruff` rule set `N` (pep8-naming) is on and clean |
|
||||||
|
| 2 | Names state purpose in the domain's language; no unexplained abbreviations, no single-letter names outside tiny scopes | Pass | Names follow the PO terms of [BC-001] (colour, palette, white shade). No single-letter names; `rgb.r`, `rgb.g` and `rgb.b` are the `colorgram` library's own attribute names. The code spells "colour" as the documents do, while library calls keep their own spelling |
|
||||||
|
| 3 | Code is produced by the project's formatter and passes its linter with no unexplained suppressions | Pass | Both commands above are clean. There is no inline suppression. The only configured exemption is `D103` for `tests/*`, and `pyproject.toml` explains it (test names state the behaviour) |
|
||||||
|
| 4 | Every function and method signature is type-annotated, including `-> None` | Pass | Every function in `src/` and `tests/`, including each test and the helper, is annotated; the `ANN` rules and `mypy --strict` enforce it |
|
||||||
|
| 5 | No bare `except:`, no swallowed exceptions; specific exceptions are raised and the cause is kept (`raise ... from`) | Pass | The code has no `try` or `except`. A missing image raises `FileNotFoundError` from the library and propagates; `test_extract_palette_raises_when_image_is_missing` proves it. The code raises no exception of its own, so no `raise ... from` is needed |
|
||||||
|
| 6 | No mutable default arguments and no shadowed builtins | Pass | The only default argument is the integer constant `EXTRACTED_COLOUR_COUNT`; the syntax-tree scan finds no shadowed builtin |
|
||||||
|
| 7 | Files, locks and connections are managed with context managers | N-A | The code opens no file itself: `colorgram` opens the image from its path. After `extract_palette` the file can be deleted on Windows, which shows that no handle stays open |
|
||||||
|
| 8 | Public modules, classes and functions have docstrings that say what, not how | Pass | Module docstring and a docstring on each public function; `extract_palette` also states its `FileNotFoundError`. `ruff` rules `D` (pydocstyle, PEP 257) are on and clean |
|
||||||
|
| 9 | Logging uses `logging`, not `print`; no secrets or personal data in log output | Pass | `src/` has no `print`, no logging and no secrets. The program does not read `.env` |
|
||||||
|
| 10 | Classes and operations trace to the Design Class Diagram they implement; deviations are recorded | N-A | No Design Class Diagram exists: design artifacts are out of scope in [BC-001] and the code has no classes. The functions trace to tasks 2, 3 and 5 of [MIL-001] instead |
|
||||||
|
| 11 | Tests exist for new behaviour, are named for the behaviour, and do not depend on order or the network | Pass | 18 tests named `test_<behaviour>_<condition>`; they use `tmp_path` and the repository's own image, and need no network. Order independence was run, not assumed (reverse order and each test alone). Review found one test name that said "at most" while asserting an exact count; it was renamed to `test_extract_palette_returns_requested_count_when_image_has_more_colours` before this record |
|
||||||
|
| 12 | Type checker runs in strict mode without errors; `Any` is justified in a comment | Pass | `mypy` strict is clean. `colorgram` ships no type information, so `pyproject.toml` sets `ignore_missing_imports` for that one module with a comment saying so; its values are converted to `Colour` at the boundary in `extract_palette` |
|
||||||
|
| 13 | Dependencies are declared and pinned in the project's dependency file, none unused | Pass | `pyproject.toml` pins `colorgram.py==1.2.0` and, in the `dev` group, `mypy==2.4.0`, `pillow==12.3.0`, `pytest==9.1.1` and `ruff==0.16.10`. Each is used: `pillow` by the tests, the others by the code and tools. There is no lock file; the pins are in `pyproject.toml` |
|
||||||
|
|
||||||
|
## Overall Verdict
|
||||||
|
|
||||||
|
Go — confirmed by S01 on 2026-10-07. All 11 applicable criteria of [QC-PY-001] pass,
|
||||||
|
including the 3 optional ones (8, 12, 13); criteria 7 and 10 are N-A with the
|
||||||
|
reasons above. The one defect found in review (a misleading test name) was
|
||||||
|
fixed before the record. A `Go` here is Go criterion 5 of [MIL-001], the last
|
||||||
|
open one.
|
||||||
|
|
||||||
|
## Action Items
|
||||||
|
|
||||||
|
| # | Action | Owner | Due |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| 1 | Confirm the verdict `Go` for the code of [MIL-001], or name the criterion you disagree with. **Closed 2026-10-07:** S01 confirmed `Go` | S01 | 2026-10-09 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
[MIL-001]: ../../milestones/mil-001-palette.md
|
||||||
|
[BC-001]: ../../business-case.md
|
||||||
|
[QC-PY-001]: ../../../framework/qc/qc-programming-python.md
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
[project]
|
||||||
|
name = "hirst-painting"
|
||||||
|
version = "0.1.0"
|
||||||
|
description = "Hirst-style spot painting drawn with turtle (Day 18 of 100 Days of Code)."
|
||||||
|
readme = "README.md"
|
||||||
|
requires-python = ">=3.13"
|
||||||
|
# Toolchain verified on 2026-10-07 (MIL-001 task 1): Python 3.13.14, Tk 8.6 and
|
||||||
|
# colorgram.py 1.2.0 with Pillow 12.3.0. Pillow is pulled in by colorgram.py.
|
||||||
|
dependencies = [
|
||||||
|
"colorgram.py==1.2.0",
|
||||||
|
]
|
||||||
|
|
||||||
|
[dependency-groups]
|
||||||
|
dev = [
|
||||||
|
"mypy==2.4.0",
|
||||||
|
"pillow==12.3.0",
|
||||||
|
"pytest==9.1.1",
|
||||||
|
"ruff==0.16.10",
|
||||||
|
]
|
||||||
|
|
||||||
|
[tool.ruff]
|
||||||
|
line-length = 88
|
||||||
|
target-version = "py313"
|
||||||
|
src = ["src", "tests"]
|
||||||
|
# The framework is a mounted submodule and is never edited from this project.
|
||||||
|
extend-exclude = ["framework", ".agents", ".claude"]
|
||||||
|
|
||||||
|
[tool.ruff.lint]
|
||||||
|
# pycodestyle, pyflakes, isort, pep8-naming, pydocstyle, pyupgrade, bugbear,
|
||||||
|
# flake8-annotations: the rules behind QC-PY-001 criteria 1, 3, 4, 6 and 8.
|
||||||
|
select = ["E", "W", "F", "I", "N", "D", "UP", "B", "ANN"]
|
||||||
|
|
||||||
|
[tool.ruff.lint.pydocstyle]
|
||||||
|
convention = "pep257"
|
||||||
|
|
||||||
|
[tool.ruff.lint.per-file-ignores]
|
||||||
|
# Test names state the behaviour, so a docstring would only repeat them.
|
||||||
|
"tests/*" = ["D103"]
|
||||||
|
|
||||||
|
[tool.mypy]
|
||||||
|
strict = true
|
||||||
|
mypy_path = "src"
|
||||||
|
files = ["src", "tests"]
|
||||||
|
|
||||||
|
[[tool.mypy.overrides]]
|
||||||
|
# colorgram.py ships no type information; its values are converted to Colour at
|
||||||
|
# the boundary in extract_palette, so nothing untyped spreads further.
|
||||||
|
module = "colorgram"
|
||||||
|
ignore_missing_imports = true
|
||||||
|
|
||||||
|
[tool.pytest.ini_options]
|
||||||
|
pythonpath = ["src"]
|
||||||
|
testpaths = ["tests"]
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
"""Hirst-style spot painting drawn with turtle."""
|
||||||
|
|
||||||
|
from collections.abc import Iterable
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import colorgram
|
||||||
|
|
||||||
|
type Colour = tuple[int, int, int]
|
||||||
|
"""A colour as red, green and blue values from 0 to 255."""
|
||||||
|
|
||||||
|
REFERENCE_IMAGE_PATH = (
|
||||||
|
Path(__file__).resolve().parent.parent / "assets" / "20260524_132700.jpg"
|
||||||
|
)
|
||||||
|
|
||||||
|
# More than a Hirst painting has colours; colorgram returns fewer when the image
|
||||||
|
# has fewer, so the palette is never padded.
|
||||||
|
EXTRACTED_COLOUR_COUNT = 30
|
||||||
|
|
||||||
|
# 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
|
||||||
|
|
||||||
|
|
||||||
|
def is_white_shade(colour: Colour) -> bool:
|
||||||
|
"""Return whether red, green and blue are all at or above the threshold."""
|
||||||
|
return all(channel >= WHITE_THRESHOLD for channel in colour)
|
||||||
|
|
||||||
|
|
||||||
|
def remove_white_shades(colours: Iterable[Colour]) -> list[Colour]:
|
||||||
|
"""Return the colours without white shades, in their original order."""
|
||||||
|
return [colour for colour in colours if not is_white_shade(colour)]
|
||||||
|
|
||||||
|
|
||||||
|
def extract_palette(
|
||||||
|
image_path: Path, colour_count: int = EXTRACTED_COLOUR_COUNT
|
||||||
|
) -> list[Colour]:
|
||||||
|
"""Return the image's colours without white shades, most common first.
|
||||||
|
|
||||||
|
Raises FileNotFoundError when the image does not exist.
|
||||||
|
"""
|
||||||
|
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)
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
"""Tests for the colour palette helpers of the Hirst painting program."""
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from PIL import Image
|
||||||
|
|
||||||
|
from hirst_painting import (
|
||||||
|
REFERENCE_IMAGE_PATH,
|
||||||
|
Colour,
|
||||||
|
extract_palette,
|
||||||
|
is_white_shade,
|
||||||
|
remove_white_shades,
|
||||||
|
)
|
||||||
|
|
||||||
|
STRIPE_WIDTH = 20
|
||||||
|
|
||||||
|
|
||||||
|
def _write_stripes(path: Path, colours: list[Colour]) -> None:
|
||||||
|
"""Save an image of vertical stripes, one flat stripe per colour."""
|
||||||
|
image = Image.new("RGB", (STRIPE_WIDTH * len(colours), STRIPE_WIDTH))
|
||||||
|
for index, colour in enumerate(colours):
|
||||||
|
left = index * STRIPE_WIDTH
|
||||||
|
image.paste(colour, (left, 0, left + STRIPE_WIDTH, STRIPE_WIDTH))
|
||||||
|
image.save(path)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"colour",
|
||||||
|
[
|
||||||
|
(255, 255, 255),
|
||||||
|
(240, 240, 240),
|
||||||
|
(245, 250, 241),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_is_white_shade_when_all_channels_at_or_above_240(colour: Colour) -> None:
|
||||||
|
assert is_white_shade(colour)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"colour",
|
||||||
|
[
|
||||||
|
(239, 239, 239),
|
||||||
|
(255, 255, 239),
|
||||||
|
(239, 255, 255),
|
||||||
|
(255, 239, 255),
|
||||||
|
(200, 30, 40),
|
||||||
|
(0, 0, 0),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_is_not_white_shade_when_any_channel_below_240(colour: Colour) -> None:
|
||||||
|
assert not is_white_shade(colour)
|
||||||
|
|
||||||
|
|
||||||
|
def test_remove_white_shades_keeps_order_and_drops_white() -> None:
|
||||||
|
colours: list[Colour] = [
|
||||||
|
(255, 255, 255),
|
||||||
|
(200, 30, 40),
|
||||||
|
(245, 245, 245),
|
||||||
|
(30, 90, 160),
|
||||||
|
(240, 240, 240),
|
||||||
|
]
|
||||||
|
|
||||||
|
assert remove_white_shades(colours) == [(200, 30, 40), (30, 90, 160)]
|
||||||
|
|
||||||
|
|
||||||
|
def test_remove_white_shades_returns_empty_list_when_all_white() -> None:
|
||||||
|
assert remove_white_shades([(255, 255, 255), (240, 240, 240)]) == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_remove_white_shades_returns_empty_list_when_no_colours() -> None:
|
||||||
|
assert remove_white_shades([]) == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_remove_white_shades_accepts_any_iterable() -> None:
|
||||||
|
colours = ((colour, 30, 40) for colour in (200, 255))
|
||||||
|
|
||||||
|
assert remove_white_shades(colours) == [(200, 30, 40), (255, 30, 40)]
|
||||||
|
|
||||||
|
|
||||||
|
def test_remove_white_shades_leaves_input_unchanged() -> None:
|
||||||
|
colours: list[Colour] = [(255, 255, 255), (200, 30, 40)]
|
||||||
|
|
||||||
|
remove_white_shades(colours)
|
||||||
|
|
||||||
|
assert colours == [(255, 255, 255), (200, 30, 40)]
|
||||||
|
|
||||||
|
|
||||||
|
def test_extract_palette_returns_colours_without_white_shades(tmp_path: Path) -> None:
|
||||||
|
image_path = tmp_path / "stripes.png"
|
||||||
|
_write_stripes(
|
||||||
|
image_path,
|
||||||
|
[(200, 30, 40), (255, 255, 255), (30, 90, 160), (240, 240, 240)],
|
||||||
|
)
|
||||||
|
|
||||||
|
assert set(extract_palette(image_path)) == {(200, 30, 40), (30, 90, 160)}
|
||||||
|
|
||||||
|
|
||||||
|
def test_extract_palette_returns_requested_count_when_image_has_more_colours(
|
||||||
|
tmp_path: Path,
|
||||||
|
) -> None:
|
||||||
|
image_path = tmp_path / "stripes.png"
|
||||||
|
_write_stripes(
|
||||||
|
image_path,
|
||||||
|
[(200, 30, 40), (30, 90, 160), (20, 120, 60), (90, 40, 130)],
|
||||||
|
)
|
||||||
|
|
||||||
|
assert len(extract_palette(image_path, colour_count=2)) == 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_extract_palette_raises_when_image_is_missing(tmp_path: Path) -> None:
|
||||||
|
with pytest.raises(FileNotFoundError):
|
||||||
|
extract_palette(tmp_path / "missing.png")
|
||||||
|
|
||||||
|
|
||||||
|
def test_reference_image_palette_has_two_colours_and_no_white_shade() -> None:
|
||||||
|
palette = extract_palette(REFERENCE_IMAGE_PATH)
|
||||||
|
|
||||||
|
assert len(palette) >= 2
|
||||||
|
assert not any(is_white_shade(colour) for colour in palette)
|
||||||
Reference in New Issue
Block a user