PyDiffGame 2.0.1__tar.gz → 2.0.2__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (68) hide show
  1. {pydiffgame-2.0.1 → pydiffgame-2.0.2}/.github/workflows/python-publish.yml +44 -3
  2. {pydiffgame-2.0.1 → pydiffgame-2.0.2}/CLAUDE.md +15 -5
  3. {pydiffgame-2.0.1 → pydiffgame-2.0.2}/CONTRIBUTING.md +31 -6
  4. {pydiffgame-2.0.1 → pydiffgame-2.0.2}/PKG-INFO +1 -1
  5. pydiffgame-2.0.2/images/logo.png +0 -0
  6. {pydiffgame-2.0.1 → pydiffgame-2.0.2}/pyproject.toml +1 -1
  7. {pydiffgame-2.0.1 → pydiffgame-2.0.2}/src/PyDiffGame/__init__.py +1 -1
  8. pydiffgame-2.0.2/tools/render_logo.py +295 -0
  9. {pydiffgame-2.0.1 → pydiffgame-2.0.2}/uv.lock +1 -1
  10. {pydiffgame-2.0.1 → pydiffgame-2.0.2}/.github/workflows/tests.yml +0 -0
  11. {pydiffgame-2.0.1 → pydiffgame-2.0.2}/.gitignore +0 -0
  12. {pydiffgame-2.0.1 → pydiffgame-2.0.2}/.pre-commit-config.yaml +0 -0
  13. {pydiffgame-2.0.1 → pydiffgame-2.0.2}/CITATIONS.bib +0 -0
  14. {pydiffgame-2.0.1 → pydiffgame-2.0.2}/CODE_OF_CONDUCT.md +0 -0
  15. {pydiffgame-2.0.1 → pydiffgame-2.0.2}/LICENSE +0 -0
  16. {pydiffgame-2.0.1 → pydiffgame-2.0.2}/README.md +0 -0
  17. {pydiffgame-2.0.1 → pydiffgame-2.0.2}/_config.yml +0 -0
  18. {pydiffgame-2.0.1 → pydiffgame-2.0.2}/docs/README.md +0 -0
  19. {pydiffgame-2.0.1 → pydiffgame-2.0.2}/images/Logo_ISTRC_Green_English.png +0 -0
  20. {pydiffgame-2.0.1 → pydiffgame-2.0.2}/images/logo_abc.png +0 -0
  21. /pydiffgame-2.0.1/images/logo.png → /pydiffgame-2.0.2/images/logo_source.png +0 -0
  22. {pydiffgame-2.0.1 → pydiffgame-2.0.2}/images/readme/masses_cost.png +0 -0
  23. {pydiffgame-2.0.1 → pydiffgame-2.0.2}/images/readme/masses_game_vs_lqr.png +0 -0
  24. {pydiffgame-2.0.1 → pydiffgame-2.0.2}/images/readme/masses_schematic.png +0 -0
  25. {pydiffgame-2.0.1 → pydiffgame-2.0.2}/requirements.txt +0 -0
  26. {pydiffgame-2.0.1 → pydiffgame-2.0.2}/src/PyDiffGame/_typing.py +0 -0
  27. {pydiffgame-2.0.1 → pydiffgame-2.0.2}/src/PyDiffGame/base.py +0 -0
  28. {pydiffgame-2.0.1 → pydiffgame-2.0.2}/src/PyDiffGame/comparison.py +0 -0
  29. {pydiffgame-2.0.1 → pydiffgame-2.0.2}/src/PyDiffGame/continuous.py +0 -0
  30. {pydiffgame-2.0.1 → pydiffgame-2.0.2}/src/PyDiffGame/discrete.py +0 -0
  31. {pydiffgame-2.0.1 → pydiffgame-2.0.2}/src/PyDiffGame/examples/InvertedPendulumComparison.py +0 -0
  32. {pydiffgame-2.0.1 → pydiffgame-2.0.2}/src/PyDiffGame/examples/MassesWithSpringsComparison.py +0 -0
  33. {pydiffgame-2.0.1 → pydiffgame-2.0.2}/src/PyDiffGame/examples/PVTOL.py +0 -0
  34. {pydiffgame-2.0.1 → pydiffgame-2.0.2}/src/PyDiffGame/examples/PVTOLComparison.py +0 -0
  35. {pydiffgame-2.0.1 → pydiffgame-2.0.2}/src/PyDiffGame/examples/QuadRotorControl.py +0 -0
  36. {pydiffgame-2.0.1 → pydiffgame-2.0.2}/src/PyDiffGame/examples/figures/2/2-players_large_1.png +0 -0
  37. {pydiffgame-2.0.1 → pydiffgame-2.0.2}/src/PyDiffGame/examples/figures/2/2-players_large_2.png +0 -0
  38. {pydiffgame-2.0.1 → pydiffgame-2.0.2}/src/PyDiffGame/examples/figures/2/LQR_large_1.png +0 -0
  39. {pydiffgame-2.0.1 → pydiffgame-2.0.2}/src/PyDiffGame/examples/figures/2/LQR_large_2.png +0 -0
  40. {pydiffgame-2.0.1 → pydiffgame-2.0.2}/src/PyDiffGame/examples/figures/2/two_masses_tikz.png +0 -0
  41. {pydiffgame-2.0.1 → pydiffgame-2.0.2}/src/PyDiffGame/examples/figures/4/4-players_large_1.png +0 -0
  42. {pydiffgame-2.0.1 → pydiffgame-2.0.2}/src/PyDiffGame/examples/figures/4/4-players_large_2.png +0 -0
  43. {pydiffgame-2.0.1 → pydiffgame-2.0.2}/src/PyDiffGame/examples/figures/4/LQR_large_1.png +0 -0
  44. {pydiffgame-2.0.1 → pydiffgame-2.0.2}/src/PyDiffGame/examples/figures/4/LQR_large_2.png +0 -0
  45. {pydiffgame-2.0.1 → pydiffgame-2.0.2}/src/PyDiffGame/examples/figures/8/8-players_large_1.png +0 -0
  46. {pydiffgame-2.0.1 → pydiffgame-2.0.2}/src/PyDiffGame/examples/figures/8/8-players_large_2.png +0 -0
  47. {pydiffgame-2.0.1 → pydiffgame-2.0.2}/src/PyDiffGame/examples/figures/8/LQR_large_1.png +0 -0
  48. {pydiffgame-2.0.1 → pydiffgame-2.0.2}/src/PyDiffGame/examples/figures/8/LQR_large_2.png +0 -0
  49. {pydiffgame-2.0.1 → pydiffgame-2.0.2}/src/PyDiffGame/examples/figures/PVTOL/PVTOL1.png +0 -0
  50. {pydiffgame-2.0.1 → pydiffgame-2.0.2}/src/PyDiffGame/examples/figures/PVTOL/PVTOL10.png +0 -0
  51. {pydiffgame-2.0.1 → pydiffgame-2.0.2}/src/PyDiffGame/examples/figures/PVTOL/PVTOL100.png +0 -0
  52. {pydiffgame-2.0.1 → pydiffgame-2.0.2}/src/PyDiffGame/examples/figures/PVTOL/PVTOL1000.png +0 -0
  53. {pydiffgame-2.0.1 → pydiffgame-2.0.2}/src/PyDiffGame/examples/figures/PVTOL0001.png +0 -0
  54. {pydiffgame-2.0.1 → pydiffgame-2.0.2}/src/PyDiffGame/examples/figures/PVTOL001.png +0 -0
  55. {pydiffgame-2.0.1 → pydiffgame-2.0.2}/src/PyDiffGame/examples/figures/PVTOL01.png +0 -0
  56. {pydiffgame-2.0.1 → pydiffgame-2.0.2}/src/PyDiffGame/examples/figures/PVTOL1.png +0 -0
  57. {pydiffgame-2.0.1 → pydiffgame-2.0.2}/src/PyDiffGame/lqr.py +0 -0
  58. {pydiffgame-2.0.1 → pydiffgame-2.0.2}/src/PyDiffGame/objective.py +0 -0
  59. {pydiffgame-2.0.1 → pydiffgame-2.0.2}/src/PyDiffGame/plotting.py +0 -0
  60. {pydiffgame-2.0.1 → pydiffgame-2.0.2}/tests/conftest.py +0 -0
  61. {pydiffgame-2.0.1 → pydiffgame-2.0.2}/tests/test_discrete.py +0 -0
  62. {pydiffgame-2.0.1 → pydiffgame-2.0.2}/tests/test_examples.py +0 -0
  63. {pydiffgame-2.0.1 → pydiffgame-2.0.2}/tests/test_game.py +0 -0
  64. {pydiffgame-2.0.1 → pydiffgame-2.0.2}/tests/test_lqr.py +0 -0
  65. {pydiffgame-2.0.1 → pydiffgame-2.0.2}/tests/test_objective.py +0 -0
  66. {pydiffgame-2.0.1 → pydiffgame-2.0.2}/tests/test_simulation.py +0 -0
  67. {pydiffgame-2.0.1 → pydiffgame-2.0.2}/tools/bump_version.py +0 -0
  68. {pydiffgame-2.0.1 → pydiffgame-2.0.2}/tools/generate_readme_figures.py +0 -0
