diff --git a/.flywheel.yml b/.flywheel.yml new file mode 100644 index 0000000..5336192 --- /dev/null +++ b/.flywheel.yml @@ -0,0 +1,7 @@ +flywheel: + streams: + - name: main-line + branches: + - name: main + release: production + auto_merge: [fix, chore, docs] diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..438ee56 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,66 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + +jobs: + # ruff and pyright are pinned to py312 in pyproject.toml, so one leg + # carries the full lint signal; only pytest is version-sensitive. + lint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + - uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0 + with: + python-version: "3.12" + - run: uv sync --all-extras + - run: uv run ruff format --check . + - run: uv run ruff check . + - run: uv run pyright + + test: + runs-on: ubuntu-latest + strategy: + matrix: + python-version: ["3.12", "3.13", "3.14"] + steps: + - uses: actions/checkout@v5 + - uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0 + with: + python-version: ${{ matrix.python-version }} + - run: uv sync --all-extras + - run: uv run pytest + + # Prove the numpy-only core: install the built wheel without extras into a + # clean environment and run the core probe (§spec:package-shape). + core-install: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + with: + fetch-depth: 0 # hatch-vcs derives the version from tag history + - uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0 + with: + python-version: "3.12" + - run: uv build + - run: uv run --isolated --no-project --with dist/*.whl python tools/check_core_install.py + + # The extraction gate: moved modules render array-for-array equal to + # bmd-signal-gen's in-tree implementation (§spec:extraction). + equivalence: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + - uses: actions/checkout@v5 + with: + repository: OpenLEDEval/bmd-signal-gen + path: bmd-signal-gen + - uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0 + with: + python-version: "3.12" + - run: uv sync --all-extras + - run: uv run pytest tests/test_bmd_equivalence.py + env: + BMD_SIGNAL_GEN_REPO: ${{ github.workspace }}/bmd-signal-gen diff --git a/.github/workflows/flywheel-pr.yml b/.github/workflows/flywheel-pr.yml new file mode 100644 index 0000000..b1a7c08 --- /dev/null +++ b/.github/workflows/flywheel-pr.yml @@ -0,0 +1,26 @@ +name: Flywheel — PR +on: + pull_request: + types: [opened, synchronize, reopened, ready_for_review, edited] +concurrency: + group: flywheel-pr-${{ github.event.pull_request.number }} + cancel-in-progress: true +jobs: + conduct: + # Skip drafts; ignore `edited` events from bots (only a human editing the + # title/body should retrigger the conventional-commit rewrite). Skips + # cleanly until the flywheel GitHub App is installed on the org and its + # credentials are configured. + if: | + vars.FLYWHEEL_GH_APP_ID != '' && + github.event.pull_request.draft == false && + (github.event.action != 'edited' || github.event.sender.type == 'User') + runs-on: ubuntu-latest + steps: + # SHA-pinned (no floating tags for third-party actions); the trailing + # # vX.Y.Z comment is for human review. Mirrors pydecklink's setup. + - uses: point-source/flywheel@59758d1940ebe054ce9fdd4b7a7dc79a0cf235e0 # v2.1.0 + with: + event: pull_request + app-id: ${{ vars.FLYWHEEL_GH_APP_ID }} + app-private-key: ${{ secrets.FLYWHEEL_GH_APP_PRIVATE_KEY }} diff --git a/.github/workflows/flywheel-push.yml b/.github/workflows/flywheel-push.yml new file mode 100644 index 0000000..02d9764 --- /dev/null +++ b/.github/workflows/flywheel-push.yml @@ -0,0 +1,22 @@ +name: Flywheel — Push +on: + push: + branches: ["**"] +concurrency: + group: flywheel-push-${{ github.ref_name }} + cancel-in-progress: false +jobs: + release: + # Skips cleanly until the flywheel GitHub App is installed on the org + # and its credentials are configured (vars.FLYWHEEL_GH_APP_ID, + # secrets.FLYWHEEL_GH_APP_PRIVATE_KEY). + if: vars.FLYWHEEL_GH_APP_ID != '' + runs-on: ubuntu-latest + steps: + # SHA-pinned (no floating tags for third-party actions); the trailing + # # vX.Y.Z comment is for human review. Mirrors pydecklink's setup. + - uses: point-source/flywheel@59758d1940ebe054ce9fdd4b7a7dc79a0cf235e0 # v2.1.0 + with: + event: push + app-id: ${{ vars.FLYWHEEL_GH_APP_ID }} + app-private-key: ${{ secrets.FLYWHEEL_GH_APP_PRIVATE_KEY }} diff --git a/.github/workflows/governance-lint.yml b/.github/workflows/governance-lint.yml new file mode 100644 index 0000000..8d90471 --- /dev/null +++ b/.github/workflows/governance-lint.yml @@ -0,0 +1,11 @@ +name: Governance Lint +on: + push: + branches: [main] + pull_request: + +jobs: + lint: + uses: repentsinner/symphonize/.github/workflows/governance-lint.yml@notation--v0 + with: + readme-type: "library" diff --git a/.markdownlint.json b/.markdownlint.json new file mode 100644 index 0000000..434659d --- /dev/null +++ b/.markdownlint.json @@ -0,0 +1 @@ +{"MD013": false, "MD024": false, "MD036": false} diff --git a/.python-version b/.python-version new file mode 100644 index 0000000..e4fba21 --- /dev/null +++ b/.python-version @@ -0,0 +1 @@ +3.12 diff --git a/.vale.ini b/.vale.ini new file mode 100644 index 0000000..59b603f --- /dev/null +++ b/.vale.ini @@ -0,0 +1,8 @@ +StylesPath = styles +MinAlertLevel = warning + +[SPEC.md] +BasedOnStyles = Requirements + +[REQUIREMENTS.md] +BasedOnStyles = Requirements diff --git a/README.md b/README.md index b66abbe..1c038d1 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,53 @@ which consumes it back over SDI. Sibling consumers and context: [color-wrangler](https://github.com/Fuse-Technical-Group/color-wrangler) (LED-surface characterization umbrella) and its component repos. +## Installation + +```sh +pip install display-patterns # core: numpy only +pip install "display-patterns[charts,io]" # chart authoring + TIFF export +``` + +Until the first PyPI release, install from a checkout: +`pip install .` (or `uv pip install .`). + +## Usage + +Render a 12-bit checkerboard at exact code values: + +```python +from display_patterns.image_generators import ROI, PatternGenerator + +generator = PatternGenerator( + bit_depth=12, width=1920, height=1080, roi=ROI(0, 0, 1920, 1080) +) +frame = generator.generate([[4095, 2048, 0], [0, 0, 0]]) # uint16 (1080, 1920, 3) +``` + +With the `charts` and `io` extras, author a chart in YAML, render it, +and write a 16-bit TIFF: + +```python +from display_patterns.charts import render_chart, write_chart_tiff +from display_patterns.charts.loaders import load_chart + +layout = load_chart("my_chart.yaml") +image = render_chart(layout, bit_depth=12) +write_chart_tiff("my_chart.tiff", image, layout) +``` + +## API + +- `display_patterns.image_generators` — core pattern math: + `PatternGenerator`, `ROI`, `ColorRangeError`. Numpy only. +- `display_patterns.charts` — chart types, colorimetric conversion, and + rendering (`charts` extra); TIFF read/write (`io` extra). +- `display_patterns.charts.loaders` — YAML chart definitions + (`charts` extra). + +All public functions and classes carry NumPy-style docstrings; the +package ships `py.typed` for static type checking. + ## Governance - [REQUIREMENTS.md](REQUIREMENTS.md) — the problem space diff --git a/REQUIREMENTS.md b/REQUIREMENTS.md index c3ba471..3b8fffb 100644 --- a/REQUIREMENTS.md +++ b/REQUIREMENTS.md @@ -14,7 +14,7 @@ real-time render graphs, and measurement pipelines. Test-pattern math lives trapped inside device tools. bmd-signal-gen's checkerboard and chart generation imports nothing from its DeckLink -layer, yet a consumer who wants only the patterns must install the +layer, yet a consumer who wants only the patterns installs the whole device tool. backlit_molecule re-derived its own frame-counter panel math because no importable source existed. Each new consumer rewrites geometry that is pure, deterministic, and identical across @@ -108,7 +108,7 @@ caller's back cannot make a checkable radiometric claim. ## Priorities §req:priorities -Must-have, in adoption order: +Essential, in adoption order: 1. Core catalog with exact code values: solid, checkerboard, region of interest, bit-depth validation. diff --git a/ROADMAP.md b/ROADMAP.md index b2c2fc4..cac220f 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -11,26 +11,13 @@ Walking skeleton: an installable package whose patterns are bmd-signal-gen's, moved verbatim so the bit-identical adoption claim is checkable before any reshaping (§spec:extraction). -### Package scaffold §road:package-scaffold - -Scaffold the Python project (uv, hatchling, ruff/pyright/pytest, -governance-lint CI, release-please) with the `display_patterns` -package, empty `charts` and `io` extras, and Python 3.12+ metadata. -§spec:package-shape. - -### Verbatim module move §road:verbatim-move - -Move `bmd_sg/image_generators/` and `bmd_sg/charts/` with their tests -into `display_patterns`, changing imports only — core fills in the -base package, chart and TIFF modules behind the `charts` and `io` -extras. §spec:extraction, §spec:catalog, §spec:file-export. -Depends on §road:package-scaffold. - ### First release §road:first-release -Publish 0.1 to PyPI via trusted publishing (configuring the PyPI -project is a human step). §spec:package-shape. Depends on -§road:verbatim-move. +Publish 0.1 to PyPI via trusted publishing. §spec:package-shape. +Blocked — deferred until the first rounds of cross-repo integration +testing complete and the PyPI org is confirmed (pending the possible +OpenLEDEval → OpenDisplayEval rename); consumers integrate via a git +dependency meanwhile. **Verify:** In a fresh environment, `pip install display-patterns` imports and renders a checkerboard with numpy alone; `pip install @@ -48,7 +35,7 @@ equivalence is banked — visible, separate changes (§spec:extraction). Reshape catalog entry points to the pure rendering signature — parameters, frame index, caller-supplied array namespace with optional device, numpy default — with stills ignoring the index. -§spec:render-model. Depends on §road:verbatim-move. +§spec:render-model. Depends on §road:extraction-spine. ### Counter panel codec §road:counter-panel diff --git a/SPEC.md b/SPEC.md index f71759f..f6b30fb 100644 --- a/SPEC.md +++ b/SPEC.md @@ -23,7 +23,7 @@ there). ## Package shape §spec:package-shape -*Status: not started* +*Status: in progress* The distribution is `display-patterns` on PyPI; the import package is `display_patterns`. The core installs with numpy as its only @@ -81,7 +81,7 @@ caller. ## Catalog §spec:catalog -*Status: not started* +*Status: in progress* The core catalog, renderable with numpy alone (§req:success-criteria): @@ -117,7 +117,7 @@ model and carries a decode side when one is meaningful. ## Extraction and compatibility §spec:extraction -*Status: not started* +*Status: in progress* The initial code is bmd-signal-gen's `bmd_sg/image_generators/` and `bmd_sg/charts/` with their tests, moved verbatim before any @@ -134,7 +134,7 @@ after the equivalence claim is banked. ## File export §spec:file-export -*Status: not started* +*Status: in progress* The `io` extra writes rendered arrays to 16-bit TIFF, serving the chart review workflow (§req:user-stories). Export is one-way — the diff --git a/display_patterns/__init__.py b/display_patterns/__init__.py new file mode 100644 index 0000000..0877c7a --- /dev/null +++ b/display_patterns/__init__.py @@ -0,0 +1,7 @@ +""" +display-patterns: deterministic display test patterns, device-free and exact. + +The core package depends on numpy alone. Chart authoring and TIFF export +live in the ``display_patterns.charts`` subpackage behind the ``charts`` +and ``io`` extras. +""" diff --git a/display_patterns/charts/__init__.py b/display_patterns/charts/__init__.py new file mode 100644 index 0000000..ad7e7a1 --- /dev/null +++ b/display_patterns/charts/__init__.py @@ -0,0 +1,57 @@ +""" +Chart production: colorimetric patch lists rendered to display RGB +(§spec:catalog). + +``color_types`` (numpy-only) loads eagerly; the conversion, renderer, +and TIFF modules load on first attribute access, so importing this +package pays only for what the caller uses — colour-science and +Pillow arrive with the ``charts`` extra, tifffile with ``io`` +(§spec:package-shape). +""" + +import importlib +from typing import TYPE_CHECKING + +from display_patterns.charts.color_types import ChartLayout, ColorValue, Patch + +if TYPE_CHECKING: + from display_patterns.charts.conversion import xyz_to_display_rgb + from display_patterns.charts.renderer import render_chart + from display_patterns.charts.tiff_reader import TiffMetadata, load_chart_tiff + from display_patterns.charts.tiff_writer import write_chart_tiff + +# name -> (submodule, extra supplying its dependencies). The runtime +# source of truth for the lazy exports; __all__ derives from it, and +# the TYPE_CHECKING block above mirrors it for static analysis. +_LAZY_EXPORTS = { + "xyz_to_display_rgb": ("display_patterns.charts.conversion", "charts"), + "render_chart": ("display_patterns.charts.renderer", "charts"), + "TiffMetadata": ("display_patterns.charts.tiff_reader", "io"), + "load_chart_tiff": ("display_patterns.charts.tiff_reader", "io"), + "write_chart_tiff": ("display_patterns.charts.tiff_writer", "io"), +} + +# Literal so ruff recognizes the re-exports (F401); a test asserts every +# name resolves, keeping this list and _LAZY_EXPORTS from drifting. +__all__ = [ + "ChartLayout", + "ColorValue", + "Patch", + "TiffMetadata", + "load_chart_tiff", + "render_chart", + "write_chart_tiff", + "xyz_to_display_rgb", +] + + +def __getattr__(name: str) -> object: + if name not in _LAZY_EXPORTS: + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") + module, extra = _LAZY_EXPORTS[name] + try: + return getattr(importlib.import_module(module), name) + except ModuleNotFoundError as error: + raise ModuleNotFoundError( + f"{name} needs the {extra!r} extra: pip install 'display-patterns[{extra}]'" + ) from error diff --git a/display_patterns/charts/color_types.py b/display_patterns/charts/color_types.py new file mode 100644 index 0000000..15e5478 --- /dev/null +++ b/display_patterns/charts/color_types.py @@ -0,0 +1,430 @@ +""" +Color types and data structures for chart generation. + +This module defines the core data types for representing color values, +patches, and chart layouts. +""" + +from dataclasses import dataclass, field +from enum import Enum +from typing import Self + +import numpy as np +from numpy.typing import NDArray + + +class ColorSpace(Enum): + """Supported color spaces for chart generation.""" + + XYZ = "XYZ" + REC709 = "ITU-R BT.709" + P3_D65 = "P3-D65" + REC2020 = "ITU-R BT.2020" + + @classmethod + def parse(cls, value: str) -> "ColorSpace": + """ + Parse a colorspace from its string value. + + Parameters + ---------- + value : str + The colorspace string (e.g., "ITU-R BT.709", "ITU-R BT.2020"). + + Returns + ------- + ColorSpace + The matching ColorSpace enum member. + + Raises + ------ + ValueError + If the value doesn't match any known colorspace. + """ + for member in cls: + if member.value == value: + return member + valid = ", ".join(f"'{m.value}'" for m in cls) + raise ValueError(f"Unknown colorspace: '{value}'. Valid: {valid}") + + +class TransferFunction(Enum): + """Supported transfer functions for encoding.""" + + LINEAR = "linear" + SRGB = "sRGB" + GAMMA_22 = "gamma2.2" + PQ = "ST.2084" + HLG = "HLG" + + @classmethod + def parse(cls, value: str) -> "TransferFunction": + """ + Parse a transfer function from its string value. + + Parameters + ---------- + value : str + The transfer function string (e.g., "sRGB", "ST.2084"). + + Returns + ------- + TransferFunction + The matching TransferFunction enum member. + + Raises + ------ + ValueError + If the value doesn't match any known transfer function. + """ + for member in cls: + if member.value == value: + return member + valid = ", ".join(f"'{m.value}'" for m in cls) + raise ValueError(f"Unknown transfer function: '{value}'. Valid: {valid}") + + +class Illuminant(Enum): + """CIE standard illuminants for XYZ reference white.""" + + D65 = "D65" # Average daylight (~6500K), standard for broadcast/display + D50 = "D50" # Horizon light (~5000K), standard for print/ICC profiles + D55 = "D55" # Mid-morning/afternoon daylight (~5500K) + A = "A" # Incandescent/tungsten (~2856K) + E = "E" # Equal energy (theoretical reference) + + @classmethod + def parse(cls, value: str) -> "Illuminant": + """ + Parse an illuminant from its string value. + + Parameters + ---------- + value : str + The illuminant string (e.g., "D65", "D50"). + + Returns + ------- + Illuminant + The matching Illuminant enum member. + + Raises + ------ + ValueError + If the value doesn't match any known illuminant. + """ + for member in cls: + if member.value == value: + return member + valid = ", ".join(f"'{m.value}'" for m in cls) + raise ValueError(f"Unknown illuminant: '{value}'. Valid: {valid}") + + +@dataclass +class LightSource: + """ + Light source specification for chromatic adaptation simulation. + + Represents a light source that can be used to simulate how an XYZ-defined + chart would appear when lit by different illuminants. Supports two modes: + + 1. CCT (Correlated Color Temperature) - uses Planckian (blackbody) locus + Best for matching cinema/production lights (Arri Skypanel, etc.) + + 2. D-series Illuminant - uses CIE daylight locus + Best for daylight simulation (D50, D55, D65, etc.) + + Only one of cct or illuminant should be specified. + + Parameters + ---------- + cct : int | None + Color temperature in Kelvin (e.g., 5600 for tungsten-balanced daylight). + Uses Planckian locus for chromaticity calculation. + illuminant : Illuminant | None + CIE standard illuminant (e.g., D55, D65). + Uses daylight locus for chromaticity calculation. + + Examples + -------- + >>> # For matching a Skypanel at 5600K + >>> light = LightSource(cct=5600) + >>> xy = light.to_xy() + + >>> # For simulating D55 daylight + >>> light = LightSource(illuminant=Illuminant.D55) + >>> xy = light.to_xy() + """ + + cct: int | None = None + illuminant: Illuminant | None = None + + def __post_init__(self) -> None: + """Validate that exactly one source type is specified.""" + if self.cct is None and self.illuminant is None: + msg = "LightSource requires either cct or illuminant" + raise ValueError(msg) + if self.cct is not None and self.illuminant is not None: + msg = "LightSource cannot have both cct and illuminant" + raise ValueError(msg) + if self.cct is not None and (self.cct < 1000 or self.cct > 25000): + msg = f"CCT must be between 1000K and 25000K, got {self.cct}K" + raise ValueError(msg) + + def to_xy(self) -> tuple[float, float]: + """ + Get CIE 1931 xy chromaticity coordinates for this light source. + + Returns + ------- + tuple[float, float] + CIE xy chromaticity coordinates. + """ + import colour + + if self.cct is not None: + # Use Planckian locus for CCT + # Kang 2002 method is accurate for 1667K-25000K + xy = colour.temperature.CCT_to_xy(self.cct, method="Kang 2002") + return (float(xy[0]), float(xy[1])) + else: + # Use CIE standard illuminant + assert self.illuminant is not None + xy = colour.CCS_ILLUMINANTS["CIE 1931 2 Degree Standard Observer"][ + self.illuminant.value + ] + return (float(xy[0]), float(xy[1])) + + def __str__(self) -> str: + """Human-readable representation.""" + if self.cct is not None: + return f"{self.cct}K (Planckian)" + else: + assert self.illuminant is not None + return f"{self.illuminant.value} (Daylight)" + + +class PatternType(Enum): + """Pattern types for patch rendering. + + Defines how a patch should be rendered - either as a solid color + or as a checkerboard pattern for gamma-invariant luminance measurement. + + Checkerboard patterns use 2x2 pixel repeating patterns of 100% white + and 0% black to produce gamma-invariant luminance values: + - CHECKERBOARD_25: 1 white + 3 black pixels = 25% luminance + - CHECKERBOARD_50: 2 white + 2 black pixels = 50% luminance + - CHECKERBOARD_75: 3 white + 1 black pixels = 75% luminance + """ + + SOLID = "solid" + CHECKERBOARD_25 = "checkerboard_25" # 2x2: 1 white, 3 black + CHECKERBOARD_50 = "checkerboard_50" # 2x2: 2 white, 2 black (diagonal) + CHECKERBOARD_75 = "checkerboard_75" # 2x2: 3 white, 1 black + + @classmethod + def parse(cls, value: str) -> "PatternType": + """ + Parse a pattern type from its string value. + + Parameters + ---------- + value : str + The pattern type string (e.g., "solid", "checkerboard_50"). + + Returns + ------- + PatternType + The matching PatternType enum member. + + Raises + ------ + ValueError + If the value doesn't match any known pattern type. + """ + for member in cls: + if member.value == value: + return member + valid = ", ".join(f"'{m.value}'" for m in cls) + raise ValueError(f"Unknown pattern type: '{value}'. Valid: {valid}") + + +@dataclass +class Colorimetry: + """ + Colorimetry metadata for a chart. + + This captures the color space, illuminant, and normalization parameters + needed to correctly interpret and convert the chart's color values. + + Parameters + ---------- + color_space : ColorSpace + The color space of the chart's color values. + illuminant : Illuminant + The CIE standard illuminant the XYZ values are referenced to. + white_point : tuple[float, float] + The white point chromaticity coordinates (x, y). + reference_white_Y : float + The Y value that corresponds to white (typically 100.0). + """ + + color_space: ColorSpace = ColorSpace.XYZ + illuminant: Illuminant = Illuminant.D65 + white_point: tuple[float, float] = (0.3127, 0.329) # D65 chromaticity + reference_white_Y: float = 100.0 + + +@dataclass +class AnnotationStripe: + """ + Position of an annotation stripe. + + Parameters + ---------- + y_start : float + Starting Y position as percentage (0.0-1.0). + y_end : float + Ending Y position as percentage (0.0-1.0). + """ + + y_start: float + y_end: float + + +@dataclass +class AnnotationLayout: + """ + Layout positions for annotation stripes. + + Parameters + ---------- + top_stripe : AnnotationStripe | None + Top annotation stripe (typically for encoding info). + bottom_stripe : AnnotationStripe | None + Bottom annotation stripe (typically for chart metadata). + """ + + top_stripe: AnnotationStripe | None = None + bottom_stripe: AnnotationStripe | None = None + + +@dataclass +class Canvas: + """ + Chart canvas dimensions and embedding settings. + + The canvas defines the native size of the chart content. When rendering + to a larger output frame, the chart is centered with surround color fill. + + Parameters + ---------- + width : int + Canvas width in pixels. + height : int + Canvas height in pixels. + surround : tuple[float, float, float] + RGB surround color (0.0-1.0) for embedding in larger frames. + Default is black (0.0, 0.0, 0.0). + """ + + width: int = 1920 + height: int = 1080 + surround: tuple[float, float, float] = (0.0, 0.0, 0.0) + + +@dataclass +class ColorValue: + """ + A color value with its color space. + + Parameters + ---------- + values : NDArray[np.float64] + Color channel values (3 components). + space : ColorSpace + The color space of the values. + """ + + values: NDArray[np.float64] + space: ColorSpace + + @classmethod + def from_xyz(cls, x: float, y: float, z: float) -> Self: + """Create a color value from XYZ tristimulus values.""" + return cls(values=np.array([x, y, z], dtype=np.float64), space=ColorSpace.XYZ) + + @classmethod + def from_rgb( + cls, r: float, g: float, b: float, space: ColorSpace = ColorSpace.REC709 + ) -> Self: + """Create a color value from RGB values (0-1 normalized).""" + return cls(values=np.array([r, g, b], dtype=np.float64), space=space) + + +@dataclass +class Patch: + """ + A color patch with percentage-based position. + + Parameters + ---------- + name : str + Patch identifier (e.g., "GS 1", "Red"). + x_pct : float + Left edge position as percentage (0.0-1.0). + y_pct : float + Top edge position as percentage (0.0-1.0). + width_pct : float + Width as percentage of total width (0.0-1.0). + height_pct : float + Height as percentage of total height (0.0-1.0). + color : ColorValue + The color of this patch (used for solid patterns, ignored for checkerboards). + pattern : PatternType + How to render the patch (solid color or checkerboard pattern). + label_text : str | None + Optional text to display on the patch for measurement validation. + """ + + name: str + x_pct: float + y_pct: float + width_pct: float + height_pct: float + color: ColorValue + pattern: PatternType = PatternType.SOLID + label_text: str | None = None + + +@dataclass +class ChartLayout: + """ + A complete chart layout with patches. + + Parameters + ---------- + name : str + Chart name (e.g., "My Color Chart"). + patches : list[Patch] + List of color patches in the chart. + source : str | None + Source file or description. + colorimetry : Colorimetry | None + Colorimetry metadata for the chart (illuminant, white point, etc.). + annotations : AnnotationLayout | None + Layout positions for annotation stripes. + canvas : Canvas | None + Canvas dimensions for chart rendering. If None, defaults to 1920x1080. + """ + + name: str + patches: list[Patch] = field(default_factory=list) + source: str | None = None + colorimetry: Colorimetry | None = None + annotations: AnnotationLayout | None = None + canvas: Canvas | None = None + + def add_patch(self, patch: Patch) -> None: + """Add a patch to the layout.""" + self.patches.append(patch) diff --git a/display_patterns/charts/conversion.py b/display_patterns/charts/conversion.py new file mode 100644 index 0000000..12f0c6b --- /dev/null +++ b/display_patterns/charts/conversion.py @@ -0,0 +1,254 @@ +""" +Color space conversion utilities using colour-science. + +This module wraps the colour-science library for XYZ to RGB conversion +with proper illuminant handling and transfer function encoding. +""" + +import colour +import numpy as np +from numpy.typing import NDArray + +from display_patterns.charts.color_types import ( + ColorSpace, + ColorValue, + Illuminant, + LightSource, + TransferFunction, +) + + +def apply_chromatic_adaptation( + xyz: NDArray[np.float64], + source_illuminant: Illuminant, + target_light_source: LightSource, + transform: str = "Bradford", +) -> NDArray[np.float64]: + """ + Apply chromatic adaptation to simulate different illumination. + + This transforms XYZ values from one illuminant to another, simulating + how a surface would appear when lit by a different light source. + + Parameters + ---------- + xyz : NDArray[np.float64] + XYZ tristimulus values (normalized, Y=1 for white). + source_illuminant : Illuminant + The CIE standard illuminant the XYZ values were measured/calculated under. + target_light_source : LightSource + The light source to simulate (CCT or D-series illuminant). + transform : str + Chromatic adaptation transform to use. Default is "Bradford". + Other options: "Von Kries", "CAT02", "CAT16". + + Returns + ------- + NDArray[np.float64] + Adapted XYZ values under the target illuminant. + + Notes + ----- + The Bradford transform is the industry standard for most applications. + It handles blue adaptation better than Von Kries. + + Examples + -------- + >>> # Simulate D65 chart lit by 5600K light + >>> xyz_d65 = np.array([0.95, 1.0, 1.09]) + >>> light_5600k = LightSource(cct=5600) + >>> xyz_adapted = apply_chromatic_adaptation( + ... xyz_d65, Illuminant.D65, light_5600k + ... ) + """ + # Get source white point from illuminant + source_xy = colour.CCS_ILLUMINANTS["CIE 1931 2 Degree Standard Observer"][ + source_illuminant.value + ] + source_XYZ = colour.xy_to_XYZ(source_xy) + + # Get target white point from light source + target_xy = target_light_source.to_xy() + target_XYZ = colour.xy_to_XYZ(target_xy) + + # Apply chromatic adaptation transform + adapted_xyz = colour.chromatic_adaptation( + XYZ=xyz, + XYZ_w=source_XYZ, + XYZ_wr=target_XYZ, + transform=transform, + ) + + return np.asarray(adapted_xyz, dtype=np.float64) + + +def xyz_to_display_rgb( + color: ColorValue, + target_space: ColorSpace = ColorSpace.REC709, + transfer_function: TransferFunction = TransferFunction.SRGB, + reference_white_Y: float = 100.0, + illuminant: Illuminant = Illuminant.D65, + simulation_light_source: LightSource | None = None, +) -> NDArray[np.float64]: + """ + Convert XYZ color value to display-ready RGB. + + Parameters + ---------- + color : ColorValue + Input color in XYZ space. + target_space : ColorSpace + Target RGB color space. + transfer_function : TransferFunction + Transfer function to apply (encoding). + reference_white_Y : float + The Y value that corresponds to white (default 100 for Y=100 normalized data). + illuminant : Illuminant + The CIE standard illuminant the XYZ values are referenced to. + This must match the illuminant used when the XYZ values were calculated. + simulation_light_source : LightSource | None + If provided, apply chromatic adaptation to simulate how the chart would + appear when lit by this light source (CCT or D-series illuminant). + The adaptation is applied before XYZ→RGB conversion. + + Returns + ------- + NDArray[np.float64] + Encoded RGB values in range [0, 1]. + + Raises + ------ + ValueError + If input color is not in XYZ space. + """ + if color.space != ColorSpace.XYZ: + msg = f"Expected XYZ color, got {color.space}" + raise ValueError(msg) + + # Normalize XYZ by reference white Y + xyz_normalized = color.values / reference_white_Y + + # Apply chromatic adaptation if simulating different illumination + if simulation_light_source is not None: + xyz_normalized = apply_chromatic_adaptation( + xyz_normalized, + source_illuminant=illuminant, + target_light_source=simulation_light_source, + ) + + # Map our ColorSpace enum to colour-science colorspace names + colorspace_map = { + ColorSpace.REC709: "ITU-R BT.709", + ColorSpace.P3_D65: "P3-D65", + ColorSpace.REC2020: "ITU-R BT.2020", + } + cs_name = colorspace_map.get(target_space) + if cs_name is None: + msg = f"Unsupported target colorspace: {target_space}" + raise ValueError(msg) + + # Get the colorspace definition + cs = colour.RGB_COLOURSPACES[cs_name] + + # Get the illuminant for XYZ→RGB conversion + # IMPORTANT: After chromatic adaptation, we want the color shift to remain + # visible on the display. We use the colorspace's native white point (D65) + # so that the adapted XYZ values are NOT reverse-adapted back. + # + # If no simulation is applied, we use the chart's native illuminant so that + # colours are correctly adapted to the display's D65 white point. + if simulation_light_source is not None: + # After CAT: use colorspace native white point to preserve the color shift + # The adapted XYZ already contains the "warmth" we want to show + illuminant_xy = cs.whitepoint + else: + # No simulation: use chart illuminant for proper display adaptation + illuminant_xy = colour.CCS_ILLUMINANTS["CIE 1931 2 Degree Standard Observer"][ + illuminant.value + ] + + # XYZ to linear RGB using colour-science + linear_rgb = colour.XYZ_to_RGB( + xyz_normalized, + colourspace=cs, + illuminant=illuminant_xy, + ) + + # Clip to gamut + linear_rgb = np.clip(linear_rgb, 0.0, 1.0) + + # Apply transfer function (encoding) + if transfer_function == TransferFunction.LINEAR: + encoded_rgb = linear_rgb + elif transfer_function == TransferFunction.SRGB: + encoded_rgb = colour.cctf_encoding(linear_rgb, function="sRGB") + elif transfer_function == TransferFunction.GAMMA_22: + encoded_rgb = np.power(linear_rgb, 1.0 / 2.2) + elif transfer_function == TransferFunction.PQ: + # PQ expects absolute luminance in cd/m², need to scale + # For now, assume reference_white maps to SDR white level + encoded_rgb = colour.cctf_encoding( + linear_rgb, + function="ST 2084", + L_p=10000, + ) + elif transfer_function == TransferFunction.HLG: + encoded_rgb = colour.cctf_encoding(linear_rgb, function="HLG") + else: + msg = f"Unsupported transfer function: {transfer_function}" + raise ValueError(msg) + + return np.asarray(encoded_rgb, dtype=np.float64) + + +def rgb_to_xyz( + color: ColorValue, + reference_white_Y: float = 100.0, + illuminant: Illuminant = Illuminant.D65, +) -> ColorValue: + """ + Convert RGB color value to XYZ. + + Parameters + ---------- + color : ColorValue + Input color in an RGB space. + reference_white_Y : float + The Y value for white (for denormalization). + illuminant : Illuminant + The CIE standard illuminant for the output XYZ values. + + Returns + ------- + ColorValue + Color in XYZ space. + """ + if color.space == ColorSpace.XYZ: + return color + + colorspace_map = { + ColorSpace.REC709: "ITU-R BT.709", + ColorSpace.P3_D65: "P3-D65", + ColorSpace.REC2020: "ITU-R BT.2020", + } + cs_name = colorspace_map.get(color.space) + if cs_name is None: + msg = f"Unsupported source colorspace: {color.space}" + raise ValueError(msg) + + cs = colour.RGB_COLOURSPACES[cs_name] + + # Map our Illuminant enum to colour-science illuminant names + illuminant_xy = colour.CCS_ILLUMINANTS["CIE 1931 2 Degree Standard Observer"][ + illuminant.value + ] + + xyz_normalized = colour.RGB_to_XYZ( + color.values, + colourspace=cs, + illuminant=illuminant_xy, + ) + + xyz = xyz_normalized * reference_white_Y + + return ColorValue(values=np.asarray(xyz, dtype=np.float64), space=ColorSpace.XYZ) diff --git a/display_patterns/charts/loaders/__init__.py b/display_patterns/charts/loaders/__init__.py new file mode 100644 index 0000000..ee2dd72 --- /dev/null +++ b/display_patterns/charts/loaders/__init__.py @@ -0,0 +1,9 @@ +""" +Chart data loaders. +""" + +from display_patterns.charts.loaders.yaml_chart import load_chart + +__all__ = [ + "load_chart", +] diff --git a/display_patterns/charts/loaders/yaml_chart.py b/display_patterns/charts/loaders/yaml_chart.py new file mode 100644 index 0000000..3fa07da --- /dev/null +++ b/display_patterns/charts/loaders/yaml_chart.py @@ -0,0 +1,273 @@ +""" +YAML chart loader. + +Loads color chart definitions from YAML files with support for +XYZ and RGB color spaces, patch layouts, and embedded metadata. +""" + +from pathlib import Path +from typing import Any + +import yaml + +from display_patterns.charts.color_types import ( + AnnotationLayout, + AnnotationStripe, + Canvas, + ChartLayout, + Colorimetry, + ColorSpace, + ColorValue, + Illuminant, + Patch, + PatternType, +) + + +def load_chart( + path: Path | str, + include_labels: bool = True, +) -> ChartLayout: + """ + Load a chart from a YAML file. + + Parameters + ---------- + path : Path | str + Path to the YAML chart file. + include_labels : bool + Whether to generate label text for each patch. + + Returns + ------- + ChartLayout + Chart layout with patches. + + Notes + ----- + YAML format: + ```yaml + name: "My Color Chart" + version: "1.0" + + colorimetry: + color_space: "XYZ" # or "Rec.709", "P3-D65", "Rec.2020" + white_point: [0.3127, 0.329] + reference_white_Y: 100.0 + + patches: + - name: "White" + color: [95.04, 99.99, 108.87] + layout: [0.0, 0.0, 0.5, 0.5] # left, top, width, height + ``` + """ + path = Path(path) + + with path.open() as f: + data = yaml.safe_load(f) + + # Parse metadata + chart_name = data.get("name", path.stem) + colorimetry_data = data.get("colorimetry", {}) + + # Parse color space + color_space_str = colorimetry_data.get("color_space", "XYZ") + color_space_map = { + "XYZ": ColorSpace.XYZ, + "Rec.709": ColorSpace.REC709, + "ITU-R BT.709": ColorSpace.REC709, + "P3-D65": ColorSpace.P3_D65, + "Rec.2020": ColorSpace.REC2020, + "ITU-R BT.2020": ColorSpace.REC2020, + } + color_space = color_space_map.get(color_space_str, ColorSpace.XYZ) + + # Parse illuminant (default to D65 for display/broadcast work) + illuminant_str = colorimetry_data.get("illuminant", "D65") + try: + illuminant = Illuminant.parse(illuminant_str) + except ValueError: + illuminant = Illuminant.D65 # Safe fallback + + # Parse white point chromaticity + white_point_list = colorimetry_data.get("white_point", [0.3127, 0.329]) + white_point = (float(white_point_list[0]), float(white_point_list[1])) + + reference_white_Y = colorimetry_data.get("reference_white_Y", 100.0) + + # Create Colorimetry object + colorimetry = Colorimetry( + color_space=color_space, + illuminant=illuminant, + white_point=white_point, + reference_white_Y=reference_white_Y, + ) + + # Parse annotations + annotations_data = data.get("annotations", {}) + annotations = None + if annotations_data: + top_stripe = None + bottom_stripe = None + + if "top_stripe" in annotations_data: + ts = annotations_data["top_stripe"] + top_stripe = AnnotationStripe( + y_start=float(ts.get("y_start", 0.17)), + y_end=float(ts.get("y_end", 0.21)), + ) + + if "bottom_stripe" in annotations_data: + bs = annotations_data["bottom_stripe"] + bottom_stripe = AnnotationStripe( + y_start=float(bs.get("y_start", 0.79)), + y_end=float(bs.get("y_end", 0.83)), + ) + + annotations = AnnotationLayout( + top_stripe=top_stripe, + bottom_stripe=bottom_stripe, + ) + + # Parse canvas + canvas_data = data.get("canvas", {}) + canvas = None + if canvas_data: + surround_list = canvas_data.get("surround", [0.0, 0.0, 0.0]) + if len(surround_list) >= 3: + surround = ( + float(surround_list[0]), + float(surround_list[1]), + float(surround_list[2]), + ) + else: + surround = (0.0, 0.0, 0.0) + + canvas = Canvas( + width=int(canvas_data.get("width", 1920)), + height=int(canvas_data.get("height", 1080)), + surround=surround, + ) + + # Parse patches + patches: list[Patch] = [] + patch_list = data.get("patches", []) + + for patch_data in patch_list: + patch = _parse_patch( + patch_data, + color_space=color_space, + include_labels=include_labels, + ) + if patch: + patches.append(patch) + + return ChartLayout( + name=chart_name, + patches=patches, + source=str(path), + colorimetry=colorimetry, + annotations=annotations, + canvas=canvas, + ) + + +def _parse_patch_color( + color_values: list[float], + color_space: ColorSpace, +) -> tuple[ColorValue, float, float | None, float | None]: + """ + Build the patch color and label metrics from raw YAML values. + + Returns + ------- + tuple[ColorValue, float, float | None, float | None] + (color, y_value, cie_x, cie_y). Chromaticity is None for RGB input. + """ + if color_space == ColorSpace.XYZ: + color = ColorValue.from_xyz(*color_values) + y_val = color_values[1] + # Calculate CIE 1931 x,y chromaticity + x_xyz, y_xyz, z_xyz = color_values + xyz_sum = x_xyz + y_xyz + z_xyz + if xyz_sum > 0: + cie_x = x_xyz / xyz_sum + cie_y = y_xyz / xyz_sum + else: + cie_x = cie_y = 0.0 + return color, y_val, cie_x, cie_y + + color = ColorValue.from_rgb(*color_values, space=color_space) + # Approximate luminance for labels + y_val = ( + 0.2126 * color_values[0] + 0.7152 * color_values[1] + 0.0722 * color_values[2] + ) + return color, y_val, None, None # Chromaticity not applicable for RGB + + +def _format_patch_label( + name: str, + color_space: ColorSpace, + y_val: float, + cie_x: float | None, + cie_y: float | None, +) -> str: + """Format the on-patch label for a color space and its label metrics.""" + if color_space == ColorSpace.XYZ: + # Greyscale patches (name starts with "GS"): just show Y value + if name.strip().upper().startswith("GS"): + return f"{name}\nY={y_val:.1f}" + # Chromatic: show only CIE x,y coordinates + return f"{name}\nx={cie_x:.4f}\ny={cie_y:.4f}" + return f"{name}\nL={y_val:.2f}" + + +def _parse_patch( + data: dict[str, Any], + color_space: ColorSpace, + include_labels: bool, +) -> Patch | None: + """Parse a single patch from YAML data.""" + name = data.get("name", "") + if not name: + return None + + # Parse color + color_values = data.get("color") + if color_values is None or len(color_values) != 3: + return None + + color, y_val, cie_x, cie_y = _parse_patch_color(color_values, color_space) + + # Parse position and size + pos = data.get("pos", [0, 0]) + size = data.get("size", [1, 1]) + if len(pos) != 2: + pos = [0, 0] + if len(size) != 2: + size = [1, 1] + + left, top = pos + width, height = size + + label_text = None + if include_labels: + label_text = _format_patch_label(name, color_space, y_val, cie_x, cie_y) + + # Parse pattern type (default to solid) + pattern_str = data.get("pattern", "solid") + try: + pattern = PatternType.parse(pattern_str) + except ValueError: + pattern = PatternType.SOLID # Safe fallback + + return Patch( + name=name, + x_pct=left, + y_pct=top, + width_pct=width, + height_pct=height, + color=color, + pattern=pattern, + label_text=label_text, + ) diff --git a/display_patterns/charts/renderer.py b/display_patterns/charts/renderer.py new file mode 100644 index 0000000..9d7ec43 --- /dev/null +++ b/display_patterns/charts/renderer.py @@ -0,0 +1,487 @@ +""" +Chart renderer for generating images from chart layouts. + +Renders charts to numpy arrays with optional text labels for +measurement validation. +""" + +import numpy as np +from numpy.typing import NDArray +from PIL import Image, ImageDraw, ImageFont + +from display_patterns.charts.color_types import ( + Canvas, + ChartLayout, + ColorSpace, + Illuminant, + LightSource, + PatternType, + TransferFunction, +) +from display_patterns.charts.conversion import xyz_to_display_rgb + + +def render_chart( + layout: ChartLayout, + output_width: int | None = None, + output_height: int | None = None, + bit_depth: int = 12, + target_space: ColorSpace = ColorSpace.REC709, + transfer_function: TransferFunction = TransferFunction.SRGB, + reference_white_Y: float = 100.0, + include_labels: bool = False, + simulation_light_source: LightSource | None = None, +) -> NDArray[np.uint16]: + """ + Render a chart layout to a numpy array. + + The chart is rendered at its native canvas dimensions (from layout.canvas, + or 1920x1080 by default). If output_width/output_height are specified and + differ from the canvas size, the chart is embedded pixel-for-pixel centered + in the output frame with the surround color filling the remaining area. + + Parameters + ---------- + layout : ChartLayout + The chart layout to render. + output_width : int | None + Output frame width in pixels. If None, uses canvas width. + output_height : int | None + Output frame height in pixels. If None, uses canvas height. + bit_depth : int + Output bit depth (8, 10, 12, or 16). + target_space : ColorSpace + Target RGB color space for conversion. + transfer_function : TransferFunction + Transfer function for encoding. + reference_white_Y : float + Reference white Y value for XYZ normalization. + include_labels : bool + Whether to render text labels on patches. + simulation_light_source : LightSource | None + If provided, apply chromatic adaptation to simulate how the chart + would appear when lit by this light source (CCT or D-series). + + Returns + ------- + NDArray[np.uint16] + Image data as uint16 array of shape (output_height, output_width, 3). + """ + # Get canvas dimensions (default to 1920x1080) + canvas = layout.canvas if layout.canvas else Canvas() + canvas_width = canvas.width + canvas_height = canvas.height + surround = canvas.surround + + # Determine output dimensions + out_width = output_width if output_width is not None else canvas_width + out_height = output_height if output_height is not None else canvas_height + + max_value = 2**bit_depth - 1 + + # Render chart at canvas size first + chart_image = _render_chart_content( + layout=layout, + width=canvas_width, + height=canvas_height, + bit_depth=bit_depth, + target_space=target_space, + transfer_function=transfer_function, + reference_white_Y=reference_white_Y, + include_labels=include_labels, + simulation_light_source=simulation_light_source, + ) + + # If output size matches canvas, we're done + if out_width == canvas_width and out_height == canvas_height: + return chart_image + + # Otherwise, embed chart at top-left of output frame with surround color + # Create output frame filled with surround color + surround_rgb = np.array(surround, dtype=np.float64) + surround_uint16 = np.clip(surround_rgb * max_value, 0, max_value).astype(np.uint16) + + output_image = np.zeros((out_height, out_width, 3), dtype=np.uint16) + output_image[:, :] = surround_uint16 + + # Place chart at top-left corner + output_image[0:canvas_height, 0:canvas_width] = chart_image + + return output_image + + +def _render_chart_content( + layout: ChartLayout, + width: int, + height: int, + bit_depth: int, + target_space: ColorSpace, + transfer_function: TransferFunction, + reference_white_Y: float, + include_labels: bool, + simulation_light_source: LightSource | None = None, +) -> NDArray[np.uint16]: + """ + Render chart content at specified dimensions. + + This is an internal function that renders the actual chart content + without frame embedding logic. + """ + max_value = 2**bit_depth - 1 + + # Create image as float first + image = np.zeros((height, width, 3), dtype=np.float64) + + # Get illuminant from chart colorimetry (default to D65) + illuminant = Illuminant.D65 + if layout.colorimetry is not None: + illuminant = layout.colorimetry.illuminant + + for patch in layout.patches: + # Calculate pixel bounds + x0 = int(patch.x_pct * width) + y0 = int(patch.y_pct * height) + x1 = int((patch.x_pct + patch.width_pct) * width) + y1 = int((patch.y_pct + patch.height_pct) * height) + + # Convert color to target space + if patch.color.space == ColorSpace.XYZ: + rgb = xyz_to_display_rgb( + patch.color, + target_space=target_space, + transfer_function=transfer_function, + reference_white_Y=reference_white_Y, + illuminant=illuminant, + simulation_light_source=simulation_light_source, + ) + elif patch.color.space == target_space: + # Already in target space, just apply transfer function + if transfer_function == TransferFunction.LINEAR: + rgb = patch.color.values + elif transfer_function == TransferFunction.SRGB: + import colour + + rgb = colour.cctf_encoding(patch.color.values, function="sRGB") + elif transfer_function == TransferFunction.GAMMA_22: + rgb = np.power(patch.color.values, 1.0 / 2.2) + else: + rgb = patch.color.values + else: + # Need cross-colorspace conversion - for now just use values directly + rgb = patch.color.values + + # Fill patch area based on pattern type. asarray normalizes the + # dtype across the conversion branches (colour-science returns + # loosely-typed arrays). + rgb_f64 = np.asarray(rgb, dtype=np.float64) + _fill_patch_region(image, x0, y0, x1, y1, rgb_f64, patch.pattern) + + # Convert to uint16 + image_uint16 = np.clip(image * max_value, 0, max_value).astype(np.uint16) + + # Add text labels on patches if requested + if include_labels: + image_uint16 = _add_labels(image_uint16, layout, width, height, bit_depth) + + # Always add annotation stripes (critical encoding/chart metadata) + image_uint16 = _add_annotation_stripes( + image_uint16, + layout=layout, + width=width, + height=height, + bit_depth=bit_depth, + target_space=target_space, + transfer_function=transfer_function, + reference_white_Y=reference_white_Y, + simulation_light_source=simulation_light_source, + ) + + return image_uint16 + + +def _fill_patch_region( + image: NDArray[np.float64], + x0: int, + y0: int, + x1: int, + y1: int, + rgb: NDArray[np.float64], + pattern: PatternType, +) -> None: + """ + Fill a patch region with solid color or checkerboard pattern. + + For checkerboard patterns, generates 2x2 pixel repeating patterns + using 100% white and 0% black to produce gamma-invariant luminance. + + Parameters + ---------- + image : NDArray[np.float64] + The image array to fill (modified in-place). + x0, y0 : int + Top-left corner of the patch. + x1, y1 : int + Bottom-right corner of the patch. + rgb : NDArray[np.float64] + RGB color values (used for solid, ignored for checkerboard). + pattern : PatternType + The pattern type to render. + """ + if pattern == PatternType.SOLID: + image[y0:y1, x0:x1, :] = rgb + else: + # Checkerboard patterns use white (1.0) and black (0.0) + white = np.array([1.0, 1.0, 1.0]) + black = np.array([0.0, 0.0, 0.0]) + + # Create coordinate grids for the patch region + height = y1 - y0 + width = x1 - x0 + + if height == 0 or width == 0: + return + + # Generate pattern based on coordinate parity + y_coords = np.arange(height).reshape(-1, 1) + x_coords = np.arange(width).reshape(1, -1) + + # Compute parity for each pixel (0-3 for 2x2 pattern) + # Position in 2x2 tile: (y % 2) * 2 + (x % 2) + tile_pos = (y_coords % 2) * 2 + (x_coords % 2) + + # Define which positions are white for each pattern + # Tile positions: 0=top-left, 1=top-right, 2=bottom-left, 3=bottom-right + if pattern == PatternType.CHECKERBOARD_50: + # Diagonal checkerboard: positions 0 and 3 are white + is_white = (tile_pos == 0) | (tile_pos == 3) + elif pattern == PatternType.CHECKERBOARD_25: + # Only position 0 is white (1 white, 3 black = 25%) + is_white = tile_pos == 0 + elif pattern == PatternType.CHECKERBOARD_75: + # Positions 0, 1, 2 are white (3 white, 1 black = 75%) + is_white = tile_pos != 3 + else: + # Default to solid fill for unknown patterns + image[y0:y1, x0:x1, :] = rgb + return + + # Fill the patch region + patch_region = image[y0:y1, x0:x1, :] + patch_region[is_white] = white + patch_region[~is_white] = black + + +def _add_labels( + image: NDArray[np.uint16], + layout: ChartLayout, + width: int, + height: int, + bit_depth: int, +) -> NDArray[np.uint16]: + """ + Add text labels to the rendered image using Pillow. + + Parameters + ---------- + image : NDArray[np.uint16] + The image to add labels to. + layout : ChartLayout + The chart layout with patch information. + width : int + Image width. + height : int + Image height. + bit_depth : int + Bit depth for scaling. + + Returns + ------- + NDArray[np.uint16] + Image with labels added. + """ + # Convert to 8-bit for Pillow (scale down) + image_8bit = (image >> (bit_depth - 8)).astype(np.uint8) + + # Create Pillow image + pil_image = Image.fromarray(image_8bit, mode="RGB") + draw = ImageDraw.Draw(pil_image) + + # Try to load a font, fall back to default + try: + font_size = max(12, min(width, height) // 50) + font = ImageFont.truetype("/System/Library/Fonts/Helvetica.ttc", font_size) + except OSError: + font = ImageFont.load_default() + + for patch in layout.patches: + if not patch.label_text: + continue + + # Calculate patch center + x_center = int((patch.x_pct + patch.width_pct / 2) * width) + y_center = int((patch.y_pct + patch.height_pct / 2) * height) + + # Determine text color based on patch luminance + # Use contrasting color (white for dark patches, black for light) + if patch.color.space == ColorSpace.XYZ: + luminance = patch.color.values[1] / 100.0 # Y component + else: + # Approximate luminance from RGB + luminance = 0.2126 * patch.color.values[0] + 0.7152 * patch.color.values[1] + luminance += 0.0722 * patch.color.values[2] + + text_color = (0, 0, 0) if luminance > 0.5 else (255, 255, 255) + + # Draw text centered on patch + draw.text( + (x_center, y_center), + patch.label_text, + fill=text_color, + font=font, + anchor="mm", + ) + + # Convert back to bit depth + result_8bit = np.array(pil_image) + result = (result_8bit.astype(np.uint16) << (bit_depth - 8)).astype(np.uint16) + + return result + + +def _add_annotation_stripes( + image: NDArray[np.uint16], + layout: ChartLayout, + width: int, + height: int, + bit_depth: int, + target_space: ColorSpace, + transfer_function: TransferFunction, + reference_white_Y: float, + simulation_light_source: LightSource | None = None, +) -> NDArray[np.uint16]: + """ + Add annotation stripes in the gap regions between chroma and greyscale patches. + + The top stripe (y: 0.17-0.21) shows encoding information. + The bottom stripe (y: 0.79-0.83) shows chart metadata. + + Parameters + ---------- + image : NDArray[np.uint16] + The image to add annotations to. + layout : ChartLayout + The chart layout with metadata. + width : int + Image width. + height : int + Image height. + bit_depth : int + Bit depth for scaling. + target_space : ColorSpace + Target color space used for encoding. + transfer_function : TransferFunction + Transfer function used for encoding. + reference_white_Y : float + Reference white Y value used. + simulation_light_source : LightSource | None + Light source used for chromatic adaptation simulation. + + Returns + ------- + NDArray[np.uint16] + Image with annotation stripes added. + """ + # Convert to 8-bit for Pillow + image_8bit = (image >> (bit_depth - 8)).astype(np.uint8) + + # Create Pillow image + pil_image = Image.fromarray(image_8bit, mode="RGB") + draw = ImageDraw.Draw(pil_image) + + # Load font - slightly smaller for annotation text + try: + font_size = max(14, min(width, height) // 60) + font = ImageFont.truetype("/System/Library/Fonts/Helvetica.ttc", font_size) + except OSError: + font = ImageFont.load_default() + + # Get stripe positions from layout annotations (or use defaults) + if layout.annotations and layout.annotations.top_stripe: + top_stripe_y = int(layout.annotations.top_stripe.y_start * height) + top_stripe_end = int(layout.annotations.top_stripe.y_end * height) + else: + top_stripe_y = int(0.17 * height) + top_stripe_end = int(0.21 * height) + + if layout.annotations and layout.annotations.bottom_stripe: + bottom_stripe_y = int(layout.annotations.bottom_stripe.y_start * height) + bottom_stripe_end = int(layout.annotations.bottom_stripe.y_end * height) + else: + bottom_stripe_y = int(0.79 * height) + bottom_stripe_end = int(0.83 * height) + + # Calculate stripe center Y positions + top_center_y = (top_stripe_y + top_stripe_end) // 2 + bottom_center_y = (bottom_stripe_y + bottom_stripe_end) // 2 + + # Text color (white on black background) + text_color = (255, 255, 255) + + # Build annotation strings + # Top stripe: Encoding information + simulation status + colorspace_name = target_space.value + transfer_name = transfer_function.value + max_code = 2**bit_depth - 1 + # Currently always full range (0 to max_value) + range_type = "Full" + + # Determine simulation status string + chart_illuminant = ( + layout.colorimetry.illuminant if layout.colorimetry else Illuminant.D65 + ) + if simulation_light_source is not None: + # Active simulation + sim_status = f"{simulation_light_source} sim" + else: + # No simulation - passthrough + sim_status = f"{chart_illuminant.value} passthrough" + + top_text = ( + f"{colorspace_name} │ " + f"{transfer_name} │ " + f"{bit_depth}-bit {range_type} (0-{max_code}) │ " + f"{sim_status}" + ) + + # Bottom stripe: Chart metadata + chart_name = layout.name if layout.name else "Unnamed Chart" + illuminant_name = ( + layout.colorimetry.illuminant.value if layout.colorimetry else "D65" + ) + white_info = f"Illuminant: {illuminant_name} │ Ref White Y: {reference_white_Y}" + + bottom_text = f"{chart_name} │ {white_info}" + + # Draw top annotation stripe text (centered horizontally) + draw.text( + (width // 2, top_center_y), + top_text, + fill=text_color, + font=font, + anchor="mm", + ) + + # Draw bottom annotation stripe text (centered horizontally) + draw.text( + (width // 2, bottom_center_y), + bottom_text, + fill=text_color, + font=font, + anchor="mm", + ) + + # Convert back to bit depth + result_8bit = np.array(pil_image) + result = (result_8bit.astype(np.uint16) << (bit_depth - 8)).astype(np.uint16) + + return result diff --git a/display_patterns/charts/tiff_reader.py b/display_patterns/charts/tiff_reader.py new file mode 100644 index 0000000..8986135 --- /dev/null +++ b/display_patterns/charts/tiff_reader.py @@ -0,0 +1,121 @@ +""" +TIFF reader for loading chart images with embedded metadata. + +Loads 16-bit TIFF files written by tiff_writer and returns the raw +numpy array data without any manipulation. +""" + +import contextlib +import json +from dataclasses import dataclass +from pathlib import Path +from typing import Any + +import numpy as np +import tifffile +from numpy.typing import NDArray + + +@dataclass +class TiffMetadata: + """ + Metadata extracted from a chart TIFF file. + + This is parsed from the JSON in the TIFF ImageDescription tag. + """ + + version: str = "1.0" + chart_name: str = "" + chart_source: str | None = None + colorspace: str = "ITU-R BT.709" + transfer_function: str = "sRGB" + bit_depth: int = 12 + reference_white_nits: float = 100.0 + created_at: str = "" + patches: list[dict[str, Any]] | None = None + + @classmethod + def from_json(cls, json_str: str) -> "TiffMetadata": + """Parse metadata from JSON string (from TIFF ImageDescription).""" + data = json.loads(json_str) + + # Handle both wrapped {"bmdsg": {...}} and unwrapped formats + if "bmdsg" in data: + data = data["bmdsg"] + + return cls( + version=data.get("version", "1.0"), + chart_name=data.get("chart_name", ""), + chart_source=data.get("chart_source"), + colorspace=data.get("colorspace", "ITU-R BT.709"), + transfer_function=data.get("transfer_function", "sRGB"), + bit_depth=data.get("bit_depth", 12), + reference_white_nits=data.get("reference_white_nits", 100.0), + created_at=data.get("created_at", ""), + patches=data.get("patches"), + ) + + +def load_chart_tiff( + path: Path | str, +) -> tuple[NDArray[np.uint16], TiffMetadata]: + """ + Load a chart TIFF file and return raw image data with metadata. + + The image data is returned as-is without any scaling or bit manipulation. + Values are in their native range based on bit_depth from metadata: + - 8-bit: 0-255 + - 10-bit: 0-1023 + - 12-bit: 0-4095 + + Parameters + ---------- + path : Path | str + Path to the TIFF file. + + Returns + ------- + tuple[NDArray[np.uint16], TiffMetadata] + Tuple of (image_data, metadata). + image_data has shape (height, width, 3) and dtype uint16. + + Raises + ------ + FileNotFoundError + If the TIFF file does not exist. + ValueError + If the TIFF file is malformed or missing required metadata. + """ + path = Path(path) + + if not path.exists(): + raise FileNotFoundError(f"TIFF file not found: {path}") + + # Load TIFF file + with tifffile.TiffFile(path) as tif: + # Read image data + image = tif.asarray() + + # Ensure we have RGB data + if image.ndim != 3 or image.shape[2] != 3: + raise ValueError( + f"Expected RGB image with shape (H, W, 3), got {image.shape}" + ) + + # Ensure uint16 dtype + if image.dtype != np.uint16: + raise ValueError(f"Expected uint16 dtype, got {image.dtype}") + + # Try to extract metadata from ImageDescription + metadata = TiffMetadata() + if tif.pages: + # pages[0] may be typed as TiffFrame, which lacks description; + # at runtime the first page is a TiffPage + description = getattr(tif.pages[0], "description", "") + if description: + # Parsing failure falls back to defaults, allowing TIFFs + # without our custom metadata + with contextlib.suppress(json.JSONDecodeError, KeyError): + metadata = TiffMetadata.from_json(description) + + return image, metadata diff --git a/display_patterns/charts/tiff_writer.py b/display_patterns/charts/tiff_writer.py new file mode 100644 index 0000000..20562e6 --- /dev/null +++ b/display_patterns/charts/tiff_writer.py @@ -0,0 +1,111 @@ +""" +TIFF writer for chart images with embedded JSON metadata. + +Writes 16-bit TIFF files with chart metadata in the ImageDescription tag. +""" + +import json +from dataclasses import asdict, dataclass +from datetime import UTC, datetime +from pathlib import Path + +import numpy as np +import tifffile +from numpy.typing import NDArray + +from display_patterns.charts.color_types import ( + ChartLayout, + ColorSpace, + TransferFunction, +) + + +@dataclass +class ChartMetadata: + """ + Metadata for a chart TIFF file. + + This is embedded as JSON in the TIFF ImageDescription tag. + """ + + version: str = "1.0" + chart_name: str = "" + chart_source: str | None = None + colorspace: str = "ITU-R BT.709" + transfer_function: str = "sRGB" + bit_depth: int = 12 + reference_white_nits: float = 100.0 + created_at: str = "" + patches: list[dict] | None = None + + def to_json(self) -> str: + """Convert to JSON string for embedding in TIFF.""" + data = asdict(self) + return json.dumps({"bmdsg": data}, indent=2) + + +def write_chart_tiff( + path: Path | str, + image: NDArray[np.uint16], + layout: ChartLayout, + colorspace: ColorSpace = ColorSpace.REC709, + transfer_function: TransferFunction = TransferFunction.SRGB, + bit_depth: int = 12, + reference_white_nits: float = 100.0, +) -> None: + """ + Write a chart image to a TIFF file with metadata. + + Parameters + ---------- + path : Path | str + Output file path. + image : NDArray[np.uint16] + Image data as uint16 array of shape (height, width, 3). + layout : ChartLayout + The chart layout (for metadata). + colorspace : ColorSpace + The colorspace of the image. + transfer_function : TransferFunction + The transfer function used for encoding. + bit_depth : int + The bit depth of the image data. + reference_white_nits : float + The reference white luminance in nits. + """ + path = Path(path) + + # Build patch metadata + patch_info = [] + for patch in layout.patches: + patch_info.append( + { + "name": patch.name, + "x_pct": patch.x_pct, + "y_pct": patch.y_pct, + "width_pct": patch.width_pct, + "height_pct": patch.height_pct, + "color_space": patch.color.space.value, + "color_values": patch.color.values.tolist(), + } + ) + + # Create metadata + metadata = ChartMetadata( + chart_name=layout.name, + chart_source=layout.source, + colorspace=colorspace.value, + transfer_function=transfer_function.value, + bit_depth=bit_depth, + reference_white_nits=reference_white_nits, + created_at=datetime.now(UTC).isoformat(), + patches=patch_info, + ) + + # Write TIFF with metadata in description + tifffile.imwrite( + path, + image, + photometric="rgb", + description=metadata.to_json(), + ) diff --git a/display_patterns/image_generators/__init__.py b/display_patterns/image_generators/__init__.py new file mode 100644 index 0000000..d97e90f --- /dev/null +++ b/display_patterns/image_generators/__init__.py @@ -0,0 +1,19 @@ +""" +Image generators for BMD signal generation. + +This package provides various pattern and image generation utilities for BMD +DeckLink devices, including checkerboard patterns, solid colors, and other +test patterns commonly used in video production and display testing. +""" + +from display_patterns.image_generators.checkerboard import ( + DEFAULT_PATTERN_GENERATOR, + ROI, + PatternGenerator, +) + +__all__ = [ + "DEFAULT_PATTERN_GENERATOR", + "ROI", + "PatternGenerator", +] diff --git a/display_patterns/image_generators/checkerboard.py b/display_patterns/image_generators/checkerboard.py new file mode 100644 index 0000000..2f7cd24 --- /dev/null +++ b/display_patterns/image_generators/checkerboard.py @@ -0,0 +1,355 @@ +"""Pattern generator for creating test patterns with validation and ROI support. + +This module provides functionality to generate checkerboard test patterns with +validation for color values and support for regions of interest (ROI). The patterns +are generated as NumPy arrays suitable for video output. +""" + +from dataclasses import dataclass + +import numpy as np +from numpy.typing import ArrayLike + + +@dataclass +class ROI: + """Region of Interest for pattern generation. + + Defines a rectangular region within an image where patterns will be drawn. + Width and height define the size of the rectangular region. + + Parameters + ---------- + x : int, optional + X coordinate of the top-left corner. Default is 0. + y : int, optional + Y coordinate of the top-left corner. Default is 0. + width : int, optional + Width of the ROI in pixels. Default is 100. + height : int, optional + Height of the ROI in pixels. Default is 100. + + Examples + -------- + Create a full-image ROI: + + >>> roi = ROI() + >>> print(f"Position: ({roi.x}, {roi.y})") + Position: (0, 0) + + Create a centered ROI: + + >>> roi = ROI(x=100, y=100, width=800, height=600) + >>> print(f"ROI: {roi.width}x{roi.height} at ({roi.x}, {roi.y})") + ROI: 800x600 at (100, 100) + """ + + x: int = 0 + y: int = 0 + width: int = 100 + height: int = 100 + + @property + def x2(self) -> int: + return self.x + self.width + + @property + def y2(self) -> int: + return self.y + self.height + + +class ColorRangeError(RuntimeError): + """Exception raised when color values are outside the valid range. + + This exception is raised when color values exceed the maximum value + for the specified bit depth (e.g., values > 255 for 8-bit, > 4095 for 12-bit). + + Parameters + ---------- + detail_string : str, optional + Additional detail about the validation error. + + Examples + -------- + >>> raise ColorRangeError("Value 300 exceeds 8-bit maximum of 255") + ColorRangeError: colors must be a required bit-depth range. + """ + + def __init__(self, detail_string: str | None = None): + super().__init__("colors must be a required bit-depth range.") + if detail_string: + self.add_note(detail_string) + + +def _validate_color(color1: ArrayLike, bitdepth: int = 8) -> bool: + """Validate RGB color values against bit depth constraints. + + Checks that all color values are within the valid range [0, 2^bitdepth - 1]. + For example, 8-bit colors must be in range [0, 255], 12-bit in range [0, 4095]. + + Parameters + --------- + color1 : ArrayLike + Color values to validate. Can be a single color (3,) or multiple colors (N, 3). + bitdepth : int, optional + Bit depth for validation. Default is 8. + + Returns + ------- + bool + True if all color values are within valid range, False otherwise. + + Examples + -------- + Validate 8-bit RGB color: + + >>> _validate_color([255, 128, 0], bitdepth=8) + True + >>> _validate_color([256, 128, 0], bitdepth=8) # Value too high + False + + Validate 12-bit RGB colors: + + >>> colors = [[4095, 2048, 0], [1024, 512, 256]] + >>> _validate_color(colors, bitdepth=12) + True + """ + color1 = np.asarray(color1) + # Check if all values are in valid range [0, 2^bitdepth - 1] + return np.all(np.logical_and(color1 >= 0, color1 <= (2**bitdepth - 1))).item() + + +class PatternGenerator: + """Generates image patterns with validation and ROI support. + + This class creates test patterns (currently checkerboard) for video output. + It supports region-of-interest (ROI) rendering and validates color values + against the specified bit depth. + + Parameters + ---------- + bit_depth : int + Bit depth for color values (e.g., 8, 10, 12). + width : int + Image width in pixels. + height : int + Image height in pixels. + roi : ROI, optional + Region of interest for pattern generation. If None, uses full image. + + Attributes + ---------- + width : int + Image width in pixels. + height : int + Image height in pixels. + bit_depth : int + Bit depth for color validation. + roi : ROI + Region of interest for pattern generation. + + Examples + -------- + Create a pattern generator for 1080p 12-bit output: + + >>> generator = PatternGenerator(bit_depth=12, width=1920, height=1080) + >>> colors = [[4095, 0, 0], [0, 4095, 0], [0, 0, 4095], [4095, 4095, 4095]] + >>> pattern = generator.generate(colors) + >>> print(f"Pattern shape: {pattern.shape}") + Pattern shape: (1080, 1920, 3) + + Create with custom ROI: + + >>> roi = ROI(x=100, y=100, width=800, height=600) + >>> generator = PatternGenerator(bit_depth=8, width=1920, height=1080, roi=roi) + """ + + def __init__( + self, + *, + bit_depth: int, + width: int, + height: int, + roi: ROI | None = None, + ) -> None: + self.width = width + self.height = height + self.bit_depth = bit_depth + + if roi is None: + self.roi = ROI(0, 0, self.width, self.height) + else: + self.roi = roi + + def _draw_checkerboard_pattern(self, colors: ArrayLike) -> np.ndarray: + """Draw checkerboard pattern within the specified ROI. + + Creates a 2x2 checkerboard pattern using the provided colors. + The pattern alternates between the four colors in a checkerboard layout: + - Top-left: colors[0] + - Top-right: colors[1] + - Bottom-left: colors[2] + - Bottom-right: colors[3] + + Parameters + ---------- + colors : ArrayLike + Array of 4 RGB colors with shape (4, 3). + + Returns + ------- + np.ndarray + Image array with checkerboard pattern drawn in ROI. + + Raises + ------ + RuntimeError + If colors array doesn't have shape (4, 3). + ColorRangeError + If color values are outside valid range for bit depth. + """ + colors = np.asarray(colors).copy() + if colors.shape != (4, 3): + raise RuntimeError("colors must be a list of (4,3) colors") + + # Validate all color values are within bit depth range + if not _validate_color(colors, self.bit_depth): + raise ColorRangeError(f"Bit depth: {self.bit_depth:d}") + + colors = np.vstack((colors, (0, 0, 0))) + color_mask = np.full((self.height, self.width), 4, dtype=np.int32) + + # This section uses advanced indexing to quickly create this array with + # numpy Do not refactor into for loops. + + # Ensure ROI is within image bounds + roi_y_end = min(self.roi.y2, self.height) + roi_x_end = min(self.roi.x2, self.width) + + # Set color indices based on coordinate parity + color_mask[ + np.arange(self.roi.y, roi_y_end, 2).reshape((-1, 1)), + np.arange(self.roi.x, roi_x_end, 2).reshape((1, -1)), + ] = 0 # Even row, even column -> color 0 + color_mask[ + np.arange(self.roi.y + 1, roi_y_end, 2).reshape((-1, 1)), + np.arange(self.roi.x, roi_x_end, 2).reshape((1, -1)), + ] = 1 # Odd row, even column -> color 1 + color_mask[ + np.arange(self.roi.y, roi_y_end, 2).reshape((-1, 1)), + np.arange(self.roi.x + 1, roi_x_end, 2).reshape((1, -1)), + ] = 2 # Even row, odd column -> color 2 + color_mask[ + np.arange(self.roi.y + 1, roi_y_end, 2).reshape((-1, 1)), + np.arange(self.roi.x + 1, roi_x_end, 2).reshape((1, -1)), + ] = 3 # Odd row, odd column -> color 3 + + # Apply colors using advanced indexing + o_image = colors[color_mask] + # End Numpy advanced indexing. + + return o_image.astype(np.uint16) + + def generate(self, colors: ArrayLike) -> np.ndarray: + """Generate a checkerboard pattern with the specified colors. + + Accepts 1-4 colors and automatically expands them to create a 4-color + checkerboard pattern. Color expansion rules: + - 1 color: All four squares use the same color + - 2 colors: Alternating pattern (color1, color2, color1, color2) + - 3 colors: Custom mapping (color1, color2, color3, color2) + - 4 colors: Direct mapping to checker squares + + Parameters + ---------- + colors : ArrayLike + Color array. Accepted shapes: + - (3,) for single RGB color + - (1, 3) for single RGB color + - (2, 3) for two RGB colors + - (3, 3) for three RGB colors + - (4, 3) for four RGB colors + + Returns + ------- + np.ndarray + Generated pattern image with shape (height, width, 3). + + Raises + ------ + RuntimeError + If colors array has invalid shape. + ColorRangeError + If color values exceed bit depth limits. + + Examples + -------- + Generate pattern with single color: + + >>> generator = PatternGenerator(bit_depth=8, width=100, height=100) + >>> pattern = generator.generate([255, 0, 0]) # Red + + Generate pattern with four different colors: + + >>> colors = [[255, 0, 0], [0, 255, 0], [0, 0, 255], [255, 255, 255]] + >>> pattern = generator.generate(colors) + """ + colors = np.asarray(colors) + + # Handle single color input: reshape (3,) to (1, 3) + if colors.shape == (3,): + colors = colors.reshape((1, 3)) + + # Validate input shape: must be 2D with 1-4 rows and exactly 3 columns + if ( + colors.ndim != 2 + or colors.shape[0] < 1 + or colors.shape[0] > 4 + or colors.shape[1] != 3 + ): + raise RuntimeError( + "Colors must have shape (1,3) to (4,3), or single color shape (3,)" + ) + + num_colors = colors.shape[0] + + # Expand colors to 4-color checkerboard pattern + if num_colors == 1: + # Single color: use same color for all four squares + colors = np.broadcast_to(colors, (4, 3)) + elif num_colors == 2: + # Two colors: tile to create checkerboard of two colors + colors = colors[(0, 1, 1, 0), :] + elif num_colors == 3: + # Three colors: map to [color1, color2, color1, color3] + # This creates a pattern where color1 appears in top-left and bottom-right + colors = colors[(0, 1, 2, 0), :] + + return self._draw_checkerboard_pattern(colors) + + +# Default pattern generator for common 1080p 12-bit use case +# Uses 100-pixel border ROI to create centered pattern (1720x880 active area) +# Creates centered pattern within specified ROI +DEFAULT_PATTERN_GENERATOR = PatternGenerator( + bit_depth=12, + width=1920, + height=1080, + roi=ROI(x=100, y=100, width=1920 - 200, height=1080 - 200), +) + +# Pre-generated pattern with 12-bit white/black checkerboard +# White: 2000/4095 ≈ 49% of 12-bit range (conservative for HDR displays) +# Black: 0/4095 = 0% (minimum luminance) +DEFAULT_PATTERN_BUFFER = DEFAULT_PATTERN_GENERATOR.generate( + ((2000, 2000, 2000), (0, 0, 0)) +) + + +__all__ = [ + "DEFAULT_PATTERN_BUFFER", + "DEFAULT_PATTERN_GENERATOR", + "ROI", + "ColorRangeError", + "PatternGenerator", +] diff --git a/display_patterns/py.typed b/display_patterns/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..6763441 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,133 @@ +[project] +name = "display-patterns" +# Version derives from git tags (hatch-vcs); flywheel cuts the tags. +dynamic = ["version"] +description = "Deterministic display test patterns, device-free and exact" +authors = [ + { name = "Ritchie Argue", email = "4462072+repentsinner@users.noreply.github.com" }, +] +readme = "README.md" +license = "BSD-3-Clause" +license-files = ["LICENSE"] +requires-python = ">=3.12" +keywords = [ + "test-patterns", + "display", + "video", + "calibration", + "measurement", + "checkerboard", + "charts", +] +classifiers = [ + "Development Status :: 3 - Alpha", + "Intended Audience :: Developers", + "Operating System :: MacOS", + "Operating System :: POSIX :: Linux", + "Operating System :: Microsoft :: Windows", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", + "Topic :: Multimedia :: Video", + "Topic :: Software Development :: Libraries :: Python Modules", +] +dependencies = [ + "numpy>=2.3.1", +] + +[project.optional-dependencies] +charts = [ + "colour-science>=0.4.6", + "pillow>=12.1.1", + "pyyaml>=6.0.1", + # Not imported directly: colour-science uses scipy opportunistically + # (CCT/chromatic adaptation) and warns at import when it is absent. + "scipy>=1.16.3", +] +io = [ + "tifffile>=2024.8.30", +] + +[project.urls] +Homepage = "https://github.com/OpenLEDEval/display-patterns" +Repository = "https://github.com/OpenLEDEval/display-patterns.git" +Issues = "https://github.com/OpenLEDEval/display-patterns/issues" +Changelog = "https://github.com/OpenLEDEval/display-patterns/blob/main/CHANGELOG.md" + +[build-system] +requires = ["hatchling", "hatch-vcs"] +build-backend = "hatchling.build" + +[tool.hatch.version] +source = "vcs" + +[tool.hatch.build.targets.wheel] +packages = ["display_patterns"] + +[dependency-groups] +dev = [ + "pyright>=1.1.403", + "pytest>=8.4.1", + "ruff>=0.12.3", +] + +[tool.ruff] +line-length = 88 +target-version = "py312" + +[tool.ruff.lint] +select = [ + "E", # pycodestyle errors + "W", # pycodestyle warnings + "F", # pyflakes + "I", # isort + "B", # flake8-bugbear + "C4", # flake8-comprehensions + "UP", # pyupgrade + "ARG", # flake8-unused-arguments + "C90", # mccabe complexity + "T20", # flake8-print + "SIM", # flake8-simplify + "TC", # flake8-type-checking + "RUF", # Ruff-specific rules +] +ignore = [ + "E501", # Line too long (handled by formatter) + "B008", # Do not perform function calls in argument defaults +] + +[tool.ruff.lint.per-file-ignores] +"tests/*" = ["ARG", "T20"] +"tools/*" = ["T20"] + +[tool.ruff.lint.isort] +known-first-party = ["display_patterns"] +split-on-trailing-comma = true + +[tool.ruff.format] +quote-style = "double" +indent-style = "space" + +[tool.pyright] +include = ["display_patterns", "tests"] +exclude = ["**/__pycache__"] +venvPath = "." +venv = ".venv" +pythonVersion = "3.12" +typeCheckingMode = "standard" +reportMissingImports = true +reportMissingTypeStubs = false +reportUnusedImport = true +reportUnusedClass = true +reportUnusedFunction = true +reportUnusedVariable = true +reportDuplicateImport = true +reportInconsistentConstructor = true +reportMissingParameterType = true +reportUnnecessaryTypeIgnoreComment = true +reportMissingModuleSource = false +reportUnnecessaryCast = true + +[tool.pytest.ini_options] +testpaths = ["tests"] diff --git a/styles/Requirements/FillerPhrases.yml b/styles/Requirements/FillerPhrases.yml new file mode 100644 index 0000000..19bef02 --- /dev/null +++ b/styles/Requirements/FillerPhrases.yml @@ -0,0 +1,12 @@ +extends: existence +message: "Filler phrase '%s' — cut it." +ignorecase: true +level: warning +scope: sentence +tokens: + - 'it should be noted that' + - 'in order to' + - 'due to the fact that' + - 'it is important to note' + - 'at this point in time' + - 'for the purpose of' diff --git a/styles/Requirements/MustDeprecated.yml b/styles/Requirements/MustDeprecated.yml new file mode 100644 index 0000000..6b28e6d --- /dev/null +++ b/styles/Requirements/MustDeprecated.yml @@ -0,0 +1,7 @@ +extends: existence +message: "'%s' is deprecated by IEEE. Use 'shall' for mandatory requirements." +ignorecase: true +level: error +scope: sentence +tokens: + - '\bmust\b' diff --git a/styles/Requirements/OrdinalHeadings.yml b/styles/Requirements/OrdinalHeadings.yml new file mode 100644 index 0000000..54490d1 --- /dev/null +++ b/styles/Requirements/OrdinalHeadings.yml @@ -0,0 +1,7 @@ +extends: existence +message: "Heading '%s' is numbered in prose. If the number is document structure, address by §slug instead; if it describes the system, keep it." +level: warning +scope: heading +nonword: true +tokens: + - '^(Stage|Phase|Step|Part|Pass|Round|Tier|Level|Iteration)\s+([0-9]+[a-z]?|[IVXivx]+)\b' diff --git a/styles/Requirements/WillDeprecated.yml b/styles/Requirements/WillDeprecated.yml new file mode 100644 index 0000000..c2d0326 --- /dev/null +++ b/styles/Requirements/WillDeprecated.yml @@ -0,0 +1,7 @@ +extends: existence +message: "'%s' is deprecated by IEEE for requirements. Use 'shall' for mandatory, 'should' for recommended." +ignorecase: true +level: warning +scope: sentence +tokens: + - '\bwill\b' diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..59e7ab4 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,65 @@ +""" +Pytest fixtures for display-patterns tests. + +Moved from bmd-signal-gen's test fixtures, trimmed to the pattern +fixtures the extracted modules use (the DeckLink fixtures stayed +behind with the device tool). +""" + +import pytest + +from display_patterns.image_generators.checkerboard import ROI, PatternGenerator + + +@pytest.fixture +def pattern_generator_12bit() -> PatternGenerator: + """ + Create a 12-bit pattern generator for testing. + + Returns + ------- + PatternGenerator + Generator configured for 1920x1080 at 12-bit depth. + """ + return PatternGenerator( + bit_depth=12, + width=1920, + height=1080, + roi=ROI(x=0, y=0, width=1920, height=1080), + ) + + +@pytest.fixture +def pattern_generator_8bit() -> PatternGenerator: + """ + Create an 8-bit pattern generator for testing. + + Returns + ------- + PatternGenerator + Generator configured for 1920x1080 at 8-bit depth. + """ + return PatternGenerator( + bit_depth=8, + width=1920, + height=1080, + roi=ROI(x=0, y=0, width=1920, height=1080), + ) + + +@pytest.fixture +def sample_colors_12bit() -> list[list[int]]: + """ + Sample 12-bit color values for testing. + + Returns + ------- + list[list[int]] + Four colors: white, black, red, green (12-bit range). + """ + return [ + [4095, 4095, 4095], # White + [0, 0, 0], # Black + [4095, 0, 0], # Red + [0, 4095, 0], # Green + ] diff --git a/tests/data/two_patch.yaml b/tests/data/two_patch.yaml new file mode 100644 index 0000000..9b8e1be --- /dev/null +++ b/tests/data/two_patch.yaml @@ -0,0 +1,23 @@ +name: "Two Patch Chart" +version: "1.0" + +colorimetry: + color_space: "XYZ" + illuminant: "D65" + white_point: [0.3127, 0.329] + reference_white_Y: 100.0 + +canvas: + width: 640 + height: 360 + surround: [0.0, 0.0, 0.0] + +patches: + - name: "GS 1" + color: [95.04, 100.0, 108.88] + pos: [0.05, 0.30] + size: [0.40, 0.40] + - name: "Red" + color: [41.24, 21.26, 1.93] + pos: [0.55, 0.30] + size: [0.40, 0.40] diff --git a/tests/test_bmd_equivalence.py b/tests/test_bmd_equivalence.py new file mode 100644 index 0000000..2239907 --- /dev/null +++ b/tests/test_bmd_equivalence.py @@ -0,0 +1,152 @@ +""" +Verbatim-move equivalence against bmd-signal-gen (§spec:extraction). + +For identical parameters, the moved modules render arrays equal to the +bmd-signal-gen in-tree implementation, array for array. The source repo +is located via ``BMD_SIGNAL_GEN_REPO`` or by finding a ``bmd-signal-gen`` +checkout beside any ancestor directory (which covers both the repo root +and a nested worktree). The tests skip only when neither resolves; CI +runs them in a dedicated job that checks out the source repo. + +This module is scaffolding for the extraction spine's equivalence +claim — retire it when the rendering-model reshape lands +(``§road:render-reshape``). + +The bmd_sg package initializer imports the DeckLink layer, which needs +device dependencies this repo does not install. A stub package entry +with the real ``__path__`` lets the pattern and chart submodules import +without executing that initializer. +""" + +import importlib +import os +import sys +import types +from collections.abc import Iterator +from pathlib import Path + +import numpy as np +import pytest + +CHART_YAML_PATH = Path(__file__).parent / "data" / "two_patch.yaml" + + +def _bmd_repo() -> Path | None: + env = os.environ.get("BMD_SIGNAL_GEN_REPO") + if env: + candidates = [Path(env)] + else: + candidates = [ + ancestor / "bmd-signal-gen" for ancestor in Path(__file__).resolve().parents + ] + for candidate in candidates: + if (candidate / "bmd_sg").is_dir(): + return candidate + return None + + +_BMD_REPO = _bmd_repo() + +pytestmark = pytest.mark.skipif( + _BMD_REPO is None, + reason="bmd-signal-gen checkout not found (set BMD_SIGNAL_GEN_REPO)", +) + + +@pytest.fixture(scope="module") +def bmd_sg_on_path() -> Iterator[None]: + """Make bmd_sg importable without running its package initializer.""" + assert _BMD_REPO is not None + stub = types.ModuleType("bmd_sg") + stub.__path__ = [str(_BMD_REPO / "bmd_sg")] + sys.modules["bmd_sg"] = stub + try: + yield + finally: + for name in [m for m in sys.modules if m.split(".")[0] == "bmd_sg"]: + del sys.modules[name] + + +CHECKERBOARD_CASES = [ + pytest.param( + 12, 1920, 1080, (100, 100, 1720, 880), [[2000, 2000, 2000]], id="solid" + ), + pytest.param( + 12, + 1920, + 1080, + (100, 100, 1720, 880), + [[2000, 2000, 2000], [0, 0, 0]], + id="two", + ), + pytest.param( + 12, + 1920, + 1080, + (0, 0, 1920, 1080), + [[4095, 0, 0], [0, 4095, 0], [0, 0, 4095]], + id="three", + ), + pytest.param( + 8, + 640, + 480, + (17, 23, 200, 100), + [[255, 0, 0], [0, 255, 0], [0, 0, 255], [255, 255, 255]], + id="four-8bit", + ), +] + + +@pytest.mark.parametrize( + ("bit_depth", "width", "height", "roi", "colors"), CHECKERBOARD_CASES +) +def test_checkerboard_arrays_match( + bmd_sg_on_path: None, + bit_depth: int, + width: int, + height: int, + roi: tuple[int, int, int, int], + colors: list[list[int]], +) -> None: + """Checkerboards render array-for-array equal to bmd-signal-gen's.""" + theirs = importlib.import_module("bmd_sg.image_generators.checkerboard") + + from display_patterns.image_generators import checkerboard as ours + + their_generator = theirs.PatternGenerator( + bit_depth=bit_depth, width=width, height=height, roi=theirs.ROI(*roi) + ) + our_generator = ours.PatternGenerator( + bit_depth=bit_depth, width=width, height=height, roi=ours.ROI(*roi) + ) + + their_pattern = their_generator.generate(colors) + our_pattern = our_generator.generate(colors) + + assert our_pattern.dtype == their_pattern.dtype + np.testing.assert_array_equal(our_pattern, their_pattern) + + +def test_chart_render_arrays_match(bmd_sg_on_path: None) -> None: + """A YAML chart renders array-for-array equal to bmd-signal-gen's.""" + pytest.importorskip("colour") + pytest.importorskip("PIL") + pytest.importorskip("yaml") + pytest.importorskip("tifffile") + + their_loader = importlib.import_module("bmd_sg.charts.loaders.yaml_chart") + their_renderer = importlib.import_module("bmd_sg.charts.renderer") + + from display_patterns.charts import renderer as our_renderer + from display_patterns.charts.loaders import yaml_chart as our_loader + + their_image = their_renderer.render_chart( + their_loader.load_chart(CHART_YAML_PATH), bit_depth=12, include_labels=True + ) + our_image = our_renderer.render_chart( + our_loader.load_chart(CHART_YAML_PATH), bit_depth=12, include_labels=True + ) + + assert our_image.dtype == their_image.dtype + np.testing.assert_array_equal(our_image, their_image) diff --git a/tests/test_charts.py b/tests/test_charts.py new file mode 100644 index 0000000..eccff88 --- /dev/null +++ b/tests/test_charts.py @@ -0,0 +1,67 @@ +""" +Chart workflow tests for the ``charts`` and ``io`` extras. + +Covers the review workflow from §req:user-stories: author a chart as a +YAML patch list, render it to a uint16 array, export a 16-bit TIFF, and +read the identical array back (§spec:catalog, §spec:file-export). +""" + +from pathlib import Path + +import numpy as np +import pytest + +pytest.importorskip("colour") +pytest.importorskip("PIL") +pytest.importorskip("yaml") +pytest.importorskip("tifffile") + +from display_patterns.charts import ( + load_chart_tiff, + render_chart, + write_chart_tiff, +) +from display_patterns.charts.loaders import load_chart + +CHART_YAML_PATH = Path(__file__).parent / "data" / "two_patch.yaml" + + +def test_yaml_chart_renders() -> None: + """A YAML-authored chart loads and renders to a uint16 array.""" + layout = load_chart(CHART_YAML_PATH) + + assert layout.name == "Two Patch Chart" + assert [patch.name for patch in layout.patches] == ["GS 1", "Red"] + + image = render_chart(layout, bit_depth=12) + + assert image.shape == (360, 640, 3) + assert image.dtype == np.uint16 + assert image.max() <= 4095 + # The white patch center lands near full code value. + assert image[180, 160].min() > 3900 + + +def test_tiff_roundtrip(tmp_path: Path) -> None: + """Writing a rendered chart to TIFF and reading it back is lossless.""" + layout = load_chart(CHART_YAML_PATH) + image = render_chart(layout, bit_depth=12) + + tiff_path = tmp_path / "two_patch.tiff" + write_chart_tiff(tiff_path, image, layout, bit_depth=12) + + loaded, metadata = load_chart_tiff(tiff_path) + + np.testing.assert_array_equal(loaded, image) + assert metadata.chart_name == "Two Patch Chart" + assert metadata.bit_depth == 12 + assert metadata.patches is not None + assert len(metadata.patches) == 2 + + +def test_every_charts_export_resolves() -> None: + """Each name in ``__all__`` resolves through the lazy export table.""" + import display_patterns.charts as charts + + for name in charts.__all__: + assert getattr(charts, name) is not None diff --git a/tests/test_core_import.py b/tests/test_core_import.py new file mode 100644 index 0000000..7ae6456 --- /dev/null +++ b/tests/test_core_import.py @@ -0,0 +1,21 @@ +""" +Core footprint test (§spec:package-shape). + +Core pattern rendering works with numpy alone: importing and using +``display_patterns.image_generators`` never pulls in the chart or IO +dependencies. The probe body lives in ``tools/check_core_install.py`` +so this test, the wheel-install CI job, and any hand run execute the +same assertions; ``--skip-env-check`` adapts it to this all-extras +environment, where the post-render module sweep is the live check. +""" + +import subprocess +import sys +from pathlib import Path + +_PROBE = Path(__file__).resolve().parents[1] / "tools" / "check_core_install.py" + + +def test_core_render_pulls_no_heavy_deps() -> None: + """Render a checkerboard in a clean interpreter; only numpy loads.""" + subprocess.run([sys.executable, str(_PROBE), "--skip-env-check"], check=True) diff --git a/tests/test_pattern_generator.py b/tests/test_pattern_generator.py new file mode 100644 index 0000000..8cdf6d7 --- /dev/null +++ b/tests/test_pattern_generator.py @@ -0,0 +1,153 @@ +""" +Tests for PatternGenerator and color validation. + +This module tests the pattern generation functionality including +checkerboard pattern creation, color validation, and ROI handling. +""" + +import numpy as np +import pytest + +from display_patterns.image_generators.checkerboard import ( + ROI, + ColorRangeError, + PatternGenerator, +) + + +class TestPatternGenerator: + """Tests for the PatternGenerator class.""" + + def test_generate_single_color( + self, pattern_generator_12bit: PatternGenerator + ) -> None: + """Test generating a pattern with a single solid color.""" + colors = [[2048, 2048, 2048]] # Mid-gray + pattern = pattern_generator_12bit.generate(colors) + + assert pattern is not None + assert pattern.shape == (1080, 1920, 3) + assert pattern.dtype == np.uint16 + + def test_generate_two_colors( + self, pattern_generator_12bit: PatternGenerator + ) -> None: + """Test generating a checkerboard with two colors.""" + colors = [[4095, 4095, 4095], [0, 0, 0]] # White and black + pattern = pattern_generator_12bit.generate(colors) + + assert pattern is not None + assert pattern.shape == (1080, 1920, 3) + + def test_generate_four_colors( + self, + pattern_generator_12bit: PatternGenerator, + sample_colors_12bit: list[list[int]], + ) -> None: + """Test generating a checkerboard with four colors.""" + pattern = pattern_generator_12bit.generate(sample_colors_12bit) + + assert pattern is not None + assert pattern.shape == (1080, 1920, 3) + + def test_color_out_of_range_raises_error( + self, pattern_generator_12bit: PatternGenerator + ) -> None: + """Test that out-of-range colors raise ColorRangeError.""" + invalid_colors = [[5000, 0, 0]] # Exceeds 12-bit max (4095) + + with pytest.raises(ColorRangeError): + pattern_generator_12bit.generate(invalid_colors) + + def test_negative_color_raises_error( + self, pattern_generator_12bit: PatternGenerator + ) -> None: + """Test that negative color values raise ColorRangeError.""" + invalid_colors = [[-1, 0, 0]] + + with pytest.raises(ColorRangeError): + pattern_generator_12bit.generate(invalid_colors) + + def test_8bit_color_range(self, pattern_generator_8bit: PatternGenerator) -> None: + """Test 8-bit color range validation.""" + valid_colors = [[255, 128, 0]] + pattern = pattern_generator_8bit.generate(valid_colors) + + assert pattern is not None + assert pattern.dtype == np.uint16 # Internal representation + + def test_8bit_out_of_range_raises_error( + self, pattern_generator_8bit: PatternGenerator + ) -> None: + """Test that 8-bit out-of-range colors raise ColorRangeError.""" + invalid_colors = [[256, 0, 0]] # Exceeds 8-bit max (255) + + with pytest.raises(ColorRangeError): + pattern_generator_8bit.generate(invalid_colors) + + +class TestROI: + """Tests for the ROI (Region of Interest) class.""" + + def test_roi_creation(self) -> None: + """Test basic ROI creation.""" + roi = ROI(x=100, y=100, width=800, height=600) + + assert roi.x == 100 + assert roi.y == 100 + assert roi.width == 800 + assert roi.height == 600 + + def test_roi_x2_property(self) -> None: + """Test ROI x2 property (right edge).""" + roi = ROI(x=100, y=100, width=800, height=600) + + assert roi.x2 == 900 # 100 + 800 + + def test_roi_y2_property(self) -> None: + """Test ROI y2 property (bottom edge).""" + roi = ROI(x=100, y=100, width=800, height=600) + + assert roi.y2 == 700 # 100 + 600 + + def test_roi_default_values(self) -> None: + """Test ROI default values.""" + roi = ROI() + + assert roi.x == 0 + assert roi.y == 0 + assert roi.width == 100 + assert roi.height == 100 + + def test_pattern_with_roi(self) -> None: + """Test pattern generation with custom ROI.""" + roi = ROI(x=100, y=100, width=400, height=300) + generator = PatternGenerator( + bit_depth=12, + width=1920, + height=1080, + roi=roi, + ) + + pattern = generator.generate([[4095, 0, 0]]) + + assert pattern is not None + assert pattern.shape == (1080, 1920, 3) + + +class TestColorRangeError: + """Tests for the ColorRangeError exception.""" + + def test_error_message(self) -> None: + """Test that ColorRangeError has appropriate message.""" + error = ColorRangeError() + + assert "bit-depth range" in str(error) + + def test_error_with_detail(self) -> None: + """Test ColorRangeError with detail string.""" + error = ColorRangeError("Value 5000 exceeds 12-bit max 4095") + + # The detail is added as a note, not part of the main message + assert len(error.__notes__) == 1 + assert "5000" in error.__notes__[0] diff --git a/tools/check_core_install.py b/tools/check_core_install.py new file mode 100644 index 0000000..6c2846d --- /dev/null +++ b/tools/check_core_install.py @@ -0,0 +1,48 @@ +"""Verify the numpy-only core footprint (§spec:package-shape). + +Renders a checkerboard and asserts the import path pulled in no chart +or IO dependency. By default the environment is first required to be +core-only (no extras installed) — the wheel-install CI job's mode. +``--skip-env-check`` drops that gate so the same probe runs in an +all-extras environment (the test suite's mode), where the post-render +module sweep is the live assertion. +""" + +import importlib.util +import sys + +# Import names of every dependency priced into an extra +# ([project.optional-dependencies] in pyproject.toml). The probe fails +# if importing or rendering the core loads any of them. +FORBIDDEN_MODULES = ("colour", "PIL", "scipy", "tifffile", "yaml") + + +def main() -> None: + if "--skip-env-check" not in sys.argv[1:]: + for name in FORBIDDEN_MODULES: + if importlib.util.find_spec(name) is not None: + sys.exit(f"{name} is installed; this check requires a core-only env") + + import numpy as np + + from display_patterns.image_generators import ROI, PatternGenerator + + generator = PatternGenerator( + bit_depth=12, width=64, height=64, roi=ROI(0, 0, 64, 64) + ) + pattern = generator.generate([[4095, 2048, 0]]) + assert pattern.shape == (64, 64, 3) + assert pattern.dtype == np.uint16 + assert set(np.unique(pattern)) == {0, 2048, 4095} + + assert "display_patterns.charts" not in sys.modules, ( + "core rendering imported the charts subpackage" + ) + loaded = sorted(m for m in sys.modules if m.split(".")[0] in FORBIDDEN_MODULES) + assert not loaded, f"core rendering imported heavy deps: {loaded}" + + print("core rendering loads numpy alone") + + +if __name__ == "__main__": + main() diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..5c14a77 --- /dev/null +++ b/uv.lock @@ -0,0 +1,428 @@ +version = 1 +revision = 3 +requires-python = ">=3.12" + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "colour-science" +version = "0.4.7" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f4/66/6e2216ffae6f0008118c99d6f425f7591e688671ab99f5636f7f65021395/colour_science-0.4.7.tar.gz", hash = "sha256:b34773dc4dd3f9ba99cca5297fa10e9f532134d994b3a2308dacbe970dfd5079", size = 13283171, upload-time = "2025-12-06T08:15:08.229Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6d/9e/ce54e33f383d7e1432be9fb39557477779ccb155c60033c491609936dacf/colour_science-0.4.7-py3-none-any.whl", hash = "sha256:fe2383d86e507fc11a4b5206ff31a7399031e0dce527e22ec06eeb491d45d9a7", size = 9065613, upload-time = "2025-12-06T08:15:14.908Z" }, +] + +[[package]] +name = "display-patterns" +source = { editable = "." } +dependencies = [ + { name = "numpy" }, +] + +[package.optional-dependencies] +charts = [ + { name = "colour-science" }, + { name = "pillow" }, + { name = "pyyaml" }, + { name = "scipy" }, +] +io = [ + { name = "tifffile" }, +] + +[package.dev-dependencies] +dev = [ + { name = "pyright" }, + { name = "pytest" }, + { name = "ruff" }, +] + +[package.metadata] +requires-dist = [ + { name = "colour-science", marker = "extra == 'charts'", specifier = ">=0.4.6" }, + { name = "numpy", specifier = ">=2.3.1" }, + { name = "pillow", marker = "extra == 'charts'", specifier = ">=12.1.1" }, + { name = "pyyaml", marker = "extra == 'charts'", specifier = ">=6.0.1" }, + { name = "scipy", marker = "extra == 'charts'", specifier = ">=1.16.3" }, + { name = "tifffile", marker = "extra == 'io'", specifier = ">=2024.8.30" }, +] +provides-extras = ["charts", "io"] + +[package.metadata.requires-dev] +dev = [ + { name = "pyright", specifier = ">=1.1.403" }, + { name = "pytest", specifier = ">=8.4.1" }, + { name = "ruff", specifier = ">=0.12.3" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + +[[package]] +name = "nodeenv" +version = "1.10.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/24/bf/d1bda4f6168e0b2e9e5958945e01910052158313224ada5ce1fb2e1113b8/nodeenv-1.10.0.tar.gz", hash = "sha256:996c191ad80897d076bdfba80a41994c2b47c68e224c542b48feba42ba00f8bb", size = 55611, upload-time = "2025-12-20T14:08:54.006Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/88/b2/d0896bdcdc8d28a7fc5717c305f1a861c26e18c05047949fb371034d98bd/nodeenv-1.10.0-py2.py3-none-any.whl", hash = "sha256:5bb13e3eed2923615535339b3c620e76779af4cb4c6a90deccc9e36b274d3827", size = 23438, upload-time = "2025-12-20T14:08:52.782Z" }, +] + +[[package]] +name = "numpy" +version = "2.5.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9a/80/db0b4559e57ec36362bedbb05530a87fafbcb6067708c946967a41d449e7/numpy-2.5.2.tar.gz", hash = "sha256:d482d171c406ae88c5b19cad3b6a1c4c5209f886ab74bc44c2c865c23f52d860", size = 20773161, upload-time = "2026-08-09T13:48:27.962Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/69/72/dccb0aaf40972777283303919f613964227266d0c13adebb79ac124f1c3e/numpy-2.5.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:14e373cfc6387177e8409dac3c7159be8eb05cd77096cd7c950268b86f62831c", size = 16891693, upload-time = "2026-08-09T13:44:51.702Z" }, + { url = "https://files.pythonhosted.org/packages/60/2e/b5aee50a1f74ac815cf8331812cb8251e29024025de462e0c047641c614c/numpy-2.5.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:4bbd96c833ecc8cc069ce518078fc8c60cb9cbfb0fea5b7a803ad65035596d03", size = 11903109, upload-time = "2026-08-09T13:44:55.501Z" }, + { url = "https://files.pythonhosted.org/packages/f3/f4/29e78102a80601cf034d4e9767022cffeca2c3b4c926e1754572ca95593d/numpy-2.5.2-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:6e8172ddfcf5cf74b811d372b570b83c60bd2de87a6fbfbebdadb4a9bd9c6cbb", size = 5350202, upload-time = "2026-08-09T13:44:58.401Z" }, + { url = "https://files.pythonhosted.org/packages/11/4b/dcd3b7eadaf4035d2c7a4289d232523a6964f602598ef7674e4bd7291f93/numpy-2.5.2-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:65f188481f1669e26f62b701e8205d19e460fa4a9b52a1414ba382330e4a3414", size = 6687736, upload-time = "2026-08-09T13:45:00.813Z" }, + { url = "https://files.pythonhosted.org/packages/e5/21/4947e0e9d6c9fc2e2ff15b8949049ee44f63adb9cacc729ab8793f97e712/numpy-2.5.2-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8ee9c4eeb8454b3660a8b53493563c3e121c2fc94fbd72b848ef814ed7b676a9", size = 15612696, upload-time = "2026-08-09T13:45:04.151Z" }, + { url = "https://files.pythonhosted.org/packages/3a/5f/62d28cf019460c7f1394105b4d49d9911a9c444cb77ab0bd95a204c5a6de/numpy-2.5.2-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3cdec01fa790a186d430433fdd4d4ffb70eed6f0eeb4bf05c8dbe2dce0a9bcb8", size = 16722264, upload-time = "2026-08-09T13:45:07.714Z" }, + { url = "https://files.pythonhosted.org/packages/14/25/3f0be4c1b9fdf5dd5e708a6806978564d7c46a055c000496309ff2a2f8af/numpy-2.5.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:7999d4ddb0c4025018373fd787510d46e04c769467af22869707b3c1cfd459ab", size = 16974396, upload-time = "2026-08-09T13:45:11.316Z" }, + { url = "https://files.pythonhosted.org/packages/22/72/6262cbdeeb45da9d971e40715f579d791603ba8ec0b5e2db1ac55454421d/numpy-2.5.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c1f017dc0875c9209d219f97feceb7d54c2661bb243deb4114478e1295808af7", size = 18476044, upload-time = "2026-08-09T13:45:14.869Z" }, + { url = "https://files.pythonhosted.org/packages/36/33/29208b8b075bde62d26a81d14b358c42b0f69b6cabd98d4ff97f37f22b05/numpy-2.5.2-cp312-cp312-win32.whl", hash = "sha256:d6a48072864e3324e194a8fbb3c657bcc5b5c869dbc64c9537b1d5c862572c0a", size = 6072817, upload-time = "2026-08-09T13:45:17.867Z" }, + { url = "https://files.pythonhosted.org/packages/7f/b9/87fea2769fe1c47c1b5b01d8310772c9d1a85d485de7cf386ef7a3332b02/numpy-2.5.2-cp312-cp312-win_amd64.whl", hash = "sha256:28ac63476ec7651484215ee7fa15a1f78b57c14621f01e392afe17b9a1390ce4", size = 12464674, upload-time = "2026-08-09T13:45:20.734Z" }, + { url = "https://files.pythonhosted.org/packages/14/52/032b97e00461ab0809bbe4c588b035620e5a14b8cdee47ecddefc7b17d33/numpy-2.5.2-cp312-cp312-win_arm64.whl", hash = "sha256:27650bb0e7140fa3d37b9923b4803645e0b125d190f326eecfd3f4dad8e8ade1", size = 10397131, upload-time = "2026-08-09T13:45:23.73Z" }, + { url = "https://files.pythonhosted.org/packages/f5/d2/6b24738a0ef4557d189b150046cd07823c50e4273e8aebd651222e24306f/numpy-2.5.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8e4cb9a754c8a0c62eaa88273a5fba3391f4a610d1dee893c0755da31c083f15", size = 16886595, upload-time = "2026-08-09T13:45:27.323Z" }, + { url = "https://files.pythonhosted.org/packages/65/60/f2d208d366f263f39c6e69ed309290717aab41078b6d04c9be2a84fa2a07/numpy-2.5.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:52c808f96484f5571a5cc863775ce50247c17dfb3b0361f8ed6b4b0456f80080", size = 11896845, upload-time = "2026-08-09T13:45:31.638Z" }, + { url = "https://files.pythonhosted.org/packages/3c/79/81e0bf24f4d020a2b1d5cd297a9f60c3f24eeb116f9bba5870443f7b6a4a/numpy-2.5.2-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:29d81e97f668489cba8ebfd796b9bdd453525d35dd9e162e2daec94bf3fc7740", size = 5343880, upload-time = "2026-08-09T13:45:34.373Z" }, + { url = "https://files.pythonhosted.org/packages/ba/cc/e3141cf06d1a8a2c7e107543fe1269c1d1af760d4d683c0794a4ee1127c2/numpy-2.5.2-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:afb3f0632d6b2e3ba04dbce8d1e48d321b369138b73830b5ca371a0e8d479d56", size = 6682264, upload-time = "2026-08-09T13:45:36.7Z" }, + { url = "https://files.pythonhosted.org/packages/29/f1/2a64a307d92c5d98f5255a4014eb43bb6103ee477087b61ecae44a3aa9b9/numpy-2.5.2-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0aadf13b60048d501e05fa699efaf7734e2494f3498a4c2a5521d822640324f3", size = 15609566, upload-time = "2026-08-09T13:45:39.518Z" }, + { url = "https://files.pythonhosted.org/packages/7b/44/59a1eb68e773c4098d107ef34a0dbdeca501d72ffcfbff9a7707343921ce/numpy-2.5.2-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:29b86ff8a6cc556b47ec6b64b194815cc80e6bf5eedcc6cddfd65318cb0b4eee", size = 16709995, upload-time = "2026-08-09T13:45:43.661Z" }, + { url = "https://files.pythonhosted.org/packages/8a/4c/3e54d4ddbc359a1295f8b633e8106bcd4d7d4a206e82df051bdfb3058755/numpy-2.5.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:6950c4b7dd562453090548ba7f5da7e59f57f85663f15d5dcc60e249192f7e59", size = 16972511, upload-time = "2026-08-09T13:45:47.094Z" }, + { url = "https://files.pythonhosted.org/packages/f2/9f/02e371638ebf19b66d46231e4be52999e87f32d1961b113bc45656608b22/numpy-2.5.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:b9727f472d2f3888053b8a75ab0cb94745a9de224bb5846dbadc0092101bc71d", size = 18465609, upload-time = "2026-08-09T13:45:50.808Z" }, + { url = "https://files.pythonhosted.org/packages/eb/ae/ad6645abc7a3510fe48e8ea1ab4598166f500057ef4ebf38bfad4f1577de/numpy-2.5.2-cp313-cp313-win32.whl", hash = "sha256:4f9744f9fbdcea0bc552e8f19e1f141f811a3f9bc2be2cc6e86d982cab23e3f4", size = 6070204, upload-time = "2026-08-09T13:45:54.111Z" }, + { url = "https://files.pythonhosted.org/packages/15/20/f3489f86d81ea460b2bcdceaed094142ca6579f6be0ec527b781d39afe68/numpy-2.5.2-cp313-cp313-win_amd64.whl", hash = "sha256:85aaccb24182c25df891ad0ec333585967e115269d5f1b17f2c9ae005bc96657", size = 12460532, upload-time = "2026-08-09T13:45:57.167Z" }, + { url = "https://files.pythonhosted.org/packages/d5/21/35b31dde1b283b79de828b80f876afd8c94e28fe1e9c375f89e261cc4c0d/numpy-2.5.2-cp313-cp313-win_arm64.whl", hash = "sha256:bd68ece1553d2023c09a4226d9e41c586ad2d20594d1a456186c33513d2cb3f2", size = 10396725, upload-time = "2026-08-09T13:46:00.478Z" }, + { url = "https://files.pythonhosted.org/packages/ac/f8/c3b222bf075b50afd8e949a07a15c4b312a4a84bd8102a332bcd953cbbb4/numpy-2.5.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:d787cf769c3baeb5f6235e778edb52c08dfa923789b5958f28e6450f96107cb1", size = 16885180, upload-time = "2026-08-09T13:46:03.939Z" }, + { url = "https://files.pythonhosted.org/packages/17/e1/2c1d4b1987795a92b5bbf7c24fe249ab96aa2573ab0d7604802c189d7b86/numpy-2.5.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:24b9dc2e3d84aa58523798805194e23e736f3f6ce2d1a5b92583ae734e6dbda8", size = 11907878, upload-time = "2026-08-09T13:46:07.045Z" }, + { url = "https://files.pythonhosted.org/packages/b9/ee/d08226fc858044355983a6e5b94f08ff6f3969e0a2b160a4a89f0ddb3445/numpy-2.5.2-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:9e9413326d726c2545bfa65d2c0876871e8d8386e77f992c1d426e180bbd4323", size = 5354922, upload-time = "2026-08-09T13:46:10.04Z" }, + { url = "https://files.pythonhosted.org/packages/94/f0/6d3d933056440ebbc5e6bad92065fc6c26a48a84a36b1208580e94eea76c/numpy-2.5.2-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:60e902ac295855348a5ca2ea4c89108989a9f5fddfad3dfc0a8f36b10358567e", size = 6679168, upload-time = "2026-08-09T13:46:12.275Z" }, + { url = "https://files.pythonhosted.org/packages/c4/3b/ecd49dd90033cceb2704d88ca905d4d7d89b0e8c739608754ffd325fa820/numpy-2.5.2-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:50e500dc868e9313530ce12ba470fe50ff3afe3d62993ed6eff652dacd555b65", size = 15624501, upload-time = "2026-08-09T13:46:15.322Z" }, + { url = "https://files.pythonhosted.org/packages/c7/99/461bd36dbdfac6c1c53efa370bd55a83227542d0d118f1677dbf1a3dacd5/numpy-2.5.2-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:318b9a4c845dbea06708a29c84ee429cc3065048db34cdb799047643492050ee", size = 16713701, upload-time = "2026-08-09T13:46:18.949Z" }, + { url = "https://files.pythonhosted.org/packages/f9/9c/2b251df9e8a5d647b62b0cbc1b90a91850c1cf4859ecb532fd0b4eacff6c/numpy-2.5.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:34c319e2963be042673fb46570501b2f06c41924e17e3563d58646b4380dfb68", size = 16986065, upload-time = "2026-08-09T13:46:23.006Z" }, + { url = "https://files.pythonhosted.org/packages/8f/25/20de43f53ff1390534a124475055a19f01fe10c920a0fd11b8e18d6d6052/numpy-2.5.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f06571a052127dc1b4e8b83029b4d1b20daa2b64a31cdd181fc6bc774e9000eb", size = 18470031, upload-time = "2026-08-09T13:46:27.102Z" }, + { url = "https://files.pythonhosted.org/packages/56/5e/0c577ca308d6da5eb79b546ba10bbe5b60148192194e2da060913b1de4f1/numpy-2.5.2-cp314-cp314-win32.whl", hash = "sha256:2cc779226e476d1e1f08c74068c419e60f41a9e0e069c92f6671d31d5c985e98", size = 6121028, upload-time = "2026-08-09T13:46:30.046Z" }, + { url = "https://files.pythonhosted.org/packages/15/5c/7bcbd5b11f94199073320410cddcbb80cee62415bfeb540874b265c2d922/numpy-2.5.2-cp314-cp314-win_amd64.whl", hash = "sha256:7587f53dfbd5edc0f7b87c6217b4c6d2d1f2ef9c3da70bc1315e7db5f8d7ec9d", size = 12597627, upload-time = "2026-08-09T13:46:32.886Z" }, + { url = "https://files.pythonhosted.org/packages/87/bc/4d0b06fba0da90ccc75af62823cb9dcedb6c9ea0cffa058cb2c9ee773a77/numpy-2.5.2-cp314-cp314-win_arm64.whl", hash = "sha256:3e4c367352d3747784248a227fbec218e193b56f7e6692e3b64fc805478ecfdf", size = 10680414, upload-time = "2026-08-09T13:46:36.036Z" }, + { url = "https://files.pythonhosted.org/packages/cd/17/f429aac9dc08833a0d0f188eba38c532a751b1a1f2ca6018a37b455cb321/numpy-2.5.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b879fb674276e331513fb136b78dbc6bd3c848309e0d841cfd63be3896c4cfc1", size = 12026967, upload-time = "2026-08-09T13:46:39.084Z" }, + { url = "https://files.pythonhosted.org/packages/ca/9f/d0849de96a2a4ceaa16662f18ee13eaa9c0aa418269fdc8c4857c56b11da/numpy-2.5.2-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:fd0d703772bba096843785bd38371e31bb4a0c1151497ad5739d182114a73f7f", size = 5473874, upload-time = "2026-08-09T13:46:42.075Z" }, + { url = "https://files.pythonhosted.org/packages/89/3c/8df216d4a4a5422a3de045301cf7df8ea47286d76f5cb7160b0128ac26b7/numpy-2.5.2-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:3a2f061cebd9e3d23bdcfaaded5e2293a4c6a5b60fa42df85d410a725ce621bf", size = 6789276, upload-time = "2026-08-09T13:46:44.387Z" }, + { url = "https://files.pythonhosted.org/packages/e6/3a/20d7e9891c4ddfadd6ff8d95bf4b29f353d8e1770553de2099880551dfb9/numpy-2.5.2-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6df895598c0edcb41030126c89e0f353b07d93238116143b7405e937359736c4", size = 15659154, upload-time = "2026-08-09T13:46:47.538Z" }, + { url = "https://files.pythonhosted.org/packages/aa/d6/f3aa3d2688bf501b858835c6bd087ae9b51a56ae6fca8e2b0990abd177af/numpy-2.5.2-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1ab3d4a901f844ea836c3e80bf463c6a27d7f3c14e8e292fcf28d348b25b9bce", size = 16748909, upload-time = "2026-08-09T13:46:51.442Z" }, + { url = "https://files.pythonhosted.org/packages/7d/8f/1c5cae8d2baf86ab802ae97a00be55bc7e21ebc11b12bbc33376c5f05342/numpy-2.5.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:cebc2d6dbb605a7703d59751dea4bd6b0ab127a5a4338a6f432df1936fef8b26", size = 17027685, upload-time = "2026-08-09T13:46:55.095Z" }, + { url = "https://files.pythonhosted.org/packages/5c/27/71d3467404aedc1c24ce79610f91b52b0b0f466c43a701aa56fc75c145ab/numpy-2.5.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:eaca7ff36f0f52e2111ec71f169d8fd3e889e7ddc0d2592e0d703fd8d3ce8fac", size = 18501181, upload-time = "2026-08-09T13:46:59.09Z" }, + { url = "https://files.pythonhosted.org/packages/14/2f/42921d27c40aea7e077f4a423ae509fd9220b028cd787bafefd8ab2b3a5f/numpy-2.5.2-cp314-cp314t-win32.whl", hash = "sha256:ddf47472af2e4280d79bac82304f5e80150211f1b9e614b760061d5fdfbb6eba", size = 6271085, upload-time = "2026-08-09T13:47:01.903Z" }, + { url = "https://files.pythonhosted.org/packages/75/e6/bad5f5d56de9b1971bac959963dda276d35c40f1854475005434bbe08692/numpy-2.5.2-cp314-cp314t-win_amd64.whl", hash = "sha256:44ef9675d908e65f9953063837c3277730f3f4437615a4cdab67b366cabaf884", size = 12787971, upload-time = "2026-08-09T13:47:04.963Z" }, + { url = "https://files.pythonhosted.org/packages/df/05/f608795cb34391acd67e38d94a3c36abd8d8576293a3a80727d7595c372c/numpy-2.5.2-cp314-cp314t-win_arm64.whl", hash = "sha256:eaa088384c46f519dacb93b7ec483a6d6b19a4a2085ae4f25ab9b1c43d387d1e", size = 10750306, upload-time = "2026-08-09T13:47:07.976Z" }, + { url = "https://files.pythonhosted.org/packages/33/c6/28de0191c5f82b7d42a0a51390ba98587048aa93a39fafb05bdbe6e8d00c/numpy-2.5.2-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:078f9b027b478c9379b9677babbf0f8b8f1ecfada27636d7b9a93990c638739f", size = 16885274, upload-time = "2026-08-09T13:47:11.439Z" }, + { url = "https://files.pythonhosted.org/packages/dd/d1/973ca116000d244897e468ea1aff30b589e5022e3c8744b71706fe33bd57/numpy-2.5.2-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:50a68f4bacd8a2b33d8da3d2269d0d78500f86ea582e4786dc10f5ef2c2c6842", size = 11907846, upload-time = "2026-08-09T13:47:15.128Z" }, + { url = "https://files.pythonhosted.org/packages/78/d9/8c4b3937ef204cb2fd88d389ccd0f265a2ffb11f35a01d2064cf46714bd6/numpy-2.5.2-cp315-cp315-macosx_14_0_arm64.whl", hash = "sha256:e79aba74ffaf5f78a050d777c184cddf8fdffabab38acf5f3ef1fecbc17895d6", size = 5354892, upload-time = "2026-08-09T13:47:18.07Z" }, + { url = "https://files.pythonhosted.org/packages/74/9b/b6ee65ea2999fdb7023935e108e6fb776ee4082aa15f159acfa857e578c8/numpy-2.5.2-cp315-cp315-macosx_14_0_x86_64.whl", hash = "sha256:9a0731745a72a184490a582fb4af2533512bd071ace67785b5fdffc0ae58dce8", size = 6679309, upload-time = "2026-08-09T13:47:20.456Z" }, + { url = "https://files.pythonhosted.org/packages/43/f3/acb18d8b137a393c8e7803a8c994c9e64bde3930692a69d826993113a159/numpy-2.5.2-cp315-cp315-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4ec954036759bcee3aa484f8603bd9c14f3e776293b85578b8734c2d72777c69", size = 15625850, upload-time = "2026-08-09T13:47:24.365Z" }, + { url = "https://files.pythonhosted.org/packages/a9/bf/a8e9bb0db815a0e265b5744ebedd3af0bd5faad8604e5b50a1cd012f3c91/numpy-2.5.2-cp315-cp315-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:dc649493697006bc90614a5f0bbc8cb3cb1866715c474e473694968d7e6b99ab", size = 16713664, upload-time = "2026-08-09T13:47:27.965Z" }, + { url = "https://files.pythonhosted.org/packages/0c/c3/6e913736b3dd6582344af32418b5fb9dab34282e8a8174ae1d54ceb0fc13/numpy-2.5.2-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:cf7de32f486e4ac9e2d93b810f9e9ac72a728dd46a32a0bb403222f27f653514", size = 16986749, upload-time = "2026-08-09T13:47:31.541Z" }, + { url = "https://files.pythonhosted.org/packages/80/09/7d3b23eff5c7428ef6c01e6f7052bb60d504c4d33e317b36b8959c24ad97/numpy-2.5.2-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:2ffa7bacab3e2ee1b19ed31766bb60bb380b68c23f051e199c5cc598afd68710", size = 18470495, upload-time = "2026-08-09T13:47:35.364Z" }, + { url = "https://files.pythonhosted.org/packages/a5/a4/68a321d825374f6eb677ffe8ef8c6b9a328304e6fd2e39d9530822776607/numpy-2.5.2-cp315-cp315-win32.whl", hash = "sha256:6b588cc8f902d6bff201c19fd00c43ab8545671e3554d014e12e14139e5e8617", size = 6120696, upload-time = "2026-08-09T13:47:38.561Z" }, + { url = "https://files.pythonhosted.org/packages/c8/23/deafbb1700f79fae9cd1e91220f133d124cc267de1b584da3fbf6db2f6cd/numpy-2.5.2-cp315-cp315-win_amd64.whl", hash = "sha256:07d4e89f3a9ab0a9ba24264ccdb642b3dd951b2281e8883a5481a4aa79cc31a7", size = 12597324, upload-time = "2026-08-09T13:47:41.401Z" }, + { url = "https://files.pythonhosted.org/packages/33/cd/3272ba105e3bbbdaeb11357eda31e7a6825ffe159e8171665660299a948f/numpy-2.5.2-cp315-cp315-win_arm64.whl", hash = "sha256:a610dc7e3c52edd39c2bc2375ff9c3fd59cb3ad00e4472d36f83bc1457145788", size = 10680466, upload-time = "2026-08-09T13:47:44.873Z" }, + { url = "https://files.pythonhosted.org/packages/0e/0e/58370637b1bb70a5c9ce2b43f4b521ccb224e36ccb76a6596b17ae4b447c/numpy-2.5.2-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:40f4d451aed46a8046a1aae41c4e55fb3612273df9c502480135e1501576a34b", size = 16993947, upload-time = "2026-08-09T13:47:48.97Z" }, + { url = "https://files.pythonhosted.org/packages/10/93/2abcb807712b289d6d60fe4cf30532f98974a8396d885650f3ba5a13026e/numpy-2.5.2-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:c081cbe16ba1ab53078e5ff29013621e33c509eedab055775d956427712c236e", size = 12025331, upload-time = "2026-08-09T13:47:52.646Z" }, + { url = "https://files.pythonhosted.org/packages/8b/3a/2898e003a5fbaf87e76c039b4ee1f5eb390471b4ffe74887c1f34c4e791e/numpy-2.5.2-cp315-cp315t-macosx_14_0_arm64.whl", hash = "sha256:0090ccdd57ec2703e9b49d0bf554767370581c1dd0a6b2bb2b2d9def317d042a", size = 5472336, upload-time = "2026-08-09T13:47:55.403Z" }, + { url = "https://files.pythonhosted.org/packages/61/a5/23f69d07c544597b29758b31b55c27dc9d541012a2c1496189fef702aec2/numpy-2.5.2-cp315-cp315t-macosx_14_0_x86_64.whl", hash = "sha256:6a9bb119fb8dd21ba30b3f0e555b7e2b081bd9883af21ec9c1c633d161cda3a8", size = 6788387, upload-time = "2026-08-09T13:47:58.192Z" }, + { url = "https://files.pythonhosted.org/packages/15/ea/c0dbdbcf22f43782510a3e492dd3da73c6112b69cac8929d16d127536fc4/numpy-2.5.2-cp315-cp315t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a839318485284a6fb31be4f8f2c91c8f2cb22f4543c4a8903f12b0671ffe07cc", size = 15667096, upload-time = "2026-08-09T13:48:01.562Z" }, + { url = "https://files.pythonhosted.org/packages/fc/5e/29c73c31748cdb0f7566642125ba17fd5b56780cddf891b085dab27e4466/numpy-2.5.2-cp315-cp315t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba0a474801b8dc67b66bf465548abc90e82b44d2611b5770f33008dcabffe8ec", size = 16751730, upload-time = "2026-08-09T13:48:05.706Z" }, + { url = "https://files.pythonhosted.org/packages/47/95/02501e8454796bb58dadf7a99d3181e0b464bf264e1003039572f9779fac/numpy-2.5.2-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:0a4035ae1129ff8777f08bfbd44f1e5d8e9c049ce0c2dd78fc0d92c13e7251c0", size = 17038686, upload-time = "2026-08-09T13:48:09.627Z" }, + { url = "https://files.pythonhosted.org/packages/0e/b5/53a681d91b5c82687067d8ea5035e02d917b5509d6f334cb06484a954714/numpy-2.5.2-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:77843ca236b777e67f8d6b3660ea116e499612703a0ecd7093f316201eb9d8e2", size = 18507727, upload-time = "2026-08-09T13:48:13.744Z" }, + { url = "https://files.pythonhosted.org/packages/42/06/6e11443f7b64ee376c860506091103bf68f92d2cab9e8d96d4501babf07c/numpy-2.5.2-cp315-cp315t-win32.whl", hash = "sha256:7354826bc6f8f69402e9b7fe28d15fcd34feebd74f856f111585c5b0c9fb0251", size = 6269775, upload-time = "2026-08-09T13:48:17.543Z" }, + { url = "https://files.pythonhosted.org/packages/f1/18/195d6b86cd72dbbc501edfa778005fa6b87afd34c153e46028cd3a0938f4/numpy-2.5.2-cp315-cp315t-win_amd64.whl", hash = "sha256:e5651f3f87add730ee6608d915009e19c911fba0cb000c7e3ea994b7d768eb12", size = 12782559, upload-time = "2026-08-09T13:48:21.023Z" }, + { url = "https://files.pythonhosted.org/packages/b4/07/458c344f0f0c178f4481dad5cca790626ffe4c34eabf9467069d06ee4999/numpy-2.5.2-cp315-cp315t-win_arm64.whl", hash = "sha256:5f8e00be2ec6f45f4e8a41a527f68d44a7d96fee92a650e4d8b1326f77f61e6e", size = 10748103, upload-time = "2026-08-09T13:48:24.21Z" }, +] + +[[package]] +name = "packaging" +version = "26.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/fa/3944b40b07da9ce895c0e6303a5ab7d53da063554f534556b134a54d6093/packaging-26.3.tar.gz", hash = "sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79", size = 313412, upload-time = "2026-08-04T18:15:28.737Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c", size = 129956, upload-time = "2026-08-04T18:15:27.159Z" }, +] + +[[package]] +name = "pillow" +version = "12.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1c/3d/bb7fca845737cf9d7dbde16ed1843984665ff2e0a518f5db43e77ec540b9/pillow-12.3.0.tar.gz", hash = "sha256:3b8182a766685eaa002637e28b4ec8d6b18819a0c71f579bf0dbaa5830297cce", size = 47025035, upload-time = "2026-07-01T11:56:38.965Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/37/bf/fb3ebff8ddcb76aac5a01389251bbbb9519922a9b520d8247c1ca864a25d/pillow-12.3.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:ba09209fbe443b4acccebe845d8a138b89a8f4fbaeedd44953490b5315d5e965", size = 5345969, upload-time = "2026-07-01T11:54:06.397Z" }, + { url = "https://files.pythonhosted.org/packages/d8/66/9a386a92561f402389a4fc70c18838bf6d35eb5eb5c6850b4b2dc64f5048/pillow-12.3.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ffd0c5368496f41b0944be820fcb7a838aa6e623d250b01acf2643939c3f99d7", size = 4780323, upload-time = "2026-07-01T11:54:09.351Z" }, + { url = "https://files.pythonhosted.org/packages/25/27/ac8f99618ffd3dde21db0f4d4b1d2ab00c0880595bfd17df103f7f39fd0c/pillow-12.3.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d9c7f76c0673154f044e9d78c8655fb4213f6ca31a836df48b40fe5d187717b9", size = 6266838, upload-time = "2026-07-01T11:54:11.71Z" }, + { url = "https://files.pythonhosted.org/packages/84/21/a35af28dcc61f37ed850a2d64c65c701321dfbf25085e469d5559360cbbf/pillow-12.3.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:78cb2c6865a35ab8ff8b75fd122f6033b92a62c82801110e48ddd6c936a45d91", size = 6940830, upload-time = "2026-07-01T11:54:13.732Z" }, + { url = "https://files.pythonhosted.org/packages/eb/51/8b08617af3ad95e33ce6d7dd2c99ed6c8298f7fb131636303956be022e25/pillow-12.3.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e491916b378fba47242221bb9ead245211b70d504f495d105d17b14a24b4907c", size = 6344383, upload-time = "2026-07-01T11:54:15.756Z" }, + { url = "https://files.pythonhosted.org/packages/1d/72/cf78ac9780bb93c28328f408973845a309d4d145041665f734572ced1b52/pillow-12.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:0dd2064cbc55aaec028ef5fbb60fa47bb6c3e7918e07ff17935284b227a9d2df", size = 7052934, upload-time = "2026-07-01T11:54:17.721Z" }, + { url = "https://files.pythonhosted.org/packages/20/20/25e0f4dc178a6bc0696793720055519a0de89e7661dae886992decbd2f81/pillow-12.3.0-cp312-cp312-win32.whl", hash = "sha256:dbce0b29841537a2fa4a214c2bbf14de3587c9680caa9b4e217568472490b28f", size = 6472684, upload-time = "2026-07-01T11:54:19.839Z" }, + { url = "https://files.pythonhosted.org/packages/45/89/da2f7971a317f83d807fdd4065c0af40208e59e692cc43d315a71a0e96d1/pillow-12.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:a2b55dd6b2a4c4b7d87ffa56bdb33fdc5fdb9a462173861a7bc097f17d91cb09", size = 7227137, upload-time = "2026-07-01T11:54:22.025Z" }, + { url = "https://files.pythonhosted.org/packages/de/47/4845a0a6c0dbf1db8456bd9fc791f13c5ced7ced20606d08a0aacfd25b49/pillow-12.3.0-cp312-cp312-win_arm64.whl", hash = "sha256:331b624368d4f1d069149002f25f44bc61c8919ce8ddb3c45bdad8f6e2d89510", size = 2568267, upload-time = "2026-07-01T11:54:24.051Z" }, + { url = "https://files.pythonhosted.org/packages/9d/ac/31fb64e1e7efb5a4b50cd3d92049ba89ac6e4d8d3bb6a74e15048ca3353e/pillow-12.3.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:21900ce7ba264168cd50defae43cd75d25c833ad4ad6e73ffc5596d12e25ac89", size = 4161684, upload-time = "2026-07-01T11:54:25.934Z" }, + { url = "https://files.pythonhosted.org/packages/87/b4/9805e23d2b4d77842b468513841fda254ee42f0289d25088340e4ff46e2d/pillow-12.3.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:4e8c2a84d977f50b9daed6eeaf3baef67d00d5d74d932288f02cb94518ee3ace", size = 4255487, upload-time = "2026-07-01T11:54:27.935Z" }, + { url = "https://files.pythonhosted.org/packages/df/39/ecf519435a200c693fe053a6ee4d835b41cf963a4dfc2551c4e637cb2a71/pillow-12.3.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:ae26d61dfa7a47befdc7572b521024e8745f3d809bd95ca9505a7bba9ef849ec", size = 3696433, upload-time = "2026-07-01T11:54:29.813Z" }, + { url = "https://files.pythonhosted.org/packages/42/92/2fc3ffad878ae8dd5469ec1bc8eb83b71f48e13efdf68f02709003982a32/pillow-12.3.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:7a743ff716f746fc19a9557f60dab1600d4613255f8a7aeb3cdde4db7eb15a66", size = 5345889, upload-time = "2026-07-01T11:54:31.97Z" }, + { url = "https://files.pythonhosted.org/packages/10/76/8803c13605b763d33d156c4678fc77f8443389c0c51c8aef707bb02015f4/pillow-12.3.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d69141514cc30b774ceea5e3ed3a6635c8d8a96edf664689b890f4089111fb35", size = 4780109, upload-time = "2026-07-01T11:54:34.026Z" }, + { url = "https://files.pythonhosted.org/packages/1f/01/e18aff37cb0b4aac47ac90f016d347a49aca667ef97f190b06ac2aabc928/pillow-12.3.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f7401aebd7f581d7f83a439d87d474999317ee099218e5ad25d125290990ba65", size = 6263736, upload-time = "2026-07-01T11:54:36.131Z" }, + { url = "https://files.pythonhosted.org/packages/f7/62/de5bdd77d935331f4f802edc11e4d82950f642caad6cb2f949837b8560e2/pillow-12.3.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0847a763afefb695bc912d7c131e7e0632d4edc1d8698f58ddabec8e46b8b6d3", size = 6937129, upload-time = "2026-07-01T11:54:38.216Z" }, + { url = "https://files.pythonhosted.org/packages/70/4d/105627a13300c5e0df1d174230b32fd1273062c96f7745fd552b945d1e1d/pillow-12.3.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:571b9fcb07b97ef3a492028fb3d2dc0993ca23a06138b0315286566d29ef718a", size = 6339562, upload-time = "2026-07-01T11:54:40.354Z" }, + { url = "https://files.pythonhosted.org/packages/6b/1d/f13de01a553988ab895ba1c722e06cf3144d4f57656fd5b81b6d881f1179/pillow-12.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:756c768d0c9c2955feb7a56c37ea24aea2e369f8d36a88da270b6a9f19e62b5e", size = 7049439, upload-time = "2026-07-01T11:54:42.489Z" }, + { url = "https://files.pythonhosted.org/packages/c9/f9/066794cca041b969964f779ee5fa66a9498bbf34248ac39c5d7954e4198f/pillow-12.3.0-cp313-cp313-win32.whl", hash = "sha256:a876864214e136f0eb367788dbd7df045f4806801518e2cfe9e13229cfe06d8f", size = 6473287, upload-time = "2026-07-01T11:54:44.9Z" }, + { url = "https://files.pythonhosted.org/packages/a6/9b/7a58e61d62be561da3a356fe2384d4059a6345fc130e23ef1c36a5b81d24/pillow-12.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:1cca606cd25738df4ed873d5ad46bbdb3d83b5cbca291f6b4ff13a4df6b0bbe8", size = 7239691, upload-time = "2026-07-01T11:54:47.141Z" }, + { url = "https://files.pythonhosted.org/packages/aa/b0/c4ed4f0ef8f8fa5ee8351537db6650bb8189f7e118842978dd6589065692/pillow-12.3.0-cp313-cp313-win_arm64.whl", hash = "sha256:b629de27fda84b42cde7edef0d85f13b958b47f6e9bbcbba9b673c562a89bd8b", size = 2568185, upload-time = "2026-07-01T11:54:49.137Z" }, + { url = "https://files.pythonhosted.org/packages/dc/01/001f65b68192f0228cc1dbbc8d2530ab5d58b61037ba0587f946fea607cd/pillow-12.3.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:9cf95fe4d0f84c82d282745d9bb08ad9f926efa00be4697e767b814ce40d4330", size = 4161736, upload-time = "2026-07-01T11:54:51.156Z" }, + { url = "https://files.pythonhosted.org/packages/1a/d2/0219746d0fd16fc8a84498e79452375be3797d3ce4044596ce565164b84f/pillow-12.3.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:8728f216dcdb6e6d555cf971cb34076139ad74b31fc2c14da4fafc741c5f6217", size = 4255435, upload-time = "2026-07-01T11:54:53.414Z" }, + { url = "https://files.pythonhosted.org/packages/c8/02/8d0bc62ef0302318c46ff2a512822d2610e81c7aa46c9b3abe6cbaca5ad0/pillow-12.3.0-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:a45650e8ce7fafffd731db8550230db6b0d306d181a90b67d3e6bca2f1990930", size = 3696262, upload-time = "2026-07-01T11:54:55.739Z" }, + { url = "https://files.pythonhosted.org/packages/85/e2/73c77d218410b14f5f2d565e8a998d5317b7b9c75368d29985139f7a46f0/pillow-12.3.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:ba54cfebe86920a559a7c4d6b9050791c20513650a1952ebe3368c7dc70306f8", size = 5350344, upload-time = "2026-07-01T11:54:57.657Z" }, + { url = "https://files.pythonhosted.org/packages/c7/da/32c752228ae345f489e3a42499d817b6c3996da7e8a3bc7a04fc806b243b/pillow-12.3.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:e158cb00350dc278f3b91551101aa7d12415a66ebf2c91d8d5ac14e56ddd3ad0", size = 4780131, upload-time = "2026-07-01T11:54:59.713Z" }, + { url = "https://files.pythonhosted.org/packages/b1/9d/8b2c807dbef61a5197c047afe99823787eb66f63daf9fb2432f91d6f0462/pillow-12.3.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e9aeb04d6aef139de265b29683e119b638208f88cf73cdd1658aa07221165321", size = 6263757, upload-time = "2026-07-01T11:55:01.778Z" }, + { url = "https://files.pythonhosted.org/packages/5c/44/c85361f65dbe00eea8576ee467c768d25129989efb76e94f205e9ca9bb46/pillow-12.3.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:251bf95b67017e27b13d82f5b326234ca62d70f9cf4c2b9032de2358a3b12c7b", size = 6936962, upload-time = "2026-07-01T11:55:03.93Z" }, + { url = "https://files.pythonhosted.org/packages/18/7e/e483414b35800b86b6f08dbbc7803fb5cd52c4d6f897f47d53ea2c7e6f65/pillow-12.3.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fe3cca2e4e8a592be0f269a1ca4835c25199d9f3ce815c8491048f785b0a0198", size = 6339171, upload-time = "2026-07-01T11:55:05.989Z" }, + { url = "https://files.pythonhosted.org/packages/f0/f4/68c491844841ede6bed70189546b3ee9731cf9f2cbad396faff5e1ccba45/pillow-12.3.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:23aceaa007d6172b02c277f0cd359c79492bbb14f7072b4ede9fbcaf20648130", size = 7048116, upload-time = "2026-07-01T11:55:08.131Z" }, + { url = "https://files.pythonhosted.org/packages/a3/34/77f3f793fed8efc7d243f21b33c5a3f0d1c97ee70346d3db855587e155ff/pillow-12.3.0-cp314-cp314-win32.whl", hash = "sha256:af8d94b0db561cf68b88a267c5c44b49e134f525d0dc2cb7ed413a66bc23559a", size = 6467209, upload-time = "2026-07-01T11:55:10.408Z" }, + { url = "https://files.pythonhosted.org/packages/f1/e0/492879f69d94f91f60fc8cd05ba03650e9520afebb2fb7aa12777d7c7f38/pillow-12.3.0-cp314-cp314-win_amd64.whl", hash = "sha256:fdafc9cce40277e0f7a0feabce0ee50dd2fa1800f3b38015e51296b5e814048d", size = 7237707, upload-time = "2026-07-01T11:55:12.745Z" }, + { url = "https://files.pythonhosted.org/packages/c9/ac/6b11f2875f1c2ac040d84e1bbf9cf22a88038f901ca1037898b280b38365/pillow-12.3.0-cp314-cp314-win_arm64.whl", hash = "sha256:e91206ee562682b51b98ef4b26a6ef48fd84e15fd4c4bc5ec768eb641d206838", size = 2565995, upload-time = "2026-07-01T11:55:14.736Z" }, + { url = "https://files.pythonhosted.org/packages/52/69/c2208e56af9bfc1913afb24020297a691eb1d4ef688474c8a04913f65e04/pillow-12.3.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:164b31cd1a0490ab6efae01aa5df49da7061be0af1b30e035b6e9a1bfe34ee6e", size = 5352503, upload-time = "2026-07-01T11:55:17.076Z" }, + { url = "https://files.pythonhosted.org/packages/07/70/e5686d753e898a45d778ff1718dba8516ead6ab6b95d85fc8c4b70650cf2/pillow-12.3.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5afb51d599ea772b8365ae807ae557f18bccfe46ab261fd1c2a9ed700fc6eb17", size = 4782956, upload-time = "2026-07-01T11:55:19.448Z" }, + { url = "https://files.pythonhosted.org/packages/d5/37/25c6692f06927ee973ff18c8d9ee98ad0b4d84ee67a09610c2dd1447958e/pillow-12.3.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3edce1d53195db527e0191f84b71d02022de0540bf43a16ed734ed7537b07385", size = 6322855, upload-time = "2026-07-01T11:55:21.613Z" }, + { url = "https://files.pythonhosted.org/packages/cc/91/420637fcb8f1bc11029e403b4538e6694744428d8246118e45719f944556/pillow-12.3.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bf16ba1b4d0b6b7c8e534936632270cf70eb00dbe09005bc345b2677b726855c", size = 6989642, upload-time = "2026-07-01T11:55:24.006Z" }, + { url = "https://files.pythonhosted.org/packages/10/08/b94d7811281ccf0d143a1cf768d1c49e1e54af63e7b708ab2ee3eb87face/pillow-12.3.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:24870b09b224f7ae3c39ed07d10e819d06f8720bc551847b1d623832b5b0e28d", size = 6391281, upload-time = "2026-07-01T11:55:26.252Z" }, + { url = "https://files.pythonhosted.org/packages/d2/87/24233f785f55474dc02ce3e739c5528a77e3a862e9333d1dd7a25cc31f70/pillow-12.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:30f2aa603c41533cc25c05acd0da21636e84a315768feb631c937177db558931", size = 7096716, upload-time = "2026-07-01T11:55:28.318Z" }, + { url = "https://files.pythonhosted.org/packages/23/26/fcb2f6e37175b04f53570b59937867e2b80ee1685e744023153028fc14f9/pillow-12.3.0-cp314-cp314t-win32.whl", hash = "sha256:4b0a7fe987b14c31ebda6083f74f22b561fd3739bc0ac51e019622e3d72668c7", size = 6474125, upload-time = "2026-07-01T11:55:30.956Z" }, + { url = "https://files.pythonhosted.org/packages/90/de/3634abee5f1c9e13c56787b7d5517b0ba8d6de51700b95578cf338349c9f/pillow-12.3.0-cp314-cp314t-win_amd64.whl", hash = "sha256:962864dc93511324d51ddbb5b9f8731bf71675b93ca612a07441896f4688fb8c", size = 7242939, upload-time = "2026-07-01T11:55:34.044Z" }, + { url = "https://files.pythonhosted.org/packages/ce/2a/fd13f8eb24de5714a6eb444a3d67e2842c6c576e159a43793adf23051351/pillow-12.3.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0740a512dc522224c77d9aa5a8d70d8b7d73fb91f2c21125d8d025d3b8990e45", size = 2567506, upload-time = "2026-07-01T11:55:35.988Z" }, + { url = "https://files.pythonhosted.org/packages/5d/dc/8fdce34ec725a33c81c6ba122b904d6b9024e50ea9ac7bede62fab54506c/pillow-12.3.0-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:0feb2e9d6ad6c9e3c06effe9d00f3f1e618a6643273576b016f591e9315a7139", size = 4162063, upload-time = "2026-07-01T11:55:37.941Z" }, + { url = "https://files.pythonhosted.org/packages/76/66/2044b9a63d3b84ff048228dfcb7cd9bf0df983e8470971bf7d4c57b693de/pillow-12.3.0-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:9e881fca225083806662a5c43d627d215f258ff43c890f831966c7d7ba9c7402", size = 4255549, upload-time = "2026-07-01T11:55:40.022Z" }, + { url = "https://files.pythonhosted.org/packages/52/7e/1f67e6f4ece6b582ee4b539decbcc9f848dc245a93ed8cd7338bafef72f1/pillow-12.3.0-cp315-cp315-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:4998562bf62a445225f22e07c896bb04b35b1b1f2eb6d760584c9c51d7a5f78c", size = 3696331, upload-time = "2026-07-01T11:55:41.98Z" }, + { url = "https://files.pythonhosted.org/packages/12/40/d306fc2c8e4d45d7f175c77edca7063be7b86fe7fe6e68f4353bf71d808c/pillow-12.3.0-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:dc624f6bc473dacdf7ef7eb8678d0d08edf15cd94fad6ae5c7d6cc67a4e4902f", size = 5350370, upload-time = "2026-07-01T11:55:44.028Z" }, + { url = "https://files.pythonhosted.org/packages/dd/44/668fb1437e8ce420f62d6106eb66e44a5971602a4d794615bdf79315d82d/pillow-12.3.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:71d6097b330eea8fd15097780c8e89cb1a8ce7838669f48c5bacd6f663dd4701", size = 4780147, upload-time = "2026-07-01T11:55:46.073Z" }, + { url = "https://files.pythonhosted.org/packages/0c/08/93fa2e70e30a2d81547e481b6ee2bb9522117221fb1e0ce4b5df70967677/pillow-12.3.0-cp315-cp315-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:28ce87c5ab450a9dd970b52e5aca5fe63ed432d18a2eaddd1979a00a1ba24ace", size = 6273659, upload-time = "2026-07-01T11:55:48.264Z" }, + { url = "https://files.pythonhosted.org/packages/f8/6d/043e96ff814fc31a33077e4cba86082167db520c93632afdf2042febbb0c/pillow-12.3.0-cp315-cp315-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6b02afb9b97f65fbca5f31db6a2a3ba21aa93030225f150fa3f249717e938fb4", size = 6947439, upload-time = "2026-07-01T11:55:50.503Z" }, + { url = "https://files.pythonhosted.org/packages/af/92/ba71d2ee2ac0edf3fa33bd9d5ee9ee080da70b1766f3ca3934f9938ddac9/pillow-12.3.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:1182d52bc2d5e5d7d0949503aa7e36d12f42205dc287e4883f407b1988820d39", size = 6353577, upload-time = "2026-07-01T11:55:52.697Z" }, + { url = "https://files.pythonhosted.org/packages/0f/ce/e63064e2122923ff687c8ad792d0d736a7b3920a56a46982e81a7fdd25d6/pillow-12.3.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:e795b7eb908249c4e43c7c99fac7c2c75dab0c43566e37db472a355f63693d71", size = 7060394, upload-time = "2026-07-01T11:55:55.149Z" }, + { url = "https://files.pythonhosted.org/packages/54/76/a09cc3ccc8d773a7283d34c38bec1708f9e3cc932093cbc4c5e71ac4060b/pillow-12.3.0-cp315-cp315-win32.whl", hash = "sha256:57b3d78c95ba9059768b10e28b813002261d3f3dfc55cc48b0c988f625175827", size = 6467375, upload-time = "2026-07-01T11:55:57.769Z" }, + { url = "https://files.pythonhosted.org/packages/3e/03/1846c49ba3b1d5550392a4bbd06d6fb4578e1cd91a803198b5c90f5f7d53/pillow-12.3.0-cp315-cp315-win_amd64.whl", hash = "sha256:fa4ecea169a355be7a3ade2c783e2ed12f0e40d2c5621cda8b3297faf7fbb9f5", size = 7237048, upload-time = "2026-07-01T11:55:59.975Z" }, + { url = "https://files.pythonhosted.org/packages/fb/bb/89f35dcc79610423f9f195504d7def7f0d1416a711541b42867e25fe3412/pillow-12.3.0-cp315-cp315-win_arm64.whl", hash = "sha256:877c3f311ff35410f690861c4409e7ccbf0cd2f878e50628a28e5a0bb689e658", size = 2566006, upload-time = "2026-07-01T11:56:02.143Z" }, + { url = "https://files.pythonhosted.org/packages/30/88/707027ba09942dfa2c28759b5c222d769290a41c6d20ea60ec250801941f/pillow-12.3.0-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:e9871b1ffbfa9656b60aeee92ed5136a5742696006fa322b29ea3d8da0ecc9cf", size = 5352509, upload-time = "2026-07-01T11:56:04.2Z" }, + { url = "https://files.pythonhosted.org/packages/b0/6d/00352fa25332c2569cd387851f568cc5a4b75a9adbfb37ac4fbce4c02eec/pillow-12.3.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:53aa02d20d10c3d814d536aa4e5ac9b84ca0ff5a88377963b085ad6822f93e64", size = 4783167, upload-time = "2026-07-01T11:56:06.631Z" }, + { url = "https://files.pythonhosted.org/packages/13/4f/9e049dfa21af7c22427275720e2490267ba8138120add5c4c574deb69782/pillow-12.3.0-cp315-cp315t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:446c34dcc4324b084a53b705127dc15717b22c5e140ae0a3c38349d4efec071e", size = 6329237, upload-time = "2026-07-01T11:56:08.868Z" }, + { url = "https://files.pythonhosted.org/packages/36/16/cf6eeaae8d0fce8dd390a33437cf68c5d5bd73834a2bc6e2f14efda0ab45/pillow-12.3.0-cp315-cp315t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cf1845d02ad822a369a49f2bb9345b1614744267682e7a03527dc3bf6eea1777", size = 6997047, upload-time = "2026-07-01T11:56:11.379Z" }, + { url = "https://files.pythonhosted.org/packages/1e/69/dbf769bdd55f48bf5733cac28edc6364ffaa072ec9ba336266e4fe66be55/pillow-12.3.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:186941b6aef820ad110fb01fb06eb925374dc3a21b17e37ec9a53b250c6fe2d1", size = 6400440, upload-time = "2026-07-01T11:56:13.908Z" }, + { url = "https://files.pythonhosted.org/packages/a0/e1/ffc9cfc2eea0d178da8018e18e959301ad9d6bc9f3edb7181e748a474b97/pillow-12.3.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:f13c32a3abd6079a66d9526e18dad9b6d280384d49d7c54040cd57b6424041d9", size = 7105895, upload-time = "2026-07-01T11:56:16.575Z" }, + { url = "https://files.pythonhosted.org/packages/18/f0/a5595c1e8c3ae44b9828cb2f0fa8155e5095ef04d6327b8f61cf44a3df85/pillow-12.3.0-cp315-cp315t-win32.whl", hash = "sha256:1657923d2d45afb66526e5b933e5b3052e6bdea196c90d3abb2424e18c77dae8", size = 6474384, upload-time = "2026-07-01T11:56:18.855Z" }, + { url = "https://files.pythonhosted.org/packages/e4/04/62bcd9f844984c5938d3b05264a61d797a29d3e0812341a8204af70bbdee/pillow-12.3.0-cp315-cp315t-win_amd64.whl", hash = "sha256:8cd2f7bdda092d99c9fc2fb7391354f306d01443d22785d0cbfafa2e2c8bb418", size = 7243537, upload-time = "2026-07-01T11:56:21.214Z" }, + { url = "https://files.pythonhosted.org/packages/3d/68/1f3066acedf37673694a7141381d8f811ae97f30d34413d236abe7d489f1/pillow-12.3.0-cp315-cp315t-win_arm64.whl", hash = "sha256:06ff022112bc9cbf83b60f8e028d94ad87b60621706487e65f673de61610ab59", size = 2567491, upload-time = "2026-07-01T11:56:23.506Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "pygments" +version = "2.20.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, +] + +[[package]] +name = "pyright" +version = "1.1.411" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "nodeenv" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7e/ab/265f7dc69d28113ebba19092e57b075f41543b2ed048429c5f56e2b88eac/pyright-1.1.411.tar.gz", hash = "sha256:d885a0551f2e763b089a02702174e7f4ba77548cddabc972ab86d1f7f1b0f998", size = 4112861, upload-time = "2026-06-25T02:14:06.37Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0a/49/385be530a6a5b78d1cbcd5c2e38debc8959a2fc6bdb716f4e581002979fc/pyright-1.1.411-py3-none-any.whl", hash = "sha256:dc7c72a8e2700c55baa127554040e067041ea53ccfd50bf96308cc4291c7d5d9", size = 6181526, upload-time = "2026-06-25T02:14:04.691Z" }, +] + +[[package]] +name = "pytest" +version = "9.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" }, +] + +[[package]] +name = "pyyaml" +version = "6.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" }, + { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" }, + { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" }, + { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" }, + { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" }, + { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" }, + { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" }, + { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" }, + { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" }, + { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" }, + { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" }, + { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" }, + { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" }, + { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" }, + { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" }, + { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" }, + { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" }, + { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" }, + { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" }, + { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" }, + { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" }, + { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" }, + { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" }, + { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" }, + { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" }, + { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" }, + { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" }, + { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" }, + { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" }, + { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" }, + { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" }, + { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" }, + { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" }, + { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" }, + { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, +] + +[[package]] +name = "ruff" +version = "0.16.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/73/e1/4508a569211b35599016e84ba65c1a992b7a4004b4b6c4bea02a851cba1b/ruff-0.16.2.tar.gz", hash = "sha256:c3d7828d12e8927a6fc65fe38e2c2541b9e762d360a1786d752cb1b8883b3c9c", size = 4885811, upload-time = "2026-08-07T13:31:01.432Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/14/57/db19951540f98859c956b50bdb4d31089b4d91e9f15e2968e7d5193806d5/ruff-0.16.2-py3-none-linux_armv6l.whl", hash = "sha256:3c8de4cf2181f01d57946d87d777aa52916976fc09942aed89938fab5e013318", size = 10847925, upload-time = "2026-08-07T13:30:14.468Z" }, + { url = "https://files.pythonhosted.org/packages/13/5a/995fe85a8470d3e391ac0f7fa8054bb454eaf33ee138196d6172ed1079c0/ruff-0.16.2-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:9a48cc05c6fbc811ca81b5d7ba95375affea6582d1b8024e455e41afbbf55344", size = 11072662, upload-time = "2026-08-07T13:30:18.143Z" }, + { url = "https://files.pythonhosted.org/packages/32/53/370d767c61c71a971a4ace36703a7ecd8c393956349a7325d7fab2b56827/ruff-0.16.2-py3-none-macosx_11_0_arm64.whl", hash = "sha256:a2c0d14fcbb26c91f0f867a6dc9bd71bbc30b1b6151829c884f23faeab2e5700", size = 10566771, upload-time = "2026-08-07T13:30:20.899Z" }, + { url = "https://files.pythonhosted.org/packages/85/d6/9d96948caf5a632be62d62202d5ec914d6856f204fd79eb036e5915e79ea/ruff-0.16.2-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:335c621622c4650330be50842561c6586ac6971bb8ab5407fe34dcc9efb16bbe", size = 10975825, upload-time = "2026-08-07T13:30:23.517Z" }, + { url = "https://files.pythonhosted.org/packages/3b/92/ea87129b3414acb0b5770563779c51804d37ac67675c7ba35447ddb14773/ruff-0.16.2-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:20e66910f2c37cc753f9ef6580c914a621b80c4fa3549d3e3521e29d0f5bfc3f", size = 10649437, upload-time = "2026-08-07T13:30:26.097Z" }, + { url = "https://files.pythonhosted.org/packages/ac/43/f8f291dcd4af5bb7872b74fdfa41a7cd7c856ca1d4069670971cf1b9f5cb/ruff-0.16.2-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c7e36fbfba65510548156902bcf1350a979a958ce0347ce0f90d73894036b39f", size = 11446761, upload-time = "2026-08-07T13:30:28.752Z" }, + { url = "https://files.pythonhosted.org/packages/71/4a/ef991fb2fcf516ab71f0808adcdd8da5e18c8cde447f4ceaf5f47a5132a5/ruff-0.16.2-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f0eab35f80df8f134aae5d1630e751901321d317cc8e50dc39e36fa3ed34cd12", size = 12336364, upload-time = "2026-08-07T13:30:31.468Z" }, + { url = "https://files.pythonhosted.org/packages/f3/24/f615e74f307e6ca0e56a482872477b856c70d530aa356abfb6dfe5ca8a80/ruff-0.16.2-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:40ea8c0594feb894e89c8c61ab9c103d38b0ea72dfde6c594107147ca31b1140", size = 11630720, upload-time = "2026-08-07T13:30:34.426Z" }, + { url = "https://files.pythonhosted.org/packages/c5/d3/8ef50149e8412a77f7ab409efdef0e2b23803707a3863da4fc64cb23d459/ruff-0.16.2-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ab3d62dde0b19facdd632008cc4827fc28ada7736c6bd35ab6f1050f0bfed53f", size = 11466130, upload-time = "2026-08-07T13:30:36.958Z" }, + { url = "https://files.pythonhosted.org/packages/dd/a7/a19334985c4dea8c381981fa252cd854c7ee52dc4b1686dc16f4a911c702/ruff-0.16.2-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:e43e1f5b8388da9eca1b9e88328d47a5cec794633ccf6f7484ac2dd15eee92c0", size = 11523634, upload-time = "2026-08-07T13:30:39.822Z" }, + { url = "https://files.pythonhosted.org/packages/6e/6c/96d192b0e742412ceda08c0a50f9669b253dde9fd6a60ea1a10c9fa79a63/ruff-0.16.2-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:c24788a980581e1d7ea3a0cbe4344c4fbeb0a6a9b1f4713aa46bb104f8294690", size = 10949807, upload-time = "2026-08-07T13:30:42.745Z" }, + { url = "https://files.pythonhosted.org/packages/fa/51/e26599ceca11e79ee255c7df515995561edf87e9ca1893284e44d98f5a86/ruff-0.16.2-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:81806b08329130005dd4a8a8394a0c9da8c6f4cafb16ba438d2a2ee6a18bedf1", size = 10646891, upload-time = "2026-08-07T13:30:45.522Z" }, + { url = "https://files.pythonhosted.org/packages/68/01/800c4b1f97bc8d7c6029e06b1f20473a3cf1e13c4933d8f3342add83fc55/ruff-0.16.2-py3-none-musllinux_1_2_i686.whl", hash = "sha256:4ce4e02bad779bef557f541a1b31f20d6abeae1cc05ed1b1ac019d4ffd1044c8", size = 11162063, upload-time = "2026-08-07T13:30:48.131Z" }, + { url = "https://files.pythonhosted.org/packages/e4/d0/1477ea50fc5a0d4b0b71d1d63d50770bdd794d90b43e37a7618e63ec9894/ruff-0.16.2-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:e0422abdf70070255fc4073ce9dfc814cc03db577013761ddd09bc1e4a9a4fbd", size = 11556038, upload-time = "2026-08-07T13:30:50.686Z" }, + { url = "https://files.pythonhosted.org/packages/b8/76/a7776f32048d991e16d4fa8ff91790b877342d3596cc3ed04acdbf1aaedc/ruff-0.16.2-py3-none-win32.whl", hash = "sha256:bf3a63d78fb39f4bf5ac8ae52051c5520505301abe19ba4e204c453b3f09bb0b", size = 10872850, upload-time = "2026-08-07T13:30:53.471Z" }, + { url = "https://files.pythonhosted.org/packages/00/0d/929c800d920e61397d82a01b60bffc68da3052c17d31de59efaad2e4ed75/ruff-0.16.2-py3-none-win_amd64.whl", hash = "sha256:bcabe2f6d0fc7819f1431793005af4e4de7371927d037345bf941252b195b9fa", size = 12023338, upload-time = "2026-08-07T13:30:56.193Z" }, + { url = "https://files.pythonhosted.org/packages/5b/6c/93e26c22c5f78ff87363e07da49c84955affbeb1098bd1936bf3b3f293bf/ruff-0.16.2-py3-none-win_arm64.whl", hash = "sha256:d614e95cedf38a2053fd351c55b103ba30d017d61688fdbfd40ee0412852a99f", size = 11374065, upload-time = "2026-08-07T13:30:58.775Z" }, +] + +[[package]] +name = "scipy" +version = "1.18.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a7/25/c2700dfaf6442b4effaa91af24ebce5dc9d31bb4a69706313aae70d72cd0/scipy-1.18.0.tar.gz", hash = "sha256:67b2ad2ad54c72ca6d04975a9b2df8c3638c34ddd5b28738e94fc2b57929d378", size = 30774447, upload-time = "2026-06-19T15:01:43.456Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6a/19/ca10ead60b0acc80b2b833c2c4a4f2ff753d0f58b811f70d911c7e94a25c/scipy-1.18.0-cp312-cp312-macosx_10_15_x86_64.whl", hash = "sha256:7bd21faaf5a1a3b2eff922d02db5f191b99a6518db9078a8fb23169f6d22259a", size = 31056519, upload-time = "2026-06-19T14:59:45.203Z" }, + { url = "https://files.pythonhosted.org/packages/96/72/1e6442a00cd2924d361aa1b642ab6373ec35c6fabf311a760be9f76e0f13/scipy-1.18.0-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:265915e79107de9f946b855e50d7470d5893ec3f54b342e1aa6201cbdcd8bb6b", size = 28681889, upload-time = "2026-06-19T14:59:48.103Z" }, + { url = "https://files.pythonhosted.org/packages/9b/2d/11dd93d21e147a73ba22bd75c0b9208d3a2e0ec76d53170ce7d9029b1015/scipy-1.18.0-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:9ab7b758be6940954a713ee466e2043e9f6e2ed965c1fce5c91039f4be3d90a9", size = 20423580, upload-time = "2026-06-19T14:59:50.665Z" }, + { url = "https://files.pythonhosted.org/packages/9c/01/93552f75e0d2a7dd115a45e59209c51e8d514daff02fc887d2623be06fe1/scipy-1.18.0-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:97b6cddaaee0a779ef6b5ca83c9604b27cc16b2b8fc22c142652df8793319fb8", size = 23054441, upload-time = "2026-06-19T14:59:53.564Z" }, + { url = "https://files.pythonhosted.org/packages/3c/23/21f5e703643d66f21faa6b4c73195bfcad70c55efcb4f1ab327cd7c4101a/scipy-1.18.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:52a96e21517c7292375c0e27dd796a811f03fcea5fd4d108fdfea8145dcf17ab", size = 33968720, upload-time = "2026-06-19T14:59:56.415Z" }, + { url = "https://files.pythonhosted.org/packages/dd/aa/1b939f6c67ed68635bb538e6752d3dacc02f66535182e939a89581a44e9c/scipy-1.18.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1f55797419e16e7f30cf88ffb3113ce0467f00cfe3f70d5c281730b21769bfc2", size = 35287115, upload-time = "2026-06-19T14:59:59.411Z" }, + { url = "https://files.pythonhosted.org/packages/b6/ff/eec46be7e9234208f801062b53e1983085eddebd693f6c9bfb03b459830d/scipy-1.18.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ad033410e2e0672ffdc1042110cef20e1c46f8fd0616cee1d44d8d58fad8fc11", size = 35577989, upload-time = "2026-06-19T15:00:02.235Z" }, + { url = "https://files.pythonhosted.org/packages/84/ca/210d4759c7210bb7d269437421959b39a33434e2776b60c5cb8a763bb30a/scipy-1.18.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:4a55985d54c769c872e64b7f4c8a81cc30ef700cc04296abbbf3705439c126de", size = 37421717, upload-time = "2026-06-19T15:00:05.102Z" }, + { url = "https://files.pythonhosted.org/packages/2b/54/9a9edb45345bd6744da5ddfb6628e5d5185920494c6a67ec45b6381004cb/scipy-1.18.0-cp312-cp312-win_amd64.whl", hash = "sha256:71ccc8faa2dd16ac310233203474a8b5cb67f10dedd54a3116d34943f4b19132", size = 36597428, upload-time = "2026-06-19T15:00:08.112Z" }, + { url = "https://files.pythonhosted.org/packages/99/0e/33f32a2a58987e26aec0f7df252cbbad1e90ae77bdbc76f40dd4ed0cf0ea/scipy-1.18.0-cp312-cp312-win_arm64.whl", hash = "sha256:d88363fd9d8fbd3511bd273f1a49efb2a540773ddf92a91d57498ce7dd7f3e76", size = 24351481, upload-time = "2026-06-19T15:00:11.103Z" }, + { url = "https://files.pythonhosted.org/packages/05/52/9c0136c2de7ae0779b7b366447766cec6d9f0702c56bb8ffeb04c8fd3af4/scipy-1.18.0-cp313-cp313-macosx_10_15_x86_64.whl", hash = "sha256:09143f676d157d9f546d663504ef9c1becb819824f1afc018814176411942446", size = 31036107, upload-time = "2026-06-19T15:00:14.03Z" }, + { url = "https://files.pythonhosted.org/packages/02/73/0291a64843270f4efb86cdcf2ee0f2048631b65ec6b405398b2b4dbf11bf/scipy-1.18.0-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:5efe260f69417b97ddae455bfb5a95e8359f7f66ad7fa9522a60feb66f169520", size = 28663303, upload-time = "2026-06-19T15:00:16.819Z" }, + { url = "https://files.pythonhosted.org/packages/d3/0f/10ffa0b697a572f4e0d48b92a88895d366422f019f723e7e14a84c050dac/scipy-1.18.0-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:68363b7eaacd8b5dd426df56d782cc156468ac79a127a1b87ca597d6e2e82197", size = 20404960, upload-time = "2026-06-19T15:00:19.635Z" }, + { url = "https://files.pythonhosted.org/packages/7e/d2/e896cea21ba8edd6c81d4c55b1ffcc717e79698dcbebf9641b4cfb4c6622/scipy-1.18.0-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:c5557d8be5da8e41353fcd4d21491fdbab83b062fc579e94dc09a7c8ab4f669b", size = 23034074, upload-time = "2026-06-19T15:00:22.107Z" }, + { url = "https://files.pythonhosted.org/packages/ea/b2/e83ea34279a52c03374477c74006256ec78df65fc877baa4617d6de1d202/scipy-1.18.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0d13bca67c096d89fb95ced0d8921807300fce0275643aef9533cc63a0773468", size = 33942038, upload-time = "2026-06-19T15:00:24.964Z" }, + { url = "https://files.pythonhosted.org/packages/f6/af/e8fe5fb136f51e2b01678b92cb4106d10d8cd68ec147ead2e7cb0ac75398/scipy-1.18.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a46f9273dbd0eb1cefba61c9b8648b4dfe3cbc14a080176f9a73e44b8336dc7f", size = 35266390, upload-time = "2026-06-19T15:00:28.059Z" }, + { url = "https://files.pythonhosted.org/packages/3a/49/2c5cbb907b56695fc67517811d1db234dfd83381a84814ec220aded2794d/scipy-1.18.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:5aba46108853ddfc77906b6557aac839d2b52e900c1d72a1180adaaab58d265f", size = 35551324, upload-time = "2026-06-19T15:00:31.014Z" }, + { url = "https://files.pythonhosted.org/packages/bb/73/eda39f7a2d306ff0ffc574afd13c0bbb6d10a603d9a413998ee269487a80/scipy-1.18.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:b6f758e35f12757b5d95c00bc6de2438e229c2664b7a92e96f205959d9f2dfa4", size = 37404785, upload-time = "2026-06-19T15:00:34.072Z" }, + { url = "https://files.pythonhosted.org/packages/b7/d2/ae881ee28d014f38e0ccbfd974a06a919ba9af34f1f74bf42b5301891d63/scipy-1.18.0-cp313-cp313-win_amd64.whl", hash = "sha256:1afac4a847207c7ff8efd321734a50b06d0280b3b2a2c0fc2f413101747ad7c7", size = 36554943, upload-time = "2026-06-19T15:00:36.903Z" }, + { url = "https://files.pythonhosted.org/packages/70/3a/21154e2d54eb3639c6bf4dbae2e531c68356bfe95990daa30df33b30d556/scipy-1.18.0-cp313-cp313-win_arm64.whl", hash = "sha256:c5dbddf60e58c2312316d097271a8e73d40eaf2eabfa4d95ed7d3695bbf2ce7b", size = 24350911, upload-time = "2026-06-19T15:00:40.062Z" }, + { url = "https://files.pythonhosted.org/packages/78/b5/915a19b3de2f7430062b509653563db1633ddbb6f021b06731521115d4e2/scipy-1.18.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:4c256ee70c0d1a8a2ace807e199ccd4e3f57037433842abb3fb36bc17eaa9578", size = 31036253, upload-time = "2026-06-19T15:00:43.216Z" }, + { url = "https://files.pythonhosted.org/packages/d7/88/b72def7262e150d16be13fca37a96481138d624e700340bc3362a7588929/scipy-1.18.0-cp314-cp314-macosx_12_0_arm64.whl", hash = "sha256:2ef3abc54a4ffc53765374b0d5728532dfdd2585ed23f6b11c206a1f0b1b9af8", size = 28673758, upload-time = "2026-06-19T15:00:46.663Z" }, + { url = "https://files.pythonhosted.org/packages/91/02/2e636a61a525632c373cf6a9c24442a3ffb79e364d38e98b32042964ac32/scipy-1.18.0-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:f2a6af57bd9e4a75d70e4117e78a1bbee84f79ae3fbb6d0111005d6ebcc4cb8d", size = 20415514, upload-time = "2026-06-19T15:00:49.399Z" }, + { url = "https://files.pythonhosted.org/packages/c9/b6/2135974442f6aba159d9d39d774a1c8cb19947016725d69fecc685df45bf/scipy-1.18.0-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:3f1ac564d3bf6c03d861d2cd87a1bea0da2887136f7fb1bf519c05a8971452d6", size = 23034398, upload-time = "2026-06-19T15:00:51.941Z" }, + { url = "https://files.pythonhosted.org/packages/f6/e6/ba89ec5abf6ee9257c0d1ec985573f3ae32742c24bc03e016388a40b1b15/scipy-1.18.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:40395a5fcd1abee49a5c7aaa98c29db393eedc835138560a588c47ec16156690", size = 33998032, upload-time = "2026-06-19T15:00:54.838Z" }, + { url = "https://files.pythonhosted.org/packages/7f/c4/bc41eb19b0fd0db868f4132920879019318d80cc522ad8f2bca4611af808/scipy-1.18.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8ca01e8ae69f1b18e9a58d91afead31be3cef0dd905a10249dac559ee15460a0", size = 35283333, upload-time = "2026-06-19T15:00:58.152Z" }, + { url = "https://files.pythonhosted.org/packages/53/a4/cbdeef6eb3830a8462a9d4ada814de5fc984345cc9ecf17cbec51a036f1e/scipy-1.18.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7a7f3b01647384dbc3a711e8c6778e0aabbe93959249fef5c7393396bcac0867", size = 35610216, upload-time = "2026-06-19T15:01:01.155Z" }, + { url = "https://files.pythonhosted.org/packages/80/4d/b2b82502b65f661d1b789c1665dcdf315d5f12194e06fc0b37946294ebae/scipy-1.18.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:6aa94e78ec192a30063a5e72e561c28af769dc311190b24fe91774eff1969709", size = 37418960, upload-time = "2026-06-19T15:01:04.155Z" }, + { url = "https://files.pythonhosted.org/packages/93/3e/902d836831474b0ab5a37d16404f7bc5fafd9efba632890e271ba952635f/scipy-1.18.0-cp314-cp314-win_amd64.whl", hash = "sha256:2d8bbdc6c817f5b4006a54d799d4f5bab6f910193cbb9a1ff310833d4d270f61", size = 37288845, upload-time = "2026-06-19T15:01:07.822Z" }, + { url = "https://files.pythonhosted.org/packages/b6/43/8d73b337a3bdb14daa0314f0434210747c02d79d729ce1777574a817dcf6/scipy-1.18.0-cp314-cp314-win_arm64.whl", hash = "sha256:18e9575f1569b2c54174e6159d32942e03731177f63dce7975f0a0c88d102f5b", size = 24988971, upload-time = "2026-06-19T15:01:11.076Z" }, + { url = "https://files.pythonhosted.org/packages/b4/b4/f11918b0508a2787031a0499a03fbe3546f3bb5ca05d01038c45b278c09a/scipy-1.18.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:f351e0dd702687d12a402b867a1b4146a256923e1c38317cbc472f6372b94707", size = 31399325, upload-time = "2026-06-19T15:01:13.723Z" }, + { url = "https://files.pythonhosted.org/packages/7b/d1/1f287b57c0ff0ee5185dff3946d92c8017d39b0e431f0ae79a3ff1859512/scipy-1.18.0-cp314-cp314t-macosx_12_0_arm64.whl", hash = "sha256:7c7a51b33ce387193c97f228320cf8e87361daa1bba750638677729598b3e677", size = 29092110, upload-time = "2026-06-19T15:01:16.908Z" }, + { url = "https://files.pythonhosted.org/packages/ff/1a/7b74eb6c392fdcb27d414c0e7558a6d0231eb3b6d73571f479bb81ea8794/scipy-1.18.0-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:84031d7b052a54fae2f8632e0ec802073d385476eb9a63079bce6e23ef9283d4", size = 20833811, upload-time = "2026-06-19T15:01:20.488Z" }, + { url = "https://files.pythonhosted.org/packages/7c/ad/f3941716320a7b9cb4d68734a903b45fe16eff5fb7da7e16f2e619304979/scipy-1.18.0-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:56abf29a7c067dde59be8b9a22d606a4ea1b2f2a4b756d9d903c62818f5dacce", size = 23396644, upload-time = "2026-06-19T15:01:23.364Z" }, + { url = "https://files.pythonhosted.org/packages/22/22/1446b62ffe07f9719b7d9b1b6a4e05a772833ae8f441fe4c22c34c9b250f/scipy-1.18.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1ad44305cfa24b1ba5803cbbebf033590ccbac1aa5d612d727b785325ab408b0", size = 34079318, upload-time = "2026-06-19T15:01:26.002Z" }, + { url = "https://files.pythonhosted.org/packages/56/3b/b87da667098bb470fa30c7011b0ba351ee976dd395c78798c66e941665a3/scipy-1.18.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:945c1761b93f38d7f99ae81ae80c63e621471608c7eeead563f6df025585cd58", size = 35324320, upload-time = "2026-06-19T15:01:28.881Z" }, + { url = "https://files.pythonhosted.org/packages/f8/a1/c7932f91909759b0267f75fdea34e91309f96b895757534b76a90b6b4344/scipy-1.18.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:1a4441f15d620578772a49e5ab48c0ee1f7a0220e387110283062729136b2553", size = 35699541, upload-time = "2026-06-19T15:01:31.968Z" }, + { url = "https://files.pythonhosted.org/packages/f7/86/5185061a1fcc41d18c5dc2463969b3a3964b31d9ac67b2fb05d4c7ff7670/scipy-1.18.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:9aac6192fac56bf2ca534389d24623f07b39ff83317d58287285e7fbd622ff76", size = 37472480, upload-time = "2026-06-19T15:01:35.136Z" }, + { url = "https://files.pythonhosted.org/packages/31/8e/f04c68e39919a010d34f2ee1367fd705b0a25a02f609d755f0bfbc0a15fc/scipy-1.18.0-cp314-cp314t-win_amd64.whl", hash = "sha256:e40baea28ae7f5475c779741e2d90b1247c78531207b49c7030e698ff81cee3f", size = 37365390, upload-time = "2026-06-19T15:01:38.091Z" }, + { url = "https://files.pythonhosted.org/packages/d5/19/969dc072906c84dd0a3b05dcf57ea750936087d7873549e408b35cfc3f97/scipy-1.18.0-cp314-cp314t-win_arm64.whl", hash = "sha256:368e0a705903c466aa5f08eefb39e6b1b6b2d659e7352a31fd9e2438365be0f8", size = 25279661, upload-time = "2026-06-19T15:01:40.817Z" }, +] + +[[package]] +name = "tifffile" +version = "2026.7.31" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/21/62/083288b8d6b9ecb2968e7573e60aa0694089e960e203934bc217169acf64/tifffile-2026.7.31.tar.gz", hash = "sha256:79b1f4b1aba3ef3e6b6f1691a32abb62f5d7383faa52a12771c695232ba40bee", size = 442177, upload-time = "2026-08-01T02:28:32.523Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/da/ed/75bf4d6ae6fec7233ef466f27dffc99f91fde53a31e69f02640b418317ec/tifffile-2026.7.31-py3-none-any.whl", hash = "sha256:81adfa08012be1c478f99b83cda2f529eef8620cfbdf94fc41eef6f1d7b47dc5", size = 271576, upload-time = "2026-08-01T02:28:31.068Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" }, +]