diff --git a/.github/workflows/cibuildwheel.yml b/.github/workflows/cibuildwheel.yml new file mode 100644 index 0000000..91f1db7 --- /dev/null +++ b/.github/workflows/cibuildwheel.yml @@ -0,0 +1,71 @@ +name: Build and Test Wheels + +on: + pull_request: + push: + tags: + - 'v*' + +jobs: + build_wheels: + name: Build wheels on ${{ matrix.os }} + runs-on: ${{ matrix.os }} + strategy: + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + + steps: + - uses: actions/checkout@v4 + with: + submodules: recursive + + - name: Install Python + uses: actions/setup-python@v5 + with: + python-version: '3.x' # cibuildwheel will use its own Python versions + + - name: Install cibuildwheel + run: python -m pip install cibuildwheel + + - name: Build wheels + run: python -m cibuildwheel --output-dir wheelhouse + env: + CIBW_BUILD_VERBOSITY: 1 + CIBW_SKIP: "cp37-* cp38-* pp*" # Skip Python 3.7, 3.8 and PyPy as per existing workflows + CIBW_ARCHS_MACOS: "x86_64 arm64" # Build for both architectures on macOS + CIBW_BEFORE_TEST: > + pip install -r {project}/requirements.txt -r {project}/requirements-dev.txt + CIBW_TEST_COMMAND: > + curl -L -o test_image.jpg "https://raw.githubusercontent.com/mohammadimtiazz/standard-test-images-for-Image-Processing/refs/heads/master/standard_test_images/tulips.png" && + python {project}/tests/test_all.py --image test_image.jpg --quality 1 --method pillow --tonal-spot && + python {project}/tests/test_all.py --image test_image.jpg --quality 1 --method cpp --tonal-spot + CIBW_BEFORE_BUILD: > + pip install setuptools wheel + CIBW_ENVIRONMENT: > + PURE_PYTHON=False + + - uses: actions/upload-artifact@v4 + with: + name: cibuildwheel-wheels-${{ matrix.os }} + path: wheelhouse/*.whl + + deploy: + name: Publish to PyPI + needs: build_wheels + runs-on: ubuntu-latest + if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags') + + steps: + - uses: actions/checkout@v4 + + - name: Download all wheels + uses: actions/download-artifact@v4 + with: + path: dist + + - name: Publish to PyPI + uses: pypa/gh-action-pypi-publish@release/v1 + with: + user: __token__ + password: ${{ secrets.PYPI_API_TOKEN }} + packages_dir: dist diff --git a/.github/workflows/default.yml b/.github/workflows/default.yml deleted file mode 100644 index 3e13fa8..0000000 --- a/.github/workflows/default.yml +++ /dev/null @@ -1,67 +0,0 @@ -name: Build and Tests (MACOS and WINDOWS) - -on: - pull_request: - push: - -jobs: - build: - runs-on: ${{ matrix.os }} - strategy: - matrix: - os: [windows-latest, macos-latest, macos-15] - python-version: ['3.9', '3.10', '3.11', '3.12', '3.13', '3.14'] - dist: [bdist_wheel] - steps: - - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v4 - with: - python-version: ${{ matrix.python-version }} - - name: Checkout code - uses: actions/checkout@v2 - - name: Download test image file - run: | - # Photo by Dillon Hunt (https://unsplash.com/@dillon_hunt) on https://unsplash.com/photos/an-aerial-view-of-the-ocean-and-rocks-zQLd8RXbenw - curl -L -o test_image.jpg "https://unsplash.com/photos/zQLd8RXbenw/download?ixid=M3wxMjA3fDB8MXx0b3BpY3x8NnNNVmpUTFNrZVF8fHx8fDJ8fDE3MzY0MDA3NTd8&force=true&w=2400" - - name: Install dependencies - run: | - python -m pip install --upgrade pip setuptools rich wheel requests pillow - - name: Build wheel - run: | - python setup.py ${{ matrix.dist }} - - name: Run tests - run: | - pip install --find-links=dist materialyoucolor --no-index - python tests/test_all.py test_image.jpg 1 - - name: Upload artifacts - uses: actions/upload-artifact@v4 - with: - name: "artifact-cp${{ matrix.python-version }}-${{ matrix.os }}.tar.gz" - path: dist - deploy: - name: Publish to PyPI - runs-on: ubuntu-latest - needs: build - if: github.event_name == 'push' && startsWith(github.event.ref, 'refs/tags') - strategy: - matrix: - os: [windows-latest, macos-latest, macos-14] - python-version: ['3.9', '3.10', '3.11', '3.12', '3.13', '3.14'] - steps: - - name: Checkout - uses: actions/checkout@v2 - - name: Download artifacts - uses: actions/download-artifact@v4 - with: - name: "artifact-cp${{ matrix.python-version }}-${{ matrix.os }}.tar.gz" - path: dist - - name: Publish to PyPI - uses: pypa/gh-action-pypi-publish@v1.12.3 - with: - user: __token__ - password: ${{ secrets.PYPI_TOKEN }} - skip-existing: true - packages-dir: dist/ - - uses: geekyeggo/delete-artifact@v5 - with: - name: "artifact-cp${{ matrix.python-version }}-${{ matrix.os }}.tar.gz" diff --git a/.github/workflows/linux.yml b/.github/workflows/linux.yml deleted file mode 100644 index 1f1c864..0000000 --- a/.github/workflows/linux.yml +++ /dev/null @@ -1,48 +0,0 @@ -name: Build and Tests (LINUX) - -on: - pull_request: - push: - -jobs: - build: - runs-on: ['ubuntu-latest'] - container: - image: quay.io/pypa/manylinux_2_28_x86_64 - steps: - - name: Checkout code - uses: actions/checkout@v2 - - name: Download test image file - run: | - # Photo by Dillon Hunt (https://unsplash.com/@dillon_hunt) on https://unsplash.com/photos/an-aerial-view-of-the-ocean-and-rocks-zQLd8RXbenw - curl -L -o test_image.jpg "https://unsplash.com/photos/zQLd8RXbenw/download?ixid=M3wxMjA3fDB8MXx0b3BpY3x8NnNNVmpUTFNrZVF8fHx8fDJ8fDE3MzY0MDA3NTd8&force=true&w=2400" - - name: Setup, Build, Test and Audit - run: | - python_versions=("cp310" "cp311" "cp312" "cp313" "cp39") - for version in "${python_versions[@]}"; do - - /opt/python/$version-$version/bin/pip install --upgrade pip setuptools rich wheel requests pillow - /opt/python/$version-$version/bin/python setup.py bdist_wheel - /opt/python/$version-$version/bin/pip install --find-links=dist materialyoucolor --no-index - /opt/python/$version-$version/bin/python tests/test_all.py test_image.jpg 1 &> /dev/null - - if [ "$version" == "cp39" ]; then - /opt/python/$version-$version/bin/python setup.py sdist - /opt/python/$version-$version/bin/pip install auditwheel - mkdir wheelhouse - mv dist/*.tar.gz wheelhouse - auditwheel repair dist/* - echo "Built dists for Python $version:" - ls wheelhouse - fi - - done - - - name: Publish to PyPI - uses: pypa/gh-action-pypi-publish@v1.12.3 - if: github.event_name == 'push' && startsWith(github.event.ref, 'refs/tags') - with: - user: __token__ - password: ${{ secrets.PYPI_TOKEN }} - packages-dir: wheelhouse - skip-existing: true diff --git a/.setup.py.kate-swp b/.setup.py.kate-swp new file mode 100644 index 0000000..513f1d0 Binary files /dev/null and b/.setup.py.kate-swp differ diff --git a/README.md b/README.md index 11f73f8..715b450 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,3 @@ -![image](https://github.com/T-Dynamos/materialyoucolor-pyhton/assets/68729523/b29c17d1-6c02-4c07-9a72-5b0198034760) - # [Material You color algorithms](https://m3.material.io/styles/color/overview) for python! It is built in reference with offical [typescript implementation](https://github.com/material-foundation/material-color-utilities/tree/main/typescript) except it's color quantization part, which is based on [c++ implementation](https://github.com/material-foundation/material-color-utilities/tree/main/cpp) thanks to [pybind](https://github.com/pybind). @@ -13,19 +11,43 @@ It is built in reference with offical [typescript implementation](https://github Run file `tests/test_all.py` as: ```console -python3 test_all.py - +usage: test_all.py [-h] [--tonal-spot] [--expressive] [--fidelity] [--fruit-salad] [--monochrome] [--neutral] [--rainbow] [--vibrant] + [--content] [--all] [--image IMAGE] [--quality QUALITY] [--method {pillow,cpp}] + +Material You Color Scheme Test + +options: + -h, --help show this help message and exit + --tonal-spot Print the tonal-spot dynamic scheme + --expressive Print the expressive dynamic scheme + --fidelity Print the fidelity dynamic scheme + --fruit-salad Print the fruit-salad dynamic scheme + --monochrome Print the monochrome dynamic scheme + --neutral Print the neutral dynamic scheme + --rainbow Print the rainbow dynamic scheme + --vibrant Print the vibrant dynamic scheme + --content Print the content dynamic scheme + --all Print all dynamic schemes (default) + --image IMAGE Path to an image file for color extraction + --quality QUALITY Quality for image quantization (default: 5) + --method {pillow,cpp} + Method for color quantization (default: cpp) ``` -Maximum quality is `1` that means use all pixels, and quality number more than `1` means how many pixels to skip in between while reading, also you can see it as compression.
Click to view result [Image Used, size was 8MB](https://unsplash.com/photos/zFMbpChjZGg/) -![image](https://github.com/T-Dynamos/materialyoucolor-pyhton/assets/68729523/9d5374c9-00b4-4b70-b82a-6792dd5c910f) -![image](https://github.com/T-Dynamos/materialyoucolor-pyhton/assets/68729523/2edd819f-8600-4c82-a18a-3b759f63a552) +image + +It is recommended to use the `cpp` backend, as the `pillow` backend is extremely memory-inefficient for large images. +Newer Pillow APIs such as `Image.get_flattened_data()` eagerly materialize the entire image into a full list, +so quality-based subsampling is applied only after full expansion and does not reduce memory usage. +While `Image.getdata()` allows sampling during iteration, it is deprecated. + +The `cpp` backend avoids these issues by operating directly on compact pixel buffers.
@@ -50,7 +72,7 @@ pip3 install https://github.com/T-Dynamos/materialyoucolor-python/archive/master ``` ### OS Specific -#### Arch Linux +#### Arch Linux (OUTDATED) ```console yay -S python-materialyoucolor @@ -62,7 +84,7 @@ Thanks :heart: to [@midn8hustlr](https://github.com/midn8hustlr) for this [AUR p Ensure these lines in `buildozer.spec`: ```python -requirements = materialyoucolor +requirements = materialyoucolor==3.0.0 p4a.branch = develop ``` @@ -103,6 +125,7 @@ print(SchemeAndroid.dark(color).props) ```python # Color in hue, chroma, tone form from materialyoucolor.hct import Hct +from materialyoucolor.dynamiccolor.color_spec import COLOR_NAMES from materialyoucolor.dynamiccolor.material_dynamic_colors import MaterialDynamicColors # There are 9 different variants of scheme. @@ -114,60 +137,84 @@ scheme = SchemeTonalSpot( # choose any scheme here Hct.from_int(0xff4181EE), # source color in hct form True, # dark mode 0.0, # contrast + spec_version="2025" ) -for color in vars(MaterialDynamicColors).keys(): - color_name = getattr(MaterialDynamicColors, color) - if hasattr(color_name, "get_hct"): # is a color - print(color, color_name.get_hct(scheme).to_rgba()) # print name of color and value in rgba format - -# background [14, 20, 21, 255] -# onBackground [222, 227, 229, 255] -# surface [14, 20, 21, 255] -# surfaceDim [14, 20, 21, 255] -# ... +mdc = MaterialDynamicColors(spec="2025") + +for color in COLOR_NAMES: + _color = getattr(mdc, color) + print(color, _color.get_rgba(scheme), _color.get_hex(scheme)) + +# background [13, 14, 18, 255] #0D0E12FF +# onBackground [227, 229, 240, 255] #E3E5F0FF +# surface [13, 14, 18, 255] #0D0E12FF +# surfaceDim [13, 14, 18, 255] #0D0E12FF +# surfaceBright [41, 44, 52, 255] #292C34FF +# surfaceContainerLowest [0, 0, 0, 255] #000000FF +# surfaceContainerLow [17, 19, 24, 255] #111318FF +# surfaceContainer [23, 25, 31, 255] #17191FFF +# surfaceContainerHigh [29, 31, 38, 255] #1D1F26FF +# surfaceContainerHighest [35, 38, 45, 255] #23262DFF +# onSurface [227, 229, 240, 255] #E3E5F0FF +# surfaceVariant [35, 38, 45, 255] #23262DFF +# onSurfaceVariant [196, 198, 208, 255] #C4C6D0FF +# outline [142, 144, 154, 255] #8E909AFF +... ``` - Generate and score colors from image ```python -# Pillow is required to open image to array of pixels -from PIL import Image -# C++ QuantizeCelebi from materialyoucolor.quantize import QuantizeCelebi, ImageQuantizeCelebi -# Material You's default scoring of colors from materialyoucolor.score.score import Score -# Open image -image = Image.open("path_to_some_image.jpg") -pixel_len = image.width * image.height -image_data = image.getdata() - -# Quality 1 means skip no pixels +# Pixel subsampling factor (quality = 1 processes all pixels) quality = 1 -pixel_array = [image_data[_] for _ in range(0, pixel_len, quality)] - -# Run algorithm -result = QuantizeCelebi(pixel_array, 128) # 128 -> number desired colors, default 128 -# Alternate C++ method -# this is generally faster, but gives less control over image -# result = ImageQuantizeCelebi("path_to_some_image.jpg", quality, 128) +# Run Celebi color quantization on an image. +# Returns a dict: {ARGB_color_int: population} +result = ImageQuantizeCelebi( + "example.jpg", + quality, + 128, # maximum number of colors +) print(result) -# {4278722365: 2320, 4278723396: 2405, 4278723657: 2366,... -# result is a dict where key is -# color in integer form (which you can convert later), and value is population -print(Score.score(result)) -# [4278722365, 4278723657] -# list of selected colors in integer form +# Rank and select the best theme colors. +# Returns a list of ARGB color integers. +selected_colors = Score.score(result) +print(selected_colors) +# {4278911493: 276721, +# 4280550664: 164247, +# 4280683034: 144830, +# ... ``` + +## Gallery + +These are some libraries which use this library for color generattion. + +* [`dots-hyprland`](https://github.com/end-4/dots-hyprland) + +image + +* [`kde-material-you-colors`](https://github.com/luisbocanegra/kde-material-you-colors) + +image + +* [`EarthFM CLONE`] + +image + +> This is my private project where the theme colors change based on the album art. + ## FAQ 1. How it is different from `avanisubbiah/material-color-utilities`? -See https://github.com/T-Dynamos/materialyoucolor-python/issues/3 +This library is up to date, fast, and uses a hybrid system for image generation. diff --git a/materialyoucolor/__init__.py b/materialyoucolor/__init__.py index 61663da..528787c 100644 --- a/materialyoucolor/__init__.py +++ b/materialyoucolor/__init__.py @@ -1 +1 @@ -__version__ = "2.0.10" +__version__ = "3.0.0" diff --git a/materialyoucolor/blend/blend.py b/materialyoucolor/blend/blend.py index 07f04e4..f767672 100644 --- a/materialyoucolor/blend/blend.py +++ b/materialyoucolor/blend/blend.py @@ -1,16 +1,25 @@ -from materialyoucolor.hct import Hct from materialyoucolor.hct.cam16 import Cam16 +from materialyoucolor.hct.hct import Hct +from materialyoucolor.utils.color_utils import lstar_from_argb from materialyoucolor.utils.math_utils import ( - sanitize_degrees_double, difference_degrees, rotation_direction, + sanitize_degrees_double, ) -from materialyoucolor.utils.color_utils import lstar_from_argb class Blend: + """ + Functions for blending in HCT and CAM16. + """ + @staticmethod def harmonize(design_color: int, source_color: int) -> int: + """ + Blend the design color's HCT hue towards the key color's HCT + hue, in a way that leaves the original color recognizable and + recognizably shifted towards the key color. + """ from_hct = Hct.from_int(design_color) to_hct = Hct.from_int(source_color) difference_degrees_ = difference_degrees(from_hct.hue, to_hct.hue) @@ -22,17 +31,24 @@ def harmonize(design_color: int, source_color: int) -> int: return Hct.from_hct(output_hue, from_hct.chroma, from_hct.tone).to_int() @staticmethod - def hct_hue(from_: int, to: int, amount: int) -> int: - ucs = Blend.cam16_ucs(from_, to, amount) + def hct_hue(from_argb: int, to_argb: int, amount: float) -> int: + """ + Blends hue from one color into another. The chroma and tone of + the original color are maintained. + """ + ucs = Blend.cam16_ucs(from_argb, to_argb, amount) ucs_cam = Cam16.from_int(ucs) - from_cam = Cam16.from_int(from_) - blended = Hct.from_hct(ucs_cam.hue, from_cam.chroma, lstar_from_argb(from_)) + from_cam = Cam16.from_int(from_argb) + blended = Hct.from_hct(ucs_cam.hue, from_cam.chroma, lstar_from_argb(from_argb)) return blended.to_int() @staticmethod - def cam16_ucs(from_: int, to: int, amount: float) -> int: - from_cam = Cam16.from_int(from_) - to_cam = Cam16.from_int(to) + def cam16_ucs(from_argb: int, to_argb: int, amount: float) -> int: + """ + Blend in CAM16-UCS space. + """ + from_cam = Cam16.from_int(from_argb) + to_cam = Cam16.from_int(to_argb) from_j = from_cam.jstar from_a = from_cam.astar from_b = from_cam.bstar diff --git a/materialyoucolor/contrast/contrast.py b/materialyoucolor/contrast/contrast.py index 5124410..81efd01 100644 --- a/materialyoucolor/contrast/contrast.py +++ b/materialyoucolor/contrast/contrast.py @@ -1,5 +1,5 @@ +from materialyoucolor.utils.color_utils import lstar_from_y, y_from_lstar from materialyoucolor.utils.math_utils import clamp_double -from materialyoucolor.utils.color_utils import y_from_lstar, lstar_from_y class Contrast: @@ -11,7 +11,7 @@ def ratio_of_tones(tone_a: float, tone_b: float) -> float: @staticmethod def ratio_of_ys(y1: float, y2: float) -> float: - lighter = y1 if y1 > y2 else y2 + lighter = max(y1, y2) darker = y1 if lighter == y2 else y2 return (lighter + 5.0) / (darker + 5.0) @@ -25,11 +25,12 @@ def lighter(tone: float, ratio: float) -> float: real_contrast = Contrast.ratio_of_ys(light_y, dark_y) delta = abs(real_contrast - ratio) if real_contrast < ratio and delta > 0.04: - return -1 + return -1.0 return_value = lstar_from_y(light_y) + 0.4 if return_value < 0 or return_value > 100: return -1.0 + return return_value @staticmethod @@ -38,24 +39,25 @@ def darker(tone: float, ratio: float) -> float: return -1.0 light_y = y_from_lstar(tone) - dark_y = (light_y + 5.0) / ratio - 5.0 + dark_y = ((light_y + 5.0) / ratio) - 5.0 real_contrast = Contrast.ratio_of_ys(light_y, dark_y) delta = abs(real_contrast - ratio) if real_contrast < ratio and delta > 0.04: - return -1 + return -1.0 return_value = lstar_from_y(dark_y) - 0.4 - if return_value < 0 or return_value > 100: - return -1 + if return_value < 0.0 or return_value > 100.0: + return -1.0 + return return_value @staticmethod - def lighter_unsafe(tone, ratio): + def lighter_unsafe(tone: float, ratio: float) -> float: lighter_safe = Contrast.lighter(tone, ratio) return 100.0 if lighter_safe < 0.0 else lighter_safe @staticmethod - def darker_unsafe(tone, ratio): + def darker_unsafe(tone: float, ratio: float) -> float: darker_safe = Contrast.darker(tone, ratio) return 0.0 if darker_safe < 0.0 else darker_safe diff --git a/materialyoucolor/dislike/__init__.py b/materialyoucolor/dislike/__init__.py index e69de29..419cd0a 100644 --- a/materialyoucolor/dislike/__init__.py +++ b/materialyoucolor/dislike/__init__.py @@ -0,0 +1 @@ +from .dislike_analyzer import DislikeAnalyzer diff --git a/materialyoucolor/dislike/dislike_analyzer.py b/materialyoucolor/dislike/dislike_analyzer.py index 8868d20..ea8f2d5 100644 --- a/materialyoucolor/dislike/dislike_analyzer.py +++ b/materialyoucolor/dislike/dislike_analyzer.py @@ -1,4 +1,4 @@ -from materialyoucolor.hct import Hct +from materialyoucolor.hct.hct import Hct class DislikeAnalyzer: diff --git a/materialyoucolor/dynamiccolor/color_spec.py b/materialyoucolor/dynamiccolor/color_spec.py new file mode 100644 index 0000000..7237551 --- /dev/null +++ b/materialyoucolor/dynamiccolor/color_spec.py @@ -0,0 +1,422 @@ +from abc import ABC, abstractmethod +from typing import Literal + +from materialyoucolor.contrast.contrast import Contrast +from materialyoucolor.hct.hct import Hct +from materialyoucolor.utils.math_utils import clamp_double + +SpecVersion = Literal["2021", "2025"] + +COLOR_NAMES = [ + # Background & surface base + "background", + "onBackground", + "surface", + "surfaceDim", + "surfaceBright", + # Surface containers (elevation scale) + "surfaceContainerLowest", + "surfaceContainerLow", + "surfaceContainer", + "surfaceContainerHigh", + "surfaceContainerHighest", + # Surface content + "onSurface", + "surfaceVariant", + "onSurfaceVariant", + # Outlines & effects + "outline", + "outlineVariant", + "inverseSurface", + "inverseOnSurface", + "shadow", + "scrim", + "surfaceTint", + # Primary colors + "primary", + "primaryDim", + "onPrimary", + "primaryContainer", + "onPrimaryContainer", + "inversePrimary", + "primaryFixed", + "primaryFixedDim", + "onPrimaryFixed", + "onPrimaryFixedVariant", + # Secondary colors + "secondary", + "secondaryDim", + "onSecondary", + "secondaryContainer", + "onSecondaryContainer", + "secondaryFixed", + "secondaryFixedDim", + "onSecondaryFixed", + "onSecondaryFixedVariant", + # Tertiary colors + "tertiary", + "tertiaryDim", + "onTertiary", + "tertiaryContainer", + "onTertiaryContainer", + "tertiaryFixed", + "tertiaryFixedDim", + "onTertiaryFixed", + "onTertiaryFixedVariant", + # Error colors + "error", + "errorDim", + "onError", + "errorContainer", + "onErrorContainer", + # Palette key colors + "primaryPaletteKeyColor", + "secondaryPaletteKeyColor", + "tertiaryPaletteKeyColor", + "neutralPaletteKeyColor", + "neutralVariantPaletteKeyColor", + "errorPaletteKeyColor", +] + + +class ColorSpecDelegate(ABC): + """ + A delegate that provides the dynamic color constraints for + MaterialDynamicColors. + + This is used to allow for different color constraints for different spec + versions. + """ + + # define them dynamically + for color_name in COLOR_NAMES: + exec( + f"@abstractmethod\ndef {color_name}(self) -> 'DynamicColor':\n\treturn None" + ) + + # Other + @abstractmethod + def highestSurface(self, s: "DynamicScheme") -> "DynamicColor": + pass + + +_spec_2021 = None +_spec_2025 = None + + +def get_spec(spec_version: SpecVersion) -> ColorSpecDelegate: + global _spec_2021, _spec_2025 + if spec_version == "2021": + if _spec_2021 is None: + from materialyoucolor.dynamiccolor.color_spec_2021 import ( + ColorSpecDelegateImpl2021, + ) + + _spec_2021 = ColorSpecDelegateImpl2021() + return _spec_2021 + elif spec_version == "2025": + if _spec_2025 is None: + from materialyoucolor.dynamiccolor.color_spec_2025 import ( + ColorSpecDelegateImpl2025, + ) + + _spec_2025 = ColorSpecDelegateImpl2025() + return _spec_2025 + else: + raise ValueError(f"Unsupported spec version: {spec_version}") + + +class ColorCalculationDelegate: + def get_hct(self, scheme: "DynamicScheme", color: "DynamicColor") -> "Hct": + raise NotImplementedError + + def get_tone(self, scheme: "DynamicScheme", color: "DynamicColor") -> float: + raise NotImplementedError + + +class ColorCalculationDelegateImpl2021(ColorCalculationDelegate): + def get_hct(self, scheme: "DynamicScheme", color: "DynamicColor") -> "Hct": + tone = color.get_tone(scheme) + palette = color.palette(scheme) + return palette.get_hct(tone) + + def get_tone(self, scheme: "DynamicScheme", color: "DynamicColor") -> float: + from materialyoucolor.dynamiccolor.dynamic_color import DynamicColor + + decreasing_contrast = scheme.contrast_level < 0 + tone_delta_pair = ( + color.tone_delta_pair(scheme) if color.tone_delta_pair else None + ) + + if tone_delta_pair: + role_a = tone_delta_pair.role_a + role_b = tone_delta_pair.role_b + delta = tone_delta_pair.delta + polarity = tone_delta_pair.polarity + stay_together = tone_delta_pair.stay_together + + a_is_nearer = ( + polarity == "nearer" + or (polarity == "lighter" and not scheme.is_dark) + or (polarity == "darker" and scheme.is_dark) + ) + nearer = role_a if a_is_nearer else role_b + farther = role_b if a_is_nearer else role_a + am_nearer = color.name == nearer.name + expansion_dir = 1 if scheme.is_dark else -1 + n_tone = nearer.tone(scheme) + f_tone = farther.tone(scheme) + + if color.background and nearer.contrast_curve and farther.contrast_curve: + bg = color.background(scheme) + n_contrast_curve = nearer.contrast_curve(scheme) + f_contrast_curve = farther.contrast_curve(scheme) + if bg and n_contrast_curve and f_contrast_curve: + bg_tone = bg.get_tone(scheme) + n_contrast = n_contrast_curve.get(scheme.contrast_level) + f_contrast = f_contrast_curve.get(scheme.contrast_level) + + if Contrast.ratio_of_tones(bg_tone, n_tone) < n_contrast: + n_tone = DynamicColor.foreground_tone(bg_tone, n_contrast) + if Contrast.ratio_of_tones(bg_tone, f_tone) < f_contrast: + f_tone = DynamicColor.foreground_tone(bg_tone, f_contrast) + if decreasing_contrast: + n_tone = DynamicColor.foreground_tone(bg_tone, n_contrast) + f_tone = DynamicColor.foreground_tone(bg_tone, f_contrast) + + if (f_tone - n_tone) * expansion_dir < delta: + f_tone = clamp_double(0, 100, n_tone + delta * expansion_dir) + if (f_tone - n_tone) * expansion_dir >= delta: + pass + else: + n_tone = clamp_double(0, 100, f_tone - delta * expansion_dir) + + if 50 <= n_tone < 60: + if expansion_dir > 0: + n_tone = 60 + f_tone = max(f_tone, n_tone + delta * expansion_dir) + else: + n_tone = 49 + f_tone = min(f_tone, n_tone + delta * expansion_dir) + elif 50 <= f_tone < 60: + if stay_together: + if expansion_dir > 0: + n_tone = 60 + f_tone = max(f_tone, n_tone + delta * expansion_dir) + else: + n_tone = 49 + f_tone = min(f_tone, n_tone + delta * expansion_dir) + else: + if expansion_dir > 0: + f_tone = 60 + else: + f_tone = 49 + return n_tone if am_nearer else f_tone + else: + answer = color.tone(scheme) + if ( + not color.background + or not color.background(scheme) + or not color.contrast_curve + or not color.contrast_curve(scheme) + ): + return answer + + bg_tone = color.background(scheme).get_tone(scheme) + desired_ratio = color.contrast_curve(scheme).get(scheme.contrast_level) + + if Contrast.ratio_of_tones(bg_tone, answer) >= desired_ratio: + pass + else: + answer = DynamicColor.foreground_tone(bg_tone, desired_ratio) + + if decreasing_contrast: + answer = DynamicColor.foreground_tone(bg_tone, desired_ratio) + + if color.is_background and 50 <= answer < 60: + if Contrast.ratio_of_tones(49, bg_tone) >= desired_ratio: + answer = 49 + else: + answer = 60 + + if not color.second_background or not color.second_background(scheme): + return answer + + bg1 = color.background(scheme) + bg2 = color.second_background(scheme) + bg_tone1 = bg1.get_tone(scheme) + bg_tone2 = bg2.get_tone(scheme) + upper = max(bg_tone1, bg_tone2) + lower = min(bg_tone1, bg_tone2) + + if ( + Contrast.ratio_of_tones(upper, answer) >= desired_ratio + and Contrast.ratio_of_tones(lower, answer) >= desired_ratio + ): + return answer + + light_option = Contrast.lighter(upper, desired_ratio) + dark_option = Contrast.darker(lower, desired_ratio) + + availables = [] + if light_option != -1: + availables.append(light_option) + if dark_option != -1: + availables.append(dark_option) + + prefers_light = DynamicColor.tone_prefers_light_foreground( + bg_tone1 + ) or DynamicColor.tone_prefers_light_foreground(bg_tone2) + if prefers_light: + return 100 if light_option < 0 else light_option + if len(availables) == 1: + return availables[0] + return 0 if dark_option < 0 else dark_option + + +class ColorCalculationDelegateImpl2025(ColorCalculationDelegate): + def get_hct(self, scheme: "DynamicScheme", color: "DynamicColor") -> "Hct": + palette = color.palette(scheme) + tone = color.get_tone(scheme) + hue = palette.hue + chroma = palette.chroma * ( + color.chroma_multiplier(scheme) if color.chroma_multiplier else 1 + ) + return Hct.from_hct(hue, chroma, tone) + + def get_tone(self, scheme: "DynamicScheme", color: "DynamicColor") -> float: + from materialyoucolor.dynamiccolor.dynamic_color import DynamicColor + + tone_delta_pair = ( + color.tone_delta_pair(scheme) if color.tone_delta_pair else None + ) + + if tone_delta_pair: + role_a = tone_delta_pair.role_a + role_b = tone_delta_pair.role_b + polarity = tone_delta_pair.polarity + constraint = tone_delta_pair.constraint + absolute_delta = ( + -tone_delta_pair.delta + if polarity == "darker" + or (polarity == "relative_lighter" and scheme.is_dark) + or (polarity == "relative_darker" and not scheme.is_dark) + else tone_delta_pair.delta + ) + + am_role_a = color.name == role_a.name + self_role = role_a if am_role_a else role_b + ref_role = role_b if am_role_a else role_a + self_tone = self_role.tone(scheme) + ref_tone = ref_role.get_tone(scheme) + relative_delta = absolute_delta * (1 if am_role_a else -1) + + if constraint == "exact": + self_tone = clamp_double(0, 100, ref_tone + relative_delta) + elif constraint == "nearer": + if relative_delta > 0: + self_tone = clamp_double( + 0, + 100, + clamp_double(ref_tone, ref_tone + relative_delta, self_tone), + ) + else: + self_tone = clamp_double( + 0, + 100, + clamp_double(ref_tone + relative_delta, ref_tone, self_tone), + ) + elif constraint == "farther": + if relative_delta > 0: + self_tone = clamp_double(ref_tone + relative_delta, 100, self_tone) + else: + self_tone = clamp_double(0, ref_tone + relative_delta, self_tone) + + if color.background and color.contrast_curve: + background = color.background(scheme) + contrast_curve = color.contrast_curve(scheme) + if background and contrast_curve: + bg_tone = background.get_tone(scheme) + self_contrast = contrast_curve.get(scheme.contrast_level) + self_tone = ( + self_tone + if Contrast.ratio_of_tones(bg_tone, self_tone) >= self_contrast + and scheme.contrast_level >= 0 + else DynamicColor.foreground_tone(bg_tone, self_contrast) + ) + + if color.is_background and not color.name.endswith("_fixed_dim"): + if self_tone >= 57: + self_tone = clamp_double(65, 100, self_tone) + else: + self_tone = clamp_double(0, 49, self_tone) + return self_tone + else: + answer = color.tone(scheme) + if ( + not color.background + or not color.background(scheme) + or not color.contrast_curve + or not color.contrast_curve(scheme) + ): + return answer + + bg_tone = color.background(scheme).get_tone(scheme) + desired_ratio = color.contrast_curve(scheme).get(scheme.contrast_level) + + answer = ( + answer + if Contrast.ratio_of_tones(bg_tone, answer) >= desired_ratio + and scheme.contrast_level >= 0 + else DynamicColor.foreground_tone(bg_tone, desired_ratio) + ) + + if color.is_background and not color.name.endswith("_fixed_dim"): + if answer >= 57: + answer = clamp_double(65, 100, answer) + else: + answer = clamp_double(0, 49, answer) + + if not color.second_background or not color.second_background(scheme): + return answer + + bg1 = color.background(scheme) + bg2 = color.second_background(scheme) + bg_tone1 = bg1.get_tone(scheme) + bg_tone2 = bg2.get_tone(scheme) + upper = max(bg_tone1, bg_tone2) + lower = min(bg_tone1, bg_tone2) + + if ( + Contrast.ratio_of_tones(upper, answer) >= desired_ratio + and Contrast.ratio_of_tones(lower, answer) >= desired_ratio + ): + return answer + + light_option = Contrast.lighter(upper, desired_ratio) + dark_option = Contrast.darker(lower, desired_ratio) + + availables = [] + if light_option != -1: + availables.append(light_option) + if dark_option != -1: + availables.append(dark_option) + + prefers_light = DynamicColor.tone_prefers_light_foreground( + bg_tone1 + ) or DynamicColor.tone_prefers_light_foreground(bg_tone2) + if prefers_light: + return 100 if light_option < 0 else light_option + if len(availables) == 1: + return availables[0] + return 0 if dark_option < 0 else dark_option + + +_spec2021_calc = ColorCalculationDelegateImpl2021() +_spec2025_calc = ColorCalculationDelegateImpl2025() + + +def get_color_calculation_delegate( + spec_version: "SpecVersion", +) -> ColorCalculationDelegate: + return _spec2025_calc if spec_version == "2025" else _spec2021_calc diff --git a/materialyoucolor/dynamiccolor/color_spec_2021.py b/materialyoucolor/dynamiccolor/color_spec_2021.py new file mode 100644 index 0000000..3d39dee --- /dev/null +++ b/materialyoucolor/dynamiccolor/color_spec_2021.py @@ -0,0 +1,651 @@ +from materialyoucolor.dislike.dislike_analyzer import DislikeAnalyzer +from materialyoucolor.dynamiccolor.color_spec import ColorSpecDelegate +from materialyoucolor.dynamiccolor.contrast_curve import ContrastCurve +from materialyoucolor.dynamiccolor.dynamic_color import DynamicColor +from materialyoucolor.dynamiccolor.dynamic_scheme import DynamicScheme +from materialyoucolor.dynamiccolor.tone_delta_pair import ToneDeltaPair +from materialyoucolor.dynamiccolor.variant import Variant +from materialyoucolor.hct.hct import Hct + + +def is_fidelity(scheme: DynamicScheme) -> bool: + return scheme.variant == Variant.FIDELITY or scheme.variant == Variant.CONTENT + + +def is_monochrome(scheme: DynamicScheme) -> bool: + return scheme.variant == Variant.MONOCHROME + + +def find_desired_chroma_by_tone( + hue: float, chroma: float, tone: float, by_decreasing_tone: bool +) -> float: + answer = tone + closest_to_chroma = Hct.from_hct(hue, chroma, tone) + if closest_to_chroma.chroma < chroma: + chroma_peak = closest_to_chroma.chroma + while closest_to_chroma.chroma < chroma: + answer += -1.0 if by_decreasing_tone else 1.0 + potential_solution = Hct.from_hct(hue, chroma, answer) + if chroma_peak > potential_solution.chroma: + break + if abs(potential_solution.chroma - chroma) < 0.4: + break + if abs(potential_solution.chroma - chroma) < abs( + closest_to_chroma.chroma - chroma + ): + closest_to_chroma = potential_solution + chroma_peak = max(chroma_peak, potential_solution.chroma) + return answer + + +class ColorSpecDelegateImpl2021(ColorSpecDelegate): + def primaryPaletteKeyColor(self) -> DynamicColor: + return DynamicColor.from_palette( + name="primaryPaletteKeyColor", + palette=lambda s: s.primary_palette, + tone=lambda s: s.primary_palette.key_color.tone, + ) + + def secondaryPaletteKeyColor(self) -> DynamicColor: + return DynamicColor.from_palette( + name="secondaryPaletteKeyColor", + palette=lambda s: s.secondary_palette, + tone=lambda s: s.secondary_palette.key_color.tone, + ) + + def tertiaryPaletteKeyColor(self) -> DynamicColor: + return DynamicColor.from_palette( + name="tertiaryPaletteKeyColor", + palette=lambda s: s.tertiary_palette, + tone=lambda s: s.tertiary_palette.key_color.tone, + ) + + def neutralPaletteKeyColor(self) -> DynamicColor: + return DynamicColor.from_palette( + name="neutralPaletteKeyColor", + palette=lambda s: s.neutral_palette, + tone=lambda s: s.neutral_palette.key_color.tone, + ) + + def neutralVariantPaletteKeyColor(self) -> DynamicColor: + return DynamicColor.from_palette( + name="neutralVariantPaletteKeyColor", + palette=lambda s: s.neutral_variant_palette, + tone=lambda s: s.neutral_variant_palette.key_color.tone, + ) + + def errorPaletteKeyColor(self) -> DynamicColor: + return DynamicColor.from_palette( + name="errorPaletteKeyColor", + palette=lambda s: s.error_palette, + tone=lambda s: s.error_palette.key_color.tone, + ) + + def background(self) -> DynamicColor: + return DynamicColor.from_palette( + name="background", + palette=lambda s: s.neutral_palette, + tone=lambda s: 6 if s.is_dark else 98, + is_background=True, + ) + + def onBackground(self) -> DynamicColor: + return DynamicColor.from_palette( + name="onBackground", + palette=lambda s: s.neutral_palette, + tone=lambda s: 90 if s.is_dark else 10, + background=lambda s: self.background(), + contrast_curve=lambda s: ContrastCurve(3, 3, 4.5, 7), + ) + + def surface(self) -> DynamicColor: + return DynamicColor.from_palette( + name="surface", + palette=lambda s: s.neutral_palette, + tone=lambda s: 6 if s.is_dark else 98, + is_background=True, + ) + + def surfaceDim(self) -> DynamicColor: + return DynamicColor.from_palette( + name="surfaceDim", + palette=lambda s: s.neutral_palette, + tone=lambda s: 6 + if s.is_dark + else ContrastCurve(87, 87, 80, 75).get(s.contrast_level), + is_background=True, + ) + + def surfaceBright(self) -> DynamicColor: + return DynamicColor.from_palette( + name="surfaceBright", + palette=lambda s: s.neutral_palette, + tone=lambda s: ContrastCurve(24, 24, 29, 34).get(s.contrast_level) + if s.is_dark + else 98, + is_background=True, + ) + + def surfaceContainerLowest(self) -> DynamicColor: + return DynamicColor.from_palette( + name="surfaceContainerLowest", + palette=lambda s: s.neutral_palette, + tone=lambda s: ContrastCurve(4, 4, 2, 0).get(s.contrast_level) + if s.is_dark + else 100, + is_background=True, + ) + + def surfaceContainerLow(self) -> DynamicColor: + return DynamicColor.from_palette( + name="surfaceContainerLow", + palette=lambda s: s.neutral_palette, + tone=lambda s: ContrastCurve(10, 10, 11, 12).get(s.contrast_level) + if s.is_dark + else ContrastCurve(96, 96, 96, 95).get(s.contrast_level), + is_background=True, + ) + + def surfaceContainer(self) -> DynamicColor: + return DynamicColor.from_palette( + name="surfaceContainer", + palette=lambda s: s.neutral_palette, + tone=lambda s: ContrastCurve(12, 12, 16, 20).get(s.contrast_level) + if s.is_dark + else ContrastCurve(94, 94, 92, 90).get(s.contrast_level), + is_background=True, + ) + + def surfaceContainerHigh(self) -> DynamicColor: + return DynamicColor.from_palette( + name="surfaceContainerHigh", + palette=lambda s: s.neutral_palette, + tone=lambda s: ContrastCurve(17, 17, 21, 25).get(s.contrast_level) + if s.is_dark + else ContrastCurve(92, 92, 88, 85).get(s.contrast_level), + is_background=True, + ) + + def surfaceContainerHighest(self) -> DynamicColor: + return DynamicColor.from_palette( + name="surfaceContainerHighest", + palette=lambda s: s.neutral_palette, + tone=lambda s: ContrastCurve(22, 22, 26, 30).get(s.contrast_level) + if s.is_dark + else ContrastCurve(90, 90, 84, 80).get(s.contrast_level), + is_background=True, + ) + + def onSurface(self) -> DynamicColor: + return DynamicColor.from_palette( + name="onSurface", + palette=lambda s: s.neutral_palette, + tone=lambda s: 90 if s.is_dark else 10, + background=lambda s: self.highestSurface(s), + contrast_curve=lambda s: ContrastCurve(4.5, 7, 11, 21), + ) + + def surfaceVariant(self) -> DynamicColor: + return DynamicColor.from_palette( + name="surfaceVariant", + palette=lambda s: s.neutral_variant_palette, + tone=lambda s: 30 if s.is_dark else 90, + is_background=True, + ) + + def onSurfaceVariant(self) -> DynamicColor: + return DynamicColor.from_palette( + name="onSurfaceVariant", + palette=lambda s: s.neutral_variant_palette, + tone=lambda s: 80 if s.is_dark else 30, + background=lambda s: self.highestSurface(s), + contrast_curve=lambda s: ContrastCurve(3, 4.5, 7, 11), + ) + + def inverseSurface(self) -> DynamicColor: + return DynamicColor.from_palette( + name="inverseSurface", + palette=lambda s: s.neutral_palette, + tone=lambda s: 90 if s.is_dark else 20, + is_background=True, + ) + + def inverseOnSurface(self) -> DynamicColor: + return DynamicColor.from_palette( + name="inverseOnSurface", + palette=lambda s: s.neutral_palette, + tone=lambda s: 20 if s.is_dark else 95, + background=lambda s: self.inverseSurface(), + contrast_curve=lambda s: ContrastCurve(4.5, 7, 11, 21), + ) + + def outline(self) -> DynamicColor: + return DynamicColor.from_palette( + name="outline", + palette=lambda s: s.neutral_variant_palette, + tone=lambda s: 60 if s.is_dark else 50, + background=lambda s: self.highestSurface(s), + contrast_curve=lambda s: ContrastCurve(1.5, 3, 4.5, 7), + ) + + def outlineVariant(self) -> DynamicColor: + return DynamicColor.from_palette( + name="outlineVariant", + palette=lambda s: s.neutral_variant_palette, + tone=lambda s: 30 if s.is_dark else 80, + background=lambda s: self.highestSurface(s), + contrast_curve=lambda s: ContrastCurve(1, 1, 3, 4.5), + ) + + def shadow(self) -> DynamicColor: + return DynamicColor.from_palette( + name="shadow", + palette=lambda s: s.neutral_palette, + tone=lambda s: 0, + ) + + def scrim(self) -> DynamicColor: + return DynamicColor.from_palette( + name="scrim", + palette=lambda s: s.neutral_palette, + tone=lambda s: 0, + ) + + def surfaceTint(self) -> DynamicColor: + return DynamicColor.from_palette( + name="surfaceTint", + palette=lambda s: s.primary_palette, + tone=lambda s: 80 if s.is_dark else 40, + is_background=True, + ) + + def primary(self) -> DynamicColor: + return DynamicColor.from_palette( + name="primary", + palette=lambda s: s.primary_palette, + tone=lambda s: 100 if is_monochrome(s) else (80 if s.is_dark else 40), + is_background=True, + background=lambda s: self.highestSurface(s), + contrast_curve=lambda s: ContrastCurve(3, 4.5, 7, 7), + tone_delta_pair=lambda s: ToneDeltaPair( + self.primaryContainer(), self.primary(), 10, "nearer", False + ), + ) + + def primaryDim(self) -> DynamicColor: + return None + + def onPrimary(self) -> DynamicColor: + return DynamicColor.from_palette( + name="onPrimary", + palette=lambda s: s.primary_palette, + tone=lambda s: 10 if is_monochrome(s) else (20 if s.is_dark else 100), + background=lambda s: self.primary(), + contrast_curve=lambda s: ContrastCurve(4.5, 7, 11, 21), + ) + + def primaryContainer(self) -> DynamicColor: + return DynamicColor.from_palette( + name="primaryContainer", + palette=lambda s: s.primary_palette, + tone=lambda s: ( + s.source_color_hct.tone + if is_fidelity(s) + else (85 if is_monochrome(s) else (30 if s.is_dark else 90)) + ), + is_background=True, + background=lambda s: self.highestSurface(s), + contrast_curve=lambda s: ContrastCurve(1, 1, 3, 4.5), + tone_delta_pair=lambda s: ToneDeltaPair( + self.primaryContainer(), self.primary(), 10, "nearer", False + ), + ) + + def onPrimaryContainer(self) -> DynamicColor: + return DynamicColor.from_palette( + name="onPrimaryContainer", + palette=lambda s: s.primary_palette, + tone=lambda s: ( + DynamicColor.foreground_tone(self.primaryContainer().tone(s), 4.5) + if is_fidelity(s) + else (0 if is_monochrome(s) else (90 if s.is_dark else 30)) + ), + background=lambda s: self.primaryContainer(), + contrast_curve=lambda s: ContrastCurve(3, 4.5, 7, 11), + ) + + def inversePrimary(self) -> DynamicColor: + return DynamicColor.from_palette( + name="inversePrimary", + palette=lambda s: s.primary_palette, + tone=lambda s: 40 if s.is_dark else 80, + background=lambda s: self.inverseSurface(), + contrast_curve=lambda s: ContrastCurve(3, 4.5, 7, 7), + ) + + def secondary(self) -> DynamicColor: + return DynamicColor.from_palette( + name="secondary", + palette=lambda s: s.secondary_palette, + tone=lambda s: 80 if s.is_dark else 40, + is_background=True, + background=lambda s: self.highestSurface(s), + contrast_curve=lambda s: ContrastCurve(3, 4.5, 7, 7), + tone_delta_pair=lambda s: ToneDeltaPair( + self.secondaryContainer(), self.secondary(), 10, "nearer", False + ), + ) + + def secondaryDim(self) -> DynamicColor: + return None + + def onSecondary(self) -> DynamicColor: + return DynamicColor.from_palette( + name="onSecondary", + palette=lambda s: s.secondary_palette, + tone=lambda s: 10 if is_monochrome(s) else (20 if s.is_dark else 100), + background=lambda s: self.secondary(), + contrast_curve=lambda s: ContrastCurve(4.5, 7, 11, 21), + ) + + def secondaryContainer(self) -> DynamicColor: + return DynamicColor.from_palette( + name="secondaryContainer", + palette=lambda s: s.secondary_palette, + tone=lambda s: ( + 30 + if is_monochrome(s) + else ( + find_desired_chroma_by_tone( + s.secondary_palette.hue, + s.secondary_palette.chroma, + 30 if s.is_dark else 90, + not s.is_dark, + ) + if is_fidelity(s) + else (30 if s.is_dark else 90) + ) + ), + is_background=True, + background=lambda s: self.highestSurface(s), + contrast_curve=lambda s: ContrastCurve(1, 1, 3, 4.5), + tone_delta_pair=lambda s: ToneDeltaPair( + self.secondaryContainer(), self.secondary(), 10, "nearer", False + ), + ) + + def onSecondaryContainer(self) -> DynamicColor: + return DynamicColor.from_palette( + name="onSecondaryContainer", + palette=lambda s: s.secondary_palette, + tone=lambda s: ( + 90 + if is_monochrome(s) + else ( + DynamicColor.foreground_tone(self.secondaryContainer().tone(s), 4.5) + if is_fidelity(s) + else (90 if s.is_dark else 30) + ) + ), + background=lambda s: self.secondaryContainer(), + contrast_curve=lambda s: ContrastCurve(3, 4.5, 7, 11), + ) + + def tertiary(self) -> DynamicColor: + return DynamicColor.from_palette( + name="tertiary", + palette=lambda s: s.tertiary_palette, + tone=lambda s: 90 + if is_monochrome(s) + else (25 if is_monochrome(s) else (80 if s.is_dark else 40)), + is_background=True, + background=lambda s: self.highestSurface(s), + contrast_curve=lambda s: ContrastCurve(3, 4.5, 7, 7), + tone_delta_pair=lambda s: ToneDeltaPair( + self.tertiaryContainer(), self.tertiary(), 10, "nearer", False + ), + ) + + def tertiaryDim(self) -> DynamicColor: + return None + + def onTertiary(self) -> DynamicColor: + return DynamicColor.from_palette( + name="onTertiary", + palette=lambda s: s.tertiary_palette, + tone=lambda s: 10 + if is_monochrome(s) + else (90 if is_monochrome(s) else (20 if s.is_dark else 100)), + background=lambda s: self.tertiary(), + contrast_curve=lambda s: ContrastCurve(4.5, 7, 11, 21), + ) + + def tertiaryContainer(self) -> DynamicColor: + return DynamicColor.from_palette( + name="tertiaryContainer", + palette=lambda s: s.tertiary_palette, + tone=lambda s: ( + 60 + if is_monochrome(s) + else ( + DislikeAnalyzer.fix_if_disliked( + s.tertiary_palette.get_hct(s.source_color_hct.tone) + ).tone + if is_fidelity(s) + else (30 if s.is_dark else 90) + ) + ), + is_background=True, + background=lambda s: self.highestSurface(s), + contrast_curve=lambda s: ContrastCurve(1, 1, 3, 4.5), + tone_delta_pair=lambda s: ToneDeltaPair( + self.tertiaryContainer(), self.tertiary(), 10, "nearer", False + ), + ) + + def onTertiaryContainer(self) -> DynamicColor: + return DynamicColor.from_palette( + name="onTertiaryContainer", + palette=lambda s: s.tertiary_palette, + tone=lambda s: ( + 0 + if is_monochrome(s) + else ( + DynamicColor.foreground_tone(self.tertiaryContainer().tone(s), 4.5) + if is_fidelity(s) + else (90 if s.is_dark else 30) + ) + ), + background=lambda s: self.tertiaryContainer(), + contrast_curve=lambda s: ContrastCurve(3, 4.5, 7, 11), + ) + + def error(self) -> DynamicColor: + return DynamicColor.from_palette( + name="error", + palette=lambda s: s.error_palette, + tone=lambda s: 80 if s.is_dark else 40, + is_background=True, + background=lambda s: self.highestSurface(s), + contrast_curve=lambda s: ContrastCurve(3, 4.5, 7, 7), + tone_delta_pair=lambda s: ToneDeltaPair( + self.errorContainer(), self.error(), 10, "nearer", False + ), + ) + + def errorDim(self) -> DynamicColor: + return None + + def onError(self) -> DynamicColor: + return DynamicColor.from_palette( + name="onError", + palette=lambda s: s.error_palette, + tone=lambda s: 20 if s.is_dark else 100, + background=lambda s: self.error(), + contrast_curve=lambda s: ContrastCurve(4.5, 7, 11, 21), + ) + + def errorContainer(self) -> DynamicColor: + return DynamicColor.from_palette( + name="errorContainer", + palette=lambda s: s.error_palette, + tone=lambda s: 30 if s.is_dark else 90, + is_background=True, + background=lambda s: self.highestSurface(s), + contrast_curve=lambda s: ContrastCurve(1, 1, 3, 4.5), + tone_delta_pair=lambda s: ToneDeltaPair( + self.errorContainer(), self.error(), 10, "nearer", False + ), + ) + + def onErrorContainer(self) -> DynamicColor: + return DynamicColor.from_palette( + name="onErrorContainer", + palette=lambda s: s.error_palette, + tone=lambda s: 90 + if is_monochrome(s) + else (10 if is_monochrome(s) else (90 if s.is_dark else 30)), + background=lambda s: self.errorContainer(), + contrast_curve=lambda s: ContrastCurve(3, 4.5, 7, 11), + ) + + def primaryFixed(self) -> DynamicColor: + return DynamicColor.from_palette( + name="primaryFixed", + palette=lambda s: s.primary_palette, + tone=lambda s: 40.0 if is_monochrome(s) else 90.0, + is_background=True, + background=lambda s: self.highestSurface(s), + contrast_curve=lambda s: ContrastCurve(1, 1, 3, 4.5), + tone_delta_pair=lambda s: ToneDeltaPair( + self.primaryFixed(), self.primaryFixedDim(), 10, "lighter", True + ), + ) + + def primaryFixedDim(self) -> DynamicColor: + return DynamicColor.from_palette( + name="primaryFixedDim", + palette=lambda s: s.primary_palette, + tone=lambda s: 30.0 if is_monochrome(s) else 80.0, + is_background=True, + background=lambda s: self.highestSurface(s), + contrast_curve=lambda s: ContrastCurve(1, 1, 3, 4.5), + tone_delta_pair=lambda s: ToneDeltaPair( + self.primaryFixed(), self.primaryFixedDim(), 10, "lighter", True + ), + ) + + def onPrimaryFixed(self) -> DynamicColor: + return DynamicColor.from_palette( + name="onPrimaryFixed", + palette=lambda s: s.primary_palette, + tone=lambda s: 100.0 if is_monochrome(s) else 10.0, + background=lambda s: self.primaryFixedDim(), + second_background=lambda s: self.primaryFixed(), + contrast_curve=lambda s: ContrastCurve(4.5, 7, 11, 21), + ) + + def onPrimaryFixedVariant(self) -> DynamicColor: + return DynamicColor.from_palette( + name="onPrimaryFixedVariant", + palette=lambda s: s.primary_palette, + tone=lambda s: 90.0 if is_monochrome(s) else 30.0, + background=lambda s: self.primaryFixedDim(), + second_background=lambda s: self.primaryFixed(), + contrast_curve=lambda s: ContrastCurve(3, 4.5, 7, 11), + ) + + def secondaryFixed(self) -> DynamicColor: + return DynamicColor.from_palette( + name="secondaryFixed", + palette=lambda s: s.secondary_palette, + tone=lambda s: 80.0 if is_monochrome(s) else 90.0, + is_background=True, + background=lambda s: self.highestSurface(s), + contrast_curve=lambda s: ContrastCurve(1, 1, 3, 4.5), + tone_delta_pair=lambda s: ToneDeltaPair( + self.secondaryFixed(), self.secondaryFixedDim(), 10, "lighter", True + ), + ) + + def secondaryFixedDim(self) -> DynamicColor: + return DynamicColor.from_palette( + name="secondaryFixedDim", + palette=lambda s: s.secondary_palette, + tone=lambda s: 70.0 if is_monochrome(s) else 80.0, + is_background=True, + background=lambda s: self.highestSurface(s), + contrast_curve=lambda s: ContrastCurve(1, 1, 3, 4.5), + tone_delta_pair=lambda s: ToneDeltaPair( + self.secondaryFixed(), self.secondaryFixedDim(), 10, "lighter", True + ), + ) + + def onSecondaryFixed(self) -> DynamicColor: + return DynamicColor.from_palette( + name="onSecondaryFixed", + palette=lambda s: s.secondary_palette, + tone=lambda s: 10.0, + background=lambda s: self.secondaryFixedDim(), + second_background=lambda s: self.secondaryFixed(), + contrast_curve=lambda s: ContrastCurve(4.5, 7, 11, 21), + ) + + def onSecondaryFixedVariant(self) -> DynamicColor: + return DynamicColor.from_palette( + name="onSecondaryFixedVariant", + palette=lambda s: s.secondary_palette, + tone=lambda s: 25.0 if is_monochrome(s) else 30.0, + background=lambda s: self.secondaryFixedDim(), + second_background=lambda s: self.secondaryFixed(), + contrast_curve=lambda s: ContrastCurve(3, 4.5, 7, 11), + ) + + def tertiaryFixed(self) -> DynamicColor: + return DynamicColor.from_palette( + name="tertiaryFixed", + palette=lambda s: s.tertiary_palette, + tone=lambda s: 40.0 if is_monochrome(s) else 90.0, + is_background=True, + background=lambda s: self.highestSurface(s), + contrast_curve=lambda s: ContrastCurve(1, 1, 3, 4.5), + tone_delta_pair=lambda s: ToneDeltaPair( + self.tertiaryFixed(), self.tertiaryFixedDim(), 10, "lighter", True + ), + ) + + def tertiaryFixedDim(self) -> DynamicColor: + return DynamicColor.from_palette( + name="tertiaryFixedDim", + palette=lambda s: s.tertiary_palette, + tone=lambda s: 30.0 if is_monochrome(s) else 80.0, + is_background=True, + background=lambda s: self.highestSurface(s), + contrast_curve=lambda s: ContrastCurve(1, 1, 3, 4.5), + tone_delta_pair=lambda s: ToneDeltaPair( + self.tertiaryFixed(), self.tertiaryFixedDim(), 10, "lighter", True + ), + ) + + def onTertiaryFixed(self) -> DynamicColor: + return DynamicColor.from_palette( + name="onTertiaryFixed", + palette=lambda s: s.tertiary_palette, + tone=lambda s: 100.0 if is_monochrome(s) else 10.0, + background=lambda s: self.tertiaryFixedDim(), + second_background=lambda s: self.tertiaryFixed(), + contrast_curve=lambda s: ContrastCurve(4.5, 7, 11, 21), + ) + + def onTertiaryFixedVariant(self) -> DynamicColor: + return DynamicColor.from_palette( + name="onTertiaryFixedVariant", + palette=lambda s: s.tertiary_palette, + tone=lambda s: 90.0 if is_monochrome(s) else 30.0, + background=lambda s: self.tertiaryFixedDim(), + second_background=lambda s: self.tertiaryFixed(), + contrast_curve=lambda s: ContrastCurve(3, 4.5, 7, 11), + ) + + def highestSurface(self, s: DynamicScheme) -> DynamicColor: + return self.surfaceBright() if s.is_dark else self.surfaceDim() diff --git a/materialyoucolor/dynamiccolor/color_spec_2025.py b/materialyoucolor/dynamiccolor/color_spec_2025.py new file mode 100644 index 0000000..5252baa --- /dev/null +++ b/materialyoucolor/dynamiccolor/color_spec_2025.py @@ -0,0 +1,1405 @@ +from typing import Optional + +from materialyoucolor.dynamiccolor.color_spec_2021 import ColorSpecDelegateImpl2021 +from materialyoucolor.dynamiccolor.contrast_curve import ContrastCurve +from materialyoucolor.dynamiccolor.dynamic_color import ( + DynamicColor, + extend_spec_version, +) +from materialyoucolor.dynamiccolor.dynamic_scheme import DynamicScheme +from materialyoucolor.dynamiccolor.tone_delta_pair import ToneDeltaPair +from materialyoucolor.dynamiccolor.variant import Variant +from materialyoucolor.hct.hct import Hct +from materialyoucolor.palettes.tonal_palette import TonalPalette +from materialyoucolor.utils.math_utils import clamp_double + + +def t_max_c( + palette: TonalPalette, + lower_bound: float = 0, + upper_bound: float = 100, + chroma_multiplier: float = 1, +) -> float: + answer = find_best_tone_for_chroma( + palette.hue, palette.chroma * chroma_multiplier, 100, True + ) + return clamp_double(lower_bound, upper_bound, answer) + + +def t_min_c( + palette: TonalPalette, lower_bound: float = 0, upper_bound: float = 100 +) -> float: + answer = find_best_tone_for_chroma(palette.hue, palette.chroma, 0, False) + return clamp_double(lower_bound, upper_bound, answer) + + +def find_best_tone_for_chroma( + hue: float, chroma: float, tone: float, by_decreasing_tone: bool +) -> float: + answer = tone + best_candidate = Hct.from_hct(hue, chroma, answer) + while best_candidate.chroma < chroma: + if tone < 0 or tone > 100: + break + tone += -1.0 if by_decreasing_tone else 1.0 + new_candidate = Hct.from_hct(hue, chroma, tone) + if best_candidate.chroma < new_candidate.chroma: + best_candidate = new_candidate + answer = tone + return answer + + +def get_curve(default_contrast: float) -> ContrastCurve: + if default_contrast == 1.5: + return ContrastCurve(1.5, 1.5, 3, 5.5) + elif default_contrast == 3: + return ContrastCurve(3, 3, 4.5, 7) + elif default_contrast == 4.5: + return ContrastCurve(4.5, 4.5, 7, 11) + elif default_contrast == 6: + return ContrastCurve(6, 6, 7, 11) + elif default_contrast == 7: + return ContrastCurve(7, 7, 11, 21) + elif default_contrast == 9: + return ContrastCurve(9, 9, 11, 21) + elif default_contrast == 11: + return ContrastCurve(11, 11, 21, 21) + elif default_contrast == 21: + return ContrastCurve(21, 21, 21, 21) + else: + return ContrastCurve(default_contrast, default_contrast, 7, 21) + + +class ColorSpecDelegateImpl2025(ColorSpecDelegateImpl2021): + def _surface_tone_2025(self, s: DynamicScheme) -> float: + if s.platform == "phone": + if s.is_dark: + return 4 + else: + if Hct.is_yellow(s.neutral_palette.hue): + return 99 + elif s.variant == Variant.VIBRANT: + return 97 + else: + return 98 + else: + return 0 + + def surface(self) -> DynamicColor: + color2025 = DynamicColor.from_palette( + name="surface", + palette=lambda s: s.neutral_palette, + tone=self._surface_tone_2025, + is_background=True, + ) + return extend_spec_version(super().surface(), "2025", color2025) + + def _surface_dim_tone_2025(self, s: DynamicScheme) -> float: + if s.is_dark: + return 4 + else: + if Hct.is_yellow(s.neutral_palette.hue): + return 90 + elif s.variant == Variant.VIBRANT: + return 85 + else: + return 87 + + def _surface_dim_chroma_multiplier_2025(self, s: DynamicScheme) -> float: + if not s.is_dark: + if s.variant == Variant.NEUTRAL: + return 2.5 + elif s.variant == Variant.TONAL_SPOT: + return 1.7 + elif s.variant == Variant.EXPRESSIVE: + return 2.7 if Hct.is_yellow(s.neutral_palette.hue) else 1.75 + elif s.variant == Variant.VIBRANT: + return 1.36 + return 1.0 + + def surfaceDim(self) -> DynamicColor: + color2025 = DynamicColor.from_palette( + name="surfaceDim", + palette=lambda s: s.neutral_palette, + tone=self._surface_dim_tone_2025, + is_background=True, + chroma_multiplier=self._surface_dim_chroma_multiplier_2025, + ) + return extend_spec_version(super().surfaceDim(), "2025", color2025) + + def _surface_bright_tone_2025(self, s: DynamicScheme) -> float: + if s.is_dark: + return 18 + else: + if Hct.is_yellow(s.neutral_palette.hue): + return 99 + elif s.variant == Variant.VIBRANT: + return 97 + else: + return 98 + + def _surface_bright_chroma_multiplier_2025(self, s: DynamicScheme) -> float: + if s.is_dark: + if s.variant == Variant.NEUTRAL: + return 2.5 + elif s.variant == Variant.TONAL_SPOT: + return 1.7 + elif s.variant == Variant.EXPRESSIVE: + return 2.7 if Hct.is_yellow(s.neutral_palette.hue) else 1.75 + elif s.variant == Variant.VIBRANT: + return 1.36 + return 1.0 + + def surfaceBright(self) -> DynamicColor: + color2025 = DynamicColor.from_palette( + name="surfaceBright", + palette=lambda s: s.neutral_palette, + tone=self._surface_bright_tone_2025, + is_background=True, + chroma_multiplier=self._surface_bright_chroma_multiplier_2025, + ) + return extend_spec_version(super().surfaceBright(), "2025", color2025) + + def surfaceContainerLowest(self) -> DynamicColor: + color2025 = DynamicColor.from_palette( + name="surfaceContainerLowest", + palette=lambda s: s.neutral_palette, + tone=lambda s: 0 if s.is_dark else 100, + is_background=True, + ) + return extend_spec_version(super().surfaceContainerLowest(), "2025", color2025) + + def _surface_container_low_tone_2025(self, s: DynamicScheme) -> float: + if s.platform == "phone": + if s.is_dark: + return 6 + else: + if Hct.is_yellow(s.neutral_palette.hue): + return 98 + elif s.variant == Variant.VIBRANT: + return 95 + else: + return 96 + else: + return 15 + + def _surface_container_low_chroma_multiplier_2025(self, s: DynamicScheme) -> float: + if s.platform == "phone": + if s.variant == Variant.NEUTRAL: + return 1.3 + elif s.variant == Variant.TONAL_SPOT: + return 1.25 + elif s.variant == Variant.EXPRESSIVE: + return 1.3 if Hct.is_yellow(s.neutral_palette.hue) else 1.15 + elif s.variant == Variant.VIBRANT: + return 1.08 + return 1.0 + + def surfaceContainerLow(self) -> DynamicColor: + color2025 = DynamicColor.from_palette( + name="surfaceContainerLow", + palette=lambda s: s.neutral_palette, + tone=self._surface_container_low_tone_2025, + is_background=True, + chroma_multiplier=self._surface_container_low_chroma_multiplier_2025, + ) + return extend_spec_version(super().surfaceContainerLow(), "2025", color2025) + + def _surface_container_tone_2025(self, s: DynamicScheme) -> float: + if s.platform == "phone": + if s.is_dark: + return 9 + else: + if Hct.is_yellow(s.neutral_palette.hue): + return 96 + elif s.variant == Variant.VIBRANT: + return 92 + else: + return 94 + else: + return 20 + + def _surface_container_chroma_multiplier_2025(self, s: DynamicScheme) -> float: + if s.platform == "phone": + if s.variant == Variant.NEUTRAL: + return 1.6 + elif s.variant == Variant.TONAL_SPOT: + return 1.4 + elif s.variant == Variant.EXPRESSIVE: + return 1.6 if Hct.is_yellow(s.neutral_palette.hue) else 1.3 + elif s.variant == Variant.VIBRANT: + return 1.15 + return 1.0 + + def surfaceContainer(self) -> DynamicColor: + color2025 = DynamicColor.from_palette( + name="surfaceContainer", + palette=lambda s: s.neutral_palette, + tone=self._surface_container_tone_2025, + is_background=True, + chroma_multiplier=self._surface_container_chroma_multiplier_2025, + ) + return extend_spec_version(super().surfaceContainer(), "2025", color2025) + + def _surface_container_high_tone_2025(self, s: DynamicScheme) -> float: + if s.platform == "phone": + if s.is_dark: + return 12 + else: + if Hct.is_yellow(s.neutral_palette.hue): + return 94 + elif s.variant == Variant.VIBRANT: + return 90 + else: + return 92 + else: + return 25 + + def _surface_container_high_chroma_multiplier_2025(self, s: DynamicScheme) -> float: + if s.platform == "phone": + if s.variant == Variant.NEUTRAL: + return 1.9 + elif s.variant == Variant.TONAL_SPOT: + return 1.5 + elif s.variant == Variant.EXPRESSIVE: + return 1.95 if Hct.is_yellow(s.neutral_palette.hue) else 1.45 + elif s.variant == Variant.VIBRANT: + return 1.22 + return 1.0 + + def surfaceContainerHigh(self) -> DynamicColor: + color2025 = DynamicColor.from_palette( + name="surfaceContainerHigh", + palette=lambda s: s.neutral_palette, + tone=self._surface_container_high_tone_2025, + is_background=True, + chroma_multiplier=self._surface_container_high_chroma_multiplier_2025, + ) + return extend_spec_version(super().surfaceContainerHigh(), "2025", color2025) + + def _surface_container_highest_tone_2025(self, s: DynamicScheme) -> float: + if s.is_dark: + return 15 + else: + if Hct.is_yellow(s.neutral_palette.hue): + return 92 + elif s.variant == Variant.VIBRANT: + return 88 + else: + return 90 + + def _surface_container_highest_chroma_multiplier_2025( + self, s: DynamicScheme + ) -> float: + if s.variant == Variant.NEUTRAL: + return 2.2 + elif s.variant == Variant.TONAL_SPOT: + return 1.7 + elif s.variant == Variant.EXPRESSIVE: + return 2.3 if Hct.is_yellow(s.neutral_palette.hue) else 1.6 + elif s.variant == Variant.VIBRANT: + return 1.29 + else: + return 1.0 + + def surfaceContainerHighest(self) -> DynamicColor: + color2025 = DynamicColor.from_palette( + name="surfaceContainerHighest", + palette=lambda s: s.neutral_palette, + tone=self._surface_container_highest_tone_2025, + is_background=True, + chroma_multiplier=self._surface_container_highest_chroma_multiplier_2025, + ) + return extend_spec_version(super().surfaceContainerHighest(), "2025", color2025) + + def _on_surface_tone_2025(self, s: DynamicScheme) -> float: + if s.variant == Variant.VIBRANT: + return t_max_c(s.neutral_palette, 0, 100, 1.1) + else: + return DynamicColor.get_initial_tone_from_background( + lambda scheme: self.highestSurface(scheme) + if scheme.platform == "phone" + else self.surfaceContainerHigh() + )(s) + + def _on_surface_chroma_multiplier_2025(self, s: DynamicScheme) -> float: + if s.platform == "phone": + if s.variant == Variant.NEUTRAL: + return 2.2 + elif s.variant == Variant.TONAL_SPOT: + return 1.7 + elif s.variant == Variant.EXPRESSIVE: + return ( + 3.0 + if Hct.is_yellow(s.neutral_palette.hue) and s.is_dark + else (2.3 if Hct.is_yellow(s.neutral_palette.hue) else 1.6) + ) + return 1.0 + + def _on_surface_contrast_curve_2025(self, s: DynamicScheme) -> ContrastCurve: + return get_curve(11) if s.is_dark and s.platform == "phone" else get_curve(9) + + def onSurface(self) -> DynamicColor: + color2025 = DynamicColor.from_palette( + name="onSurface", + palette=lambda s: s.neutral_palette, + tone=self._on_surface_tone_2025, + chroma_multiplier=self._on_surface_chroma_multiplier_2025, + background=lambda s: self.highestSurface(s) + if s.platform == "phone" + else self.surfaceContainerHigh(), + contrast_curve=self._on_surface_contrast_curve_2025, + ) + return extend_spec_version(super().onSurface(), "2025", color2025) + + def _on_surface_variant_chroma_multiplier_2025(self, s: DynamicScheme) -> float: + if s.platform == "phone": + if s.variant == Variant.NEUTRAL: + return 2.2 + elif s.variant == Variant.TONAL_SPOT: + return 1.7 + elif s.variant == Variant.EXPRESSIVE: + return ( + 3.0 + if Hct.is_yellow(s.neutral_palette.hue) and s.is_dark + else (2.3 if Hct.is_yellow(s.neutral_palette.hue) else 1.6) + ) + return 1.0 + + def _on_surface_variant_contrast_curve_2025( + self, s: DynamicScheme + ) -> ContrastCurve: + return ( + get_curve(6) + if s.platform == "phone" and s.is_dark + else (get_curve(4.5) if s.platform == "phone" else get_curve(7)) + ) + + def onSurfaceVariant(self) -> DynamicColor: + color2025 = DynamicColor.from_palette( + name="onSurfaceVariant", + palette=lambda s: s.neutral_palette, + chroma_multiplier=self._on_surface_variant_chroma_multiplier_2025, + background=lambda s: self.highestSurface(s) + if s.platform == "phone" + else self.surfaceContainerHigh(), + contrast_curve=self._on_surface_variant_contrast_curve_2025, + ) + return extend_spec_version(super().onSurfaceVariant(), "2025", color2025) + + def _outline_chroma_multiplier_2025(self, s: DynamicScheme) -> float: + if s.platform == "phone": + if s.variant == Variant.NEUTRAL: + return 2.2 + elif s.variant == Variant.TONAL_SPOT: + return 1.7 + elif s.variant == Variant.EXPRESSIVE: + return ( + 3.0 + if Hct.is_yellow(s.neutral_palette.hue) and s.is_dark + else (2.3 if Hct.is_yellow(s.neutral_palette.hue) else 1.6) + ) + return 1.0 + + def _outline_contrast_curve_2025(self, s: DynamicScheme) -> ContrastCurve: + return get_curve(3) if s.platform == "phone" else get_curve(4.5) + + def outline(self) -> DynamicColor: + color2025 = DynamicColor.from_palette( + name="outline", + palette=lambda s: s.neutral_palette, + chroma_multiplier=self._outline_chroma_multiplier_2025, + background=lambda s: self.highestSurface(s) + if s.platform == "phone" + else self.surfaceContainerHigh(), + contrast_curve=self._outline_contrast_curve_2025, + ) + return extend_spec_version(super().outline(), "2025", color2025) + + def _outline_variant_chroma_multiplier_2025(self, s: DynamicScheme) -> float: + if s.platform == "phone": + if s.variant == Variant.NEUTRAL: + return 2.2 + elif s.variant == Variant.TONAL_SPOT: + return 1.7 + elif s.variant == Variant.EXPRESSIVE: + return ( + 3.0 + if Hct.is_yellow(s.neutral_palette.hue) and s.is_dark + else (2.3 if Hct.is_yellow(s.neutral_palette.hue) else 1.6) + ) + return 1.0 + + def _outline_variant_contrast_curve_2025(self, s: DynamicScheme) -> ContrastCurve: + return get_curve(1.5) if s.platform == "phone" else get_curve(3) + + def outlineVariant(self) -> DynamicColor: + color2025 = DynamicColor.from_palette( + name="outlineVariant", + palette=lambda s: s.neutral_palette, + chroma_multiplier=self._outline_variant_chroma_multiplier_2025, + background=lambda s: self.highestSurface(s) + if s.platform == "phone" + else self.surfaceContainerHigh(), + contrast_curve=self._outline_variant_contrast_curve_2025, + ) + return extend_spec_version(super().outlineVariant(), "2025", color2025) + + def inverseSurface(self) -> DynamicColor: + color2025 = DynamicColor.from_palette( + name="inverseSurface", + palette=lambda s: s.neutral_palette, + tone=lambda s: 98 if s.is_dark else 4, + is_background=True, + ) + return extend_spec_version(super().inverseSurface(), "2025", color2025) + + def _inverse_on_surface_contrast_curve_2025( + self, s: DynamicScheme + ) -> ContrastCurve: + return get_curve(7) + + def inverseOnSurface(self) -> DynamicColor: + color2025 = DynamicColor.from_palette( + name="inverseOnSurface", + palette=lambda s: s.neutral_palette, + background=lambda s: self.inverseSurface(), + contrast_curve=self._inverse_on_surface_contrast_curve_2025, + ) + return extend_spec_version(super().inverseOnSurface(), "2025", color2025) + + def _primary_tone_2025(self, s: DynamicScheme) -> float: + if s.variant == Variant.NEUTRAL: + return ( + 80 + if s.platform == "phone" and s.is_dark + else (40 if s.platform == "phone" else 90) + ) + elif s.variant == Variant.TONAL_SPOT: + return ( + 80 + if s.platform == "phone" and s.is_dark + else ( + t_max_c(s.primary_palette) + if s.platform == "phone" + else t_max_c(s.primary_palette, 0, 90) + ) + ) + elif s.variant == Variant.EXPRESSIVE: + if s.platform == "phone": + if Hct.is_yellow(s.primary_palette.hue): + return t_max_c(s.primary_palette, 0, 25) + elif Hct.is_cyan(s.primary_palette.hue): + return t_max_c(s.primary_palette, 0, 88) + else: + return t_max_c(s.primary_palette, 0, 98) + else: + return t_max_c(s.primary_palette) + elif s.variant == Variant.VIBRANT: + if s.platform == "phone": + if Hct.is_cyan(s.primary_palette.hue): + return t_max_c(s.primary_palette, 0, 88) + else: + return t_max_c(s.primary_palette, 0, 98) + else: + return t_max_c(s.primary_palette) + return 0.0 # Should not reach here + + def _primary_contrast_curve_2025(self, s: DynamicScheme) -> ContrastCurve: + return get_curve(4.5) if s.platform == "phone" else get_curve(7) + + def _primary_tone_delta_pair_2025( + self, s: DynamicScheme + ) -> Optional[ToneDeltaPair]: + if s.platform == "phone": + return ToneDeltaPair( + self.primaryContainer(), + self.primary(), + 5, + "relative_lighter", + True, + "farther", + ) + return None + + def primary(self) -> DynamicColor: + color2025 = DynamicColor.from_palette( + name="primary", + palette=lambda s: s.primary_palette, + tone=self._primary_tone_2025, + is_background=True, + background=lambda s: self.highestSurface(s) + if s.platform == "phone" + else self.surfaceContainerHigh(), + contrast_curve=self._primary_contrast_curve_2025, + tone_delta_pair=self._primary_tone_delta_pair_2025, + ) + return extend_spec_version(super().primary(), "2025", color2025) + + def primaryDim(self) -> DynamicColor: + return DynamicColor.from_palette( + name="primaryDim", + palette=lambda s: s.primary_palette, + tone=lambda s: ( + 85 + if s.variant == Variant.NEUTRAL + else ( + t_max_c(s.primary_palette, 0, 90) + if s.variant == Variant.TONAL_SPOT + else t_max_c(s.primary_palette) + ) + ), + is_background=True, + background=lambda s: self.surfaceContainerHigh(), + contrast_curve=lambda s: get_curve(4.5), + tone_delta_pair=lambda s: ToneDeltaPair( + self.primaryDim(), self.primary(), 5, "darker", True, "farther" + ), + ) + + def _on_primary_background_2025(self, s: DynamicScheme) -> DynamicColor: + return self.primary() if s.platform == "phone" else self.primaryDim() + + def _on_primary_contrast_curve_2025(self, s: DynamicScheme) -> ContrastCurve: + return get_curve(6) if s.platform == "phone" else get_curve(7) + + def onPrimary(self) -> DynamicColor: + color2025 = DynamicColor.from_palette( + name="onPrimary", + palette=lambda s: s.primary_palette, + background=self._on_primary_background_2025, + contrast_curve=self._on_primary_contrast_curve_2025, + ) + return extend_spec_version(super().onPrimary(), "2025", color2025) + + def _primary_container_tone_2025(self, s: DynamicScheme) -> float: + if s.platform == "watch": + return 30 + elif s.variant == Variant.NEUTRAL: + return 30 if s.is_dark else 90 + elif s.variant == Variant.TONAL_SPOT: + return ( + t_min_c(s.primary_palette, 35, 93) + if s.is_dark + else t_max_c(s.primary_palette, 0, 90) + ) + elif s.variant == Variant.EXPRESSIVE: + return ( + t_max_c(s.primary_palette, 30, 93) + if s.is_dark + else ( + t_max_c(s.primary_palette, 78, 88) + if Hct.is_cyan(s.primary_palette.hue) + else t_max_c(s.primary_palette, 78, 90) + ) + ) + elif s.variant == Variant.VIBRANT: + return ( + t_min_c(s.primary_palette, 66, 93) + if s.is_dark + else ( + t_max_c(s.primary_palette, 66, 88) + if Hct.is_cyan(s.primary_palette.hue) + else t_max_c(s.primary_palette, 66, 93) + ) + ) + return 0.0 # Should not reach here + + def _primary_container_background_2025( + self, s: DynamicScheme + ) -> Optional[DynamicColor]: + return self.highestSurface(s) if s.platform == "phone" else None + + def _primary_container_tone_delta_pair_2025( + self, s: DynamicScheme + ) -> Optional[ToneDeltaPair]: + if s.platform == "phone": + return None + return ToneDeltaPair( + self.primaryContainer(), + self.primaryDim(), + 10, + "darker", + True, + "farther", + ) + + def _primary_container_contrast_curve_2025( + self, s: DynamicScheme + ) -> Optional[ContrastCurve]: + if s.platform == "phone" and s.contrast_level > 0: + return get_curve(1.5) + return None + + def primaryContainer(self) -> DynamicColor: + color2025 = DynamicColor.from_palette( + name="primaryContainer", + palette=lambda s: s.primary_palette, + tone=self._primary_container_tone_2025, + is_background=True, + background=self._primary_container_background_2025, + tone_delta_pair=self._primary_container_tone_delta_pair_2025, + contrast_curve=self._primary_container_contrast_curve_2025, + ) + return extend_spec_version(super().primaryContainer(), "2025", color2025) + + def _on_primary_container_contrast_curve_2025( + self, s: DynamicScheme + ) -> ContrastCurve: + return get_curve(6) if s.platform == "phone" else get_curve(7) + + def onPrimaryContainer(self) -> DynamicColor: + color2025 = DynamicColor.from_palette( + name="onPrimaryContainer", + palette=lambda s: s.primary_palette, + background=lambda s: self.primaryContainer(), + contrast_curve=self._on_primary_container_contrast_curve_2025, + ) + return extend_spec_version(super().onPrimaryContainer(), "2025", color2025) + + def _primary_fixed_tone_2025(self, s: DynamicScheme) -> float: + # Create a temporary scheme with is_dark=False and contrast_level=0 + # to get the tone from primary_container in a light, normal contrast context. + temp_s = DynamicScheme( + source_color_hct=s.source_color_hct, + variant=s.variant, + contrast_level=0, + is_dark=False, + platform=s.platform, + spec_version=s.spec_version, + primary_palette=s.primary_palette, + secondary_palette=s.secondary_palette, + tertiary_palette=s.tertiary_palette, + neutral_palette=s.neutral_palette, + neutral_variant_palette=s.neutral_variant_palette, + error_palette=s.error_palette, + ) + return self.primaryContainer().get_tone(temp_s) + + def _primary_fixed_background_2025( + self, s: DynamicScheme + ) -> Optional[DynamicColor]: + return self.highestSurface(s) if s.platform == "phone" else None + + def _primary_fixed_contrast_curve_2025( + self, s: DynamicScheme + ) -> Optional[ContrastCurve]: + if s.platform == "phone" and s.contrast_level > 0: + return get_curve(1.5) + return None + + def primaryFixed(self) -> DynamicColor: + color2025 = DynamicColor.from_palette( + name="primaryFixed", + palette=lambda s: s.primary_palette, + tone=self._primary_fixed_tone_2025, + is_background=True, + background=self._primary_fixed_background_2025, + contrast_curve=self._primary_fixed_contrast_curve_2025, + ) + return extend_spec_version(super().primaryFixed(), "2025", color2025) + + def _primary_fixed_dim_tone_delta_pair_2025( + self, s: DynamicScheme + ) -> ToneDeltaPair: + return ToneDeltaPair( + self.primaryFixedDim(), self.primaryFixed(), 5, "darker", True, "exact" + ) + + def primaryFixedDim(self) -> DynamicColor: + color2025 = DynamicColor.from_palette( + name="primaryFixedDim", + palette=lambda s: s.primary_palette, + tone=lambda s: self.primaryFixed().get_tone(s), + is_background=True, + tone_delta_pair=self._primary_fixed_dim_tone_delta_pair_2025, + ) + return extend_spec_version(super().primaryFixedDim(), "2025", color2025) + + def _on_primary_fixed_contrast_curve_2025(self, s: DynamicScheme) -> ContrastCurve: + return get_curve(7) + + def onPrimaryFixed(self) -> DynamicColor: + color2025 = DynamicColor.from_palette( + name="onPrimaryFixed", + palette=lambda s: s.primary_palette, + background=lambda s: self.primaryFixedDim(), + contrast_curve=self._on_primary_fixed_contrast_curve_2025, + ) + return extend_spec_version(super().onPrimaryFixed(), "2025", color2025) + + def _on_primary_fixed_variant_contrast_curve_2025( + self, s: DynamicScheme + ) -> ContrastCurve: + return get_curve(4.5) + + def onPrimaryFixedVariant(self) -> DynamicColor: + color2025 = DynamicColor.from_palette( + name="onPrimaryFixedVariant", + palette=lambda s: s.primary_palette, + background=lambda s: self.primaryFixedDim(), + contrast_curve=self._on_primary_fixed_variant_contrast_curve_2025, + ) + return extend_spec_version(super().onPrimaryFixedVariant(), "2025", color2025) + + def _inverse_primary_tone_2025(self, s: DynamicScheme) -> float: + return t_max_c(s.primary_palette) + + def _inverse_primary_contrast_curve_2025(self, s: DynamicScheme) -> ContrastCurve: + return get_curve(6) if s.platform == "phone" else get_curve(7) + + def inversePrimary(self) -> DynamicColor: + color2025 = DynamicColor.from_palette( + name="inversePrimary", + palette=lambda s: s.primary_palette, + tone=self._inverse_primary_tone_2025, + background=lambda s: self.inverseSurface(), + contrast_curve=self._inverse_primary_contrast_curve_2025, + ) + return extend_spec_version(super().inversePrimary(), "2025", color2025) + + def _secondary_tone_2025(self, s: DynamicScheme) -> float: + if s.platform == "watch": + return ( + 90 + if s.variant == Variant.NEUTRAL + else t_max_c(s.secondary_palette, 0, 90) + ) + elif s.variant == Variant.NEUTRAL: + return ( + t_min_c(s.secondary_palette, 0, 98) + if s.is_dark + else t_max_c(s.secondary_palette) + ) + elif s.variant == Variant.VIBRANT: + return ( + t_max_c(s.secondary_palette, 0, 90) + if s.is_dark + else t_max_c(s.secondary_palette, 0, 98) + ) + else: # EXPRESSIVE and TONAL_SPOT + return 80 if s.is_dark else t_max_c(s.secondary_palette) + + def _secondary_contrast_curve_2025(self, s: DynamicScheme) -> ContrastCurve: + return get_curve(4.5) if s.platform == "phone" else get_curve(7) + + def _secondary_tone_delta_pair_2025( + self, s: DynamicScheme + ) -> Optional[ToneDeltaPair]: + if s.platform == "phone": + return ToneDeltaPair( + self.secondaryContainer(), + self.secondary(), + 5, + "relative_lighter", + True, + "farther", + ) + return None + + def secondary(self) -> DynamicColor: + color2025 = DynamicColor.from_palette( + name="secondary", + palette=lambda s: s.secondary_palette, + tone=self._secondary_tone_2025, + is_background=True, + background=lambda s: self.highestSurface(s) + if s.platform == "phone" + else self.surfaceContainerHigh(), + contrast_curve=self._secondary_contrast_curve_2025, + tone_delta_pair=self._secondary_tone_delta_pair_2025, + ) + return extend_spec_version(super().secondary(), "2025", color2025) + + def secondaryDim(self) -> DynamicColor: + return DynamicColor.from_palette( + name="secondaryDim", + palette=lambda s: s.secondary_palette, + tone=lambda s: ( + 85 + if s.variant == Variant.NEUTRAL + else t_max_c(s.secondary_palette, 0, 90) + ), + is_background=True, + background=lambda s: self.surfaceContainerHigh(), + contrast_curve=lambda s: get_curve(4.5), + tone_delta_pair=lambda s: ToneDeltaPair( + self.secondaryDim(), self.secondary(), 5, "darker", True, "farther" + ), + ) + + def _on_secondary_background_2025(self, s: DynamicScheme) -> DynamicColor: + return self.secondary() if s.platform == "phone" else self.secondaryDim() + + def _on_secondary_contrast_curve_2025(self, s: DynamicScheme) -> ContrastCurve: + return get_curve(6) if s.platform == "phone" else get_curve(7) + + def onSecondary(self) -> DynamicColor: + color2025 = DynamicColor.from_palette( + name="onSecondary", + palette=lambda s: s.secondary_palette, + background=self._on_secondary_background_2025, + contrast_curve=self._on_secondary_contrast_curve_2025, + ) + return extend_spec_version(super().onSecondary(), "2025", color2025) + + def _secondary_container_tone_2025(self, s: DynamicScheme) -> float: + if s.platform == "watch": + return 30 + elif s.variant == Variant.VIBRANT: + return ( + t_min_c(s.secondary_palette, 30, 40) + if s.is_dark + else t_max_c(s.secondary_palette, 84, 90) + ) + elif s.variant == Variant.EXPRESSIVE: + return 15 if s.is_dark else t_max_c(s.secondary_palette, 90, 95) + else: + return 25 if s.is_dark else 90 + + def _secondary_container_background_2025( + self, s: DynamicScheme + ) -> Optional[DynamicColor]: + return self.highestSurface(s) if s.platform == "phone" else None + + def _secondary_container_tone_delta_pair_2025( + self, s: DynamicScheme + ) -> Optional[ToneDeltaPair]: + if s.platform == "watch": + return ToneDeltaPair( + self.secondaryContainer(), + self.secondaryDim(), + 10, + "darker", + True, + "farther", + ) + return None + + def _secondary_container_contrast_curve_2025( + self, s: DynamicScheme + ) -> Optional[ContrastCurve]: + if s.platform == "phone" and s.contrast_level > 0: + return get_curve(1.5) + return None + + def secondaryContainer(self) -> DynamicColor: + color2025 = DynamicColor.from_palette( + name="secondaryContainer", + palette=lambda s: s.secondary_palette, + tone=self._secondary_container_tone_2025, + is_background=True, + background=self._secondary_container_background_2025, + tone_delta_pair=self._secondary_container_tone_delta_pair_2025, + contrast_curve=self._secondary_container_contrast_curve_2025, + ) + return extend_spec_version(super().secondaryContainer(), "2025", color2025) + + def _on_secondary_container_contrast_curve_2025( + self, s: DynamicScheme + ) -> ContrastCurve: + return get_curve(6) if s.platform == "phone" else get_curve(7) + + def onSecondaryContainer(self) -> DynamicColor: + color2025 = DynamicColor.from_palette( + name="onSecondaryContainer", + palette=lambda s: s.secondary_palette, + background=lambda s: self.secondaryContainer(), + contrast_curve=self._on_secondary_container_contrast_curve_2025, + ) + return extend_spec_version(super().onSecondaryContainer(), "2025", color2025) + + def _secondary_fixed_tone_2025(self, s: DynamicScheme) -> float: + temp_s = DynamicScheme( + source_color_hct=s.source_color_hct, + variant=s.variant, + contrast_level=0, + is_dark=False, + platform=s.platform, + spec_version=s.spec_version, + primary_palette=s.primary_palette, + secondary_palette=s.secondary_palette, + tertiary_palette=s.tertiary_palette, + neutral_palette=s.neutral_palette, + neutral_variant_palette=s.neutral_variant_palette, + error_palette=s.error_palette, + ) + return self.secondaryContainer().get_tone(temp_s) + + def _secondary_fixed_background_2025( + self, s: DynamicScheme + ) -> Optional[DynamicColor]: + return self.highestSurface(s) if s.platform == "phone" else None + + def _secondary_fixed_contrast_curve_2025( + self, s: DynamicScheme + ) -> Optional[ContrastCurve]: + if s.platform == "phone" and s.contrast_level > 0: + return get_curve(1.5) + return None + + def secondaryFixed(self) -> DynamicColor: + color2025 = DynamicColor.from_palette( + name="secondaryFixed", + palette=lambda s: s.secondary_palette, + tone=self._secondary_fixed_tone_2025, + is_background=True, + background=self._secondary_fixed_background_2025, + contrast_curve=self._secondary_fixed_contrast_curve_2025, + ) + return extend_spec_version(super().secondaryFixed(), "2025", color2025) + + def _secondary_fixed_dim_tone_delta_pair_2025( + self, s: DynamicScheme + ) -> ToneDeltaPair: + return ToneDeltaPair( + self.secondaryFixedDim(), + self.secondaryFixed(), + 5, + "darker", + True, + "exact", + ) + + def secondaryFixedDim(self) -> DynamicColor: + color2025 = DynamicColor.from_palette( + name="secondaryFixedDim", + palette=lambda s: s.secondary_palette, + tone=lambda s: self.secondaryFixed().get_tone(s), + is_background=True, + tone_delta_pair=self._secondary_fixed_dim_tone_delta_pair_2025, + ) + return extend_spec_version(super().secondaryFixedDim(), "2025", color2025) + + def _on_secondary_fixed_contrast_curve_2025( + self, s: DynamicScheme + ) -> ContrastCurve: + return get_curve(7) + + def onSecondaryFixed(self) -> DynamicColor: + color2025 = DynamicColor.from_palette( + name="onSecondaryFixed", + palette=lambda s: s.secondary_palette, + background=lambda s: self.secondaryFixedDim(), + contrast_curve=self._on_secondary_fixed_contrast_curve_2025, + ) + return extend_spec_version(super().onSecondaryFixed(), "2025", color2025) + + def _on_secondary_fixed_variant_contrast_curve_2025( + self, s: DynamicScheme + ) -> ContrastCurve: + return get_curve(4.5) + + def onSecondaryFixedVariant(self) -> DynamicColor: + color2025 = DynamicColor.from_palette( + name="onSecondaryFixedVariant", + palette=lambda s: s.secondary_palette, + background=lambda s: self.secondaryFixedDim(), + contrast_curve=self._on_secondary_fixed_variant_contrast_curve_2025, + ) + return extend_spec_version(super().onSecondaryFixedVariant(), "2025", color2025) + + def _tertiary_tone_2025(self, s: DynamicScheme) -> float: + if s.platform == "watch": + return ( + t_max_c(s.tertiary_palette, 0, 90) + if s.variant == Variant.TONAL_SPOT + else t_max_c(s.tertiary_palette) + ) + elif s.variant == Variant.EXPRESSIVE or s.variant == Variant.VIBRANT: + if Hct.is_cyan(s.tertiary_palette.hue): + return t_max_c(s.tertiary_palette, 0, 88) + elif s.is_dark: + return t_max_c(s.tertiary_palette, 0, 98) + else: + return t_max_c(s.tertiary_palette, 0, 100) + else: # NEUTRAL and TONAL_SPOT + return ( + t_max_c(s.tertiary_palette, 0, 98) + if s.is_dark + else t_max_c(s.tertiary_palette) + ) + + def _tertiary_contrast_curve_2025(self, s: DynamicScheme) -> ContrastCurve: + return get_curve(4.5) if s.platform == "phone" else get_curve(7) + + def _tertiary_tone_delta_pair_2025( + self, s: DynamicScheme + ) -> Optional[ToneDeltaPair]: + if s.platform == "phone": + return ToneDeltaPair( + self.tertiaryContainer(), + self.tertiary(), + 5, + "relative_lighter", + True, + "farther", + ) + return None + + def tertiary(self) -> DynamicColor: + color2025 = DynamicColor.from_palette( + name="tertiary", + palette=lambda s: s.tertiary_palette, + tone=self._tertiary_tone_2025, + is_background=True, + background=lambda s: self.highestSurface(s) + if s.platform == "phone" + else self.surfaceContainerHigh(), + contrast_curve=self._tertiary_contrast_curve_2025, + tone_delta_pair=self._tertiary_tone_delta_pair_2025, + ) + return extend_spec_version(super().tertiary(), "2025", color2025) + + def tertiaryDim(self) -> DynamicColor: + return DynamicColor.from_palette( + name="tertiaryDim", + palette=lambda s: s.tertiary_palette, + tone=lambda s: ( + t_max_c(s.tertiary_palette, 0, 90) + if s.variant == Variant.TONAL_SPOT + else t_max_c(s.tertiary_palette) + ), + is_background=True, + background=lambda s: self.surfaceContainerHigh(), + contrast_curve=lambda s: get_curve(4.5), + tone_delta_pair=lambda s: ToneDeltaPair( + self.tertiaryDim(), self.tertiary(), 5, "darker", True, "farther" + ), + ) + + def _on_tertiary_background_2025(self, s: DynamicScheme) -> DynamicColor: + return self.tertiary() if s.platform == "phone" else self.tertiaryDim() + + def _on_tertiary_contrast_curve_2025(self, s: DynamicScheme) -> ContrastCurve: + return get_curve(6) if s.platform == "phone" else get_curve(7) + + def onTertiary(self) -> DynamicColor: + color2025 = DynamicColor.from_palette( + name="onTertiary", + palette=lambda s: s.tertiary_palette, + background=self._on_tertiary_background_2025, + contrast_curve=self._on_tertiary_contrast_curve_2025, + ) + return extend_spec_version(super().onTertiary(), "2025", color2025) + + def _tertiary_container_tone_2025(self, s: DynamicScheme) -> float: + if s.platform == "watch": + return ( + t_max_c(s.tertiary_palette, 0, 90) + if s.variant == Variant.TONAL_SPOT + else t_max_c(s.tertiary_palette) + ) + elif s.variant == Variant.NEUTRAL: + return ( + t_max_c(s.tertiary_palette, 0, 93) + if s.is_dark + else t_max_c(s.tertiary_palette, 0, 96) + ) + elif s.variant == Variant.TONAL_SPOT: + return ( + t_max_c(s.tertiary_palette, 0, 93) + if s.is_dark + else t_max_c(s.tertiary_palette, 0, 100) + ) + elif s.variant == Variant.EXPRESSIVE: + if Hct.is_cyan(s.tertiary_palette.hue): + return t_max_c(s.tertiary_palette, 75, 88) + elif s.is_dark: + return t_max_c(s.tertiary_palette, 75, 93) + else: + return t_max_c(s.tertiary_palette, 75, 100) + elif s.variant == Variant.VIBRANT: + return ( + t_max_c(s.tertiary_palette, 0, 93) + if s.is_dark + else t_max_c(s.tertiary_palette, 72, 100) + ) + return 0.0 # Should not reach here + + def _tertiary_container_background_2025( + self, s: DynamicScheme + ) -> Optional[DynamicColor]: + return self.highestSurface(s) if s.platform == "phone" else None + + def _tertiary_container_tone_delta_pair_2025( + self, s: DynamicScheme + ) -> Optional[ToneDeltaPair]: + if s.platform == "watch": + return ToneDeltaPair( + self.tertiaryContainer(), + self.tertiaryDim(), + 10, + "darker", + True, + "farther", + ) + return None + + def _tertiary_container_contrast_curve_2025( + self, s: DynamicScheme + ) -> Optional[ContrastCurve]: + if s.platform == "phone" and s.contrast_level > 0: + return get_curve(1.5) + return None + + def tertiaryContainer(self) -> DynamicColor: + color2025 = DynamicColor.from_palette( + name="tertiaryContainer", + palette=lambda s: s.tertiary_palette, + tone=self._tertiary_container_tone_2025, + is_background=True, + background=self._tertiary_container_background_2025, + tone_delta_pair=self._tertiary_container_tone_delta_pair_2025, + contrast_curve=self._tertiary_container_contrast_curve_2025, + ) + return extend_spec_version(super().tertiaryContainer(), "2025", color2025) + + def _on_tertiary_container_contrast_curve_2025( + self, s: DynamicScheme + ) -> ContrastCurve: + return get_curve(6) if s.platform == "phone" else get_curve(7) + + def onTertiaryContainer(self) -> DynamicColor: + color2025 = DynamicColor.from_palette( + name="onTertiaryContainer", + palette=lambda s: s.tertiary_palette, + background=lambda s: self.tertiaryContainer(), + contrast_curve=self._on_tertiary_container_contrast_curve_2025, + ) + return extend_spec_version(super().onTertiaryContainer(), "2025", color2025) + + def _tertiary_fixed_tone_2025(self, s: DynamicScheme) -> float: + temp_s = DynamicScheme( + source_color_hct=s.source_color_hct, + variant=s.variant, + contrast_level=0, + is_dark=False, + platform=s.platform, + spec_version=s.spec_version, + primary_palette=s.primary_palette, + secondary_palette=s.secondary_palette, + tertiary_palette=s.tertiary_palette, + neutral_palette=s.neutral_palette, + neutral_variant_palette=s.neutral_variant_palette, + error_palette=s.error_palette, + ) + return self.tertiaryContainer().get_tone(temp_s) + + def _tertiary_fixed_background_2025( + self, s: DynamicScheme + ) -> Optional[DynamicColor]: + return self.highestSurface(s) if s.platform == "phone" else None + + def _tertiary_fixed_contrast_curve_2025( + self, s: DynamicScheme + ) -> Optional[ContrastCurve]: + if s.platform == "phone" and s.contrast_level > 0: + return get_curve(1.5) + return None + + def tertiaryFixed(self) -> DynamicColor: + color2025 = DynamicColor.from_palette( + name="tertiaryFixed", + palette=lambda s: s.tertiary_palette, + tone=self._tertiary_fixed_tone_2025, + is_background=True, + background=self._tertiary_fixed_background_2025, + contrast_curve=self._tertiary_fixed_contrast_curve_2025, + ) + return extend_spec_version(super().tertiaryFixed(), "2025", color2025) + + def _tertiary_fixed_dim_tone_delta_pair_2025( + self, s: DynamicScheme + ) -> ToneDeltaPair: + return ToneDeltaPair( + self.tertiaryFixedDim(), self.tertiaryFixed(), 5, "darker", True, "exact" + ) + + def tertiaryFixedDim(self) -> DynamicColor: + color2025 = DynamicColor.from_palette( + name="tertiaryFixedDim", + palette=lambda s: s.tertiary_palette, + tone=lambda s: self.tertiaryFixed().get_tone(s), + is_background=True, + tone_delta_pair=self._tertiary_fixed_dim_tone_delta_pair_2025, + ) + return extend_spec_version(super().tertiaryFixedDim(), "2025", color2025) + + def _on_tertiary_fixed_contrast_curve_2025(self, s: DynamicScheme) -> ContrastCurve: + return get_curve(7) + + def onTertiaryFixed(self) -> DynamicColor: + color2025 = DynamicColor.from_palette( + name="onTertiaryFixed", + palette=lambda s: s.tertiary_palette, + background=lambda s: self.tertiaryFixedDim(), + contrast_curve=self._on_tertiary_fixed_contrast_curve_2025, + ) + return extend_spec_version(super().onTertiaryFixed(), "2025", color2025) + + def _on_tertiary_fixed_variant_contrast_curve_2025( + self, s: DynamicScheme + ) -> ContrastCurve: + return get_curve(4.5) + + def onTertiaryFixedVariant(self) -> DynamicColor: + color2025 = DynamicColor.from_palette( + name="onTertiaryFixedVariant", + palette=lambda s: s.tertiary_palette, + background=lambda s: self.tertiaryFixedDim(), + contrast_curve=self._on_tertiary_fixed_variant_contrast_curve_2025, + ) + return extend_spec_version(super().onTertiaryFixedVariant(), "2025", color2025) + + def _error_tone_2025(self, s: DynamicScheme) -> float: + if s.platform == "phone": + return ( + t_min_c(s.error_palette, 0, 98) + if s.is_dark + else t_max_c(s.error_palette) + ) + else: + return t_min_c(s.error_palette) + + def _error_contrast_curve_2025(self, s: DynamicScheme) -> ContrastCurve: + return get_curve(4.5) if s.platform == "phone" else get_curve(7) + + def _error_tone_delta_pair_2025(self, s: DynamicScheme) -> Optional[ToneDeltaPair]: + if s.platform == "phone": + return ToneDeltaPair( + self.errorContainer(), + self.error(), + 5, + "relative_lighter", + True, + "farther", + ) + return None + + def error(self) -> DynamicColor: + color2025 = DynamicColor.from_palette( + name="error", + palette=lambda s: s.error_palette, + tone=self._error_tone_2025, + is_background=True, + background=lambda s: self.highestSurface(s) + if s.platform == "phone" + else self.surfaceContainerHigh(), + contrast_curve=self._error_contrast_curve_2025, + tone_delta_pair=self._error_tone_delta_pair_2025, + ) + return extend_spec_version(super().error(), "2025", color2025) + + def errorDim(self) -> DynamicColor: + return DynamicColor.from_palette( + name="errorDim", + palette=lambda s: s.error_palette, + tone=lambda s: t_min_c(s.error_palette), + is_background=True, + background=lambda s: self.surfaceContainerHigh(), + contrast_curve=lambda s: get_curve(4.5), + tone_delta_pair=lambda s: ToneDeltaPair( + self.errorDim(), self.error(), 5, "darker", True, "farther" + ), + ) + + def _on_error_background_2025(self, s: DynamicScheme) -> DynamicColor: + return self.error() if s.platform == "phone" else self.errorDim() + + def _on_error_contrast_curve_2025(self, s: DynamicScheme) -> ContrastCurve: + return get_curve(6) if s.platform == "phone" else get_curve(7) + + def onError(self) -> DynamicColor: + color2025 = DynamicColor.from_palette( + name="onError", + palette=lambda s: s.error_palette, + background=self._on_error_background_2025, + contrast_curve=self._on_error_contrast_curve_2025, + ) + return extend_spec_version(super().onError(), "2025", color2025) + + def _error_container_tone_2025(self, s: DynamicScheme) -> float: + if s.platform == "watch": + return 30 + else: + return ( + t_min_c(s.error_palette, 30, 93) + if s.is_dark + else t_max_c(s.error_palette, 0, 90) + ) + + def _error_container_background_2025( + self, s: DynamicScheme + ) -> Optional[DynamicColor]: + return self.highestSurface(s) if s.platform == "phone" else None + + def _error_container_tone_delta_pair_2025( + self, s: DynamicScheme + ) -> Optional[ToneDeltaPair]: + if s.platform == "watch": + return ToneDeltaPair( + self.errorContainer(), + self.errorDim(), + 10, + "darker", + True, + "farther", + ) + return None + + def _error_container_contrast_curve_2025( + self, s: DynamicScheme + ) -> Optional[ContrastCurve]: + if s.platform == "phone" and s.contrast_level > 0: + return get_curve(1.5) + return None + + def errorContainer(self) -> DynamicColor: + color2025 = DynamicColor.from_palette( + name="errorContainer", + palette=lambda s: s.error_palette, + tone=self._error_container_tone_2025, + is_background=True, + background=self._error_container_background_2025, + tone_delta_pair=self._error_container_tone_delta_pair_2025, + contrast_curve=self._error_container_contrast_curve_2025, + ) + return extend_spec_version(super().errorContainer(), "2025", color2025) + + def _on_error_container_contrast_curve_2025( + self, s: DynamicScheme + ) -> ContrastCurve: + return get_curve(4.5) if s.platform == "phone" else get_curve(7) + + def onErrorContainer(self) -> DynamicColor: + color2025 = DynamicColor.from_palette( + name="onErrorContainer", + palette=lambda s: s.error_palette, + background=lambda s: self.errorContainer(), + contrast_curve=self._on_error_container_contrast_curve_2025, + ) + return extend_spec_version(super().onErrorContainer(), "2025", color2025) + + def surfaceVariant(self) -> DynamicColor: + color2025 = self.surfaceContainerHighest().clone() + color2025.name = "surfaceVariant" + return extend_spec_version(super().surfaceVariant(), "2025", color2025) + + def surfaceTint(self) -> DynamicColor: + color2025 = self.primary().clone() + color2025.name = "surfaceTint" + return extend_spec_version(super().surfaceTint(), "2025", color2025) + + def background(self) -> DynamicColor: + color2025 = self.surface().clone() + color2025.name = "background" + return extend_spec_version(super().background(), "2025", color2025) + + def onBackground(self) -> DynamicColor: + color2025 = self.onSurface().clone() + color2025.name = "onBackground" + color2025.tone = ( + lambda s: 100.0 if s.platform == "watch" else self.onSurface().get_tone(s) + ) + return extend_spec_version(super().onBackground(), "2025", color2025) diff --git a/materialyoucolor/dynamiccolor/dynamic_color.py b/materialyoucolor/dynamiccolor/dynamic_color.py index 4f78349..bbba516 100644 --- a/materialyoucolor/dynamiccolor/dynamic_color.py +++ b/materialyoucolor/dynamiccolor/dynamic_color.py @@ -1,232 +1,158 @@ -from materialyoucolor.contrast import Contrast -from materialyoucolor.hct import Hct -from materialyoucolor.palettes.tonal_palette import TonalPalette -from materialyoucolor.scheme.dynamic_scheme import DynamicScheme +from __future__ import annotations + +from dataclasses import dataclass +from typing import TYPE_CHECKING, Callable, Optional + +from materialyoucolor.contrast.contrast import Contrast +from materialyoucolor.dynamiccolor.color_spec import ( + SpecVersion, + get_color_calculation_delegate, +) from materialyoucolor.dynamiccolor.contrast_curve import ContrastCurve from materialyoucolor.dynamiccolor.tone_delta_pair import ToneDeltaPair +from materialyoucolor.hct.hct import Hct +from materialyoucolor.palettes.tonal_palette import TonalPalette +from materialyoucolor.utils.color_utils import hex_from_argb, rgba_from_argb +@dataclass class FromPaletteOptions: - def __init__( - self, - name=str, - palette=None, - tone=int, - is_background=bool, - background=None, - second_background=None, - contrast_curve=None, - tone_delta_pair=None, - ): - self.name = name - self.palette = palette - self.tone = tone - self.is_background = is_background - self.background = background - self.second_background = second_background - self.contrast_curve = contrast_curve - self.tone_delta_pair = tone_delta_pair - + name: str = "" + palette: Callable[[DynamicScheme], TonalPalette] = None + tone: Callable[[DynamicScheme], float] = None + chroma_multiplier: Optional[Callable[[DynamicScheme], float]] = None + is_background: bool = False + background: Optional[Callable[[DynamicScheme], "DynamicColor"]] = None + second_background: Optional[Callable[[DynamicScheme], "DynamicColor"]] = None + contrast_curve: Optional[Callable[[DynamicScheme], ContrastCurve]] = None + tone_delta_pair: Optional[Callable[[DynamicScheme], ToneDeltaPair]] = None + + +def validate_extended_color( + original_color: "DynamicColor", + spec_version: SpecVersion, + extended_color: "DynamicColor", +): + if original_color.name != extended_color.name: + raise ValueError( + f"Attempting to extend color {original_color.name} with color {extended_color.name} " + f"of different name for spec version {spec_version}." + ) + if original_color.is_background != extended_color.is_background: + raise ValueError( + f"Attempting to extend color {original_color.name} as a " + f"{'background' if original_color.is_background else 'foreground'} with color {extended_color.name} as a " + f"{'background' if extended_color.is_background else 'foreground'} for spec version {spec_version}." + ) -class DynamicColor: - hct_cache = dict[DynamicScheme, Hct] - name = str - palette = None - tone = int - is_background = bool - background = None - second_background = None - contrast_curve = None - tone_delta_pair = None - def __init__( - self, - name=str, - palette=None, - tone=int, - is_background=bool, - background=None, - second_background=None, - contrast_curve=None, - tone_delta_pair=None, - ): - self.name = name - self.palette = palette - self.tone = tone - self.is_background = is_background - self.background = background - self.second_background = second_background - self.contrast_curve = contrast_curve - self.tone_delta_pair = tone_delta_pair +def extend_spec_version(original, spec, extended): + validate_extended_color(original, spec, extended) + + def choose(s, _ext=extended, _orig=original, _spec=spec): + return _ext if s.spec_version == _spec else _orig + + return DynamicColor( + name=original.name, + is_background=original.is_background, + palette=lambda s: choose(s).palette(s), + tone=lambda s: ( + choose(s).tone(s) if choose(s).tone is not None else original.tone(s) + ), + chroma_multiplier=lambda s: ( + choose(s).chroma_multiplier(s) if choose(s).chroma_multiplier else 1.0 + ), + background=lambda s: ( + choose(s).background(s) if choose(s).background else None + ), + second_background=lambda s: ( + choose(s).second_background(s) if choose(s).second_background else None + ), + contrast_curve=lambda s: ( + choose(s).contrast_curve(s) if choose(s).contrast_curve else None + ), + tone_delta_pair=lambda s: ( + choose(s).tone_delta_pair(s) if choose(s).tone_delta_pair else None + ), + ) + + +class DynamicColor(FromPaletteOptions): + hct_cache = {} + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) self.hct_cache = {} if not self.background and self.second_background: raise ValueError( - f"Color {name} has secondBackground defined, but background is not defined." + f"Color {self.name} has second_background defined, but background is not defined." ) if not self.background and self.contrast_curve: raise ValueError( - f"Color {name} has contrastCurve defined, but background is not defined." + f"Color {self.name} has contrast_curve defined, but background is not defined." ) if self.background and not self.contrast_curve: raise ValueError( - f"Color {name} has background defined, but contrastCurve is not defined." + f"Color {self.name} has background defined, but contrast_curve is not defined." ) + @classmethod + def from_palette(cls, *args, **kwargs) -> "DynamicColor": + return cls(*args, **kwargs) + @staticmethod - def from_palette(args: FromPaletteOptions): - return DynamicColor( - args.name, - args.palette, - args.tone, - args.is_background, - args.background, - args.second_background, - args.contrast_curve, - args.tone_delta_pair, + def get_initial_tone_from_background( + background: Optional[Callable[[DynamicScheme], "DynamicColor"]] = None, + ) -> Callable[[DynamicScheme], float]: + if background is None: + return lambda s: 50 + return lambda s: background(s).get_tone(s) if background(s) else 50 + + def clone(self) -> "DynamicColor": + return DynamicColor.from_palette( + name=self.name, + palette=self.palette, + tone=self.tone, + is_background=self.is_background, + chroma_multiplier=self.chroma_multiplier, + background=self.background, + second_background=self.second_background, + contrast_curve=self.contrast_curve, + tone_delta_pair=self.tone_delta_pair, ) + def clear_cache(self): + self.hct_cache.clear() + def get_argb(self, scheme: DynamicScheme) -> int: return self.get_hct(scheme).to_int() + def get_hex(self, scheme: DynamicScheme) -> str: + return hex_from_argb(self.get_hct(scheme).to_int()) + + def get_rgba(self, scheme: DynamicScheme) -> list[int]: + return rgba_from_argb(self.get_hct(scheme).to_int()) + def get_hct(self, scheme: DynamicScheme) -> Hct: cached_answer = self.hct_cache.get(scheme) if cached_answer is not None: return cached_answer - tone = self.get_tone(scheme) - answer = self.palette(scheme).get_hct(tone) + answer = get_color_calculation_delegate(scheme.spec_version).get_hct( + scheme, self + ) if len(self.hct_cache) > 4: self.hct_cache.clear() self.hct_cache[scheme] = answer return answer - def get_tone(self, scheme): - decreasing_contrast = scheme.contrast_level < 0 - - if self.tone_delta_pair: - tone_delta_pair = self.tone_delta_pair(scheme) - role_a, role_b = tone_delta_pair.role_a, tone_delta_pair.role_b - delta, polarity, stay_together = ( - tone_delta_pair.delta, - tone_delta_pair.polarity, - tone_delta_pair.stay_together, - ) - - bg = self.background(scheme) - bg_tone = bg.get_tone(scheme) - - a_is_nearer = ( - polarity == "nearer" - or (polarity == "lighter" and not scheme.is_dark) - or (polarity == "darker" and scheme.is_dark) - ) - nearer, farther = (role_a, role_b) if a_is_nearer else (role_b, role_a) - am_nearer = self.name == nearer.name - expansion_dir = 1 if scheme.is_dark else -1 - - n_contrast = nearer.contrast_curve.get(scheme.contrast_level) - f_contrast = farther.contrast_curve.get(scheme.contrast_level) - - n_initial_tone = nearer.tone(scheme) - n_tone = ( - n_initial_tone - if Contrast.ratio_of_tones(bg_tone, n_initial_tone) >= n_contrast - else DynamicColor.foreground_tone(bg_tone, n_contrast) - ) - - f_initial_tone = farther.tone(scheme) - f_tone = ( - f_initial_tone - if Contrast.ratio_of_tones(bg_tone, f_initial_tone) >= f_contrast - else DynamicColor.foreground_tone(bg_tone, f_contrast) - ) - - if decreasing_contrast: - n_tone = DynamicColor.foreground_tone(bg_tone, n_contrast) - f_tone = DynamicColor.foreground_tone(bg_tone, f_contrast) - - if (f_tone - n_tone) * expansion_dir >= delta: - pass - else: - f_tone = ( - min(max(n_tone + delta * expansion_dir, 0), 100) - if (f_tone - n_tone) * expansion_dir >= delta - else min(max(f_tone - delta * expansion_dir, 0), 100) - ) - - if 50 <= n_tone < 60: - if expansion_dir > 0: - n_tone, f_tone = 60, max(f_tone, n_tone + delta * expansion_dir) - else: - n_tone, f_tone = 49, min(f_tone, n_tone + delta * expansion_dir) - elif 50 <= f_tone < 60: - if stay_together: - if expansion_dir > 0: - n_tone, f_tone = 60, max(f_tone, n_tone + delta * expansion_dir) - else: - n_tone, f_tone = 49, min(f_tone, n_tone + delta * expansion_dir) - else: - if expansion_dir > 0: - f_tone = 60 - else: - f_tone = 49 - - return n_tone if am_nearer else f_tone - - else: - answer = self.tone(scheme) - - if self.background is None: - return answer - - bg_tone = self.background(scheme).get_tone(scheme) - desired_ratio = self.contrast_curve.get(scheme.contrast_level) - - if Contrast.ratio_of_tones(bg_tone, answer) >= desired_ratio: - pass - else: - answer = DynamicColor.foreground_tone(bg_tone, desired_ratio) - - if decreasing_contrast: - answer = DynamicColor.foreground_tone(bg_tone, desired_ratio) - - if self.is_background and 50 <= answer < 60: - answer = ( - 49 if Contrast.ratio_of_tones(49, bg_tone) >= desired_ratio else 60 - ) - - if self.second_background: - bg1, bg2 = self.background, self.second_background - bg_tone1, bg_tone2 = bg1(scheme).get_tone(scheme), bg2(scheme).get_tone( - scheme - ) - upper, lower = max(bg_tone1, bg_tone2), min(bg_tone1, bg_tone2) - - if ( - Contrast.ratio_of_tones(upper, answer) >= desired_ratio - and Contrast.ratio_of_tones(lower, answer) >= desired_ratio - ): - return answer - - light_option = Contrast.lighter(upper, desired_ratio) - dark_option = Contrast.darker(lower, desired_ratio) - availables = [light_option] if light_option != -1 else [] - if dark_option != -1: - availables.append(dark_option) - - prefers_light = DynamicColor.tone_prefers_light_foreground( - bg_tone1 - ) or DynamicColor.tone_prefers_light_foreground(bg_tone2) - return ( - light_option - if prefers_light and (light_option == -1 or dark_option == -1) - else dark_option - ) - - return answer + def get_tone(self, scheme: DynamicScheme) -> float: + return get_color_calculation_delegate(scheme.spec_version).get_tone( + scheme, self + ) @staticmethod - def foreground_tone(bg_tone, ratio): + def foreground_tone(bg_tone: float, ratio: float) -> float: lighter_tone = Contrast.lighter_unsafe(bg_tone, ratio) darker_tone = Contrast.darker_unsafe(bg_tone, ratio) lighter_ratio = Contrast.ratio_of_tones(lighter_tone, bg_tone) @@ -254,15 +180,15 @@ def foreground_tone(bg_tone, ratio): ) @staticmethod - def tone_prefers_light_foreground(tone): + def tone_prefers_light_foreground(tone: float) -> bool: return round(tone) < 60.0 @staticmethod - def tone_allows_light_foreground(tone): + def tone_allows_light_foreground(tone: float) -> bool: return round(tone) <= 49.0 @staticmethod - def enable_light_foreground(tone): + def enable_light_foreground(tone: float) -> float: if DynamicColor.tone_prefers_light_foreground( tone ) and not DynamicColor.tone_allows_light_foreground(tone): diff --git a/materialyoucolor/dynamiccolor/dynamic_scheme.py b/materialyoucolor/dynamiccolor/dynamic_scheme.py new file mode 100644 index 0000000..b4a6001 --- /dev/null +++ b/materialyoucolor/dynamiccolor/dynamic_scheme.py @@ -0,0 +1,717 @@ +from __future__ import annotations + +from typing import Literal + +from materialyoucolor.dislike.dislike_analyzer import DislikeAnalyzer +from materialyoucolor.dynamiccolor.dynamic_color import DynamicColor +from materialyoucolor.dynamiccolor.variant import Variant +from materialyoucolor.hct.hct import Hct +from materialyoucolor.palettes.tonal_palette import TonalPalette +from materialyoucolor.temperature.temperature_cache import TemperatureCache +from materialyoucolor.utils.math_utils import sanitize_degrees_double + +SpecVersion = Literal["2021", "2025"] +Platform = Literal["phone", "watch"] + + +class DynamicSchemePalettesDelegate: + def get_primary_palette( + self, + variant: Variant, + source_color_hct: Hct, + is_dark: bool, + platform: Platform, + contrast_level: float, + ) -> TonalPalette: + raise NotImplementedError + + def get_secondary_palette( + self, + variant: Variant, + source_color_hct: Hct, + is_dark: bool, + platform: Platform, + contrast_level: float, + ) -> TonalPalette: + raise NotImplementedError + + def get_tertiary_palette( + self, + variant: Variant, + source_color_hct: Hct, + is_dark: bool, + platform: Platform, + contrast_level: float, + ) -> TonalPalette: + raise NotImplementedError + + def get_neutral_palette( + self, + variant: Variant, + source_color_hct: Hct, + is_dark: bool, + platform: Platform, + contrast_level: float, + ) -> TonalPalette: + raise NotImplementedError + + def get_neutral_variant_palette( + self, + variant: Variant, + source_color_hct: Hct, + is_dark: bool, + platform: Platform, + contrast_level: float, + ) -> TonalPalette: + raise NotImplementedError + + def get_error_palette( + self, + variant: Variant, + source_color_hct: Hct, + is_dark: bool, + platform: Platform, + contrast_level: float, + ) -> TonalPalette | None: + raise NotImplementedError + + +class DynamicScheme: + DEFAULT_SPEC_VERSION: SpecVersion = "2025" + DEFAULT_PLATFORM: Platform = "phone" + + def __init__( + self, + source_color_hct: Hct, + variant: Variant, + contrast_level: float, + is_dark: bool, + platform: Platform = "phone", + spec_version: SpecVersion = "2021", + primary_palette: TonalPalette = None, + secondary_palette: TonalPalette = None, + tertiary_palette: TonalPalette = None, + neutral_palette: TonalPalette = None, + neutral_variant_palette: TonalPalette = None, + error_palette: TonalPalette = None, + ): + self.source_color_argb = source_color_hct.to_int() + self.source_color_hct = source_color_hct + self.variant = variant + self.contrast_level = contrast_level + self.is_dark = is_dark + self.platform = platform + self.spec_version = self._maybe_fallback_spec_version(spec_version, variant) + + spec = get_spec(self.spec_version) + self.primary_palette = primary_palette or spec.get_primary_palette( + self.variant, + self.source_color_hct, + self.is_dark, + self.platform, + self.contrast_level, + ) + self.secondary_palette = secondary_palette or spec.get_secondary_palette( + self.variant, + self.source_color_hct, + self.is_dark, + self.platform, + self.contrast_level, + ) + self.tertiary_palette = tertiary_palette or spec.get_tertiary_palette( + self.variant, + self.source_color_hct, + self.is_dark, + self.platform, + self.contrast_level, + ) + self.neutral_palette = neutral_palette or spec.get_neutral_palette( + self.variant, + self.source_color_hct, + self.is_dark, + self.platform, + self.contrast_level, + ) + self.neutral_variant_palette = ( + neutral_variant_palette + or spec.get_neutral_variant_palette( + self.variant, + self.source_color_hct, + self.is_dark, + self.platform, + self.contrast_level, + ) + ) + self.error_palette = ( + error_palette + or spec.get_error_palette( + self.variant, + self.source_color_hct, + self.is_dark, + self.platform, + self.contrast_level, + ) + or TonalPalette.from_hue_and_chroma(25.0, 84.0) + ) + + @staticmethod + def _maybe_fallback_spec_version( + spec_version: SpecVersion, variant: Variant + ) -> SpecVersion: + if variant in [ + Variant.EXPRESSIVE, + Variant.VIBRANT, + Variant.TONAL_SPOT, + Variant.NEUTRAL, + ]: + return spec_version + return "2021" + + def __str__(self): + return ( + f"Scheme: " + f"variant={self.variant.name}, " + f"mode={'dark' if self.is_dark else 'light'}, " + f"platform={self.platform}, " + f"contrastLevel={self.contrast_level:.1f}, " + f"seed={self.source_color_hct}, " + f"specVersion={self.spec_version}" + ) + + @staticmethod + def get_piecewise_hue( + source_color_hct: Hct, hue_breakpoints: list[float], hues: list[float] + ) -> float: + size = min(len(hue_breakpoints) - 1, len(hues)) + source_hue = source_color_hct.hue + for i in range(size): + if source_hue >= hue_breakpoints[i] and source_hue < hue_breakpoints[i + 1]: + return sanitize_degrees_double(hues[i]) + # No condition matched, return the source hue. + return source_hue + + @staticmethod + def get_rotated_hue( + source_color_hct: Hct, hue_breakpoints: list[float], rotations: list[float] + ) -> float: + rotation = DynamicScheme.get_piecewise_hue( + source_color_hct, hue_breakpoints, rotations + ) + if min(len(hue_breakpoints) - 1, len(rotations)) <= 0: + # No condition matched, return the source hue. + rotation = 0 + return sanitize_degrees_double(source_color_hct.hue + rotation) + + def get_argb(self, dynamic_color: DynamicColor) -> int: + return dynamic_color.get_argb(self) + + def get_hct(self, dynamic_color: DynamicColor) -> Hct: + return dynamic_color.get_hct(self) + + +class DynamicSchemePalettesDelegateImpl2021(DynamicSchemePalettesDelegate): + def get_primary_palette( + self, + variant: Variant, + source_color_hct: Hct, + is_dark: bool, + platform: Platform, + contrast_level: float, + ) -> TonalPalette: + if variant == Variant.CONTENT or variant == Variant.FIDELITY: + return TonalPalette.from_hue_and_chroma( + source_color_hct.hue, source_color_hct.chroma + ) + if variant == Variant.FRUIT_SALAD: + return TonalPalette.from_hue_and_chroma( + sanitize_degrees_double(source_color_hct.hue - 50.0), 48.0 + ) + if variant == Variant.MONOCHROME: + return TonalPalette.from_hue_and_chroma(source_color_hct.hue, 0.0) + if variant == Variant.NEUTRAL: + return TonalPalette.from_hue_and_chroma(source_color_hct.hue, 12.0) + if variant == Variant.RAINBOW: + return TonalPalette.from_hue_and_chroma(source_color_hct.hue, 48.0) + if variant == Variant.TONAL_SPOT: + return TonalPalette.from_hue_and_chroma(source_color_hct.hue, 36.0) + if variant == Variant.EXPRESSIVE: + return TonalPalette.from_hue_and_chroma( + sanitize_degrees_double(source_color_hct.hue + 240), 40 + ) + if variant == Variant.VIBRANT: + return TonalPalette.from_hue_and_chroma(source_color_hct.hue, 200.0) + raise ValueError(f"Unsupported variant: {variant}") + + def get_secondary_palette( + self, + variant: Variant, + source_color_hct: Hct, + is_dark: bool, + platform: Platform, + contrast_level: float, + ) -> TonalPalette: + if variant == Variant.CONTENT or variant == Variant.FIDELITY: + return TonalPalette.from_hue_and_chroma( + source_color_hct.hue, + max(source_color_hct.chroma - 32.0, source_color_hct.chroma * 0.5), + ) + if variant == Variant.FRUIT_SALAD: + return TonalPalette.from_hue_and_chroma( + sanitize_degrees_double(source_color_hct.hue - 50.0), 36.0 + ) + if variant == Variant.MONOCHROME: + return TonalPalette.from_hue_and_chroma(source_color_hct.hue, 0.0) + if variant == Variant.NEUTRAL: + return TonalPalette.from_hue_and_chroma(source_color_hct.hue, 8.0) + if variant == Variant.RAINBOW: + return TonalPalette.from_hue_and_chroma(source_color_hct.hue, 16.0) + if variant == Variant.TONAL_SPOT: + return TonalPalette.from_hue_and_chroma(source_color_hct.hue, 16.0) + if variant == Variant.EXPRESSIVE: + return TonalPalette.from_hue_and_chroma( + DynamicScheme.get_rotated_hue( + source_color_hct, + [0, 21, 51, 121, 151, 191, 271, 321, 360], + [45, 95, 45, 20, 45, 90, 45, 45, 45], + ), + 24.0, + ) + if variant == Variant.VIBRANT: + return TonalPalette.from_hue_and_chroma( + DynamicScheme.get_rotated_hue( + source_color_hct, + [0, 41, 61, 101, 131, 181, 251, 301, 360], + [18, 15, 10, 12, 15, 18, 15, 12, 12], + ), + 24.0, + ) + raise ValueError(f"Unsupported variant: {variant}") + + def get_tertiary_palette( + self, + variant: Variant, + source_color_hct: Hct, + is_dark: bool, + platform: Platform, + contrast_level: float, + ) -> TonalPalette: + if variant == Variant.CONTENT: + return TonalPalette.from_hct( + DislikeAnalyzer.fix_if_disliked( + TemperatureCache(source_color_hct).analogous(count=3, divisions=6)[ + 2 + ] + ) + ) + if variant == Variant.FIDELITY: + return TonalPalette.from_hct( + DislikeAnalyzer.fix_if_disliked( + TemperatureCache(source_color_hct).complement + ) + ) + if variant == Variant.FRUIT_SALAD: + return TonalPalette.from_hue_and_chroma(source_color_hct.hue, 36.0) + if variant == Variant.MONOCHROME: + return TonalPalette.from_hue_and_chroma(source_color_hct.hue, 0.0) + if variant == Variant.NEUTRAL: + return TonalPalette.from_hue_and_chroma(source_color_hct.hue, 16.0) + if variant == Variant.RAINBOW or variant == Variant.TONAL_SPOT: + return TonalPalette.from_hue_and_chroma( + sanitize_degrees_double(source_color_hct.hue + 60.0), 24.0 + ) + if variant == Variant.EXPRESSIVE: + return TonalPalette.from_hue_and_chroma( + DynamicScheme.get_rotated_hue( + source_color_hct, + [0, 21, 51, 121, 151, 191, 271, 321, 360], + [120, 120, 20, 45, 20, 15, 20, 120, 120], + ), + 32.0, + ) + if variant == Variant.VIBRANT: + return TonalPalette.from_hue_and_chroma( + DynamicScheme.get_rotated_hue( + source_color_hct, + [0, 41, 61, 101, 131, 181, 251, 301, 360], + [35, 30, 20, 25, 30, 35, 30, 25, 25], + ), + 32.0, + ) + raise ValueError(f"Unsupported variant: {variant}") + + def get_neutral_palette( + self, + variant: Variant, + source_color_hct: Hct, + is_dark: bool, + platform: Platform, + contrast_level: float, + ) -> TonalPalette: + if variant == Variant.CONTENT or variant == Variant.FIDELITY: + return TonalPalette.from_hue_and_chroma( + source_color_hct.hue, source_color_hct.chroma / 8.0 + ) + if variant == Variant.FRUIT_SALAD: + return TonalPalette.from_hue_and_chroma(source_color_hct.hue, 10.0) + if variant == Variant.MONOCHROME: + return TonalPalette.from_hue_and_chroma(source_color_hct.hue, 0.0) + if variant == Variant.NEUTRAL: + return TonalPalette.from_hue_and_chroma(source_color_hct.hue, 2.0) + if variant == Variant.RAINBOW: + return TonalPalette.from_hue_and_chroma(source_color_hct.hue, 0.0) + if variant == Variant.TONAL_SPOT: + return TonalPalette.from_hue_and_chroma(source_color_hct.hue, 6.0) + if variant == Variant.EXPRESSIVE: + return TonalPalette.from_hue_and_chroma( + sanitize_degrees_double(source_color_hct.hue + 15), 8 + ) + if variant == Variant.VIBRANT: + return TonalPalette.from_hue_and_chroma(source_color_hct.hue, 10) + raise ValueError(f"Unsupported variant: {variant}") + + def get_neutral_variant_palette( + self, + variant: Variant, + source_color_hct: Hct, + is_dark: bool, + platform: Platform, + contrast_level: float, + ) -> TonalPalette: + if variant == Variant.CONTENT: + return TonalPalette.from_hue_and_chroma( + source_color_hct.hue, (source_color_hct.chroma / 8.0) + 4.0 + ) + if variant == Variant.FIDELITY: + return TonalPalette.from_hue_and_chroma( + source_color_hct.hue, (source_color_hct.chroma / 8.0) + 4.0 + ) + if variant == Variant.FRUIT_SALAD: + return TonalPalette.from_hue_and_chroma(source_color_hct.hue, 16.0) + if variant == Variant.MONOCHROME: + return TonalPalette.from_hue_and_chroma(source_color_hct.hue, 0.0) + if variant == Variant.NEUTRAL: + return TonalPalette.from_hue_and_chroma(source_color_hct.hue, 2.0) + if variant == Variant.RAINBOW: + return TonalPalette.from_hue_and_chroma(source_color_hct.hue, 0.0) + if variant == Variant.TONAL_SPOT: + return TonalPalette.from_hue_and_chroma(source_color_hct.hue, 8.0) + if variant == Variant.EXPRESSIVE: + return TonalPalette.from_hue_and_chroma( + sanitize_degrees_double(source_color_hct.hue + 15), 12 + ) + if variant == Variant.VIBRANT: + return TonalPalette.from_hue_and_chroma(source_color_hct.hue, 12) + raise ValueError(f"Unsupported variant: {variant}") + + def get_error_palette( + self, + variant: Variant, + source_color_hct: Hct, + is_dark: bool, + platform: Platform, + contrast_level: float, + ) -> TonalPalette | None: + return None + + +class DynamicSchemePalettesDelegateImpl2025(DynamicSchemePalettesDelegate): + def get_primary_palette( + self, + variant: Variant, + source_color_hct: Hct, + is_dark: bool, + platform: Platform, + contrast_level: float, + ) -> TonalPalette: + if variant == Variant.NEUTRAL: + return TonalPalette.from_hue_and_chroma( + source_color_hct.hue, + 12 + if platform == "phone" and Hct.is_blue(source_color_hct.hue) + else ( + 8 + if platform == "phone" + else (16 if Hct.is_blue(source_color_hct.hue) else 12) + ), + ) + if variant == Variant.TONAL_SPOT: + return TonalPalette.from_hue_and_chroma( + source_color_hct.hue, 26 if platform == "phone" and is_dark else 32 + ) + if variant == Variant.EXPRESSIVE: + return TonalPalette.from_hue_and_chroma( + source_color_hct.hue, + 36 + if platform == "phone" and is_dark + else (48 if platform == "phone" else 40), + ) + if variant == Variant.VIBRANT: + return TonalPalette.from_hue_and_chroma( + source_color_hct.hue, 74 if platform == "phone" else 56 + ) + return super().get_primary_palette( + variant, source_color_hct, is_dark, platform, contrast_level + ) + + def get_secondary_palette( + self, + variant: Variant, + source_color_hct: Hct, + is_dark: bool, + platform: Platform, + contrast_level: float, + ) -> TonalPalette: + if variant == Variant.NEUTRAL: + return TonalPalette.from_hue_and_chroma( + source_color_hct.hue, + 6 + if platform == "phone" and Hct.is_blue(source_color_hct.hue) + else ( + 4 + if platform == "phone" + else (10 if Hct.is_blue(source_color_hct.hue) else 6) + ), + ) + if variant == Variant.TONAL_SPOT: + return TonalPalette.from_hue_and_chroma(source_color_hct.hue, 16) + if variant == Variant.EXPRESSIVE: + return TonalPalette.from_hue_and_chroma( + DynamicScheme.get_rotated_hue( + source_color_hct, + [0, 105, 140, 204, 253, 278, 300, 333, 360], + [-160, 155, -100, 96, -96, -156, -165, -160], + ), + 16 + if platform == "phone" and is_dark + else (24 if platform == "phone" else 24), + ) + if variant == Variant.VIBRANT: + return TonalPalette.from_hue_and_chroma( + DynamicScheme.get_rotated_hue( + source_color_hct, + [0, 38, 105, 140, 333, 360], + [-14, 10, -14, 10, -14], + ), + 56 if platform == "phone" else 36, + ) + return super().get_secondary_palette( + variant, source_color_hct, is_dark, platform, contrast_level + ) + + def get_tertiary_palette( + self, + variant: Variant, + source_color_hct: Hct, + is_dark: bool, + platform: Platform, + contrast_level: float, + ) -> TonalPalette: + if variant == Variant.NEUTRAL: + return TonalPalette.from_hue_and_chroma( + DynamicScheme.get_rotated_hue( + source_color_hct, + [0, 38, 105, 161, 204, 278, 333, 360], + [-32, 26, 10, -39, 24, -15, -32], + ), + 20 if platform == "phone" else 36, + ) + if variant == Variant.TONAL_SPOT: + return TonalPalette.from_hue_and_chroma( + DynamicScheme.get_rotated_hue( + source_color_hct, + [0, 20, 71, 161, 333, 360], + [-40, 48, -32, 40, -32], + ), + 28 if platform == "phone" else 32, + ) + if variant == Variant.EXPRESSIVE: + return TonalPalette.from_hue_and_chroma( + DynamicScheme.get_rotated_hue( + source_color_hct, + [0, 105, 140, 204, 253, 278, 300, 333, 360], + [-165, 160, -105, 101, -101, -160, -170, -165], + ), + 48, + ) + if variant == Variant.VIBRANT: + return TonalPalette.from_hue_and_chroma( + DynamicScheme.get_rotated_hue( + source_color_hct, + [0, 38, 71, 105, 140, 161, 253, 333, 360], + [-72, 35, 24, -24, 62, 50, 62, -72], + ), + 56, + ) + return super().get_tertiary_palette( + variant, source_color_hct, is_dark, platform, contrast_level + ) + + @staticmethod + def _get_expressive_neutral_hue(source_color_hct: Hct) -> float: + hue = DynamicScheme.get_rotated_hue( + source_color_hct, [0, 71, 124, 253, 278, 300, 360], [10, 0, 10, 0, 10, 0] + ) + return hue + + @staticmethod + def _get_expressive_neutral_chroma( + source_color_hct: Hct, is_dark: bool, platform: Platform + ) -> float: + neutral_hue = DynamicSchemePalettesDelegateImpl2025._get_expressive_neutral_hue( + source_color_hct + ) + return ( + 6 + if platform == "phone" and is_dark and Hct.is_yellow(neutral_hue) + else ( + 14 + if platform == "phone" and is_dark + else (18 if platform == "phone" else 12) + ) + ) + + @staticmethod + def _get_vibrant_neutral_hue(source_color_hct: Hct) -> float: + return DynamicScheme.get_rotated_hue( + source_color_hct, [0, 38, 105, 140, 333, 360], [-14, 10, -14, 10, -14] + ) + + @staticmethod + def _get_vibrant_neutral_chroma(source_color_hct: Hct, platform: Platform) -> float: + neutral_hue = DynamicSchemePalettesDelegateImpl2025._get_vibrant_neutral_hue( + source_color_hct + ) + return 28 if platform == "phone" else (28 if Hct.is_blue(neutral_hue) else 20) + + def get_neutral_palette( + self, + variant: Variant, + source_color_hct: Hct, + is_dark: bool, + platform: Platform, + contrast_level: float, + ) -> TonalPalette: + if variant == Variant.NEUTRAL: + return TonalPalette.from_hue_and_chroma( + source_color_hct.hue, 1.4 if platform == "phone" else 6 + ) + if variant == Variant.TONAL_SPOT: + return TonalPalette.from_hue_and_chroma( + source_color_hct.hue, 5 if platform == "phone" else 10 + ) + if variant == Variant.EXPRESSIVE: + return TonalPalette.from_hue_and_chroma( + DynamicSchemePalettesDelegateImpl2025._get_expressive_neutral_hue( + source_color_hct + ), + DynamicSchemePalettesDelegateImpl2025._get_expressive_neutral_chroma( + source_color_hct, is_dark, platform + ), + ) + if variant == Variant.VIBRANT: + return TonalPalette.from_hue_and_chroma( + DynamicSchemePalettesDelegateImpl2025._get_vibrant_neutral_hue( + source_color_hct + ), + DynamicSchemePalettesDelegateImpl2025._get_vibrant_neutral_chroma( + source_color_hct, platform + ), + ) + return super().get_neutral_palette( + variant, source_color_hct, is_dark, platform, contrast_level + ) + + def get_neutral_variant_palette( + self, + variant: Variant, + source_color_hct: Hct, + is_dark: bool, + platform: Platform, + contrast_level: float, + ) -> TonalPalette: + if variant == Variant.NEUTRAL: + return TonalPalette.from_hue_and_chroma( + source_color_hct.hue, (1.4 if platform == "phone" else 6) * 2.2 + ) + if variant == Variant.TONAL_SPOT: + return TonalPalette.from_hue_and_chroma( + source_color_hct.hue, (5 if platform == "phone" else 10) * 1.7 + ) + if variant == Variant.EXPRESSIVE: + expressive_neutral_hue = ( + DynamicSchemePalettesDelegateImpl2025._get_expressive_neutral_hue( + source_color_hct + ) + ) + expressive_neutral_chroma = ( + DynamicSchemePalettesDelegateImpl2025._get_expressive_neutral_chroma( + source_color_hct, is_dark, platform + ) + ) + return TonalPalette.from_hue_and_chroma( + expressive_neutral_hue, + expressive_neutral_chroma + * ( + 1.6 + if expressive_neutral_hue >= 105 and expressive_neutral_hue < 125 + else 2.3 + ), + ) + if variant == Variant.VIBRANT: + vibrant_neutral_hue = ( + DynamicSchemePalettesDelegateImpl2025._get_vibrant_neutral_hue( + source_color_hct + ) + ) + vibrant_neutral_chroma = ( + DynamicSchemePalettesDelegateImpl2025._get_vibrant_neutral_chroma( + source_color_hct, platform + ) + ) + return TonalPalette.from_hue_and_chroma( + vibrant_neutral_hue, vibrant_neutral_chroma * 1.29 + ) + return super().get_neutral_variant_palette( + variant, source_color_hct, is_dark, platform, contrast_level + ) + + def get_error_palette( + self, + variant: Variant, + source_color_hct: Hct, + is_dark: bool, + platform: Platform, + contrast_level: float, + ) -> TonalPalette | None: + error_hue = DynamicScheme.get_piecewise_hue( + source_color_hct, + [0, 3, 13, 23, 33, 43, 153, 273, 360], + [12, 22, 32, 12, 22, 32, 22, 12], + ) + if variant == Variant.NEUTRAL: + return TonalPalette.from_hue_and_chroma( + error_hue, 50 if platform == "phone" else 40 + ) + if variant == Variant.TONAL_SPOT: + return TonalPalette.from_hue_and_chroma( + error_hue, 60 if platform == "phone" else 48 + ) + if variant == Variant.EXPRESSIVE: + return TonalPalette.from_hue_and_chroma( + error_hue, 64 if platform == "phone" else 48 + ) + if variant == Variant.VIBRANT: + return TonalPalette.from_hue_and_chroma( + error_hue, 80 if platform == "phone" else 60 + ) + return super().get_error_palette( + variant, source_color_hct, is_dark, platform, contrast_level + ) + + +_spec2021 = DynamicSchemePalettesDelegateImpl2021() +_spec2025 = DynamicSchemePalettesDelegateImpl2025() + + +def get_spec(spec_version: SpecVersion) -> DynamicSchemePalettesDelegate: + return _spec2025 if spec_version == "2025" else _spec2021 diff --git a/materialyoucolor/dynamiccolor/material_dynamic_colors.py b/materialyoucolor/dynamiccolor/material_dynamic_colors.py index 4f62bcc..c51ee37 100644 --- a/materialyoucolor/dynamiccolor/material_dynamic_colors.py +++ b/materialyoucolor/dynamiccolor/material_dynamic_colors.py @@ -1,766 +1,29 @@ -from materialyoucolor.hct import Hct -from materialyoucolor.scheme.dynamic_scheme import DynamicScheme -from materialyoucolor.scheme.variant import Variant -from materialyoucolor.dynamiccolor.contrast_curve import ContrastCurve -from materialyoucolor.dynamiccolor.tone_delta_pair import ToneDeltaPair -from materialyoucolor.dynamiccolor.dynamic_color import FromPaletteOptions, DynamicColor -from materialyoucolor.dislike.dislike_analyzer import DislikeAnalyzer +from __future__ import annotations +from typing import List, Optional -def is_fidelity(scheme: DynamicScheme) -> bool: - return scheme.variant == Variant.FIDELITY or scheme.variant == Variant.CONTENT - - -def is_monochrome(scheme: DynamicScheme) -> bool: - return scheme.variant == Variant.MONOCHROME - - -def find_desired_chroma_by_tone( - hue: float, chroma: float, tone: float, by_decreasing_tone: float -) -> float: - answer = tone - - closest_to_chroma = Hct.from_hct(hue, chroma, tone) - if closest_to_chroma.chroma < chroma: - chroma_peak = closest_to_chroma.chroma - while closest_to_chroma.chroma < chroma: - answer += -1.0 if by_decreasing_tone else 1.0 - potential_solution = Hct.from_hct(hue, chroma, answer) - if chroma_peak > potential_solution.chroma: - break - if abs(potential_solution.chroma - chroma) < 0.4: - break - - potential_delta = abs(potential_solution.chroma - chroma) - current_delta = abs(closest_to_chroma.chroma - chroma) - if potential_delta < current_delta: - closest_to_chroma = potential_solution - chroma_peak = max(chroma_peak, potential_solution.chroma) - - return answer - - -def secondary_container_tone(s): - initial_tone = 30 if s.is_dark else 90 - if is_monochrome(s): - return 30 if s.is_dark else 85 - if is_fidelity(s): - return initial_tone - return find_desired_chroma_by_tone( - s.secondary_palette.hue, - s.secondary_palette.chroma, - initial_tone, - False if s.is_dark else True, - ) - - -def on_secondary_container_tone(s): - if is_monochrome(s): - return 90 if s.is_dark else 10 - if not is_fidelity(s): - return 90 if s.is_dark else 30 - return DynamicColor.foreground_tone( - MaterialDynamicColors.secondaryContainer.tone(s), 4.5 - ) - - -def tertiary_container_tone(s): - if not is_monochrome(s): - return 60 if s.is_dark else 49 - if not is_fidelity(s): - return 30 if s.is_dark else 90 - proposed_hct = s.tertiary_palette.get_hct(s.source_color_hct.tone) - return DislikeAnalyzer.fix_if_disliked(proposed_hct).tone - - -def on_tertiary_container_tone(s): - if not is_monochrome(s): - return 0 if s.is_dark else 100 - if not is_fidelity(s): - return 90 if s.is_dark else 30 - return DynamicColor.foreground_tone( - MaterialDynamicColors.tertiaryContainer.tone(s), 4.5 - ) +from materialyoucolor.dynamiccolor.color_spec import ( + COLOR_NAMES, + ColorSpecDelegate, + get_spec, +) +from materialyoucolor.dynamiccolor.dynamic_color import DynamicColor +from materialyoucolor.dynamiccolor.dynamic_scheme import DynamicScheme class MaterialDynamicColors: content_accent_tone_delta = 15.0 - @staticmethod - def highestSurface(s: DynamicScheme) -> DynamicColor: - return ( - MaterialDynamicColors.surfaceBright - if s.is_dark - else MaterialDynamicColors.surfaceDim - ) + def __init__(self, spec="2025"): + self.color_spec: ColorSpecDelegate = get_spec(spec) - primary_paletteKeyColor = DynamicColor.from_palette( - FromPaletteOptions( - name="primary_palette_key_color", - palette=lambda s: s.primary_palette, - tone=lambda s: s.primary_palette.key_color.tone, + # define them dynamically + for color_name in COLOR_NAMES: + exec( + f"@property\ndef {color_name}(self) -> DynamicColor:\n\treturn self.color_spec.{color_name}()" ) - ) - secondary_paletteKeyColor = DynamicColor.from_palette( - FromPaletteOptions( - name="secondary_palette_key_color", - palette=lambda s: s.secondary_palette, - tone=lambda s: s.secondary_palette.key_color.tone, - ) - ) - - tertiary_paletteKeyColor = DynamicColor.from_palette( - FromPaletteOptions( - name="tertiary_palette_key_color", - palette=lambda s: s.tertiary_palette, - tone=lambda s: s.tertiary_palette.key_color.tone, - ) - ) - - neutral_paletteKeyColor = DynamicColor.from_palette( - FromPaletteOptions( - name="neutral_palette_key_color", - palette=lambda s: s.neutral_palette, - tone=lambda s: s.neutral_palette.key_color.tone, - ) - ) - - neutral_variant_paletteKeyColor = DynamicColor.from_palette( - FromPaletteOptions( - name="neutral_variant_palette_key_color", - palette=lambda s: s.neutral_variant_palette, - tone=lambda s: s.neutral_variant_palette.key_color.tone, - ) - ) - - background = DynamicColor.from_palette( - FromPaletteOptions( - name="background", - palette=lambda s: s.neutral_palette, - tone=lambda s: 6 if s.is_dark else 98, - is_background=True, - ) - ) - - onBackground = DynamicColor.from_palette( - FromPaletteOptions( - name="on_background", - palette=lambda s: s.neutral_palette, - tone=lambda s: 90 if s.is_dark else 10, - background=lambda s: MaterialDynamicColors.background, - contrast_curve=ContrastCurve(3, 3, 4.5, 7), - ) - ) - - surface = DynamicColor.from_palette( - FromPaletteOptions( - name="surface", - palette=lambda s: s.neutral_palette, - tone=lambda s: 6 if s.is_dark else 98, - is_background=True, - ) - ) - - surfaceDim = DynamicColor.from_palette( - FromPaletteOptions( - name="surface_dim", - palette=lambda s: s.neutral_palette, - tone=lambda s: ( - 6 if s.is_dark else ContrastCurve(87, 87, 80, 75).get(s.contrast_level) - ), - is_background=True, - ) - ) - - surfaceBright = DynamicColor.from_palette( - FromPaletteOptions( - name="surface_bright", - palette=lambda s: s.neutral_palette, - tone=lambda s: ( - ContrastCurve(24, 24, 29, 34).get(s.contrast_level) if s.is_dark else 98 - ), - is_background=True, - ) - ) - - surfaceContainerLowest = DynamicColor.from_palette( - FromPaletteOptions( - name="surface_container_lowest", - palette=lambda s: s.neutral_palette, - tone=lambda s: ( - ContrastCurve(4, 4, 2, 0).get(s.contrast_level) if s.is_dark else 100 - ), - is_background=True, - ) - ) - - surfaceContainerLow = DynamicColor.from_palette( - FromPaletteOptions( - name="surface_container_low", - palette=lambda s: s.neutral_palette, - tone=lambda s: ( - ContrastCurve(10, 10, 11, 12).get(s.contrast_level) - if s.is_dark - else ContrastCurve(96, 96, 96, 95).get(s.contrast_level) - ), - is_background=True, - ) - ) - - surfaceContainer = DynamicColor.from_palette( - FromPaletteOptions( - name="surface_container", - palette=lambda s: s.neutral_palette, - tone=lambda s: ( - ContrastCurve(12, 12, 16, 20).get(s.contrast_level) - if s.is_dark - else ContrastCurve(94, 94, 92, 90).get(s.contrast_level) - ), - is_background=True, - ) - ) - - surfaceContainerHigh = DynamicColor.from_palette( - FromPaletteOptions( - name="surface_container_high", - palette=lambda s: s.neutral_palette, - tone=lambda s: ( - ContrastCurve(17, 17, 21, 25).get(s.contrast_level) - if s.is_dark - else ContrastCurve(92, 92, 88, 85).get(s.contrast_level) - ), - is_background=True, - ) - ) - - surfaceContainerHighest = DynamicColor.from_palette( - FromPaletteOptions( - name="surface_container_highest", - palette=lambda s: s.neutral_palette, - tone=lambda s: ( - ContrastCurve(22, 22, 26, 30).get(s.contrast_level) - if s.is_dark - else ContrastCurve(90, 90, 84, 80).get(s.contrast_level) - ), - is_background=True, - ) - ) - - onSurface = DynamicColor.from_palette( - FromPaletteOptions( - name="on_surface", - palette=lambda s: s.neutral_palette, - tone=lambda s: 90 if s.is_dark else 10, - background=lambda s: MaterialDynamicColors.highestSurface(s), - contrast_curve=ContrastCurve(4.5, 7, 11, 21), - ) - ) - - surfaceVariant = DynamicColor.from_palette( - FromPaletteOptions( - name="surface_variant", - palette=lambda s: s.neutral_variant_palette, - tone=lambda s: 30 if s.is_dark else 90, - is_background=True, - ) - ) - - onSurfaceVariant = DynamicColor.from_palette( - FromPaletteOptions( - name="on_surface_variant", - palette=lambda s: s.neutral_variant_palette, - tone=lambda s: 80 if s.is_dark else 30, - background=lambda s: MaterialDynamicColors.highestSurface(s), - contrast_curve=ContrastCurve(3, 4.5, 7, 11), - ) - ) - - inverseSurface = DynamicColor.from_palette( - FromPaletteOptions( - name="inverse_surface", - palette=lambda s: s.neutral_palette, - tone=lambda s: 90 if s.is_dark else 20, - ) - ) - - inverseOnSurface = DynamicColor.from_palette( - FromPaletteOptions( - name="inverse_on_surface", - palette=lambda s: s.neutral_palette, - tone=lambda s: 20 if s.is_dark else 95, - background=lambda s: MaterialDynamicColors.inverseSurface, - contrast_curve=ContrastCurve(4.5, 7, 11, 21), - ) - ) - - outline = DynamicColor.from_palette( - FromPaletteOptions( - name="outline", - palette=lambda s: s.neutral_variant_palette, - tone=lambda s: 60 if s.is_dark else 50, - background=lambda s: MaterialDynamicColors.highestSurface(s), - contrast_curve=ContrastCurve(1.5, 3, 4.5, 7), - ) - ) - - outlineVariant = DynamicColor.from_palette( - FromPaletteOptions( - name="outline_variant", - palette=lambda s: s.neutral_variant_palette, - tone=lambda s: 30 if s.is_dark else 80, - background=lambda s: MaterialDynamicColors.highestSurface(s), - contrast_curve=ContrastCurve(1, 1, 3, 4.5), - ) - ) - - shadow = DynamicColor.from_palette( - FromPaletteOptions( - name="shadow", - palette=lambda s: s.neutral_palette, - tone=lambda s: 0, - ) - ) - - scrim = DynamicColor.from_palette( - FromPaletteOptions( - name="scrim", - palette=lambda s: s.neutral_palette, - tone=lambda s: 0, - ) - ) - - surfaceTint = DynamicColor.from_palette( - FromPaletteOptions( - name="surface_tint", - palette=lambda s: s.primary_palette, - tone=lambda s: 80 if s.is_dark else 40, - is_background=True, - ) - ) - - primary = DynamicColor.from_palette( - FromPaletteOptions( - name="primary", - palette=lambda s: s.primary_palette, - tone=lambda s: 100 if is_monochrome(s) else (80 if s.is_dark else 40), - is_background=True, - background=lambda s: MaterialDynamicColors.highestSurface(s), - contrast_curve=ContrastCurve(3, 4.5, 7, 7), - tone_delta_pair=lambda s: ToneDeltaPair( - MaterialDynamicColors.primaryContainer, - MaterialDynamicColors.primary, - 10, - "nearer", - False, - ), - ) - ) - - onPrimary = DynamicColor.from_palette( - FromPaletteOptions( - name="on_primary", - palette=lambda s: s.primary_palette, - tone=lambda s: 10 if is_monochrome(s) else (20 if s.is_dark else 100), - background=lambda s: MaterialDynamicColors.primary, - contrast_curve=ContrastCurve(4.5, 7, 11, 21), - ) - ) - - primaryContainer = DynamicColor.from_palette( - FromPaletteOptions( - name="primary_container", - palette=lambda s: s.primary_palette, - tone=lambda s: ( - s.source_color_hct.tone - if is_fidelity(s) - else (85 if is_monochrome(s) else (30 if s.is_dark else 90)) - ), - is_background=True, - background=lambda s: MaterialDynamicColors.highestSurface(s), - contrast_curve=ContrastCurve(1, 1, 3, 4.5), - tone_delta_pair=lambda s: ToneDeltaPair( - MaterialDynamicColors.primaryContainer, - MaterialDynamicColors.primary, - 10, - "nearer", - False, - ), - ) - ) - - onPrimaryContainer = DynamicColor.from_palette( - FromPaletteOptions( - name="on_primary_container", - palette=lambda s: s.primary_palette, - tone=lambda s: ( - DynamicColor.foreground_tone( - MaterialDynamicColors.primaryContainer.tone(s), 4.5 - ) - if is_fidelity(s) - else (0 if is_monochrome(s) else (90 if s.is_dark else 30)) - ), - background=lambda s: MaterialDynamicColors.primaryContainer, - contrast_curve=ContrastCurve(3, 4.5, 7, 11), - ) - ) - - inversePrimary = DynamicColor.from_palette( - FromPaletteOptions( - name="inverse_primary", - palette=lambda s: s.primary_palette, - tone=lambda s: 40 if s.is_dark else 80, - background=lambda s: MaterialDynamicColors.inverseSurface, - contrast_curve=ContrastCurve(3, 4.5, 7, 7), - ) - ) - - secondary = DynamicColor.from_palette( - FromPaletteOptions( - name="secondary", - palette=lambda s: s.secondary_palette, - tone=lambda s: 80 if s.is_dark else 40, - is_background=True, - background=lambda s: MaterialDynamicColors.highestSurface(s), - contrast_curve=ContrastCurve(3, 4.5, 7, 7), - tone_delta_pair=lambda s: ToneDeltaPair( - MaterialDynamicColors.secondaryContainer, - MaterialDynamicColors.secondary, - 10, - "nearer", - False, - ), - ) - ) - - onSecondary = DynamicColor.from_palette( - FromPaletteOptions( - name="on_secondary", - palette=lambda s: s.secondary_palette, - tone=lambda s: 10 if is_monochrome(s) else (20 if s.is_dark else 100), - background=lambda s: MaterialDynamicColors.secondary, - contrast_curve=ContrastCurve(4.5, 7, 11, 21), - ) - ) - - secondaryContainer = DynamicColor.from_palette( - FromPaletteOptions( - name="secondary_container", - palette=lambda s: s.secondary_palette, - tone=lambda s: secondary_container_tone(s), - is_background=True, - background=lambda s: MaterialDynamicColors.highestSurface(s), - contrast_curve=ContrastCurve(1, 1, 3, 4.5), - tone_delta_pair=lambda s: ToneDeltaPair( - MaterialDynamicColors.secondaryContainer, - MaterialDynamicColors.secondary, - 10, - "nearer", - False, - ), - ) - ) - - onSecondaryContainer = DynamicColor.from_palette( - FromPaletteOptions( - name="on_secondary_container", - palette=lambda s: s.secondary_palette, - tone=lambda s: on_secondary_container_tone(s), - background=lambda s: MaterialDynamicColors.secondaryContainer, - contrast_curve=ContrastCurve(3.0, 4.5, 7, 11), - ) - ) - - tertiary = DynamicColor.from_palette( - FromPaletteOptions( - name="tertiary", - palette=lambda s: s.tertiary_palette, - tone=lambda s: ( - (90 if s.is_dark else 25) - if is_monochrome(s) - else (80 if s.is_dark else 40) - ), - is_background=True, - background=lambda s: MaterialDynamicColors.highestSurface(s), - contrast_curve=ContrastCurve(3, 4.5, 7, 7), - tone_delta_pair=lambda s: ToneDeltaPair( - MaterialDynamicColors.tertiaryContainer, - MaterialDynamicColors.tertiary, - 10, - "nearer", - False, - ), - ) - ) - - onTertiary = DynamicColor.from_palette( - FromPaletteOptions( - name="on_tertiary", - palette=lambda s: s.tertiary_palette, - tone=lambda s: ( - (10 if s.is_dark else 90) - if is_monochrome(s) - else (20 if s.is_dark else 100) - ), - background=lambda s: MaterialDynamicColors.tertiary, - contrast_curve=ContrastCurve(4.5, 7, 11, 21), - ) - ) - - tertiaryContainer = DynamicColor.from_palette( - FromPaletteOptions( - name="tertiary_container", - palette=lambda s: s.tertiary_palette, - tone=lambda s: tertiary_container_tone(s), - is_background=True, - background=lambda s: MaterialDynamicColors.highestSurface(s), - contrast_curve=ContrastCurve(1, 1, 3, 4.5), - tone_delta_pair=lambda s: ToneDeltaPair( - MaterialDynamicColors.tertiaryContainer, - MaterialDynamicColors.tertiary, - 10, - "nearer", - False, - ), - ) - ) - - onTertiaryContainer = DynamicColor.from_palette( - FromPaletteOptions( - name="on_tertiary_container", - palette=lambda s: s.tertiary_palette, - tone=lambda s: on_tertiary_container_tone(s), - background=lambda s: MaterialDynamicColors.tertiaryContainer, - contrast_curve=ContrastCurve(3.0, 4.5, 7, 11), - ) - ) - - error = DynamicColor.from_palette( - FromPaletteOptions( - name="error", - palette=lambda s: s.error_palette, - tone=lambda s: 80 if s.is_dark else 40, - is_background=True, - background=lambda s: MaterialDynamicColors.highestSurface(s), - contrast_curve=ContrastCurve(3, 4.5, 7, 7), - tone_delta_pair=lambda s: ToneDeltaPair( - MaterialDynamicColors.errorContainer, - MaterialDynamicColors.error, - 10, - "nearer", - False, - ), - ) - ) - - onError = DynamicColor.from_palette( - FromPaletteOptions( - name="on_error", - palette=lambda s: s.error_palette, - tone=lambda s: 20 if s.is_dark else 100, - background=lambda s: MaterialDynamicColors.error, - contrast_curve=ContrastCurve(4.5, 7, 11, 21), - ) - ) - - errorContainer = DynamicColor.from_palette( - FromPaletteOptions( - name="error_container", - palette=lambda s: s.error_palette, - tone=lambda s: 30 if s.is_dark else 90, - is_background=True, - background=lambda s: MaterialDynamicColors.highestSurface(s), - contrast_curve=ContrastCurve(1, 1, 3, 4.5), - tone_delta_pair=lambda s: ToneDeltaPair( - MaterialDynamicColors.errorContainer, - MaterialDynamicColors.error, - 10, - "nearer", - False, - ), - ) - ) - - onErrorContainer = DynamicColor.from_palette( - FromPaletteOptions( - name="on_error_container", - palette=lambda s: s.error_palette, - tone=lambda s: 90 if s.is_dark else (10 if is_monochrome(s) else 30), - background=lambda s: MaterialDynamicColors.errorContainer, - contrast_curve=ContrastCurve(3.0, 4.5, 7, 11), - ) - ) - - primaryFixed = DynamicColor.from_palette( - FromPaletteOptions( - name="primary_fixed", - palette=lambda s: s.primary_palette, - tone=lambda s: 40.0 if is_monochrome(s) else 90.0, - is_background=True, - background=lambda s: MaterialDynamicColors.highestSurface(s), - contrast_curve=ContrastCurve(1, 1, 3, 4.5), - tone_delta_pair=lambda s: ToneDeltaPair( - MaterialDynamicColors.primaryFixed, - MaterialDynamicColors.primaryFixedDim, - 10, - "lighter", - True, - ), - ) - ) - - primaryFixedDim = DynamicColor.from_palette( - FromPaletteOptions( - name="primary_fixed_dim", - palette=lambda s: s.primary_palette, - tone=lambda s: 30.0 if is_monochrome(s) else 80.0, - is_background=True, - background=lambda s: MaterialDynamicColors.highestSurface(s), - contrast_curve=ContrastCurve(1, 1, 3, 4.5), - tone_delta_pair=lambda s: ToneDeltaPair( - MaterialDynamicColors.primaryFixed, - MaterialDynamicColors.primaryFixedDim, - 10, - "lighter", - True, - ), - ) - ) - - onPrimaryFixed = DynamicColor.from_palette( - FromPaletteOptions( - name="on_primary_fixed", - palette=lambda s: s.primary_palette, - tone=lambda s: 100.0 if is_monochrome(s) else 10.0, - background=lambda s: MaterialDynamicColors.primaryFixedDim, - second_background=lambda s: MaterialDynamicColors.primaryFixed, - contrast_curve=ContrastCurve(4.5, 7, 11, 21), - ) - ) - - onPrimaryFixedVariant = DynamicColor.from_palette( - FromPaletteOptions( - name="on_primary_fixed_variant", - palette=lambda s: s.primary_palette, - tone=lambda s: 90.0 if is_monochrome(s) else 30.0, - background=lambda s: MaterialDynamicColors.primaryFixedDim, - second_background=lambda s: MaterialDynamicColors.primaryFixed, - contrast_curve=ContrastCurve(3, 4.5, 7, 11), - ) - ) - - secondaryFixed = DynamicColor.from_palette( - FromPaletteOptions( - name="secondary_fixed", - palette=lambda s: s.secondary_palette, - tone=lambda s: 80.0 if is_monochrome(s) else 90.0, - is_background=True, - background=lambda s: MaterialDynamicColors.highestSurface(s), - contrast_curve=ContrastCurve(1, 1, 3, 4.5), - tone_delta_pair=lambda s: ToneDeltaPair( - MaterialDynamicColors.secondaryFixed, - MaterialDynamicColors.secondaryFixedDim, - 10, - "lighter", - True, - ), - ) - ) - - secondaryFixedDim = DynamicColor.from_palette( - FromPaletteOptions( - name="secondary_fixed_dim", - palette=lambda s: s.secondary_palette, - tone=lambda s: 70.0 if is_monochrome(s) else 80.0, - is_background=True, - background=lambda s: MaterialDynamicColors.highestSurface(s), - contrast_curve=ContrastCurve(1, 1, 3, 4.5), - tone_delta_pair=lambda s: ToneDeltaPair( - MaterialDynamicColors.secondaryFixed, - MaterialDynamicColors.secondaryFixedDim, - 10, - "lighter", - True, - ), - ) - ) - - onSecondaryFixed = DynamicColor.from_palette( - FromPaletteOptions( - name="on_secondary_fixed", - palette=lambda s: s.secondary_palette, - tone=lambda s: 10.0, - background=lambda s: MaterialDynamicColors.secondaryFixedDim, - second_background=lambda s: MaterialDynamicColors.secondaryFixed, - contrast_curve=ContrastCurve(4.5, 7, 11, 21), - ) - ) - - onSecondaryFixedVariant = DynamicColor.from_palette( - FromPaletteOptions( - name="on_secondary_fixed_variant", - palette=lambda s: s.secondary_palette, - tone=lambda s: 25.0 if is_monochrome(s) else 30.0, - background=lambda s: MaterialDynamicColors.secondaryFixedDim, - second_background=lambda s: MaterialDynamicColors.secondaryFixed, - contrast_curve=ContrastCurve(3, 4.5, 7, 11), - ) - ) - - tertiaryFixed = DynamicColor.from_palette( - FromPaletteOptions( - name="tertiary_fixed", - palette=lambda s: s.tertiary_palette, - tone=lambda s: 40.0 if is_monochrome(s) else 90.0, - is_background=True, - background=lambda s: MaterialDynamicColors.highestSurface(s), - contrast_curve=ContrastCurve(1, 1, 3, 4.5), - tone_delta_pair=lambda s: ToneDeltaPair( - MaterialDynamicColors.tertiaryFixed, - MaterialDynamicColors.tertiaryFixedDim, - 10, - "lighter", - True, - ), - ) - ) - - tertiaryFixedDim = DynamicColor.from_palette( - FromPaletteOptions( - name="tertiary_fixed_dim", - palette=lambda s: s.tertiary_palette, - tone=lambda s: 30.0 if is_monochrome(s) else 80.0, - is_background=True, - background=lambda s: MaterialDynamicColors.highestSurface(s), - contrast_curve=ContrastCurve(1, 1, 3, 4.5), - tone_delta_pair=lambda s: ToneDeltaPair( - MaterialDynamicColors.tertiaryFixed, - MaterialDynamicColors.tertiaryFixedDim, - 10, - "lighter", - True, - ), - ) - ) - - onTertiaryFixed = DynamicColor.from_palette( - FromPaletteOptions( - name="on_tertiary_fixed", - palette=lambda s: s.tertiary_palette, - tone=lambda s: 100.0 if is_monochrome(s) else 10.0, - background=lambda s: MaterialDynamicColors.tertiaryFixedDim, - second_background=lambda s: MaterialDynamicColors.tertiaryFixed, - contrast_curve=ContrastCurve(4.5, 7, 11, 21), - ) - ) - - onTertiaryFixedVariant = DynamicColor.from_palette( - FromPaletteOptions( - name="on_tertiary_fixed_variant", - palette=lambda s: s.tertiary_palette, - tone=lambda s: 90.0 if is_monochrome(s) else 30.0, - background=lambda s: MaterialDynamicColors.tertiaryFixedDim, - second_background=lambda s: MaterialDynamicColors.tertiaryFixed, - contrast_curve=ContrastCurve(3, 4.5, 7, 11), - ) - ) + @property + def all_colors(self) -> List[DynamicColor]: + colors = [getattr(self, _) for _ in COLOR_NAMES] + return [c for c in colors if c is not None] diff --git a/materialyoucolor/dynamiccolor/tone_delta_pair.py b/materialyoucolor/dynamiccolor/tone_delta_pair.py index 112592a..65ddff9 100644 --- a/materialyoucolor/dynamiccolor/tone_delta_pair.py +++ b/materialyoucolor/dynamiccolor/tone_delta_pair.py @@ -1,19 +1,24 @@ -from typing import Union +from typing import Literal, Optional -TonePolarity = Union["darker", "lighter", "nearer", "farther"] +TonePolarity = Literal[ + "darker", "lighter", "nearer", "farther", "relative_darker", "relative_lighter" +] +DeltaConstraint = Literal["exact", "nearer", "farther"] class ToneDeltaPair: def __init__( self, - role_a: None, # DynamicColor, - role_b: None, # DynamicColor, - delta: int, + role_a: "DynamicColor", + role_b: "DynamicColor", + delta: float, polarity: TonePolarity, stay_together: bool, + constraint: Optional[DeltaConstraint] = "exact", ): self.role_a = role_a self.role_b = role_b self.delta = delta self.polarity = polarity self.stay_together = stay_together + self.constraint = constraint diff --git a/materialyoucolor/dynamiccolor/variant.py b/materialyoucolor/dynamiccolor/variant.py new file mode 100644 index 0000000..7349a28 --- /dev/null +++ b/materialyoucolor/dynamiccolor/variant.py @@ -0,0 +1,13 @@ +from enum import Enum + + +class Variant(Enum): + MONOCHROME = 1 + NEUTRAL = 2 + TONAL_SPOT = 3 + VIBRANT = 4 + EXPRESSIVE = 5 + FIDELITY = 6 + CONTENT = 7 + RAINBOW = 8 + FRUIT_SALAD = 9 diff --git a/materialyoucolor/hct/cam16.py b/materialyoucolor/hct/cam16.py index aa13a9f..7b4e177 100644 --- a/materialyoucolor/hct/cam16.py +++ b/materialyoucolor/hct/cam16.py @@ -1,11 +1,23 @@ import math -from materialyoucolor.utils.math_utils import signum -from materialyoucolor.utils.color_utils import linearized, argb_from_xyz + from materialyoucolor.hct.viewing_conditions import ViewingConditions +from materialyoucolor.utils.color_utils import argb_from_xyz, linearized +from materialyoucolor.utils.math_utils import sanitize_degrees_double, signum class Cam16: - def __init__(self, hue, chroma, j, q, m, s, jstar, astar, bstar): + def __init__( + self, + hue: float, + chroma: float, + j: float, + q: float, + m: float, + s: float, + jstar: float, + astar: float, + bstar: float, + ): self.hue = hue self.chroma = chroma self.j = j @@ -16,32 +28,37 @@ def __init__(self, hue, chroma, j, q, m, s, jstar, astar, bstar): self.astar = astar self.bstar = bstar - def distance(self, other_cam16) -> float: - d_j = self.jstar - other_cam16.jstar - d_a = self.astar - other_cam16.astar - d_b = self.bstar - other_cam16.bstar + def distance(self, other: "Cam16") -> float: + d_j = self.jstar - other.jstar + d_a = self.astar - other.astar + d_b = self.bstar - other.bstar d_e_prime = math.sqrt(d_j * d_j + d_a * d_a + d_b * d_b) - return 1.41 * pow(d_e_prime, 0.63) + d_e = 1.41 * math.pow(d_e_prime, 0.63) + return d_e @staticmethod - def from_int(argb: int): - return Cam16.from_int_in_viewing_conditions(argb, ViewingConditions.DEFAULT()) + def from_int(argb: int) -> "Cam16": + return Cam16.from_int_in_viewing_conditions(argb, ViewingConditions.DEFAULT) @staticmethod def from_int_in_viewing_conditions( argb: int, viewing_conditions: ViewingConditions - ): + ) -> "Cam16": red = (argb & 0x00FF0000) >> 16 green = (argb & 0x0000FF00) >> 8 blue = argb & 0x000000FF red_l = linearized(red) green_l = linearized(green) blue_l = linearized(blue) - x = 0.41233895 * red_l + 0.35762064 * green_l + 0.18051042 * blue_l y = 0.2126 * red_l + 0.7152 * green_l + 0.0722 * blue_l z = 0.01932141 * red_l + 0.11916382 * green_l + 0.95034478 * blue_l + return Cam16.from_xyz_in_viewing_conditions(x, y, z, viewing_conditions) + @staticmethod + def from_xyz_in_viewing_conditions( + x: float, y: float, z: float, viewing_conditions: ViewingConditions + ) -> "Cam16": r_c = 0.401288 * x + 0.650173 * y - 0.051461 * z g_c = -0.250268 * x + 1.204414 * y + 0.045854 * z b_c = -0.002079 * x + 0.048952 * y + 0.953127 * z @@ -50,9 +67,9 @@ def from_int_in_viewing_conditions( g_d = viewing_conditions.rgb_d[1] * g_c b_d = viewing_conditions.rgb_d[2] * b_c - r_af = pow((viewing_conditions.fl * abs(r_d)) / 100.0, 0.42) - g_af = pow((viewing_conditions.fl * abs(g_d)) / 100.0, 0.42) - b_af = pow((viewing_conditions.fl * abs(b_d)) / 100.0, 0.42) + r_af = math.pow((viewing_conditions.fl * abs(r_d)) / 100.0, 0.42) + g_af = math.pow((viewing_conditions.fl * abs(g_d)) / 100.0, 0.42) + b_af = math.pow((viewing_conditions.fl * abs(b_d)) / 100.0, 0.42) r_a = (signum(r_d) * 400.0 * r_af) / (r_af + 27.13) g_a = (signum(g_d) * 400.0 * g_af) / (g_af + 27.13) @@ -64,15 +81,11 @@ def from_int_in_viewing_conditions( p2 = (40.0 * r_a + 20.0 * g_a + b_a) / 20.0 atan2 = math.atan2(b, a) atan_degrees = (atan2 * 180.0) / math.pi - hue = ( - atan_degrees + 360.0 - if atan_degrees < 0 - else atan_degrees if atan_degrees >= 360 else atan_degrees - ) + hue = sanitize_degrees_double(atan_degrees) hue_radians = (hue * math.pi) / 180.0 ac = p2 * viewing_conditions.nbb - j = 100.0 * pow( + j = 100.0 * math.pow( ac / viewing_conditions.aw, viewing_conditions.c * viewing_conditions.z ) q = ( @@ -85,29 +98,29 @@ def from_int_in_viewing_conditions( e_hue = 0.25 * (math.cos((hue_prime * math.pi) / 180.0 + 2.0) + 3.8) p1 = (50000.0 / 13.0) * e_hue * viewing_conditions.nc * viewing_conditions.ncb t = (p1 * math.sqrt(a * a + b * b)) / (u + 0.305) - alpha = pow(t, 0.9) * pow(1.64 - pow(0.29, viewing_conditions.n), 0.73) + alpha = math.pow(t, 0.9) * math.pow( + 1.64 - math.pow(0.29, viewing_conditions.n), 0.73 + ) c = alpha * math.sqrt(j / 100.0) m = c * viewing_conditions.f_l_root s = 50.0 * math.sqrt( (alpha * viewing_conditions.c) / (viewing_conditions.aw + 4.0) ) - j_star = ((1.0 + 100.0 * 0.007) * j) / (1.0 + 0.007 * j) - m_star = (1.0 / 0.0228) * math.log(1.0 + 0.0228 * m) - a_star = m_star * math.cos(hue_radians) - b_star = m_star * math.sin(hue_radians) + jstar = ((1.0 + 100.0 * 0.007) * j) / (1.0 + 0.007 * j) + mstar = (1.0 / 0.0228) * math.log(1.0 + 0.0228 * m) + astar = mstar * math.cos(hue_radians) + bstar = mstar * math.sin(hue_radians) - return Cam16(hue, c, j, q, m, s, j_star, a_star, b_star) + return Cam16(hue, c, j, q, m, s, jstar, astar, bstar) @staticmethod - def from_jch(j: float, c: float, h: float): - return Cam16.from_jch_in_viewing_conditions( - j, c, h, ViewingConditions.DEFAULT() - ) + def from_jch(j: float, c: float, h: float) -> "Cam16": + return Cam16.from_jch_in_viewing_conditions(j, c, h, ViewingConditions.DEFAULT) @staticmethod def from_jch_in_viewing_conditions( j: float, c: float, h: float, viewing_conditions: ViewingConditions - ): + ) -> "Cam16": q = ( (4.0 / viewing_conditions.c) * math.sqrt(j / 100.0) @@ -120,22 +133,22 @@ def from_jch_in_viewing_conditions( (alpha * viewing_conditions.c) / (viewing_conditions.aw + 4.0) ) hue_radians = (h * math.pi) / 180.0 - j_star = ((1.0 + 100.0 * 0.007) * j) / (1.0 + 0.007 * j) - m_star = (1.0 / 0.0228) * math.log(1.0 + 0.0228 * m) - a_star = m_star * math.cos(hue_radians) - b_star = m_star * math.sin(hue_radians) - return Cam16(h, c, j, q, m, s, j_star, a_star, b_star) + jstar = ((1.0 + 100.0 * 0.007) * j) / (1.0 + 0.007 * j) + mstar = (1.0 / 0.0228) * math.log(1.0 + 0.0228 * m) + astar = mstar * math.cos(hue_radians) + bstar = mstar * math.sin(hue_radians) + return Cam16(h, c, j, q, m, s, jstar, astar, bstar) @staticmethod - def from_ucs(jstar: float, astar: float, bstar: float): + def from_ucs(jstar: float, astar: float, bstar: float) -> "Cam16": return Cam16.from_ucs_in_viewing_conditions( - jstar, astar, bstar, ViewingConditions.DEFAULT() + jstar, astar, bstar, ViewingConditions.DEFAULT ) @staticmethod def from_ucs_in_viewing_conditions( jstar: float, astar: float, bstar: float, viewing_conditions: ViewingConditions - ): + ) -> "Cam16": a = astar b = bstar m = math.sqrt(a * a + b * b) @@ -148,114 +161,11 @@ def from_ucs_in_viewing_conditions( return Cam16.from_jch_in_viewing_conditions(j, c, h, viewing_conditions) def to_int(self) -> int: - return self.viewed(ViewingConditions.DEFAULT()) + return self.viewed(ViewingConditions.DEFAULT) def viewed(self, viewing_conditions: ViewingConditions) -> int: - alpha = ( - 0.0 - if self.chroma == 0.0 or self.j == 0.0 - else self.chroma / math.sqrt(self.j / 100.0) - ) - - t = pow(alpha / pow(1.64 - pow(0.29, viewing_conditions.n), 0.73), 1.0 / 0.9) - hRad = (self.hue * math.pi) / 180.0 - - eHue = 0.25 * (math.cos(hRad + 2.0) + 3.8) - ac = viewing_conditions.aw * pow( - self.j / 100.0, 1.0 / viewing_conditions.c / viewing_conditions.z - ) - p1 = eHue * (50000.0 / 13.0) * viewing_conditions.nc * viewing_conditions.ncb - p2 = ac / viewing_conditions.nbb - - hSin = math.sin(hRad) - hCos = math.cos(hRad) - - gamma = (23.0 * (p2 + 0.305) * t) / ( - 23.0 * p1 + 11.0 * t * hCos + 108.0 * t * hSin - ) - a = gamma * hCos - b = gamma * hSin - rA = (460.0 * p2 + 451.0 * a + 288.0 * b) / 1403.0 - gA = (460.0 * p2 - 891.0 * a - 261.0 * b) / 1403.0 - bA = (460.0 * p2 - 220.0 * a - 6300.0 * b) / 1403.0 - - rCBase = max(0, (27.13 * abs(rA)) / (400.0 - abs(rA))) - rC = signum(rA) * (100.0 / viewing_conditions.fl) * pow(rCBase, 1.0 / 0.42) - gCBase = max(0, (27.13 * abs(gA)) / (400.0 - abs(gA))) - gC = signum(gA) * (100.0 / viewing_conditions.fl) * pow(gCBase, 1.0 / 0.42) - bCBase = max(0, (27.13 * abs(bA)) / (400.0 - abs(bA))) - bC = signum(bA) * (100.0 / viewing_conditions.fl) * pow(bCBase, 1.0 / 0.42) - rF = rC / viewing_conditions.rgb_d[0] - gF = gC / viewing_conditions.rgb_d[1] - bF = bC / viewing_conditions.rgb_d[2] - - x = 1.86206786 * rF - 1.01125463 * gF + 0.14918677 * bF - y = 0.38752654 * rF + 0.62144744 * gF - 0.00897398 * bF - z = -0.01584150 * rF - 0.03412294 * gF + 1.04996444 * bF - - return argb_from_xyz(x, y, z) - - @staticmethod - def from_xyz_in_viewing_conditions( - x: float, y: float, z: float, viewing_conditions: ViewingConditions - ): - r_c = 0.401288 * x + 0.650173 * y - 0.051461 * z - g_c = -0.250268 * x + 1.204414 * y + 0.045854 * z - b_c = -0.002079 * x + 0.048952 * y + 0.953127 * z - - r_d = viewing_conditions.rgb_d[0] * r_c - g_d = viewing_conditions.rgb_d[1] * g_c - b_d = viewing_conditions.rgb_d[2] * b_c - - r_af = pow(viewing_conditions.fl * abs(r_d) / 100.0, 0.42) - g_af = pow(viewing_conditions.fl * abs(g_d) / 100.0, 0.42) - b_af = pow(viewing_conditions.fl * abs(b_d) / 100.0, 0.42) - r_a = signum(r_d) * 400.0 * r_af / (r_af + 27.13) - g_a = signum(g_d) * 400.0 * g_af / (g_af + 27.13) - b_a = signum(b_d) * 400.0 * b_af / (b_af + 27.13) - - a = (11.0 * r_a + -12.0 * g_a + b_a) / 11.0 - b = (r_a + g_a - 2.0 * b_a) / 9.0 - - u = (20.0 * r_a + 20.0 * g_a + 21.0 * b_a) / 20.0 - p2 = (40.0 * r_a + 20.0 * g_a + b_a) / 20.0 - - atan2 = math.atan2(b, a) - atan_degrees = atan2 * 180.0 / math.pi - hue = ( - atan_degrees + 360.0 - if atan_degrees < 0 - else atan_degrees - 360.0 if atan_degrees >= 360 else atan_degrees - ) - hue_radians = hue * math.pi / 180.0 - - ac = p2 * viewing_conditions.nbb - - J = 100.0 * pow( - ac / viewing_conditions.aw, viewing_conditions.c * viewing_conditions.z - ) - Q = ( - (4.0 / viewing_conditions.c) - * math.sqrt(J / 100.0) - * (viewing_conditions.aw + 4.0) - * viewing_conditions.f_l_root - ) - - hue_prime = hue + 360.0 if hue < 20.14 else hue - e_hue = (1.0 / 4.0) * (math.cos(hue_prime * math.pi / 180.0 + 2.0) + 3.8) - p1 = 50000.0 / 13.0 * e_hue * viewing_conditions.nc * viewing_conditions.ncb - t = p1 * math.sqrt(a * a + b * b) / (u + 0.305) - alpha = pow(t, 0.9) * pow(1.64 - pow(0.29, viewing_conditions.n), 0.73) - C = alpha * math.sqrt(J / 100.0) - M = C * viewing_conditions.f_l_root - s = 50.0 * math.sqrt( - (alpha * viewing_conditions.c) / (viewing_conditions.aw + 4.0) - ) - j_star = (1.0 + 100.0 * 0.007) * J / (1.0 + 0.007 * J) - m_star = math.log(1.0 + 0.0228 * M) / 0.0228 - a_star = m_star * math.cos(hue_radians) - b_star = m_star * math.sin(hue_radians) - return Cam16(hue, C, J, Q, M, s, j_star, a_star, b_star) + xyz = self.xyz_in_viewing_conditions(viewing_conditions) + return argb_from_xyz(xyz[0], xyz[1], xyz[2]) def xyz_in_viewing_conditions( self, viewing_conditions: ViewingConditions @@ -283,8 +193,8 @@ def xyz_in_viewing_conditions( h_sin = math.sin(h_rad) h_cos = math.cos(h_rad) - gamma = (23.0 * (p2 + 0.305) * t) / ( - 23.0 * p1 + 11 * t * h_cos + 108.0 * t * h_sin + gamma = ( + 23.0 * (p2 + 0.305) * t / (23.0 * p1 + 11 * t * h_cos + 108.0 * t * h_sin) ) a = gamma * h_cos b = gamma * h_sin diff --git a/materialyoucolor/hct/hct.py b/materialyoucolor/hct/hct.py index 91218f2..48a347f 100644 --- a/materialyoucolor/hct/hct.py +++ b/materialyoucolor/hct/hct.py @@ -1,12 +1,21 @@ -from materialyoucolor.utils.color_utils import lstar_from_argb, lstar_from_y -from materialyoucolor.hct.viewing_conditions import ViewingConditions -from materialyoucolor.hct.cam16 import Cam16 +from typing import TYPE_CHECKING + from materialyoucolor.hct.hct_solver import HctSolver -from materialyoucolor.utils.color_utils import rgba_from_argb +from materialyoucolor.hct.viewing_conditions import ViewingConditions +from materialyoucolor.utils.color_utils import ( + lstar_from_argb, + lstar_from_y, + rgba_from_argb, +) + +if TYPE_CHECKING: + pass class Hct: def __init__(self, argb: int): + from materialyoucolor.hct.cam16 import Cam16 # Local import + cam = Cam16.from_int(argb) self.internal_hue = cam.hue self.internal_chroma = cam.chroma @@ -14,6 +23,8 @@ def __init__(self, argb: int): self.argb = argb def set_internal_state(self, argb: int): + from materialyoucolor.hct.cam16 import Cam16 # Local import + cam = Cam16.from_int(argb) self.internal_hue = cam.hue self.internal_chroma = cam.chroma @@ -22,7 +33,7 @@ def set_internal_state(self, argb: int): @staticmethod def from_hct(hue: float, chroma: float, tone: float): - return Hct(int(HctSolver.solve_to_int(hue, chroma, tone))) + return Hct(HctSolver.solve_to_int(hue, chroma, tone)) @staticmethod def from_int(argb: int): @@ -41,11 +52,7 @@ def hue(self) -> float: @hue.setter def hue(self, new_hue: float): self.set_internal_state( - int( - HctSolver.solve_to_int( - new_hue, self.internal_chroma, self.internal_tone - ) - ) + HctSolver.solve_to_int(new_hue, self.internal_chroma, self.internal_tone) ) @property @@ -65,14 +72,30 @@ def tone(self) -> float: @tone.setter def tone(self, new_tone: float): self.set_internal_state( - int( - HctSolver.solve_to_int( - self.internal_hue, self.internal_chroma, new_tone - ) - ) + HctSolver.solve_to_int(self.internal_hue, self.internal_chroma, new_tone) ) - def in_viewing_conditions(self, vc: ViewingConditions): + def set_value(self, property_name: str, value: float): + setattr(self, property_name, value) + + def __str__(self) -> str: + return f"HCT({self.hue:.0f}, {self.chroma:.0f}, {self.tone:.0f})" + + @staticmethod + def is_blue(hue: float) -> bool: + return 250 <= hue < 270 + + @staticmethod + def is_yellow(hue: float) -> bool: + return 105 <= hue < 125 + + @staticmethod + def is_cyan(hue: float) -> bool: + return 170 <= hue < 207 + + def in_viewing_conditions(self, vc: ViewingConditions) -> "Hct": + from materialyoucolor.hct.cam16 import Cam16 # Local import + cam = Cam16.from_int(self.to_int()) viewed_in_vc = cam.xyz_in_viewing_conditions(vc) recast_in_vc = Cam16.from_xyz_in_viewing_conditions( diff --git a/materialyoucolor/hct/hct_solver.py b/materialyoucolor/hct/hct_solver.py index 0311235..825ff10 100644 --- a/materialyoucolor/hct/hct_solver.py +++ b/materialyoucolor/hct/hct_solver.py @@ -1,17 +1,17 @@ import math -from materialyoucolor.utils.math_utils import ( - signum, - matrix_multiply, - sanitize_degrees_double, -) +from materialyoucolor.hct.cam16 import Cam16 +from materialyoucolor.hct.viewing_conditions import ViewingConditions from materialyoucolor.utils.color_utils import ( - argb_from_lstar, argb_from_linrgb, + argb_from_lstar, y_from_lstar, ) -from materialyoucolor.hct.cam16 import Cam16 -from materialyoucolor.hct.viewing_conditions import ViewingConditions +from materialyoucolor.utils.math_utils import ( + matrix_multiply, + sanitize_degrees_double, + signum, +) class HctSolver: @@ -322,12 +322,12 @@ def true_delinearized(rgb_component: float) -> float: if normalized <= 0.0031308: delinearized = normalized * 12.92 else: - delinearized = 1.055 * pow(normalized, 1.0 / 2.4) - 0.055 + delinearized = 1.055 * math.pow(normalized, 1.0 / 2.4) - 0.055 return delinearized * 255.0 @staticmethod def chromatic_adaptation(component: float) -> float: - af = pow(abs(component), 0.42) + af = math.pow(abs(component), 0.42) return signum(component) * 400.0 * af / (af + 27.13) @staticmethod @@ -370,7 +370,7 @@ def is_bounded(x: float) -> bool: return 0.0 <= x <= 100.0 @staticmethod - def nth_vertex(y: float, n: float) -> list[float]: + def nth_vertex(y: float, n: int) -> list[float]: kr = HctSolver.Y_FROM_LINRGB[0] kg = HctSolver.Y_FROM_LINRGB[1] kb = HctSolver.Y_FROM_LINRGB[2] @@ -487,7 +487,7 @@ def bisect_to_limit(y: float, target_hue: float) -> list[float]: break else: m_plane = math.floor((l_plane + r_plane) / 2.0) - mid_plane_coordinate = HctSolver.CRITICAL_PLANES[m_plane] + mid_plane_coordinate = HctSolver.CRITICAL_PLANES[int(m_plane)] mid = HctSolver.set_coordinate( left, mid_plane_coordinate, right, axis ) @@ -512,7 +512,7 @@ def inverse_chromatic_adaptation(adapted: float) -> float: @staticmethod def find_result_by_j(hue_radians: float, chroma: float, y: float) -> int: j = math.sqrt(y) * 11.0 - viewing_conditions = ViewingConditions.DEFAULT() + viewing_conditions = ViewingConditions.DEFAULT t_inner_coeff = 1 / math.pow(1.64 - math.pow(0.29, viewing_conditions.n), 0.73) e_hue = 0.25 * (math.cos(hue_radians + 2.0) + 3.8) p1 = e_hue * (50000.0 / 13.0) * viewing_conditions.nc * viewing_conditions.ncb @@ -588,4 +588,4 @@ def solve_to_int(hue_degrees: float, chroma: float, lstar: float) -> int: @staticmethod def solve_to_cam(hueDegrees: float, chroma: float, lstar: float) -> Cam16: - return Cam16.from_int(int(HctSolver.solve_to_int(hueDegrees, chroma, lstar))) + return Cam16.from_int(HctSolver.solve_to_int(hueDegrees, chroma, lstar)) diff --git a/materialyoucolor/hct/viewing_conditions.py b/materialyoucolor/hct/viewing_conditions.py index 8effc05..ec2ae81 100644 --- a/materialyoucolor/hct/viewing_conditions.py +++ b/materialyoucolor/hct/viewing_conditions.py @@ -1,10 +1,23 @@ import math -from materialyoucolor.utils.math_utils import lerp + from materialyoucolor.utils.color_utils import white_point_d65, y_from_lstar +from materialyoucolor.utils.math_utils import lerp class ViewingConditions: - def __init__(self, n, aw, nbb, ncb, c, nc, rgb_d, fl, f_l_root, z): + def __init__( + self, + n: float, + aw: float, + nbb: float, + ncb: float, + c: float, + nc: float, + rgb_d: list[float], + fl: float, + f_l_root: float, + z: float, + ): self.n = n self.aw = aw self.nbb = nbb @@ -18,12 +31,12 @@ def __init__(self, n, aw, nbb, ncb, c, nc, rgb_d, fl, f_l_root, z): @staticmethod def make( - white_point=white_point_d65(), - adapting_luminance=(200.0 / math.pi) * y_from_lstar(50.0) / 100.0, - background_lstar=50.0, - surround=2.0, - discounting_illuminant=False, - ): + white_point: list[float] = white_point_d65(), + adapting_luminance: float = (200.0 / math.pi) * y_from_lstar(50.0) / 100.0, + background_lstar: float = 50.0, + surround: float = 2.0, + discounting_illuminant: bool = False, + ) -> "ViewingConditions": xyz = white_point r_w = xyz[0] * 0.401288 + xyz[1] * 0.650173 + xyz[2] * -0.051461 g_w = xyz[0] * -0.250268 + xyz[1] * 1.204414 + xyz[2] * 0.045854 @@ -39,7 +52,7 @@ def make( if discounting_illuminant else f * (1.0 - (1.0 / 3.6) * math.exp((-adapting_luminance - 42.0) / 92.0)) ) - d = 1.0 if d > 1.0 else 0.0 if d < 0.0 else d + d = 1.0 if d > 1.0 else (0.0 if d < 0.0 else d) nc = f rgb_d = [ d * (100.0 / r_w) + 1.0 - d, @@ -47,7 +60,7 @@ def make( d * (100.0 / b_w) + 1.0 - d, ] k = 1.0 / (5.0 * adapting_luminance + 1.0) - k4 = k**4 + k4 = k * k * k * k k4_f = 1.0 - k4 fl = k4 * adapting_luminance + 0.1 * k4_f * k4_f * ( (5.0 * adapting_luminance) ** (1 / 3) @@ -69,4 +82,5 @@ def make( aw = (2.0 * rgb_a[0] + rgb_a[1] + 0.05 * rgb_a[2]) * nbb return ViewingConditions(n, aw, nbb, ncb, c, nc, rgb_d, fl, pow(fl, 0.25), z) - DEFAULT = make + +ViewingConditions.DEFAULT = ViewingConditions.make() diff --git a/materialyoucolor/palettes/core_palette.py b/materialyoucolor/palettes/core_palette.py index bd6aa5f..bd05df1 100644 --- a/materialyoucolor/palettes/core_palette.py +++ b/materialyoucolor/palettes/core_palette.py @@ -1,9 +1,19 @@ +from typing import Optional + from materialyoucolor.hct.hct import Hct from materialyoucolor.palettes.tonal_palette import TonalPalette class CorePaletteColors: - def __init__(self, primary, secondary, tertiary, neutral, neutral_variant, error): + def __init__( + self, + primary: int, + secondary: Optional[int] = None, + tertiary: Optional[int] = None, + neutral: Optional[int] = None, + neutral_variant: Optional[int] = None, + error: Optional[int] = None, + ): self.primary = primary self.secondary = secondary self.tertiary = tertiary @@ -13,67 +23,58 @@ def __init__(self, primary, secondary, tertiary, neutral, neutral_variant, error class CorePalette: - def __init__(self): - self.a1 = None - self.a2 = None - self.a3 = None - self.n1 = None - self.n2 = None - self.error = None + def __init__(self, argb: int, is_content: bool): + hct = Hct.from_int(argb) + hue = hct.hue + chroma = hct.chroma + if is_content: + self.a1 = TonalPalette.from_hue_and_chroma(hue, chroma) + self.a2 = TonalPalette.from_hue_and_chroma(hue, chroma / 3) + self.a3 = TonalPalette.from_hue_and_chroma(hue + 60, chroma / 2) + self.n1 = TonalPalette.from_hue_and_chroma(hue, min(chroma / 12, 4)) + self.n2 = TonalPalette.from_hue_and_chroma(hue, min(chroma / 6, 8)) + else: + self.a1 = TonalPalette.from_hue_and_chroma(hue, max(48, chroma)) + self.a2 = TonalPalette.from_hue_and_chroma(hue, 16) + self.a3 = TonalPalette.from_hue_and_chroma(hue + 60, 24) + self.n1 = TonalPalette.from_hue_and_chroma(hue, 4) + self.n2 = TonalPalette.from_hue_and_chroma(hue, 8) + self.error = TonalPalette.from_hue_and_chroma(25, 84) @staticmethod - def of(argb: int): - return CorePalette._create_core_palette(argb, False) + def of(argb: int) -> "CorePalette": + return CorePalette(argb, False) @staticmethod - def content_of(argb: int): - return CorePalette._create_core_palette(argb, True) + def content_of(argb: int) -> "CorePalette": + return CorePalette(argb, True) @staticmethod - def from_colors(colors: CorePaletteColors): + def from_colors(colors: CorePaletteColors) -> "CorePalette": return CorePalette._create_palette_from_colors(False, colors) @staticmethod - def content_from_colors(colors: CorePaletteColors): + def content_from_colors(colors: CorePaletteColors) -> "CorePalette": return CorePalette._create_palette_from_colors(True, colors) @staticmethod - def _create_palette_from_colors(content: bool, colors: CorePaletteColors): - palette = CorePalette() + def _create_palette_from_colors( + content: bool, colors: CorePaletteColors + ) -> "CorePalette": + palette = CorePalette(colors.primary, content) if colors.secondary: - p = CorePalette._create_core_palette(colors.secondary, content) + p = CorePalette(colors.secondary, content) palette.a2 = p.a1 if colors.tertiary: - p = CorePalette._create_core_palette(colors.tertiary, content) + p = CorePalette(colors.tertiary, content) palette.a3 = p.a1 if colors.error: - p = CorePalette._create_core_palette(colors.error, content) + p = CorePalette(colors.error, content) palette.error = p.a1 if colors.neutral: - p = CorePalette._create_core_palette(colors.neutral, content) + p = CorePalette(colors.neutral, content) palette.n1 = p.n1 if colors.neutral_variant: - p = CorePalette._create_core_palette(colors.neutral_variant, content) + p = CorePalette(colors.neutral_variant, content) palette.n2 = p.n2 return palette - - @staticmethod - def _create_core_palette(argb: int, is_content: bool): - hct = Hct.from_int(argb) - hue = hct.hue - chroma = hct.chroma - palette = CorePalette() - if is_content: - palette.a1 = TonalPalette.from_hue_and_chroma(hue, chroma) - palette.a2 = TonalPalette.from_hue_and_chroma(hue, chroma / 3) - palette.a3 = TonalPalette.from_hue_and_chroma(hue + 60, chroma / 2) - palette.n1 = TonalPalette.from_hue_and_chroma(hue, min(chroma / 12, 4)) - palette.n2 = TonalPalette.from_hue_and_chroma(hue, min(chroma / 6, 8)) - else: - palette.a1 = TonalPalette.from_hue_and_chroma(hue, max(48, chroma)) - palette.a2 = TonalPalette.from_hue_and_chroma(hue, 16) - palette.a3 = TonalPalette.from_hue_and_chroma(hue + 60, 24) - palette.n1 = TonalPalette.from_hue_and_chroma(hue, 4) - palette.n2 = TonalPalette.from_hue_and_chroma(hue, 8) - palette.error = TonalPalette.from_hue_and_chroma(25, 84) - return palette diff --git a/materialyoucolor/palettes/core_palettes.py b/materialyoucolor/palettes/core_palettes.py new file mode 100644 index 0000000..21db6fd --- /dev/null +++ b/materialyoucolor/palettes/core_palettes.py @@ -0,0 +1,17 @@ +from materialyoucolor.palettes.tonal_palette import TonalPalette + + +class CorePalettes: + def __init__( + self, + primary: TonalPalette, + secondary: TonalPalette, + tertiary: TonalPalette, + neutral: TonalPalette, + neutral_variant: TonalPalette, + ): + self.primary = primary + self.secondary = secondary + self.tertiary = tertiary + self.neutral = neutral + self.neutral_variant = neutral_variant diff --git a/materialyoucolor/palettes/tonal_palette.py b/materialyoucolor/palettes/tonal_palette.py index 7d9417c..0bf90be 100644 --- a/materialyoucolor/palettes/tonal_palette.py +++ b/materialyoucolor/palettes/tonal_palette.py @@ -1,6 +1,4 @@ -from materialyoucolor.hct import Hct -from materialyoucolor.utils.color_utils import rgba_from_argb -from materialyoucolor.utils.color_utils import argb_from_rgb +from materialyoucolor.hct.hct import Hct class KeyColor: @@ -10,7 +8,7 @@ def __init__(self, hue: float, requested_chroma: float): self.hue = hue self.requested_chroma = requested_chroma - def create(self): + def create(self) -> Hct: pivot_tone = 50 tone_step_size = 1 epsilon = 0.01 @@ -30,7 +28,9 @@ def create(self): upper_tone = mid_tone else: if lower_tone == mid_tone: - return Hct.from_hct(self.hue, self.requested_chroma, lower_tone) + return Hct.from_hct( + self.hue, self.requested_chroma, float(lower_tone) + ) lower_tone = mid_tone else: if is_ascending: @@ -38,44 +38,61 @@ def create(self): else: upper_tone = mid_tone - return Hct.from_hct(self.hue, self.requested_chroma, lower_tone) + return Hct.from_hct(self.hue, self.requested_chroma, float(lower_tone)) def max_chroma(self, tone: int) -> float: if tone in self.chroma_cache: return self.chroma_cache[tone] - chroma = Hct.from_hct(self.hue, self.max_chroma_value, tone).chroma + chroma = Hct.from_hct(self.hue, self.max_chroma_value, float(tone)).chroma self.chroma_cache[tone] = chroma return chroma class TonalPalette: - def __init__(self, hue, chroma, key_color): + def __init__(self, hue: float, chroma: float, key_color: Hct): self.hue = hue self.chroma = chroma self.key_color = key_color self.cache = {} @staticmethod - def from_int(argb: int): + def from_int(argb: int) -> "TonalPalette": hct = Hct.from_int(argb) return TonalPalette.from_hct(hct) @staticmethod - def from_hct(hct: Hct): + def from_hct(hct: Hct) -> "TonalPalette": return TonalPalette(hct.hue, hct.chroma, hct) @staticmethod - def from_hue_and_chroma(hue: float, chroma: float): + def from_hue_and_chroma(hue: float, chroma: float) -> "TonalPalette": key_color = KeyColor(hue, chroma).create() return TonalPalette(hue, chroma, key_color) - def tone(self, tone: float) -> list[float]: + def tone(self, tone: float) -> int: argb = self.cache.get(tone) if argb is None: - argb = Hct.from_hct(self.hue, self.chroma, tone).to_int() + if tone == 99 and Hct.is_yellow(self.hue): + argb = self.average_argb(self.tone(98), self.tone(100)) + else: + argb = Hct.from_hct(self.hue, self.chroma, tone).to_int() self.cache[tone] = argb - return rgba_from_argb(argb) + return argb def get_hct(self, tone: float) -> Hct: - return Hct.from_int(argb_from_rgb(*self.tone(tone))) + return Hct.from_int(self.tone(tone)) + + def average_argb(self, argb1: int, argb2: int) -> int: + red1 = (argb1 >> 16) & 0xFF + green1 = (argb1 >> 8) & 0xFF + blue1 = argb1 & 0xFF + red2 = (argb2 >> 16) & 0xFF + green2 = (argb2 >> 8) & 0xFF + blue2 = argb2 & 0xFF + red = round((red1 + red2) / 2) + green = round((green1 + green2) / 2) + blue = round((blue1 + blue2) / 2) + return ( + (0xFF << 24) | ((red & 0xFF) << 16) | ((green & 0xFF) << 8) | (blue & 0xFF) + ) diff --git a/materialyoucolor/scheme/__init__.py b/materialyoucolor/scheme/__init__.py index 066c681..a1b0500 100644 --- a/materialyoucolor/scheme/__init__.py +++ b/materialyoucolor/scheme/__init__.py @@ -1 +1,11 @@ +from .scheme_content import SchemeContent +from .scheme_expressive import SchemeExpressive +from .scheme_fidelity import SchemeFidelity +from .scheme_fruit_salad import SchemeFruitSalad +from .scheme_monochrome import SchemeMonochrome +from .scheme_neutral import SchemeNeutral +from .scheme_rainbow import SchemeRainbow +from .scheme_tonal_spot import SchemeTonalSpot +from .scheme_vibrant import SchemeVibrant from .scheme import Scheme +from .scheme_android import SchemeAndroid diff --git a/materialyoucolor/scheme/dynamic_scheme.py b/materialyoucolor/scheme/dynamic_scheme.py deleted file mode 100644 index 2b5be4f..0000000 --- a/materialyoucolor/scheme/dynamic_scheme.py +++ /dev/null @@ -1,64 +0,0 @@ -from materialyoucolor.hct import Hct -from materialyoucolor.palettes.tonal_palette import TonalPalette -from materialyoucolor.scheme.variant import Variant -from materialyoucolor.utils.math_utils import sanitize_degrees_double - - -class DynamicSchemeOptions: - def __init__( - self, - source_color_hct: Hct, - variant: Variant, - contrast_level: int, - is_dark: bool, - primary_palette: TonalPalette, - secondary_palette: TonalPalette, - tertiary_palette: TonalPalette, - neutral_palette: TonalPalette, - neutral_variant_palette: TonalPalette, - error_palette: TonalPalette = None, - ): - self.source_color_argb = source_color_hct.to_int() - self.source_color_hct = source_color_hct - self.variant = variant - self.contrast_level = contrast_level - self.is_dark = is_dark - self.primary_palette = primary_palette - self.secondary_palette = secondary_palette - self.tertiary_palette = tertiary_palette - self.neutral_palette = neutral_palette - self.neutral_variant_palette = neutral_variant_palette - self.error_palette = error_palette - - -class DynamicScheme: - def __init__(self, args: DynamicSchemeOptions): - self.source_color_argb = args.source_color_argb - self.variant = args.variant - self.contrast_level = args.contrast_level - self.is_dark = args.is_dark - self.source_color_hct = args.source_color_hct - self.primary_palette = args.primary_palette - self.secondary_palette = args.secondary_palette - self.tertiary_palette = args.tertiary_palette - self.neutral_palette = args.neutral_palette - self.neutral_variant_palette = args.neutral_variant_palette - if args.error_palette is None: - self.error_palette = TonalPalette.from_hue_and_chroma(25.0, 84.0) - - @staticmethod - def get_rotated_hue(source_color, hues, rotations): - source_hue = source_color.hue - if len(hues) != len(rotations): - raise ValueError( - f"mismatch between hue length {len(hues)} & rotations {len(rotations)}" - ) - if len(rotations) == 1: - return sanitize_degrees_double(source_color.hue + rotations[0]) - size = len(hues) - for i in range(size - 1): - this_hue = hues[i] - next_hue = hues[i + 1] - if this_hue < source_hue < next_hue: - return sanitize_degrees_double(source_hue + rotations[i]) - return source_hue diff --git a/materialyoucolor/scheme/scheme.py b/materialyoucolor/scheme/scheme.py index 1f5dd07..76536ef 100644 --- a/materialyoucolor/scheme/scheme.py +++ b/materialyoucolor/scheme/scheme.py @@ -1,26 +1,144 @@ from materialyoucolor.palettes.core_palette import CorePalette -from materialyoucolor.utils.color_utils import argb_from_rgba class Scheme: def __init__(self, props: dict): self.props = props - [setattr(self, _, self.props[_]) for _ in self.props.keys()] - @staticmethod - def light_from_rgb(rgb: list[int]): - return Scheme.light_from_core_palette(CorePalette.of(argb_from_rgba(rgb))) + @property + def primary(self) -> int: + return self.props["primary"] + + @property + def on_primary(self) -> int: + return self.props["onPrimary"] + + @property + def primary_container(self) -> int: + return self.props["primaryContainer"] + + @property + def on_primary_container(self) -> int: + return self.props["onPrimaryContainer"] + + @property + def secondary(self) -> int: + return self.props["secondary"] + + @property + def on_secondary(self) -> int: + return self.props["onSecondary"] + + @property + def secondary_container(self) -> int: + return self.props["secondaryContainer"] + + @property + def on_secondary_container(self) -> int: + return self.props["onSecondaryContainer"] + + @property + def tertiary(self) -> int: + return self.props["tertiary"] + + @property + def on_tertiary(self) -> int: + return self.props["onTertiary"] + + @property + def tertiary_container(self) -> int: + return self.props["tertiaryContainer"] + + @property + def on_tertiary_container(self) -> int: + return self.props["onTertiaryContainer"] + + @property + def error(self) -> int: + return self.props["error"] + + @property + def on_error(self) -> int: + return self.props["onError"] + + @property + def error_container(self) -> int: + return self.props["errorContainer"] + + @property + def on_error_container(self) -> int: + return self.props["onErrorContainer"] + + @property + def background(self) -> int: + return self.props["background"] + + @property + def on_background(self) -> int: + return self.props["onBackground"] + + @property + def surface(self) -> int: + return self.props["surface"] + + @property + def on_surface(self) -> int: + return self.props["onSurface"] + + @property + def surface_variant(self) -> int: + return self.props["surfaceVariant"] + + @property + def on_surface_variant(self) -> int: + return self.props["onSurfaceVariant"] + + @property + def outline(self) -> int: + return self.props["outline"] + + @property + def outline_variant(self) -> int: + return self.props["outlineVariant"] + + @property + def shadow(self) -> int: + return self.props["shadow"] + + @property + def scrim(self) -> int: + return self.props["scrim"] + + @property + def inverse_surface(self) -> int: + return self.props["inverseSurface"] + + @property + def inverse_on_surface(self) -> int: + return self.props["inverseOnSurface"] + + @property + def inverse_primary(self) -> int: + return self.props["inversePrimary"] @staticmethod - def light(argb: int): + def light(argb: int) -> "Scheme": return Scheme.light_from_core_palette(CorePalette.of(argb)) @staticmethod - def light_content(argb: int): + def dark(argb: int) -> "Scheme": + return Scheme.dark_from_core_palette(CorePalette.of(argb)) + + @staticmethod + def light_content(argb: int) -> "Scheme": return Scheme.light_from_core_palette(CorePalette.content_of(argb)) @staticmethod - def light_from_core_palette(core: CorePalette): + def dark_content(argb: int) -> "Scheme": + return Scheme.dark_from_core_palette(CorePalette.content_of(argb)) + + @staticmethod + def light_from_core_palette(core: CorePalette) -> "Scheme": return Scheme( { "primary": core.a1.tone(40), @@ -39,11 +157,9 @@ def light_from_core_palette(core: CorePalette): "onError": core.error.tone(100), "errorContainer": core.error.tone(90), "onErrorContainer": core.error.tone(10), - "background": core.n1.tone( - 98 - ), # Original was 99, but that didn't worked in light shades of yellow like: [4294309340, 4294638290, 4294967264] + "background": core.n1.tone(99), "onBackground": core.n1.tone(10), - "surface": core.n1.tone(98), # Here also same + "surface": core.n1.tone(99), "onSurface": core.n1.tone(10), "surfaceVariant": core.n2.tone(90), "onSurfaceVariant": core.n2.tone(30), @@ -58,19 +174,7 @@ def light_from_core_palette(core: CorePalette): ) @staticmethod - def dark_from_rgb(rgb: list[int]): - return Scheme.dark_from_core_palette(CorePalette.of(argb_from_rgba(rgb))) - - @staticmethod - def dark(argb: int): - return Scheme.dark_from_core_palette(CorePalette.of(argb)) - - @staticmethod - def dark_content(argb: int): - return Scheme.dark_from_core_palette(CorePalette.content_of(argb)) - - @staticmethod - def dark_from_core_palette(core: CorePalette): + def dark_from_core_palette(core: CorePalette) -> "Scheme": return Scheme( { "primary": core.a1.tone(80), @@ -104,3 +208,6 @@ def dark_from_core_palette(core: CorePalette): "inversePrimary": core.a1.tone(40), } ) + + def to_json(self) -> dict: + return self.props diff --git a/materialyoucolor/scheme/scheme_android.py b/materialyoucolor/scheme/scheme_android.py index 9b47c86..57a60b6 100644 --- a/materialyoucolor/scheme/scheme_android.py +++ b/materialyoucolor/scheme/scheme_android.py @@ -96,3 +96,6 @@ def dark_from_core_palette(core: CorePalette): "scrim": core.n1.tone(80), } ) + + def to_json(self): + return self.props diff --git a/materialyoucolor/scheme/scheme_content.py b/materialyoucolor/scheme/scheme_content.py index ec8321f..652fda1 100644 --- a/materialyoucolor/scheme/scheme_content.py +++ b/materialyoucolor/scheme/scheme_content.py @@ -1,35 +1,26 @@ -from materialyoucolor.dislike.dislike_analyzer import DislikeAnalyzer -from materialyoucolor.temperature.temperature_cache import TemperatureCache -from materialyoucolor.scheme.dynamic_scheme import DynamicSchemeOptions, DynamicScheme -from materialyoucolor.scheme.variant import Variant -from materialyoucolor.palettes.tonal_palette import TonalPalette +from materialyoucolor.dynamiccolor.dynamic_scheme import ( + DynamicScheme, + Platform, + SpecVersion, +) +from materialyoucolor.dynamiccolor.variant import Variant +from materialyoucolor.hct.hct import Hct class SchemeContent(DynamicScheme): - def __init__(self, source_color_hct, is_dark, contrast_level): + def __init__( + self, + source_color_hct: Hct, + is_dark: bool, + contrast_level: float, + spec_version: SpecVersion = DynamicScheme.DEFAULT_SPEC_VERSION, + platform: Platform = DynamicScheme.DEFAULT_PLATFORM, + ): super().__init__( - DynamicSchemeOptions( - source_color_hct=source_color_hct, - variant=Variant.CONTENT, - contrast_level=contrast_level, - is_dark=is_dark, - primary_palette=TonalPalette.from_hue_and_chroma( - source_color_hct.hue, source_color_hct.chroma - ), - secondary_palette=TonalPalette.from_hue_and_chroma( - source_color_hct.hue, - max(source_color_hct.chroma - 32.0, source_color_hct.chroma * 0.5), - ), - tertiary_palette=TonalPalette.from_int( - DislikeAnalyzer.fix_if_disliked( - TemperatureCache(source_color_hct).analogous(3, 6)[2] - ).to_int() - ), - neutral_palette=TonalPalette.from_hue_and_chroma( - source_color_hct.hue, source_color_hct.chroma / 8.0 - ), - neutral_variant_palette=TonalPalette.from_hue_and_chroma( - source_color_hct.hue, source_color_hct.chroma / 8.0 + 4.0 - ), - ) + source_color_hct=source_color_hct, + variant=Variant.CONTENT, + contrast_level=contrast_level, + is_dark=is_dark, + platform=platform, + spec_version=spec_version, ) diff --git a/materialyoucolor/scheme/scheme_expressive.py b/materialyoucolor/scheme/scheme_expressive.py index 1b7c3f3..4602ae0 100644 --- a/materialyoucolor/scheme/scheme_expressive.py +++ b/materialyoucolor/scheme/scheme_expressive.py @@ -1,45 +1,26 @@ -from materialyoucolor.scheme.dynamic_scheme import DynamicSchemeOptions, DynamicScheme -from materialyoucolor.scheme.variant import Variant -from materialyoucolor.palettes.tonal_palette import TonalPalette -from materialyoucolor.utils.math_utils import sanitize_degrees_double +from materialyoucolor.dynamiccolor.dynamic_scheme import ( + DynamicScheme, + Platform, + SpecVersion, +) +from materialyoucolor.dynamiccolor.variant import Variant +from materialyoucolor.hct.hct import Hct class SchemeExpressive(DynamicScheme): - hues = [0.0, 21.0, 51.0, 121.0, 151.0, 191.0, 271.0, 321.0, 360.0] - secondary_rotations = [45.0, 95.0, 45.0, 20.0, 45.0, 90.0, 45.0, 45.0, 45.0] - tertiary_rotations = [120.0, 120.0, 20.0, 45.0, 20.0, 15.0, 20.0, 120.0, 120.0] - - def __init__(self, source_color_hct, is_dark, contrast_level): + def __init__( + self, + source_color_hct: Hct, + is_dark: bool, + contrast_level: float, + spec_version: SpecVersion = DynamicScheme.DEFAULT_SPEC_VERSION, + platform: Platform = DynamicScheme.DEFAULT_PLATFORM, + ): super().__init__( - DynamicSchemeOptions( - source_color_hct=source_color_hct, - variant=Variant.EXPRESSIVE, - contrast_level=contrast_level, - is_dark=is_dark, - primary_palette=TonalPalette.from_hue_and_chroma( - sanitize_degrees_double(source_color_hct.hue + 240.0), 40.0 - ), - secondary_palette=TonalPalette.from_hue_and_chroma( - DynamicScheme.get_rotated_hue( - source_color_hct, - SchemeExpressive.hues, - SchemeExpressive.secondary_rotations, - ), - 24.0, - ), - tertiary_palette=TonalPalette.from_hue_and_chroma( - DynamicScheme.get_rotated_hue( - source_color_hct, - SchemeExpressive.hues, - SchemeExpressive.tertiary_rotations, - ), - 32.0, - ), - neutral_palette=TonalPalette.from_hue_and_chroma( - source_color_hct.hue + 15, 8.0 - ), - neutral_variant_palette=TonalPalette.from_hue_and_chroma( - source_color_hct.hue + 15, 12.0 - ), - ) + source_color_hct=source_color_hct, + variant=Variant.EXPRESSIVE, + contrast_level=contrast_level, + is_dark=is_dark, + platform=platform, + spec_version=spec_version, ) diff --git a/materialyoucolor/scheme/scheme_fidelity.py b/materialyoucolor/scheme/scheme_fidelity.py index 8d88f55..26fe128 100644 --- a/materialyoucolor/scheme/scheme_fidelity.py +++ b/materialyoucolor/scheme/scheme_fidelity.py @@ -1,35 +1,26 @@ -from materialyoucolor.dislike.dislike_analyzer import DislikeAnalyzer -from materialyoucolor.temperature.temperature_cache import TemperatureCache -from materialyoucolor.scheme.dynamic_scheme import DynamicSchemeOptions, DynamicScheme -from materialyoucolor.scheme.variant import Variant -from materialyoucolor.palettes.tonal_palette import TonalPalette +from materialyoucolor.dynamiccolor.dynamic_scheme import ( + DynamicScheme, + Platform, + SpecVersion, +) +from materialyoucolor.dynamiccolor.variant import Variant +from materialyoucolor.hct.hct import Hct class SchemeFidelity(DynamicScheme): - def __init__(self, source_color_hct, is_dark, contrast_level): + def __init__( + self, + source_color_hct: Hct, + is_dark: bool, + contrast_level: float, + spec_version: SpecVersion = DynamicScheme.DEFAULT_SPEC_VERSION, + platform: Platform = DynamicScheme.DEFAULT_PLATFORM, + ): super().__init__( - DynamicSchemeOptions( - source_color_hct=source_color_hct, - variant=Variant.FIDELITY, - contrast_level=contrast_level, - is_dark=is_dark, - primary_palette=TonalPalette.from_hue_and_chroma( - source_color_hct.hue, source_color_hct.chroma - ), - secondary_palette=TonalPalette.from_hue_and_chroma( - source_color_hct.hue, - max(source_color_hct.chroma - 32.0, source_color_hct.chroma * 0.5), - ), - tertiary_palette=TonalPalette.from_int( - DislikeAnalyzer.fix_if_disliked( - TemperatureCache(source_color_hct).complement() - ).to_int() - ), - neutral_palette=TonalPalette.from_hue_and_chroma( - source_color_hct.hue, source_color_hct.chroma / 8.0 - ), - neutral_variant_palette=TonalPalette.from_hue_and_chroma( - source_color_hct.hue, source_color_hct.chroma / 8.0 + 4.0 - ), - ) + source_color_hct=source_color_hct, + variant=Variant.FIDELITY, + contrast_level=contrast_level, + is_dark=is_dark, + platform=platform, + spec_version=spec_version, ) diff --git a/materialyoucolor/scheme/scheme_fruit_salad.py b/materialyoucolor/scheme/scheme_fruit_salad.py index b18b76e..17202aa 100644 --- a/materialyoucolor/scheme/scheme_fruit_salad.py +++ b/materialyoucolor/scheme/scheme_fruit_salad.py @@ -1,31 +1,26 @@ -from materialyoucolor.scheme.dynamic_scheme import DynamicSchemeOptions, DynamicScheme -from materialyoucolor.scheme.variant import Variant -from materialyoucolor.palettes.tonal_palette import TonalPalette -from materialyoucolor.utils.math_utils import sanitize_degrees_double +from materialyoucolor.dynamiccolor.dynamic_scheme import ( + DynamicScheme, + Platform, + SpecVersion, +) +from materialyoucolor.dynamiccolor.variant import Variant +from materialyoucolor.hct.hct import Hct class SchemeFruitSalad(DynamicScheme): - def __init__(self, source_color_hct, is_dark, contrast_level): + def __init__( + self, + source_color_hct: Hct, + is_dark: bool, + contrast_level: float, + spec_version: SpecVersion = DynamicScheme.DEFAULT_SPEC_VERSION, + platform: Platform = DynamicScheme.DEFAULT_PLATFORM, + ): super().__init__( - DynamicSchemeOptions( - source_color_hct=source_color_hct, - variant=Variant.FRUIT_SALAD, - contrast_level=contrast_level, - is_dark=is_dark, - primary_palette=TonalPalette.from_hue_and_chroma( - sanitize_degrees_double(source_color_hct.hue - 50.0), 48.0 - ), - secondary_palette=TonalPalette.from_hue_and_chroma( - sanitize_degrees_double(source_color_hct.hue - 50.0), 36.0 - ), - tertiary_palette=TonalPalette.from_hue_and_chroma( - source_color_hct.hue, 36.0 - ), - neutral_palette=TonalPalette.from_hue_and_chroma( - source_color_hct.hue, 10.0 - ), - neutral_variant_palette=TonalPalette.from_hue_and_chroma( - source_color_hct.hue, 16.0 - ), - ) + source_color_hct=source_color_hct, + variant=Variant.FRUIT_SALAD, + contrast_level=contrast_level, + is_dark=is_dark, + platform=platform, + spec_version=spec_version, ) diff --git a/materialyoucolor/scheme/scheme_monochrome.py b/materialyoucolor/scheme/scheme_monochrome.py index 0ca6e6e..1ea1d4c 100644 --- a/materialyoucolor/scheme/scheme_monochrome.py +++ b/materialyoucolor/scheme/scheme_monochrome.py @@ -1,30 +1,26 @@ -from materialyoucolor.scheme.dynamic_scheme import DynamicSchemeOptions, DynamicScheme -from materialyoucolor.scheme.variant import Variant -from materialyoucolor.palettes.tonal_palette import TonalPalette +from materialyoucolor.dynamiccolor.dynamic_scheme import ( + DynamicScheme, + Platform, + SpecVersion, +) +from materialyoucolor.dynamiccolor.variant import Variant +from materialyoucolor.hct.hct import Hct class SchemeMonochrome(DynamicScheme): - def __init__(self, source_color_hct, is_dark, contrast_level): + def __init__( + self, + source_color_hct: Hct, + is_dark: bool, + contrast_level: float, + spec_version: SpecVersion = DynamicScheme.DEFAULT_SPEC_VERSION, + platform: Platform = DynamicScheme.DEFAULT_PLATFORM, + ): super().__init__( - DynamicSchemeOptions( - source_color_hct=source_color_hct, - variant=Variant.MONOCHROME, - contrast_level=contrast_level, - is_dark=is_dark, - primary_palette=TonalPalette.from_hue_and_chroma( - source_color_hct.hue, 0.0 - ), - secondary_palette=TonalPalette.from_hue_and_chroma( - source_color_hct.hue, 0.0 - ), - tertiary_palette=TonalPalette.from_hue_and_chroma( - source_color_hct.hue, 0.0 - ), - neutral_palette=TonalPalette.from_hue_and_chroma( - source_color_hct.hue, 0.0 - ), - neutral_variant_palette=TonalPalette.from_hue_and_chroma( - source_color_hct.hue, 0.0 - ), - ) + source_color_hct=source_color_hct, + variant=Variant.MONOCHROME, + contrast_level=contrast_level, + is_dark=is_dark, + platform=platform, + spec_version=spec_version, ) diff --git a/materialyoucolor/scheme/scheme_neutral.py b/materialyoucolor/scheme/scheme_neutral.py index faf8ad7..68da26a 100644 --- a/materialyoucolor/scheme/scheme_neutral.py +++ b/materialyoucolor/scheme/scheme_neutral.py @@ -1,30 +1,26 @@ -from materialyoucolor.scheme.dynamic_scheme import DynamicSchemeOptions, DynamicScheme -from materialyoucolor.scheme.variant import Variant -from materialyoucolor.palettes.tonal_palette import TonalPalette +from materialyoucolor.dynamiccolor.dynamic_scheme import ( + DynamicScheme, + Platform, + SpecVersion, +) +from materialyoucolor.dynamiccolor.variant import Variant +from materialyoucolor.hct.hct import Hct class SchemeNeutral(DynamicScheme): - def __init__(self, source_color_hct, is_dark, contrast_level): + def __init__( + self, + source_color_hct: Hct, + is_dark: bool, + contrast_level: float, + spec_version: SpecVersion = DynamicScheme.DEFAULT_SPEC_VERSION, + platform: Platform = DynamicScheme.DEFAULT_PLATFORM, + ): super().__init__( - DynamicSchemeOptions( - source_color_hct=source_color_hct, - variant=Variant.SPRITZ, - contrast_level=contrast_level, - is_dark=is_dark, - primary_palette=TonalPalette.from_hue_and_chroma( - source_color_hct.hue, 12.0 - ), - secondary_palette=TonalPalette.from_hue_and_chroma( - source_color_hct.hue, 8.0 - ), - tertiary_palette=TonalPalette.from_hue_and_chroma( - source_color_hct.hue, 16.0 - ), - neutral_palette=TonalPalette.from_hue_and_chroma( - source_color_hct.hue, 2.0 - ), - neutral_variant_palette=TonalPalette.from_hue_and_chroma( - source_color_hct.hue, 2.0 - ), - ) + source_color_hct=source_color_hct, + variant=Variant.NEUTRAL, + contrast_level=contrast_level, + is_dark=is_dark, + platform=platform, + spec_version=spec_version, ) diff --git a/materialyoucolor/scheme/scheme_rainbow.py b/materialyoucolor/scheme/scheme_rainbow.py index c4612bb..f334516 100644 --- a/materialyoucolor/scheme/scheme_rainbow.py +++ b/materialyoucolor/scheme/scheme_rainbow.py @@ -1,31 +1,26 @@ -from materialyoucolor.scheme.dynamic_scheme import DynamicSchemeOptions, DynamicScheme -from materialyoucolor.scheme.variant import Variant -from materialyoucolor.palettes.tonal_palette import TonalPalette -from materialyoucolor.utils.math_utils import sanitize_degrees_double +from materialyoucolor.dynamiccolor.dynamic_scheme import ( + DynamicScheme, + Platform, + SpecVersion, +) +from materialyoucolor.dynamiccolor.variant import Variant +from materialyoucolor.hct.hct import Hct class SchemeRainbow(DynamicScheme): - def __init__(self, source_color_hct, is_dark, contrast_level): + def __init__( + self, + source_color_hct: Hct, + is_dark: bool, + contrast_level: float, + spec_version: SpecVersion = DynamicScheme.DEFAULT_SPEC_VERSION, + platform: Platform = DynamicScheme.DEFAULT_PLATFORM, + ): super().__init__( - DynamicSchemeOptions( - source_color_hct=source_color_hct, - variant=Variant.RAINBOW, - contrast_level=contrast_level, - is_dark=is_dark, - primary_palette=TonalPalette.from_hue_and_chroma( - source_color_hct.hue, 48.0 - ), - secondary_palette=TonalPalette.from_hue_and_chroma( - source_color_hct.hue, 16.0 - ), - tertiary_palette=TonalPalette.from_hue_and_chroma( - sanitize_degrees_double(source_color_hct.hue + 60.0), 24.0 - ), - neutral_palette=TonalPalette.from_hue_and_chroma( - source_color_hct.hue, 0.0 - ), - neutral_variant_palette=TonalPalette.from_hue_and_chroma( - source_color_hct.hue, 0.0 - ), - ) + source_color_hct=source_color_hct, + variant=Variant.RAINBOW, + contrast_level=contrast_level, + is_dark=is_dark, + platform=platform, + spec_version=spec_version, ) diff --git a/materialyoucolor/scheme/scheme_tonal_spot.py b/materialyoucolor/scheme/scheme_tonal_spot.py index d8beb78..0fca04f 100644 --- a/materialyoucolor/scheme/scheme_tonal_spot.py +++ b/materialyoucolor/scheme/scheme_tonal_spot.py @@ -1,32 +1,26 @@ -from materialyoucolor.scheme.dynamic_scheme import DynamicSchemeOptions, DynamicScheme -from materialyoucolor.scheme.variant import Variant -from materialyoucolor.palettes.tonal_palette import TonalPalette -from materialyoucolor.utils.math_utils import sanitize_degrees_double +from materialyoucolor.dynamiccolor.dynamic_scheme import ( + DynamicScheme, + Platform, + SpecVersion, +) +from materialyoucolor.dynamiccolor.variant import Variant +from materialyoucolor.hct.hct import Hct class SchemeTonalSpot(DynamicScheme): - def __init__(self, source_color_hct, is_dark, contrast_level): + def __init__( + self, + source_color_hct: Hct, + is_dark: bool, + contrast_level: float, + spec_version: SpecVersion = DynamicScheme.DEFAULT_SPEC_VERSION, + platform: Platform = DynamicScheme.DEFAULT_PLATFORM, + ): super().__init__( - DynamicSchemeOptions( - source_color_hct=source_color_hct, - variant=Variant.TONAL_SPOT, - contrast_level=contrast_level, - is_dark=is_dark, - primary_palette=TonalPalette.from_hue_and_chroma( - source_color_hct.hue, 36.0 - ), - secondary_palette=TonalPalette.from_hue_and_chroma( - source_color_hct.hue, 16.0 - ), - tertiary_palette=TonalPalette.from_hue_and_chroma( - sanitize_degrees_double(source_color_hct.hue + 60.0), - 24.0, - ), - neutral_palette=TonalPalette.from_hue_and_chroma( - source_color_hct.hue, 6.0 - ), - neutral_variant_palette=TonalPalette.from_hue_and_chroma( - source_color_hct.hue, 8.0 - ), - ) + source_color_hct=source_color_hct, + variant=Variant.TONAL_SPOT, + contrast_level=contrast_level, + is_dark=is_dark, + platform=platform, + spec_version=spec_version, ) diff --git a/materialyoucolor/scheme/scheme_vibrant.py b/materialyoucolor/scheme/scheme_vibrant.py index 7a9621b..87a6be3 100644 --- a/materialyoucolor/scheme/scheme_vibrant.py +++ b/materialyoucolor/scheme/scheme_vibrant.py @@ -1,44 +1,26 @@ -from materialyoucolor.scheme.dynamic_scheme import DynamicSchemeOptions, DynamicScheme -from materialyoucolor.scheme.variant import Variant -from materialyoucolor.palettes.tonal_palette import TonalPalette +from materialyoucolor.dynamiccolor.dynamic_scheme import ( + DynamicScheme, + Platform, + SpecVersion, +) +from materialyoucolor.dynamiccolor.variant import Variant +from materialyoucolor.hct.hct import Hct class SchemeVibrant(DynamicScheme): - hues = [0.0, 41.0, 61.0, 101.0, 131.0, 181.0, 251.0, 301.0, 360.0] - secondary_rotations = [18.0, 15.0, 10.0, 12.0, 15.0, 18.0, 15.0, 12.0, 12.0] - tertiary_rotations = [35.0, 30.0, 20.0, 25.0, 30.0, 35.0, 30.0, 25.0, 25.0] - - def __init__(self, source_color_hct, is_dark, contrast_level): + def __init__( + self, + source_color_hct: Hct, + is_dark: bool, + contrast_level: float, + spec_version: SpecVersion = DynamicScheme.DEFAULT_SPEC_VERSION, + platform: Platform = DynamicScheme.DEFAULT_PLATFORM, + ): super().__init__( - DynamicSchemeOptions( - source_color_hct=source_color_hct, - variant=Variant.VIBRANT, - contrast_level=contrast_level, - is_dark=is_dark, - primary_palette=TonalPalette.from_hue_and_chroma( - source_color_hct.hue, 200.0 - ), - secondary_palette=TonalPalette.from_hue_and_chroma( - DynamicScheme.get_rotated_hue( - source_color_hct, - SchemeVibrant.hues, - SchemeVibrant.secondary_rotations, - ), - 24.0, - ), - tertiary_palette=TonalPalette.from_hue_and_chroma( - DynamicScheme.get_rotated_hue( - source_color_hct, - SchemeVibrant.hues, - SchemeVibrant.tertiary_rotations, - ), - 32.0, - ), - neutral_palette=TonalPalette.from_hue_and_chroma( - source_color_hct.hue, 10.0 - ), - neutral_variant_palette=TonalPalette.from_hue_and_chroma( - source_color_hct.hue, 12.0 - ), - ) + source_color_hct=source_color_hct, + variant=Variant.VIBRANT, + contrast_level=contrast_level, + is_dark=is_dark, + platform=platform, + spec_version=spec_version, ) diff --git a/materialyoucolor/scheme/variant.py b/materialyoucolor/scheme/variant.py deleted file mode 100644 index 405b2cb..0000000 --- a/materialyoucolor/scheme/variant.py +++ /dev/null @@ -1,10 +0,0 @@ -class Variant: - MONOCHROME = "MONOCHROME" - SPRITZ = "SPRITZ" - TONAL_SPOT = "TONAL_SPOT" - VIBRANT = "VIBRANT" - EXPRESSIVE = "EXPRESSIVE" - FIDELITY = "FIDELITY" - CONTENT = "CONTENT" - RAINBOW = "RAINBOW" - FRUIT_SALAD = "FRUIT_SALAD" diff --git a/materialyoucolor/score/score.py b/materialyoucolor/score/score.py index 3df5d95..59b6d21 100644 --- a/materialyoucolor/score/score.py +++ b/materialyoucolor/score/score.py @@ -1,28 +1,30 @@ -from materialyoucolor.hct import Hct -from materialyoucolor.utils.math_utils import sanitize_degrees_int, difference_degrees -from materialyoucolor.dislike.dislike_analyzer import DislikeAnalyzer +from typing import Any, Dict, List, Optional + +from materialyoucolor.hct.hct import Hct +from materialyoucolor.utils.math_utils import difference_degrees, sanitize_degrees_int class ScoreOptions: def __init__( self, - desired: int, - fallback_color_argb: int, - filter: bool, - dislike_filter: False, + desired: int = 4, + fallback_color_argb: int = 0xFF4285F4, + filter: bool = True, ): self.desired = desired self.fallback_color_argb = fallback_color_argb self.filter = filter - self.dislike_filter = dislike_filter -SCORE_OPTION_DEFAULTS = ScoreOptions( - desired=4, - fallback_color_argb=0xFF4285F4, # Google Blue. - filter=True, # Avoid unsuitable colors. - dislike_filter=False, # Fix globally disliked colors -) +SCORE_OPTION_DEFAULTS = ScoreOptions() + + +def compare(a: Dict[str, Any], b: Dict[str, Any]) -> int: + if a["score"] > b["score"]: + return -1 + elif a["score"] < b["score"]: + return 1 + return 0 class Score: @@ -37,21 +39,22 @@ def __init__(self): pass @staticmethod - def score(colors_to_population: dict, options: ScoreOptions = None) -> list[int]: + def score( + colors_to_population: Dict[int, int], options: Optional[ScoreOptions] = None + ) -> List[int]: if options is None: options = SCORE_OPTION_DEFAULTS desired = options.desired fallback_color_argb = options.fallback_color_argb filter_enabled = options.filter - dislike_filter = options.dislike_filter - colors_hct = [] + colors_hct: List[Hct] = [] hue_population = [0] * 360 population_sum = 0 - for rgb, population in colors_to_population.items(): - hct = Hct.from_int(rgb) + for argb, population in colors_to_population.items(): + hct = Hct.from_int(argb) colors_hct.append(hct) hue = int(hct.hue) hue_population[hue] += population @@ -62,12 +65,12 @@ def score(colors_to_population: dict, options: ScoreOptions = None) -> list[int] for hue in range(360): proportion = hue_population[hue] / population_sum for i in range(hue - 14, hue + 16): - neighbor_hue = int(sanitize_degrees_int(i)) + neighbor_hue = sanitize_degrees_int(i) hue_excited_proportions[neighbor_hue] += proportion - scored_hct = [] + scored_hct: List[Dict[str, Any]] = [] for hct in colors_hct: - hue = int(sanitize_degrees_int(round(hct.hue))) + hue = sanitize_degrees_int(round(hct.hue)) proportion = hue_excited_proportions[hue] if filter_enabled and ( @@ -83,15 +86,16 @@ def score(colors_to_population: dict, options: ScoreOptions = None) -> list[int] else Score.WEIGHT_CHROMA_ABOVE ) chroma_score = (hct.chroma - Score.TARGET_CHROMA) * chroma_weight - score = proportion_score + chroma_score - scored_hct.append({"hct": hct, "score": score}) + score_value = proportion_score + chroma_score + scored_hct.append({"hct": hct, "score": score_value}) scored_hct.sort(key=lambda x: x["score"], reverse=True) - chosen_colors = [] + chosen_colors: List[Hct] = [] for difference_degrees_ in range(90, 14, -1): chosen_colors.clear() - for hct in [item["hct"] for item in scored_hct]: + for item in scored_hct: + hct = item["hct"] duplicate_hue = any( difference_degrees(hct.hue, chosen_hct.hue) < difference_degrees_ for chosen_hct in chosen_colors @@ -103,15 +107,10 @@ def score(colors_to_population: dict, options: ScoreOptions = None) -> list[int] if len(chosen_colors) >= desired: break - colors = [] + colors: List[int] = [] if not chosen_colors: colors.append(fallback_color_argb) - - if dislike_filter: + else: for chosen_hct in chosen_colors: - chosen_colors[chosen_colors.index(chosen_hct)] = ( - DislikeAnalyzer.fix_if_disliked(chosen_hct) - ) - for chosen_hct in chosen_colors: - colors.append(chosen_hct.to_int()) + colors.append(chosen_hct.to_int()) return colors diff --git a/materialyoucolor/temperature/__init__.py b/materialyoucolor/temperature/__init__.py index e69de29..d1087e6 100644 --- a/materialyoucolor/temperature/__init__.py +++ b/materialyoucolor/temperature/__init__.py @@ -0,0 +1 @@ +from .temperature_cache import TemperatureCache diff --git a/materialyoucolor/temperature/temperature_cache.py b/materialyoucolor/temperature/temperature_cache.py index fc7b5ac..52c5de6 100644 --- a/materialyoucolor/temperature/temperature_cache.py +++ b/materialyoucolor/temperature/temperature_cache.py @@ -1,49 +1,51 @@ import math -from materialyoucolor.hct import Hct +from typing import Dict, List, Optional + +from materialyoucolor.hct.hct import Hct +from materialyoucolor.utils.color_utils import lab_from_argb from materialyoucolor.utils.math_utils import ( - sanitize_degrees_int, sanitize_degrees_double, + sanitize_degrees_int, ) -from materialyoucolor.utils.color_utils import lab_from_argb class TemperatureCache: def __init__(self, input_hct: Hct): - self.input_hct = input_hct - self.hcts_by_temp_cache = [] - self.hcts_by_hue_cache = [] - self.temps_by_hct_cache = {} - self.input_hct_relative_temperature_cache = -1.0 - self.complement_cache = None + self.input = input_hct + self.hcts_by_temp_cache: List[Hct] = [] + self.hcts_by_hue_cache: List[Hct] = [] + self.temps_by_hct_cache: Dict[Hct, float] = {} + self.input_relative_temperature_cache: float = -1.0 + self.complement_cache: Optional[Hct] = None @property - def hcts_by_temp(self): - if self.hcts_by_temp_cache: + def hcts_by_temp(self) -> List[Hct]: + if len(self.hcts_by_temp_cache) > 0: return self.hcts_by_temp_cache - hcts = self.hcts_by_hue + [self.input_hct] + hcts = self.hcts_by_hue + [self.input] temperatures_by_hct = self.temps_by_hct - hcts.sort(key=lambda x: temperatures_by_hct[x]) + hcts.sort(key=lambda x: temperatures_by_hct.get(x, 0.0)) self.hcts_by_temp_cache = hcts return hcts @property def warmest(self) -> Hct: - return self.hcts_by_temp[-1] + return self.hcts_by_temp[len(self.hcts_by_temp) - 1] @property def coldest(self) -> Hct: return self.hcts_by_temp[0] - def analogous(self, count: int, divisions: int) -> list: - start_hue = round(self.input_hct.hue) + def analogous(self, count: int = 5, divisions: int = 12) -> List[Hct]: + start_hue = round(self.input.hue) start_hct = self.hcts_by_hue[start_hue] last_temp = self.relative_temperature(start_hct) all_colors = [start_hct] absolute_total_temp_delta = 0.0 for i in range(360): - hue = int(sanitize_degrees_int(start_hue + i)) + hue = sanitize_degrees_int(start_hue + i) hct = self.hcts_by_hue[hue] temp = self.relative_temperature(hct) temp_delta = abs(temp - last_temp) @@ -56,7 +58,7 @@ def analogous(self, count: int, divisions: int) -> list: last_temp = self.relative_temperature(start_hct) while len(all_colors) < divisions: - hue = int(sanitize_degrees_int(start_hue + hue_addend)) + hue = sanitize_degrees_int(start_hue + hue_addend) hct = self.hcts_by_hue[hue] temp = self.relative_temperature(hct) temp_delta = abs(temp - last_temp) @@ -82,9 +84,9 @@ def analogous(self, count: int, divisions: int) -> list: all_colors.append(hct) break - answers = [self.input_hct] + answers = [self.input] - increase_hue_count = int((count - 1) / 2.0) + increase_hue_count = math.floor((count - 1) / 2.0) for i in range(1, increase_hue_count + 1): index = 0 - i while index < 0: @@ -104,27 +106,28 @@ def analogous(self, count: int, divisions: int) -> list: return answers + @property def complement(self) -> Hct: if self.complement_cache is not None: return self.complement_cache coldest_hue = self.coldest.hue - coldest_temp = self.temps_by_hct[self.coldest] + coldest_temp = self.temps_by_hct.get(self.coldest, 0.0) warmest_hue = self.warmest.hue - warmest_temp = self.temps_by_hct[self.warmest] + warmest_temp = self.temps_by_hct.get(self.warmest, 0.0) range_temp = warmest_temp - coldest_temp start_hue_is_coldest_to_warmest = TemperatureCache.is_between( - self.input_hct.hue, coldest_hue, warmest_hue + self.input.hue, coldest_hue, warmest_hue ) start_hue = warmest_hue if start_hue_is_coldest_to_warmest else coldest_hue end_hue = coldest_hue if start_hue_is_coldest_to_warmest else warmest_hue direction_of_rotation = 1.0 smallest_error = 1000.0 - answer = self.hcts_by_hue[round(self.input_hct.hue)] + answer = self.hcts_by_hue[round(self.input.hue)] complement_relative_temp = 1.0 - self.input_relative_temperature - for hue_addend in range(0, 360): + for hue_addend in range(0, 361): hue = sanitize_degrees_double( start_hue + direction_of_rotation * hue_addend ) @@ -132,7 +135,7 @@ def complement(self) -> Hct: continue possible_answer = self.hcts_by_hue[round(hue)] relative_temp = ( - self.temps_by_hct[possible_answer] - coldest_temp + self.temps_by_hct.get(possible_answer, 0.0) - coldest_temp ) / range_temp error = abs(complement_relative_temp - relative_temp) if error < smallest_error: @@ -143,30 +146,30 @@ def complement(self) -> Hct: return self.complement_cache def relative_temperature(self, hct: Hct) -> float: - range_temp = self.temps_by_hct[self.warmest] - self.temps_by_hct[self.coldest] - difference_from_coldest = ( - self.temps_by_hct[hct] - self.temps_by_hct[self.coldest] + range_temp = self.temps_by_hct.get(self.warmest, 0.0) - self.temps_by_hct.get( + self.coldest, 0.0 ) + difference_from_coldest = self.temps_by_hct.get( + hct, 0.0 + ) - self.temps_by_hct.get(self.coldest, 0.0) if range_temp == 0.0: return 0.5 return difference_from_coldest / range_temp @property def input_relative_temperature(self) -> float: - if self.input_hct_relative_temperature_cache >= 0.0: - return self.input_hct_relative_temperature_cache + if self.input_relative_temperature_cache >= 0.0: + return self.input_relative_temperature_cache - self.input_hct_relative_temperature_cache = self.relative_temperature( - self.input_hct - ) - return self.input_hct_relative_temperature_cache + self.input_relative_temperature_cache = self.relative_temperature(self.input) + return self.input_relative_temperature_cache @property - def temps_by_hct(self) -> dict[Hct:float]: - if self.temps_by_hct_cache: + def temps_by_hct(self) -> Dict[Hct, float]: + if len(self.temps_by_hct_cache) > 0: return self.temps_by_hct_cache - all_hcts = self.hcts_by_hue + [self.input_hct] + all_hcts = self.hcts_by_hue + [self.input] temperatures_by_hct = {} for e in all_hcts: temperatures_by_hct[e] = TemperatureCache.raw_temperature(e) @@ -175,12 +178,12 @@ def temps_by_hct(self) -> dict[Hct:float]: return temperatures_by_hct @property - def hcts_by_hue(self) -> list[Hct]: - if self.hcts_by_hue_cache: + def hcts_by_hue(self) -> List[Hct]: + if len(self.hcts_by_hue_cache) > 0: return self.hcts_by_hue_cache hcts = [ - Hct.from_hct(hue, self.input_hct.chroma, self.input_hct.tone) + Hct.from_hct(float(hue), self.input.chroma, self.input.tone) for hue in range(0, 361) ] self.hcts_by_hue_cache = hcts diff --git a/materialyoucolor/utils/color_utils.py b/materialyoucolor/utils/color_utils.py index 02c4e32..cc494de 100644 --- a/materialyoucolor/utils/color_utils.py +++ b/materialyoucolor/utils/color_utils.py @@ -1,4 +1,6 @@ -from materialyoucolor.utils.math_utils import matrix_multiply, clamp_int +import math + +from materialyoucolor.utils.math_utils import clamp_int, matrix_multiply SRGB_TO_XYZ = [ [0.41233895, 0.35762064, 0.18051042], @@ -27,10 +29,8 @@ WHITE_POINT_D65 = [95.047, 100.0, 108.883] -def argb_from_rgb(red: float, green: float, blue: float, alpha=255) -> int: - return ( - alpha << 24 | (int(red) & 255) << 16 | (int(green) & 255) << 8 | int(blue) & 255 - ) +def argb_from_rgb(red: int, green: int, blue: int) -> int: + return 255 << 24 | (red & 255) << 16 | (green & 255) << 8 | blue & 255 def argb_from_linrgb(linrgb: list[float]) -> int: @@ -40,27 +40,27 @@ def argb_from_linrgb(linrgb: list[float]) -> int: return argb_from_rgb(r, g, b) -def alpha_from_argb(argb) -> float: +def alpha_from_argb(argb: int) -> int: return (argb >> 24) & 255 -def red_from_argb(argb) -> float: +def red_from_argb(argb: int) -> int: return (argb >> 16) & 255 -def green_from_argb(argb) -> float: +def green_from_argb(argb: int) -> int: return (argb >> 8) & 255 -def blue_from_argb(argb) -> float: +def blue_from_argb(argb: int) -> int: return argb & 255 -def is_opaque(argb) -> bool: +def is_opaque(argb: int) -> bool: return alpha_from_argb(argb) >= 255 -def argb_from_xyz(x, y, z) -> int: +def argb_from_xyz(x: float, y: float, z: float) -> int: matrix = XYZ_TO_SRGB linear_r = matrix[0][0] * x + matrix[0][1] * y + matrix[0][2] * z linear_g = matrix[1][0] * x + matrix[1][1] * y + matrix[1][2] * z @@ -71,14 +71,14 @@ def argb_from_xyz(x, y, z) -> int: return argb_from_rgb(r, g, b) -def xyz_from_argb(argb) -> list[float]: +def xyz_from_argb(argb: int) -> list[float]: r = linearized(red_from_argb(argb)) g = linearized(green_from_argb(argb)) b = linearized(blue_from_argb(argb)) return matrix_multiply([r, g, b], SRGB_TO_XYZ) -def argb_from_lab(l, a, b) -> float: +def argb_from_lab(l: float, a: float, b: float) -> int: white_point = WHITE_POINT_D65 fy = (l + 16.0) / 116.0 fx = a / 500.0 + fy @@ -119,6 +119,38 @@ def argb_from_lstar(lstar: float) -> int: return argb_from_rgb(component, component, component) +def rgba_from_argb(argb: int) -> list[int]: + r = red_from_argb(argb) + g = green_from_argb(argb) + b = blue_from_argb(argb) + a = alpha_from_argb(argb) + return [r, g, b, a] + + +def hex_from_rgba(rgba: list[int]): + return "#{:02X}{:02X}{:02X}{:02X}".format(*rgba) + + +def hex_from_argb(argb: int) -> str: + return hex_from_rgba(rgba_from_argb(argb)) + + +def clamp_component(value: int) -> int: + if value < 0: + return 0 + if value > 255: + return 255 + return value + + +def argb_from_rgba(rgba: list[int]) -> int: + return (rgba[3] << 24) | (rgba[0] << 16) | (rgba[1] << 8) | rgba[2] + + +def argb_from_rgba_01(rgba: list[int]) -> int: + return argb_from_rgba([int(_ * 255) for _ in rgba]) + + def lstar_from_argb(argb: int) -> float: y = xyz_from_argb(argb)[1] return 116.0 * lab_f(y / 100.0) - 16.0 @@ -128,75 +160,41 @@ def y_from_lstar(lstar: float) -> float: return 100.0 * lab_invf((lstar + 16.0) / 116.0) -def srgb_to_argb(srgb): - return int("0xff{:06X}".format(0xFFFFFF & srgb), 16) - - def lstar_from_y(y: float) -> float: return lab_f(y / 100.0) * 116.0 - 16.0 -def linearized(rgb_component: float) -> float: +def srgb_to_argb(srgb): + return int("0xff{:06X}".format(0xFFFFFF & srgb), 16) + + +def linearized(rgb_component: int) -> float: normalized = rgb_component / 255.0 if normalized <= 0.040449936: return normalized / 12.92 * 100.0 else: - return pow((normalized + 0.055) / 1.055, 2.4) * 100.0 + return math.pow((normalized + 0.055) / 1.055, 2.4) * 100.0 -def delinearized(rgb_component: float) -> float: +def delinearized(rgb_component: float) -> int: normalized = rgb_component / 100.0 + delinearized_val = 0.0 if normalized <= 0.0031308: - delinearized = normalized * 12.92 + delinearized_val = normalized * 12.92 else: - delinearized = 1.055 * pow(normalized, 1.0 / 2.4) - 0.055 - return clamp_int(0, 255, round(delinearized * 255)) + delinearized_val = 1.055 * math.pow(normalized, 1.0 / 2.4) - 0.055 + return clamp_int(0, 255, round(delinearized_val * 255.0)) def white_point_d65() -> list[float]: return WHITE_POINT_D65 -class Rgba: - r: float - g: float - b: float - a: float - - -def rgba_from_argb(argb: int) -> list[float]: - r = red_from_argb(argb) - g = green_from_argb(argb) - b = blue_from_argb(argb) - a = alpha_from_argb(argb) - return [r, g, b, a] - - -def argb_from_rgba(rgba: list[int]) -> int: - r_value = clamp_component(rgba[0]) - g_value = clamp_component(rgba[1]) - b_value = clamp_component(rgba[2]) - a_value = clamp_component(rgba[3]) - return (a_value << 24) | (r_value << 16) | (g_value << 8) | b_value - - -def argb_from_rgba_01(rgba: list[int]) -> int: - return argb_from_rgba([int(_ * 255) for _ in rgba]) - - -def clamp_component(value: int) -> int: - if value < 0: - return 0 - if value > 255: - return 255 - return value - - def lab_f(t: float) -> float: e = 216.0 / 24389.0 kappa = 24389.0 / 27.0 if t > e: - return pow(t, 1.0 / 3.0) + return math.pow(t, 1.0 / 3.0) else: return (kappa * t + 16) / 116 diff --git a/materialyoucolor/utils/image_utils.py b/materialyoucolor/utils/image_utils.py new file mode 100644 index 0000000..2c68b58 --- /dev/null +++ b/materialyoucolor/utils/image_utils.py @@ -0,0 +1,13 @@ +from typing import List + +from materialyoucolor.quantize import QuantizeCelebi +from materialyoucolor.score.score import Score + + +def source_color_from_image_bytes(image_data: List[int], pixel_len=None) -> int: + if pixel_len is None: + pixel_len = len(image_data) + result = QuantizeCelebi([image_data[i] for i in range(0, pixel_len)], 128) + ranked = Score.score(result) + top = ranked[0] + return top diff --git a/materialyoucolor/utils/math_utils.py b/materialyoucolor/utils/math_utils.py index 246cb9b..5923d91 100644 --- a/materialyoucolor/utils/math_utils.py +++ b/materialyoucolor/utils/math_utils.py @@ -1,17 +1,17 @@ -def signum(num: float) -> float: +def signum(num: float) -> int: if num < 0: - return -1.0 + return -1 elif num == 0: - return 0.0 + return 0 else: - return 1.0 + return 1 def lerp(start: float, stop: float, amount: float) -> float: return (1.0 - amount) * start + amount * stop -def clamp_int(min_val: float, max_val: float, input_val: float) -> float: +def clamp_int(min_val: int, max_val: int, input_val: int) -> int: if input_val < min_val: return min_val elif input_val > max_val: @@ -27,17 +27,17 @@ def clamp_double(min_val: float, max_val: float, input_val: float) -> float: return input_val -def sanitize_degrees_int(degrees: float) -> float: - degrees = degrees % 360.0 +def sanitize_degrees_int(degrees: int) -> int: + degrees = degrees % 360 if degrees < 0: - degrees += 360.0 + degrees = degrees + 360 return degrees def sanitize_degrees_double(degrees: float) -> float: degrees = degrees % 360.0 if degrees < 0: - degrees += 360.0 + degrees = degrees + 360.0 return degrees diff --git a/materialyoucolor/utils/platform_utils.py b/materialyoucolor/utils/platform_utils.py index f6c7f04..9bd0938 100644 --- a/materialyoucolor/utils/platform_utils.py +++ b/materialyoucolor/utils/platform_utils.py @@ -1,50 +1,45 @@ import json +import math import os from glob import glob as path_find -import math from timeit import default_timer -from materialyoucolor.scheme.scheme_tonal_spot import SchemeTonalSpot +from materialyoucolor.hct import Hct +from materialyoucolor.palettes.tonal_palette import TonalPalette +from materialyoucolor.dynamiccolor.dynamic_scheme import ( + DynamicScheme, +) +from materialyoucolor.dynamiccolor.variant import Variant +from materialyoucolor.scheme.scheme_content import SchemeContent from materialyoucolor.scheme.scheme_expressive import SchemeExpressive +from materialyoucolor.scheme.scheme_fidelity import SchemeFidelity from materialyoucolor.scheme.scheme_fruit_salad import SchemeFruitSalad from materialyoucolor.scheme.scheme_monochrome import SchemeMonochrome +from materialyoucolor.scheme.scheme_neutral import SchemeNeutral from materialyoucolor.scheme.scheme_rainbow import SchemeRainbow +from materialyoucolor.scheme.scheme_tonal_spot import SchemeTonalSpot from materialyoucolor.scheme.scheme_vibrant import SchemeVibrant -from materialyoucolor.scheme.scheme_neutral import SchemeNeutral -from materialyoucolor.scheme.scheme_fidelity import SchemeFidelity -from materialyoucolor.scheme.scheme_content import SchemeContent - -from materialyoucolor.scheme.dynamic_scheme import DynamicSchemeOptions, DynamicScheme -from materialyoucolor.palettes.tonal_palette import TonalPalette -from materialyoucolor.scheme.variant import Variant +from materialyoucolor.score.score import Score from materialyoucolor.utils.color_utils import argb_from_rgba_01, srgb_to_argb from materialyoucolor.utils.math_utils import sanitize_degrees_double -from materialyoucolor.hct import Hct -from materialyoucolor.score.score import Score -from materialyoucolor.dynamiccolor.material_dynamic_colors import MaterialDynamicColors try: - from materialyoucolor.quantize import QuantizeCelebi + from materialyoucolor.quantize import ImageQuantizeCelebi except: - QuantizeCelebi = None + ImageQuantizeCelebi = None autoclass = None _is_android = "ANDROID_ARGUMENT" in os.environ if _is_android: - from jnius import autoclass from android import mActivity + from jnius import autoclass Integer = autoclass("java.lang.Integer") BuildVERSION = autoclass("android.os.Build$VERSION") context = mActivity.getApplicationContext() WallpaperManager = autoclass("android.app.WallpaperManager").getInstance(mActivity) -try: - from PIL import Image -except Exception: - Image = None - SCHEMES = { "TONAL_SPOT": SchemeTonalSpot, "SPRITZ": SchemeNeutral, @@ -156,41 +151,34 @@ def _get_android_12_above( logger("Got system theme style '{}'".format(selected_scheme)) # Get system colors - get_system_color = lambda color_name: srgb_to_argb( - context.getColor( - context.getResources().getIdentifier( - COLOR_NAMES[color_name].format(APPROX_TONE), - "color", - "android", + def get_system_color(color_name): + return srgb_to_argb( + context.getColor( + context.getResources().getIdentifier( + COLOR_NAMES[color_name].format(APPROX_TONE), + "color", + "android", + ) ) ) - ) + color_names = COLOR_NAMES.copy() for color_name in COLOR_NAMES.keys(): hct = Hct.from_int(get_system_color(color_name)) color_names[color_name] = TonalPalette.from_hue_and_chroma(hct.hue, hct.chroma) return DynamicScheme( - DynamicSchemeOptions( - reverse_color_from_primary( - get_system_color("primary_palette"), - selected_scheme, - ), - getattr(Variant, selected_scheme), - contrast, - dark_mode, - **color_names, - ) + reverse_color_from_primary( + get_system_color("primary_palette"), + selected_scheme, + ), + getattr(Variant, selected_scheme), + contrast, + dark_mode, + **color_names, ) -def open_wallpaper_file(file_path) -> Image: - try: - return Image.open(file_path) - except Exception: - return None - - def get_dynamic_scheme( # Scheme options dark_mode=True, @@ -204,7 +192,8 @@ def get_dynamic_scheme( message_logger=print, logger_head="MaterialYouColor", ) -> DynamicScheme: - logger = lambda message: message_logger(logger_head + " : " + message) + def logger(message): + return message_logger(logger_head + " : " + message) selected_scheme = None selected_color = None @@ -276,31 +265,17 @@ def get_dynamic_scheme( not selected_scheme and not selected_color and fallback_wallpaper_path - and (image := open_wallpaper_file(fallback_wallpaper_path)) - and QuantizeCelebi is not None + and ImageQuantizeCelebi is not None ): timer_start = default_timer() - pixel_len = image.width * image.height - image_data = image.getdata() - # TODO: Think about getting data from bitmap - pixel_array = [ - image_data[_] - for _ in range( - 0, pixel_len, dynamic_color_quality if not _is_android else 1 - ) - ] - logger( - f"Created an array of pixels from a " - f"system wallpaper file - {default_timer() - timer_start} sec." - ) - timer_start = default_timer() - colors = QuantizeCelebi(pixel_array, 128) + colors = ImageQuantizeCelebi(fallback_wallpaper_path, dynamic_color_quality) selected_color = Score.score(colors)[0] WALLPAPER_CACHE[fallback_wallpaper_path] = [ selected_color, os.path.getsize(fallback_wallpaper_path), ] - logger(f"Got dominant colors - " f"{default_timer() - timer_start} sec.") + + logger(f"Got dominant colors - {default_timer() - timer_start} sec.") return ( ( diff --git a/materialyoucolor/utils/theme_utils.py b/materialyoucolor/utils/theme_utils.py index e07eff8..8889e96 100644 --- a/materialyoucolor/utils/theme_utils.py +++ b/materialyoucolor/utils/theme_utils.py @@ -1,18 +1,47 @@ -from materialyoucolor.blend import Blend +from typing import Any, Dict, List, Optional + +from materialyoucolor.blend.blend import Blend from materialyoucolor.palettes.core_palette import CorePalette from materialyoucolor.palettes.tonal_palette import TonalPalette -from materialyoucolor.scheme import Scheme -from materialyoucolor.dislike.dislike_analyzer import DislikeAnalyzer -from materialyoucolor.hct import Hct +from materialyoucolor.scheme.scheme import Scheme +from materialyoucolor.utils.image_utils import source_color_from_image_bytes +from materialyoucolor.utils.string_utils import hex_from_argb + + +class CustomColor: + def __init__(self, value: int, name: str, blend: bool): + self.value = value + self.name = name + self.blend = blend + + +class ColorGroup: + def __init__( + self, color: int, on_color: int, color_container: int, on_color_container: int + ): + self.color = color + self.on_color = on_color + self.color_container = color_container + self.on_color_container = on_color_container + + +class CustomColorGroup: + def __init__( + self, color: CustomColor, value: int, light: ColorGroup, dark: ColorGroup + ): + self.color = color + self.value = value + self.light = light + self.dark = dark class Theme: def __init__( self, source: int, - schemes: dict, - palettes: dict, - custom_colors: list[dict], + schemes: Dict[str, Scheme], + palettes: Dict[str, TonalPalette], + custom_colors: List[CustomColorGroup], ): self.source = source self.schemes = schemes @@ -20,39 +49,12 @@ def __init__( self.custom_colors = custom_colors -def custom_color(custom_color : int, source_color=None, blend=False): - value = DislikeAnalyzer.fix_if_disliked(Hct.from_int(custom_color)).to_int() - if blend: - value = Blend.harmonize(custom_color, source_color) - palette = CorePalette.of(value) - tones = palette.a1 - return { - "color": custom_color, - "theme_color": source_color, - "blended": blend, - "light": { - "color": tones.tone(40), - "onColor": tones.tone(100), - "colorContainer": tones.tone(90), - "onColorContainer": tones.tone(10), - }, - "dark": { - "color": tones.tone(80), - "onColor": tones.tone(20), - "colorContainer": tones.tone(30), - "onColorContainer": tones.tone(90), - }, - } - - def theme_from_source_color( - source: int, custom_colors=[], fix_if_disliked=False + source: int, custom_colors: Optional[List[CustomColor]] = None ) -> Theme: - palette = CorePalette.of( - DislikeAnalyzer.fix_if_disliked(Hct.from_int(source)).to_int() - if fix_if_disliked - else source - ) + if custom_colors is None: + custom_colors = [] + palette = CorePalette.of(source) return Theme( source, {"light": Scheme.light(source), "dark": Scheme.dark(source)}, @@ -64,8 +66,19 @@ def theme_from_source_color( "neutralVariant": palette.n2, "error": palette.error, }, - [ - custom_color(color, blend=True, source_color=source) - for color in custom_colors - ], + [custom_color_group(source, c) for c in custom_colors], + ) + + +def custom_color_group(source: int, custom_color: CustomColor) -> CustomColorGroup: + value = custom_color.value + if custom_color.blend: + value = Blend.harmonize(custom_color.value, source) + palette = CorePalette.of(value) + tones = palette.a1 + return CustomColorGroup( + custom_color, + value, + ColorGroup(tones.tone(40), tones.tone(100), tones.tone(90), tones.tone(10)), + ColorGroup(tones.tone(80), tones.tone(20), tones.tone(30), tones.tone(90)), ) diff --git a/requirements-dev.txt b/requirements-dev.txt new file mode 100644 index 0000000..f301a86 --- /dev/null +++ b/requirements-dev.txt @@ -0,0 +1,2 @@ +psutil +rich diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..3868fb1 --- /dev/null +++ b/requirements.txt @@ -0,0 +1 @@ +pillow diff --git a/setup.py b/setup.py index 28601da..eb8377f 100755 --- a/setup.py +++ b/setup.py @@ -663,7 +663,5 @@ def download_files(base_url, folder, file_map): extra_compile_args=["-std=c++17"] if os.name != "nt" else ["/std:c++17"], extra_link_args=["-shared"] if platform.system() != "Darwin" else [], ) - if not PURE_PYTHON - else () - ], + ] if not PURE_PYTHON else [], ) diff --git a/tests/test_all.py b/tests/test_all.py index 697eeba..1d1d715 100644 --- a/tests/test_all.py +++ b/tests/test_all.py @@ -1,59 +1,125 @@ -MAX_COLOR = 128 - +import argparse import os -import requests -from timeit import default_timer -import gc -import sys import time -from materialyoucolor.utils.color_utils import rgba_from_argb -from materialyoucolor.quantize import QuantizeCelebi, ImageQuantizeCelebi -from materialyoucolor.score.score import Score -from materialyoucolor.hct import Hct -from materialyoucolor.dynamiccolor.material_dynamic_colors import MaterialDynamicColors -from materialyoucolor.scheme.scheme_android import SchemeAndroid -from materialyoucolor.scheme.scheme import Scheme -from materialyoucolor.scheme.scheme_tonal_spot import SchemeTonalSpot -from materialyoucolor.scheme.scheme_vibrant import SchemeVibrant -from materialyoucolor.scheme.scheme_content import SchemeContent -from materialyoucolor.scheme.scheme_neutral import SchemeNeutral -from materialyoucolor.scheme.scheme_rainbow import SchemeRainbow -from materialyoucolor.scheme.scheme_fidelity import SchemeFidelity -from materialyoucolor.scheme.scheme_fruit_salad import SchemeFruitSalad -from materialyoucolor.scheme.scheme_monochrome import SchemeMonochrome -from materialyoucolor.scheme.scheme_expressive import SchemeExpressive + +import psutil +from PIL import Image from rich.console import Console from rich.table import Table -from PIL import Image -rgba_to_hex = lambda rgba: "#{:02X}{:02X}{:02X}{:02X}".format(*map(round, rgba)) +from materialyoucolor.dynamiccolor.color_spec import COLOR_NAMES +from materialyoucolor.dynamiccolor.material_dynamic_colors import MaterialDynamicColors +from materialyoucolor.hct.hct import Hct + +# dynamic schemes +# import all schemes +from materialyoucolor.scheme import * + +# non-dynamic scheme +from materialyoucolor.scheme.scheme import Scheme +from materialyoucolor.scheme.scheme_android import SchemeAndroid +from materialyoucolor.score.score import Score +from materialyoucolor.utils.color_utils import hex_from_rgba, rgba_from_argb + + +def get_current_rss_mb(): + process = psutil.Process(os.getpid()) + return process.memory_info().rss / (1024 * 1024) + + +# from materialyoucolor.scheme.[]. import [] +MAX_COLOR = 128 + +DYNAMIC_SCHEMES = { + "tonal-spot": SchemeTonalSpot, + "expressive": SchemeExpressive, + "fidelity": SchemeFidelity, + "fruit-salad": SchemeFruitSalad, + "monochrome": SchemeMonochrome, + "neutral": SchemeNeutral, + "rainbow": SchemeRainbow, + "vibrant": SchemeVibrant, + "content": SchemeContent, +} console = Console() -quality = int(sys.argv[2]) - -########### PILLOW METHOD ############# -start = default_timer() -image = Image.open(sys.argv[1]) -pixel_len = image.width * image.height -image_data = image.getdata() -# start = default_timer() -colors = QuantizeCelebi([image_data[i] for i in range(0, pixel_len, quality)], MAX_COLOR) -end = default_timer() -print(f"Color[pillow] generation took {end-start:.4f} secs") -############################## - -########## C++ Method ########## -start = default_timer() -# loading using c++ method -colors = ImageQuantizeCelebi(sys.argv[1], quality, MAX_COLOR) -end = default_timer() -print(f"Color[stb_image] generation took {end-start:.4f} secs") -###################### + +# Argument parsing +parser = argparse.ArgumentParser(description="Material You Color Scheme Test") +for scheme_name in DYNAMIC_SCHEMES.keys(): + parser.add_argument( + f"--{scheme_name}", + action="store_true", + help=f"Print the {scheme_name} dynamic scheme", + ) +parser.add_argument( + "--all", action="store_true", help="Print all dynamic schemes (default)" +) +parser.add_argument( + "--image", type=str, help="Path to an image file for color extraction" +) +parser.add_argument( + "--quality", + type=int, + default=5, + help="Quality for image quantization (default: 5)", +) +parser.add_argument( + "--method", + type=str, + choices=["pillow", "cpp"], + default="cpp", + help="Method for color quantization (default: cpp)", +) +args = parser.parse_args() + +colors = {} +if args.image: + if args.method == "pillow": + from materialyoucolor.quantize import QuantizeCelebi + + print("########## PILLOW METHOD ##########") + initial_memory = get_current_rss_mb() + start = time.time() + image = Image.open(args.image) + pixel_len = image.width * image.height + try: + image_data = image.get_flattened_data() + except: + image_data = image.getdata() + colors = QuantizeCelebi( + [image_data[i] for i in range(0, pixel_len, args.quality)], MAX_COLOR + ) + end = time.time() + final_memory = get_current_rss_mb() + print(f"Color[pillow] generation took {end - start:.4f} secs") + print(f"Peak RAM usage (Pillow): {final_memory - initial_memory:.2f} MB") + elif args.method == "cpp": + # Import ImageQuantizeCelebi for C++ method + from materialyoucolor.quantize import ImageQuantizeCelebi + + print("########## C++ Method ##########") + initial_memory = get_current_rss_mb() + start = time.time() + colors = ImageQuantizeCelebi(args.image, args.quality, MAX_COLOR) + end = time.time() + final_memory = get_current_rss_mb() + print(f"Color[stb_image] generation took {end - start:.4f} secs") + print(f"Peak RAM usage (C++): {final_memory - initial_memory:.2f} MB") +else: + # Since quantization is skipped, we need some default colors for testing. + # Let's use some hardcoded colors for now. + colors = { + 0xFFFF0000: 100, # Red + 0xFF00FF00: 500, # Green + 0xFF0000FF: 25, # Blue + 0xFFFFFF00: 75, # Yellow + } + selected = Score.score(colors) if os.name == "nt": - # UnicodeEncodeError: 'charmap' codec can't encode characters in position 0-5: character maps to exit(0) print("All dominant colors ({}) :\n".format(MAX_COLOR)) @@ -61,7 +127,12 @@ for color in colors.keys(): rgb = rgba_from_argb(color)[:-1] - print("\x1b[48;2;{};{};{}m \x1b[0m".format(*rgb), end="") + print( + "\x1b[48;2;{};{};{}m \x1b[0m".format( + round(rgb[0]), round(rgb[1]), round(rgb[2]) + ), + end="", + ) pused_colors += 1 if pused_colors % 16 == 0: print() @@ -74,27 +145,30 @@ for color in selected: rgb = rgba_from_argb(color) - __ = rgba_to_hex(rgb)[:-2] st.add_row( - "[{}]██████[/{}]".format(__, __), - str(rgb[:-1]), + "[{}]██████[/{}]".format(*[hex_from_rgba(rgb)[:-2]] * 2), + str([round(c) for c in rgb[:-1]]), str(colors[color]), ) console.print(st) + +# SKIP STATIC SCHEME def print_scheme(scheme_function, name): print() - schemes = [scheme_function(rgb) for rgb in selected] + schemes = [scheme_function(color_argb) for color_argb in selected] # Pass ARGB int ssct = Table(title=name, title_justify="left") ssct.add_column("Name") - for rgb in selected: - co = rgba_to_hex(rgba_from_argb(rgb))[:-2] + for color_argb in selected: # Use color_argb for clarity + co = hex_from_rgba(rgba_from_argb(color_argb))[:-2] ssct.add_column("[{}]██████[/{}]".format(co, co)) - for key in schemes[0].props.keys(): + for key in schemes[0].to_json().keys(): __ = (key,) for scheme in schemes: - color = rgba_to_hex(scheme.props[key])[:-2] + color = hex_from_rgba(rgba_from_argb(scheme.to_json()[key]))[ + :-2 + ] # Ensure color is ARGB int __ += ("[{}]██████[/{}]".format(color, color),) ssct.add_row(*__) console.print(ssct) @@ -117,42 +191,65 @@ def print_scheme(scheme_function, name): def print_dynamic_scheme(scheme_class): print() - color = rgba_to_hex(rgba_from_argb(selected[0]))[:-2] + + color = hex_from_rgba(rgba_from_argb(selected[0]))[:-2] contrast = 0 ssct = Table(title=str(scheme_class).split(".")[-1][:-2], title_justify="left") ssct.add_column("Color : [{}]██████[/{}]".format(color, color)) - ssct.add_column("Light") - ssct.add_column("Dark") - scheme_l = scheme_class(Hct.from_int(selected[0]), False, contrast) - scheme_d = scheme_class(Hct.from_int(selected[0]), True, contrast) - - for color in vars(MaterialDynamicColors).keys(): - __ = getattr(MaterialDynamicColors, color) - if hasattr(__, "get_hct"): - ssct.add_row( - color, - "[{}]██████[/{}]".format( - *[rgba_to_hex(__.get_hct(scheme_l).to_rgba())[:-2]] * 2 - ), - "[{}]██████[/{}]".format( - *[rgba_to_hex(__.get_hct(scheme_d).to_rgba())[:-2]] * 2 - ), - ) + + ssct.add_column("L(2021)") + ssct.add_column("L(2025)") + ssct.add_column("D(2021)") + ssct.add_column("D(2025)") + + opts_l = [Hct.from_int(selected[0]), False, contrast] + opts_d = [Hct.from_int(selected[0]), True, contrast] + + mdc2025 = MaterialDynamicColors(spec="2025") + scheme_l_2025 = scheme_class(*opts_l, spec_version="2025") + scheme_d_2025 = scheme_class(*opts_d, spec_version="2025") + + mdc2021 = MaterialDynamicColors(spec="2021") + scheme_l_2021 = scheme_class(*opts_l, spec_version="2021") + scheme_d_2021 = scheme_class(*opts_d, spec_version="2021") + + get_color = lambda c, l: ( + "[{}]██████[/{}]".format( + # strip alpha from hex + # | + # V + *[c.get_hex(l)[:-2]] + * 2 + ) + if c is not None + else " NONE" + ) + + for color in COLOR_NAMES: + c_2025 = getattr(mdc2025, color) + c_2021 = getattr(mdc2021, color) + ssct.add_row( + color, + get_color(c_2021, scheme_l_2021), + get_color(c_2025, scheme_l_2025), + get_color(c_2021, scheme_d_2021), + get_color(c_2025, scheme_d_2025), + ) + console.print(ssct) print() -[ +# Determine which schemes to print +schemes_to_print = [] +if args.all or not any( + getattr(args, name.replace("-", "_")) for name in DYNAMIC_SCHEMES.keys() +): + schemes_to_print = DYNAMIC_SCHEMES.values() +else: + for name, scheme_class in DYNAMIC_SCHEMES.items(): + if getattr(args, name.replace("-", "_")): + schemes_to_print.append(scheme_class) + +for s in schemes_to_print: print_dynamic_scheme(s) - for s in [ - SchemeTonalSpot, - SchemeExpressive, - SchemeFidelity, - SchemeFruitSalad, - SchemeMonochrome, - SchemeNeutral, - SchemeRainbow, - SchemeVibrant, - SchemeContent, - ] -]