@@ -1,9 +1,28 @@
1
1
  # Publishes the package to PyPI and creates the matching GitHub Release,
2
- # auto-incrementing the version each run.
2
+ # auto-incrementing the version when source files change on master.
3
3
  #
4
- # Flow (just run it — Actions -> Upload Python Package -> Run workflow):
4
+ # Triggers:
5
+ # - push to master that touches SOURCE files (the package or its packaging
6
+ # metadata) — docs / tests / tooling / CI changes do not cut a release.
7
+ # - workflow_dispatch: manual trigger remains available for on-demand re-runs
8
+ # (use this if you ever need to publish despite no source-file changes).
9
+ #
10
+ # Loop prevention: the workflow itself commits a "chore: bump version" commit
11
+ # back to master. Three independent guards stop that commit from re-triggering
12
+ # the workflow:
13
+ # 1. GitHub Actions natively skips push events whose head commit message
14
+ # contains "[skip ci]" (which the bump commit always does).
15
+ # 2. The bump-version job is guarded with an explicit `if:` so it only runs
16
+ # for human commits / manual dispatch, never for github-actions[bot].
17
+ # 3. The bump commit message itself contains "[skip ci]" so even if (1) and
18
+ # (2) ever break, the message still signals "do not republish".
19
+ #
20
+ # Concurrency: serialize runs so two pushes close together don't race on the
21
+ # bump commit's git push.
22
+ #
23
+ # Flow per run:
5
24
  # 1. bump-version : increment the version (carry-at-9 via tools/bump_version.py),
6
- # commit it back to master.
25
+ # commit it back to master with [skip ci].
7
26
  # 2. release-build : build the dists from the bumped master.
8
27
  # 3. pypi-publish : upload to PyPI via Trusted Publishing (OIDC, no tokens).
9
28
  # 4. github-release: create the v<version> GitHub Release with notes + dists.
@@ -13,14 +32,36 @@
13
32
  name: Upload Python Package
14
33
 
15
34
  on:
35
+ push:
36
+ branches: [master]
37
+ # Only publish when source files change. Docs/tests/tooling commits do not
38
+ # cut a release; mixed commits do (the filter matches if ANY changed file
39
+ # matches).
40
+ paths:
41
+ - 'src/PyDiffGame/**'
42
+ - 'pyproject.toml'
16
43
  workflow_dispatch:
17
44
 
18
45
  permissions:
19
46
  contents: read
20
47
 
48
+ concurrency:
49
+ group: publish-master
50
+ cancel-in-progress: false
51
+
21
52
  jobs:
22
53
  bump-version:
23
54
  runs-on: ubuntu-latest
55
+ # Defense-in-depth loop guard: only release for human commits or manual
56
+ # dispatch — never for the workflow's own [skip ci] bump commit.
57
+ if: >-
58
+ ${{
59
+ github.event_name == 'workflow_dispatch' ||
60
+ (
61
+ github.actor != 'github-actions[bot]' &&
62
+ !contains(github.event.head_commit.message, '[skip ci]')
63
+ )
64
+ }}
24
65
  permissions:
25
66
  contents: write
26
67
  outputs:
@@ -16,11 +16,21 @@
16
16
  do not bump it in ordinary PRs — the release run does it.
17
17
 
18
18
  ## Releasing
19
- - `Actions -> Upload Python Package -> Run workflow` (on `master`). The workflow
20
- auto-increments the version, commits it to `master`, builds with `uv build`,
21
- publishes to PyPI via Trusted Publishing (OIDC, no tokens), and creates the matching
22
- `v<version>` GitHub Release with notes and the built dists attached. It is idempotent
23
- (`skip-existing`).
19
+ - **Continuous deployment, gated on source changes.** Every commit to `master`
20
+ that touches `src/PyDiffGame/**` or `pyproject.toml` automatically triggers
21
+ the publish workflow. Docs / tests / tooling / CI changes do **not** trigger
22
+ a release on their own; mixed commits do.
23
+ - The workflow auto-increments the version (`tools/bump_version.py`), commits
24
+ the bump to `master` as `chore: bump version to X.Y.Z [skip ci]`, builds with
25
+ `uv build`, publishes to PyPI via Trusted Publishing (OIDC, no tokens), and
26
+ creates the matching `v<version>` GitHub Release with notes and the built
27
+ dists attached. It is idempotent (`skip-existing`).
28
+ - Manual on-demand publish stays available via
29
+ `Actions -> Upload Python Package -> Run workflow` (`workflow_dispatch`).
30
+ - Three independent guards prevent the bump commit from re-triggering the
31
+ workflow (an infinite loop): `[skip ci]` in the message (which GitHub Actions
32
+ natively honors), an explicit job-level `if:` filtering out
33
+ `github-actions[bot]`, and `paths:` filter scoping to source files.
24
34
 
25
35
  ## Docs
26
36
  - `README.md` is the single canonical readme and is also the PyPI long-description
@@ -41,15 +41,25 @@ they pass locally.
41
41
 
42
42
  ## Releasing
43
43
 
44
- Publishing a new version is a single automated step just run the publish
45
- workflow: **Actions -> Upload Python Package -> Run workflow** (on `master`).
44
+ The project is on **continuous deployment for source changes**: every commit
45
+ to `master` that touches source files automatically cuts a new release.
46
46
 
47
- The run automatically:
47
+ A commit triggers a release when it changes any of:
48
+
49
+ - `src/PyDiffGame/**` — the package itself
50
+ - `pyproject.toml` — packaging metadata, dependencies, classifiers
51
+
52
+ Docs (`*.md`, `docs/**`), tests (`tests/**`), tooling (`tools/**`, `.github/**`,
53
+ `.pre-commit-config.yaml`), images and the lock file do **not** trigger a
54
+ release on their own. A mixed commit (e.g. `src/foo.py` + `README.md`) does
55
+ trigger one — the path filter matches if any changed file matches.
56
+
57
+ When a release-triggering commit lands on `master`, the publish workflow:
48
58
 
49
59
  1. **Increments the version** with `tools/bump_version.py`, which rolls each
50
- component over at 9 (`2.0.9 -> 2.1.0`, `2.9.9 -> 3.0.0`), updating both
51
- `pyproject.toml` and `src/PyDiffGame/__init__.py`, and commits the bump to
52
- `master`.
60
+ component over at 9 (`2.0.9 -> 2.1.0`, `2.9.9 -> 3.0.0`), updates both
61
+ `pyproject.toml` and `src/PyDiffGame/__init__.py`, and commits the bump back
62
+ to `master` as `chore: bump version to X.Y.Z [skip ci]`.
53
63
  2. Builds the distributions with `uv build`.
54
64
  3. Uploads them to PyPI via
55
65
  [Trusted Publishing](https://docs.pypi.org/trusted-publishers/) (OIDC — no
@@ -57,11 +67,26 @@ The run automatically:
57
67
  4. Creates a `v<version>` GitHub Release with auto-generated notes and the built
58
68
  wheels/sdist attached.
59
69
 
70
+ The workflow can also be triggered manually from **Actions -> Upload Python
71
+ Package -> Run workflow** if you ever need to re-run it.
72
+
60
73
  You normally never edit the version by hand. To bump it locally (e.g. to test),
61
74
  run `uv run python tools/bump_version.py` (`--dry-run` to preview, `--current`
62
75
  to print the current version). The PyPI upload is idempotent (`skip-existing`),
63
76
  so re-running the workflow is safe.
64
77
 
78
+ ### What stops an infinite loop?
79
+
80
+ The publish workflow itself commits the version bump back to `master`. Three
81
+ independent guards stop that commit from re-triggering the workflow:
82
+
83
+ 1. The bump commit message contains `[skip ci]`, which GitHub Actions natively
84
+ honors by **not creating a workflow run at all** for that push.
85
+ 2. The `bump-version` job has an explicit `if:` that skips when the actor is
86
+ `github-actions[bot]` or the head-commit message contains `[skip ci]`.
87
+ 3. `bump_version.py` is the single source of truth for the version, so a
88
+ tampered bump commit still wouldn't double-bump.
89
+
65
90
  Thank you for your contribution!
66
91
 
67
92
  Joshua Shay Kricheli
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: PyDiffGame
3
- Version: 2.0.1
3
+ Version: 2.0.2
4
4
  Summary: Nash-equilibrium solutions to linear-quadratic differential games, via a reduction of the Game Hamilton-Jacobi-Bellman equations to coupled algebraic and differential Riccati equations for multi-objective dynamical control systems.
5
5
  Project-URL: Homepage, https://krichelj.github.io/PyDiffGame/
6
6
  Project-URL: Repository, https://github.com/krichelj/PyDiffGame
Binary file
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "PyDiffGame"
7
- version = "2.0.1"
7
+ version = "2.0.2"
8
8
  authors = [
9
9
  { name = "Joshua Shay Kricheli", email = "skricheli2@gmail.com" },
10
10
  ]
@@ -34,7 +34,7 @@ from PyDiffGame.discrete import DiscretePyDiffGame
34
34
  from PyDiffGame.lqr import ContinuousLQR, DiscreteLQR
35
35
  from PyDiffGame.objective import GameObjective, LQRObjective, Objective
36
36
 
37
- __version__ = "2.0.1"
37
+ __version__ = "2.0.2"
38
38
 
39
39
  __all__ = [
40
40
  "PyDiffGame",
@@ -0,0 +1,295 @@
1
+ """Render a translucent, glossy-glass variant of the PyDiffGame logo.
2
+
3
+ Reads the original ``images/logo.png`` (red glasses-shaped mark + black
4
+ wordmark) and restyles each layer as polished glass: a vertical tonal gradient
5
+ that keeps the original palette (red mark, near-black wordmark), a soft
6
+ matched-colour outer glow, a top-edge inner highlight, a broad top-half gloss
7
+ band ("glaring" specular), and a crisp edge stroke. The transformation is
8
+ deterministic so iterations can be compared side-by-side.
9
+
10
+ Knobs are exposed via CLI flags so iterations can tune the look without
11
+ editing the file.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import argparse
17
+ from pathlib import Path
18
+
19
+ import numpy as np
20
+ from PIL import Image, ImageChops, ImageEnhance, ImageFilter
21
+
22
+ ROOT = Path(__file__).resolve().parent.parent
23
+ # Source is the immutable original (red mark + black wordmark) committed
24
+ # alongside this script. The renderer reads it and writes a styled copy
25
+ # (typically over images/logo.png) so the source survives restyles.
26
+ SRC = ROOT / "images" / "logo_source.png"
27
+
28
+
29
+ def _split_mark_and_text(src: Image.Image) -> tuple[Image.Image, Image.Image]:
30
+ """Split the original logo into (mark, wordmark) layers by colour.
31
+
32
+ The original logo has a red mark (the abstract glasses/molecule) and a
33
+ black wordmark "PYDIFFGAME". We separate them so the new style can treat
34
+ them differently (different hues, different translucency).
35
+ """
36
+ rgba = np.asarray(src.convert("RGBA"), dtype=np.int16)
37
+ r, g, b, a = rgba[..., 0], rgba[..., 1], rgba[..., 2], rgba[..., 3]
38
+ # Red mark: red dominates and pixel is not transparent.
39
+ red_mask = (r > 120) & (r - g > 40) & (r - b > 40) & (a > 32)
40
+ # Black wordmark: low luminance, non-transparent.
41
+ luma = (r + g + b) // 3
42
+ black_mask = (luma < 90) & (a > 32) & ~red_mask
43
+
44
+ mark = np.zeros_like(rgba, dtype=np.uint8)
45
+ mark[red_mask] = [255, 255, 255, 255]
46
+
47
+ text = np.zeros_like(rgba, dtype=np.uint8)
48
+ text[black_mask] = [255, 255, 255, 255]
49
+
50
+ return (
51
+ Image.fromarray(mark, "RGBA"),
52
+ Image.fromarray(text, "RGBA"),
53
+ )
54
+
55
+
56
+ def _tint(mask: Image.Image, color: tuple[int, int, int], alpha: int) -> Image.Image:
57
+ """Tint a white-on-transparent mask with ``color`` at ``alpha`` opacity."""
58
+ arr = np.asarray(mask, dtype=np.uint8).copy()
59
+ a_in = arr[..., 3].astype(np.float32) / 255.0
60
+ out = np.zeros_like(arr)
61
+ out[..., 0] = color[0]
62
+ out[..., 1] = color[1]
63
+ out[..., 2] = color[2]
64
+ out[..., 3] = (a_in * alpha).astype(np.uint8)
65
+ return Image.fromarray(out, "RGBA")
66
+
67
+
68
+ def _gradient_fill(
69
+ mask: Image.Image,
70
+ top_color: tuple[int, int, int],
71
+ bottom_color: tuple[int, int, int],
72
+ alpha: int,
73
+ ) -> Image.Image:
74
+ """Fill ``mask`` with a vertical gradient anchored to the mask's bbox.
75
+
76
+ Pixels above the bbox get ``top_color``, pixels below get ``bottom_color``,
77
+ and within the bbox the colour interpolates linearly top→bottom. The
78
+ output alpha is the mask alpha scaled by ``alpha``.
79
+ """
80
+ w, h = mask.size
81
+ a = np.asarray(mask, dtype=np.uint8)[..., 3]
82
+ bbox = mask.getbbox()
83
+ if bbox is None:
84
+ return Image.new("RGBA", (w, h), (0, 0, 0, 0))
85
+ y0, y1 = bbox[1], bbox[3]
86
+ ys = np.arange(h, dtype=np.float32)
87
+ t = np.clip((ys - y0) / max(y1 - y0, 1), 0.0, 1.0)
88
+ top_arr = np.array(top_color, dtype=np.float32)
89
+ bot_arr = np.array(bottom_color, dtype=np.float32)
90
+ line = top_arr * (1 - t)[:, None] + bot_arr * t[:, None]
91
+ rgb = np.broadcast_to(line[:, None, :], (h, w, 3)).astype(np.uint8)
92
+ a_norm = a.astype(np.float32) / 255.0
93
+ out_alpha = (a_norm * (alpha / 255.0) * 255.0).astype(np.uint8)
94
+ out = np.zeros((h, w, 4), dtype=np.uint8)
95
+ out[..., :3] = rgb
96
+ out[..., 3] = out_alpha
97
+ return Image.fromarray(out, "RGBA")
98
+
99
+
100
+ def _outer_glow(
101
+ mask: Image.Image,
102
+ color: tuple[int, int, int],
103
+ radius: float,
104
+ alpha: int,
105
+ ) -> Image.Image:
106
+ """Soft outer glow built by blurring a tinted copy of the mask."""
107
+ glow = _tint(mask, color, alpha)
108
+ return glow.filter(ImageFilter.GaussianBlur(radius=radius))
109
+
110
+
111
+ def _inner_highlight(mask: Image.Image, offset: int, alpha: int) -> Image.Image:
112
+ """Top-edge inner highlight to suggest glass curvature."""
113
+ base = np.asarray(mask, dtype=np.uint8)
114
+ a = base[..., 3]
115
+ shifted = np.zeros_like(a)
116
+ shifted[offset:, :] = a[:-offset, :]
117
+ inner = np.minimum(a, 255 - shifted)
118
+ out = np.zeros_like(base)
119
+ out[..., 0] = 255
120
+ out[..., 1] = 255
121
+ out[..., 2] = 255
122
+ out[..., 3] = (inner.astype(np.float32) / 255.0 * alpha).astype(np.uint8)
123
+ img = Image.fromarray(out, "RGBA").filter(ImageFilter.GaussianBlur(radius=0.8))
124
+ return img
125
+
126
+
127
+ def _gloss_band(
128
+ mask: Image.Image,
129
+ height_frac: float,
130
+ alpha: int,
131
+ feather: float,
132
+ color: tuple[int, int, int] = (255, 255, 255),
133
+ ) -> Image.Image:
134
+ """Broad top-half specular gloss clipped to the shape (the "glare").
135
+
136
+ Builds a vertical falloff that is fully opaque at the top of the mask's
137
+ bounding box and fades to zero ``height_frac`` of the way down it, then
138
+ multiplies that falloff by the shape's own alpha so the gloss only
139
+ appears on the shape's surface. A small Gaussian blur softens the edge.
140
+ """
141
+ a = np.asarray(mask, dtype=np.uint8)[..., 3]
142
+ bbox = mask.getbbox()
143
+ if bbox is None:
144
+ return Image.new("RGBA", mask.size, (0, 0, 0, 0))
145
+ y0, y1 = bbox[1], bbox[3]
146
+ h = max(y1 - y0, 1)
147
+ band_h = max(int(h * height_frac), 1)
148
+
149
+ falloff = np.zeros_like(a, dtype=np.float32)
150
+ ys = np.arange(a.shape[0], dtype=np.float32)
151
+ weight = np.clip(1.0 - (ys - y0) / band_h, 0.0, 1.0)
152
+ falloff[:] = weight[:, None]
153
+
154
+ a_norm = a.astype(np.float32) / 255.0
155
+ out_alpha = (a_norm * falloff * (alpha / 255.0) * 255.0).astype(np.uint8)
156
+
157
+ out = np.zeros((*a.shape, 4), dtype=np.uint8)
158
+ out[..., 0] = color[0]
159
+ out[..., 1] = color[1]
160
+ out[..., 2] = color[2]
161
+ out[..., 3] = out_alpha
162
+ img = Image.fromarray(out, "RGBA")
163
+ if feather > 0:
164
+ img = img.filter(ImageFilter.GaussianBlur(radius=feather))
165
+ return img
166
+
167
+
168
+ def _stroke(mask: Image.Image, color: tuple[int, int, int], alpha: int, width: int = 1) -> Image.Image:
169
+ """Crisp outline around the shape (used for glass-edge feel)."""
170
+ a = np.asarray(mask, dtype=np.uint8)[..., 3]
171
+ eroded = Image.fromarray(a, "L").filter(ImageFilter.MinFilter(2 * width + 1))
172
+ edge = ImageChops.subtract(Image.fromarray(a, "L"), eroded)
173
+ out = np.zeros((*a.shape, 4), dtype=np.uint8)
174
+ out[..., 0] = color[0]
175
+ out[..., 1] = color[1]
176
+ out[..., 2] = color[2]
177
+ out[..., 3] = (np.asarray(edge, dtype=np.float32) / 255.0 * alpha).astype(np.uint8)
178
+ return Image.fromarray(out, "RGBA")
179
+
180
+
181
+ def render(
182
+ out_path: Path,
183
+ *,
184
+ mark_top: tuple[int, int, int] = (255, 95, 105),
185
+ mark_bottom: tuple[int, int, int] = (170, 10, 25),
186
+ text_top: tuple[int, int, int] = (8, 8, 12),
187
+ text_bottom: tuple[int, int, int] = (0, 0, 5),
188
+ mark_fill_alpha: int = 230,
189
+ text_fill_alpha: int = 252,
190
+ mark_glow_alpha: int = 140,
191
+ text_glow_alpha: int = 80,
192
+ mark_glow_radius: float = 5.5,
193
+ text_glow_radius: float = 2.8,
194
+ highlight_alpha: int = 235,
195
+ stroke_alpha: int = 200,
196
+ mark_gloss_alpha: int = 90,
197
+ text_gloss_alpha: int = 70,
198
+ gloss_height: float = 0.5,
199
+ gloss_feather: float = 3.5,
200
+ background: str = "transparent",
201
+ ) -> Path:
202
+ """Render the translucent cool-toned logo and save to ``out_path``."""
203
+ src = Image.open(SRC).convert("RGBA")
204
+ w, h = src.size
205
+ mark_mask, text_mask = _split_mark_and_text(src)
206
+
207
+ # Canvas (transparent or dark backdrop so glow reads in previews).
208
+ if background == "dark":
209
+ canvas = Image.new("RGBA", (w, h), (8, 14, 28, 255))
210
+ elif background == "light":
211
+ canvas = Image.new("RGBA", (w, h), (245, 248, 255, 255))
212
+ else:
213
+ canvas = Image.new("RGBA", (w, h), (0, 0, 0, 0))
214
+
215
+ # --- Mark (glasses) ---
216
+ # Order: outer glow (rim light) -> bbox-anchored gradient fill -> top
217
+ # gloss band (the broad specular glare) -> rim edge stroke -> crisp
218
+ # top-edge inner highlight on top.
219
+ mark_fill = _gradient_fill(mark_mask, mark_top, mark_bottom, mark_fill_alpha)
220
+ mark_glow = _outer_glow(mark_mask, mark_bottom, mark_glow_radius, mark_glow_alpha)
221
+ mark_gloss = _gloss_band(mark_mask, gloss_height, mark_gloss_alpha, gloss_feather)
222
+ mark_stroke = _stroke(mark_mask, mark_bottom, stroke_alpha, width=1)
223
+ mark_highlight = _inner_highlight(mark_mask, offset=3, alpha=highlight_alpha)
224
+
225
+ canvas.alpha_composite(mark_glow)
226
+ canvas.alpha_composite(mark_fill)
227
+ canvas.alpha_composite(mark_gloss)
228
+ canvas.alpha_composite(mark_stroke)
229
+ canvas.alpha_composite(mark_highlight)
230
+
231
+ # --- Wordmark ---
232
+ text_fill = _gradient_fill(text_mask, text_top, text_bottom, text_fill_alpha)
233
+ text_glow = _outer_glow(text_mask, text_bottom, text_glow_radius, text_glow_alpha)
234
+ text_gloss = _gloss_band(text_mask, gloss_height, text_gloss_alpha, gloss_feather)
235
+ text_stroke = _stroke(text_mask, text_bottom, max(stroke_alpha - 40, 0), width=1)
236
+ text_highlight = _inner_highlight(text_mask, offset=2, alpha=int(highlight_alpha * 0.75))
237
+
238
+ canvas.alpha_composite(text_glow)
239
+ canvas.alpha_composite(text_fill)
240
+ canvas.alpha_composite(text_gloss)
241
+ canvas.alpha_composite(text_stroke)
242
+ canvas.alpha_composite(text_highlight)
243
+
244
+ # Light overall sharpen so glass edges stay crisp after blurs.
245
+ sharpened = ImageEnhance.Sharpness(canvas).enhance(1.15)
246
+ sharpened.save(out_path)
247
+ return out_path
248
+
249
+
250
+ def _parse() -> argparse.Namespace:
251
+ p = argparse.ArgumentParser()
252
+ p.add_argument("--out", type=Path, required=True)
253
+ p.add_argument("--background", choices=["transparent", "dark", "light"], default="transparent")
254
+ p.add_argument("--mark-top", nargs=3, type=int, default=[255, 95, 105])
255
+ p.add_argument("--mark-bottom", nargs=3, type=int, default=[170, 10, 25])
256
+ p.add_argument("--text-top", nargs=3, type=int, default=[8, 8, 12])
257
+ p.add_argument("--text-bottom", nargs=3, type=int, default=[0, 0, 5])
258
+ p.add_argument("--mark-fill-alpha", type=int, default=230)
259
+ p.add_argument("--text-fill-alpha", type=int, default=252)
260
+ p.add_argument("--mark-glow-alpha", type=int, default=140)
261
+ p.add_argument("--text-glow-alpha", type=int, default=80)
262
+ p.add_argument("--mark-glow-radius", type=float, default=5.5)
263
+ p.add_argument("--text-glow-radius", type=float, default=2.8)
264
+ p.add_argument("--highlight-alpha", type=int, default=235)
265
+ p.add_argument("--stroke-alpha", type=int, default=200)
266
+ p.add_argument("--mark-gloss-alpha", type=int, default=90)
267
+ p.add_argument("--text-gloss-alpha", type=int, default=70)
268
+ p.add_argument("--gloss-height", type=float, default=0.5)
269
+ p.add_argument("--gloss-feather", type=float, default=3.5)
270
+ return p.parse_args()
271
+
272
+
273
+ if __name__ == "__main__":
274
+ args = _parse()
275
+ render(
276
+ args.out,
277
+ background=args.background,
278
+ mark_top=tuple(args.mark_top),
279
+ mark_bottom=tuple(args.mark_bottom),
280
+ text_top=tuple(args.text_top),
281
+ text_bottom=tuple(args.text_bottom),
282
+ mark_fill_alpha=args.mark_fill_alpha,
283
+ text_fill_alpha=args.text_fill_alpha,
284
+ mark_glow_alpha=args.mark_glow_alpha,
285
+ text_glow_alpha=args.text_glow_alpha,
286
+ mark_glow_radius=args.mark_glow_radius,
287
+ text_glow_radius=args.text_glow_radius,
288
+ highlight_alpha=args.highlight_alpha,
289
+ stroke_alpha=args.stroke_alpha,
290
+ mark_gloss_alpha=args.mark_gloss_alpha,
291
+ text_gloss_alpha=args.text_gloss_alpha,
292
+ gloss_height=args.gloss_height,
293
+ gloss_feather=args.gloss_feather,
294
+ )
295
+ print(f"wrote {args.out}")
@@ -877,7 +877,7 @@ wheels = [
877
877
 
878
878
  [[package]]
879
879
  name = "pydiffgame"
880
- version = "2.0.0"
880
+ version = "2.0.1"
881
881
  source = { editable = "." }
882
882
  dependencies = [
883
883
  { name = "matplotlib" },
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes