starhue 1.0.0__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.
- starhue-1.0.0/LICENSE +28 -0
- starhue-1.0.0/PKG-INFO +6 -0
- starhue-1.0.0/README.md +281 -0
- starhue-1.0.0/pyproject.toml +12 -0
- starhue-1.0.0/setup.cfg +4 -0
- starhue-1.0.0/starhue/__init__.py +57 -0
- starhue-1.0.0/starhue/__main__.py +8 -0
- starhue-1.0.0/starhue/cct.py +82 -0
- starhue-1.0.0/starhue/classify.py +80 -0
- starhue-1.0.0/starhue/cli.py +125 -0
- starhue-1.0.0/starhue/color.py +348 -0
- starhue-1.0.0/starhue/constants.py +30 -0
- starhue-1.0.0/starhue/physics.py +170 -0
- starhue-1.0.0/starhue/render.py +204 -0
- starhue-1.0.0/starhue/star.py +137 -0
- starhue-1.0.0/starhue.egg-info/PKG-INFO +6 -0
- starhue-1.0.0/starhue.egg-info/SOURCES.txt +17 -0
- starhue-1.0.0/starhue.egg-info/dependency_links.txt +1 -0
- starhue-1.0.0/starhue.egg-info/top_level.txt +1 -0
starhue-1.0.0/LICENSE
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
BSD 3-Clause License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026, Devesh Aggarwal
|
|
4
|
+
|
|
5
|
+
Redistribution and use in source and binary forms, with or without
|
|
6
|
+
modification, are permitted provided that the following conditions are met:
|
|
7
|
+
|
|
8
|
+
1. Redistributions of source code must retain the above copyright notice, this
|
|
9
|
+
list of conditions and the following disclaimer.
|
|
10
|
+
|
|
11
|
+
2. Redistributions in binary form must reproduce the above copyright notice,
|
|
12
|
+
this list of conditions and the following disclaimer in the documentation
|
|
13
|
+
and/or other materials provided with the distribution.
|
|
14
|
+
|
|
15
|
+
3. Neither the name of the copyright holder nor the names of its
|
|
16
|
+
contributors may be used to endorse or promote products derived from
|
|
17
|
+
this software without specific prior written permission.
|
|
18
|
+
|
|
19
|
+
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
|
|
20
|
+
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
|
|
21
|
+
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
|
|
22
|
+
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
|
|
23
|
+
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
|
|
24
|
+
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
|
|
25
|
+
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
|
|
26
|
+
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
|
|
27
|
+
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
|
|
28
|
+
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
starhue-1.0.0/PKG-INFO
ADDED
starhue-1.0.0/README.md
ADDED
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
# starhue
|
|
2
|
+
|
|
3
|
+
`starhue` takes a temperature value in kelvin, and returns the actual sRGB color that a
|
|
4
|
+
blackbody of that temperature would show to your eye — along with the physics behind it:
|
|
5
|
+
the Planck spectrum, the Wien peak, Stefan–Boltzmann exitance, and the Harvard spectral
|
|
6
|
+
class. It also runs in reverse, turning a color back into a correlated color temperature
|
|
7
|
+
(CCT), and renders it all as a star card in your terminal.
|
|
8
|
+
|
|
9
|
+
<p align="center">
|
|
10
|
+
<img src="starhue/assets/sun.png" alt="starhue card for the Sun (5772 K)" width="640">
|
|
11
|
+
</p>
|
|
12
|
+
|
|
13
|
+
---
|
|
14
|
+
|
|
15
|
+
## Installation
|
|
16
|
+
|
|
17
|
+
`starhue` is pure standard library — no third-party dependencies, just Python 3.8+.
|
|
18
|
+
Clone the repo and install it editable into a virtualenv:
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
git clone https://github.com/devesh-aggarwal/starhue.git
|
|
22
|
+
cd starhue
|
|
23
|
+
python -m venv .venv && source .venv/bin/activate
|
|
24
|
+
python -m pip install -e .
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
There is nothing else to fetch. Confirm it works:
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
python -m starhue 5772 # should print a star card for the Sun
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
## Quick start
|
|
34
|
+
|
|
35
|
+
The standard use case is one or more temperatures in kelvin → a star card per temperature:
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
python -m starhue 5772 # the Sun
|
|
39
|
+
python -m starhue 3500 5772 12100 # several stars at once
|
|
40
|
+
python -m starhue --from-color '#ffd1a3' # inverse: a color → its nearest blackbody
|
|
41
|
+
python -m starhue 5772 --no-spectrum # card without the sparkline
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
`python -m starhue 5772` prints:
|
|
45
|
+
|
|
46
|
+
```text
|
|
47
|
+
╭────────────────────────────────────────────────╮
|
|
48
|
+
│ ★ 5772 K class G │
|
|
49
|
+
│ ████████ #fff1ea rgb(255, 241, 234) │
|
|
50
|
+
│ yellow, Sun-like · neutral white │
|
|
51
|
+
│ │
|
|
52
|
+
│ peak λ 502.0 nm · 3.393e+14 Hz │
|
|
53
|
+
│ exitance 62.94 MW/m² │
|
|
54
|
+
│ │
|
|
55
|
+
│ ▄▄▅▆▆▇▇▇██████████▇▇▇▇▇▆▆▆▆▆▅▅▅▅▅▄▄▄▄▄▄▃▃▃▃▃▃▃ │
|
|
56
|
+
│ ▲ │
|
|
57
|
+
│ 300nm 1100nm │
|
|
58
|
+
╰────────────────────────────────────────────────╯
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
Reading the card: the swatch with its `#rrggbb` / `rgb()` values is the star's display
|
|
62
|
+
color; **peak λ** is the Wien peak wavelength (nm) and its frequency (Hz); **exitance** is
|
|
63
|
+
the Stefan–Boltzmann radiant exitance (here in MW/m²); the sparkline is the Planck spectrum
|
|
64
|
+
sampled 300–1100 nm, with ▲ marking the peak. *(In a real terminal the swatch and sparkline
|
|
65
|
+
are full 24-bit color.)*
|
|
66
|
+
|
|
67
|
+
## Python API
|
|
68
|
+
|
|
69
|
+
```python
|
|
70
|
+
import starhue
|
|
71
|
+
|
|
72
|
+
# One-liners — pick the output format you want
|
|
73
|
+
starhue.temperature_to_color(5772) # '#fff1ea' (hex by default)
|
|
74
|
+
starhue.temperature_to_color(3500, 'rgb') # (255, 200, 140) (r, g, b), 0–255
|
|
75
|
+
starhue.wavelength_to_color(550, 'rgb') # color of a single wavelength
|
|
76
|
+
|
|
77
|
+
# The high-level object
|
|
78
|
+
star = starhue.Star(5772)
|
|
79
|
+
star.hex # '#fff1ea'
|
|
80
|
+
star.rgb # (255, 241, 234)
|
|
81
|
+
star.peak_wavelength_nm # 502.0 — Wien's displacement law
|
|
82
|
+
star.peak_frequency_hz # 3.39e14 — frequency-form Wien's law
|
|
83
|
+
star.radiant_exitance # 6.29e7 W/m² — Stefan–Boltzmann
|
|
84
|
+
star.spectral_type.letter # 'G'
|
|
85
|
+
star.appearance # 'neutral white'
|
|
86
|
+
|
|
87
|
+
# The raw spectrum: list of (wavelength_nm, radiance)
|
|
88
|
+
star.spectrum(380, 750, samples=100, normalize=True)
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
### Inverse: color → temperature (CCT)
|
|
92
|
+
|
|
93
|
+
Go the other way too. The correlated color temperature is the nearest point on
|
|
94
|
+
the Planckian locus in CIE 1960 *uv* space, so it round-trips with the forward
|
|
95
|
+
model. `color_to_temperature` takes the color as a `#rrggbb` string or an
|
|
96
|
+
`(r, g, b)` triple and returns the temperature in kelvin.
|
|
97
|
+
|
|
98
|
+
```python
|
|
99
|
+
starhue.color_to_temperature('#ffd1a3') # 3911.0
|
|
100
|
+
starhue.color_to_temperature((205, 217, 255)) # ~10000
|
|
101
|
+
|
|
102
|
+
starhue.Star.from_color('#fff1ea') # Star(5773 K, #fff1ea, class G)
|
|
103
|
+
starhue.Star.from_color((255, 200, 140)) # also takes an (r, g, b) triple
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
## API reference
|
|
107
|
+
|
|
108
|
+
These are the functions and objects you call. They're re-exported onto the
|
|
109
|
+
top-level `starhue` namespace, so `import starhue` is enough to reach all of
|
|
110
|
+
them; the table notes which submodule each lives in if you'd rather import from
|
|
111
|
+
there.
|
|
112
|
+
|
|
113
|
+
### Forward — temperature / wavelength → color
|
|
114
|
+
|
|
115
|
+
Each returns a `#rrggbb` string by default, or an `(r, g, b)` triple (0–255) with
|
|
116
|
+
`format="rgb"`.
|
|
117
|
+
|
|
118
|
+
| Name | Signature → returns | Description |
|
|
119
|
+
|---|---|---|
|
|
120
|
+
| `temperature_to_color` | `(temperature_k, format="hex", step_nm=1.0) → str \| (int, int, int)` | Color of a blackbody at this temperature (full colorimetric pipeline). |
|
|
121
|
+
| `wavelength_to_color` | `(wavelength_nm, format="hex") → str \| (int, int, int)` | Display color of a single monochromatic wavelength (a spectral ramp, for plots). |
|
|
122
|
+
|
|
123
|
+
### Inverse — color → temperature
|
|
124
|
+
|
|
125
|
+
| Name | Signature → returns | Description |
|
|
126
|
+
|---|---|---|
|
|
127
|
+
| `color_to_temperature` | `(color) → float` | Correlated color temperature (K) of a `#rrggbb` hex string or `(r, g, b)` triple — the nearest blackbody on the Planckian locus. |
|
|
128
|
+
|
|
129
|
+
### Color utilities
|
|
130
|
+
|
|
131
|
+
| Name | Signature → returns | Description |
|
|
132
|
+
|---|---|---|
|
|
133
|
+
| `hex_to_rgb` | `(value) → (int, int, int)` | Parse `#rgb`/`#rrggbb` → `(r, g, b)`, 0–255. |
|
|
134
|
+
|
|
135
|
+
### Physics
|
|
136
|
+
|
|
137
|
+
| Name | Signature → returns | Description |
|
|
138
|
+
|---|---|---|
|
|
139
|
+
| `planck` | `(wavelength_m, temperature_k) → float` | Planck spectral radiance, W·sr⁻¹·m⁻³ (wavelength in **metres**). |
|
|
140
|
+
| `planck_nm` | `(wavelength_nm, temperature_k) → float` | Planck spectral radiance per nm (wavelength in **nanometres**). |
|
|
141
|
+
| `spectrum` | `(temperature_k, lo_nm=300, hi_nm=1100, samples=200, *, normalize=False) → list[(float, float)]` | Sample the Planck curve → `(wavelength_nm, radiance)` pairs. |
|
|
142
|
+
| `wien_peak_wavelength` | `(temperature_k, unit="nm") → float` | Wavelength of peak radiance (Wien); **nm** by default, `unit="m"` for metres. |
|
|
143
|
+
| `wien_peak_frequency` | `(temperature_k) → float` | Frequency of peak radiance (frequency-form Wien), Hz. |
|
|
144
|
+
| `stefan_boltzmann` | `(temperature_k) → float` | Total radiant exitance σT⁴, W·m⁻². |
|
|
145
|
+
|
|
146
|
+
### Classification
|
|
147
|
+
|
|
148
|
+
| Name | Kind | Signature → returns | Description |
|
|
149
|
+
|---|---|---|---|
|
|
150
|
+
| `spectral_class` | function | `(temperature_k) → SpectralType` | Harvard spectral type (O/B/A/F/G/K/M) for an effective temperature. |
|
|
151
|
+
| `appearance_name` | function | `(temperature_k) → str` | Friendly perceptual color name, e.g. `"warm amber"`, as seen in a vacuum *(in `starhue.classify`)*. |
|
|
152
|
+
| `SpectralType` | object | `NamedTuple(letter, description)` | The value `spectral_class` returns — read `.letter` (e.g. `'G'`) and `.description` off it. |
|
|
153
|
+
|
|
154
|
+
### The `Star` object (`starhue.Star`)
|
|
155
|
+
|
|
156
|
+
`Star(temperature_k, *, color_step_nm=1.0)` — the high-level facade. The keyword
|
|
157
|
+
`color_step_nm` is the wavelength step (nm) of the color integration; smaller is
|
|
158
|
+
more accurate but slower.
|
|
159
|
+
|
|
160
|
+
**Inverse constructor** (color → nearest blackbody):
|
|
161
|
+
|
|
162
|
+
| Constructor | Signature → returns | Description |
|
|
163
|
+
|---|---|---|
|
|
164
|
+
| `Star.from_color` | `(color_value) → Star` | From either a `#rrggbb` hex string or an sRGB `(r, g, b)` triple (0–255). |
|
|
165
|
+
|
|
166
|
+
**Attributes, properties & methods:**
|
|
167
|
+
|
|
168
|
+
| Member | Kind | Type / returns | Description |
|
|
169
|
+
|---|---|---|---|
|
|
170
|
+
| `temperature` | attribute | `float` | The star's temperature, K. |
|
|
171
|
+
| `rgb` | property | `(int, int, int)` | Display sRGB color, 0–255. |
|
|
172
|
+
| `hex` | property | `str` | Display sRGB color as `#rrggbb`. |
|
|
173
|
+
| `peak_wavelength_nm` | property | `float` | Wien peak wavelength, nm. |
|
|
174
|
+
| `peak_frequency_hz` | property | `float` | Wien peak frequency (frequency form), Hz. |
|
|
175
|
+
| `radiant_exitance` | property | `float` | Stefan–Boltzmann exitance, W·m⁻². |
|
|
176
|
+
| `spectral_type` | property | `SpectralType` | Harvard spectral classification. |
|
|
177
|
+
| `appearance` | property | `str` | Friendly perceptual color name, e.g. `"neutral white"`. |
|
|
178
|
+
| `planck(wavelength_nm)` | method | `float` | Spectral radiance at a wavelength in nm. |
|
|
179
|
+
| `spectrum(lo_nm=300, hi_nm=1100, samples=200, *, normalize=False)` | method | `list[(float, float)]` | Sample the Planck curve → `(wavelength_nm, radiance)` pairs. |
|
|
180
|
+
|
|
181
|
+
Anything not listed here (the colorimetry conversion steps in `starhue.color`,
|
|
182
|
+
the physical constants in `starhue.constants`) is internal plumbing the functions
|
|
183
|
+
above build on — usable, but not the intended surface.
|
|
184
|
+
|
|
185
|
+
## CLI reference
|
|
186
|
+
|
|
187
|
+
```
|
|
188
|
+
python -m starhue [TEMPERATURES ...] [options]
|
|
189
|
+
|
|
190
|
+
positional:
|
|
191
|
+
TEMPERATURES one or more temperatures in kelvin (default: 5772, the Sun)
|
|
192
|
+
|
|
193
|
+
options:
|
|
194
|
+
--no-spectrum hide the spectrum sparkline in cards
|
|
195
|
+
--from-color COLOR inverse mode: a color (#rrggbb or r,g,b) → its nearest blackbody
|
|
196
|
+
--color / --no-color force or disable ANSI color (auto-detected by default)
|
|
197
|
+
--version
|
|
198
|
+
```
|
|
199
|
+
|
|
200
|
+
```bash
|
|
201
|
+
python -m starhue --from-color '#ffd1a3' # what temperature is this color?
|
|
202
|
+
```
|
|
203
|
+
|
|
204
|
+
Color output honours the `NO_COLOR` and `FORCE_COLOR` conventions.
|
|
205
|
+
|
|
206
|
+
## Module map
|
|
207
|
+
|
|
208
|
+
| module | role |
|
|
209
|
+
|---|---|
|
|
210
|
+
| `constants` | exact SI / CODATA physical constants |
|
|
211
|
+
| `physics` | Planck's law, Wien's law, Stefan–Boltzmann, spectrum sampling |
|
|
212
|
+
| `color` | CIE 1931 CMF → XYZ → sRGB; chromaticity; wavelength → display color |
|
|
213
|
+
| `cct` | inverse direction: color → correlated color temperature |
|
|
214
|
+
| `classify` | Harvard spectral class + perceptual color names |
|
|
215
|
+
| `star` | the high-level `Star` object |
|
|
216
|
+
| `render` | terminal card + spectrum sparkline |
|
|
217
|
+
| `cli` | the command-line interface |
|
|
218
|
+
|
|
219
|
+
## The science
|
|
220
|
+
|
|
221
|
+
Everything is computed from first principles with the exact 2019-SI constants.
|
|
222
|
+
|
|
223
|
+
**Planck's law** — spectral radiance of a blackbody:
|
|
224
|
+
|
|
225
|
+
$$B_\lambda(T) = \frac{2hc^2}{\lambda^5}\,\frac{1}{\exp\!\left(\dfrac{hc}{\lambda k_B T}\right) - 1}$$
|
|
226
|
+
|
|
227
|
+
**Wien's displacement law** — where that curve peaks: $\lambda_\text{max} = b / T$.
|
|
228
|
+
|
|
229
|
+
**Stefan–Boltzmann law** — total power radiated per unit area: $j^\star = \sigma T^4$.
|
|
230
|
+
|
|
231
|
+
**Temperature → color** follows the standard colorimetry pipeline:
|
|
232
|
+
|
|
233
|
+
1. Sample the Planck curve across the visible band (360–830 nm).
|
|
234
|
+
2. Integrate against the **CIE 1931 2° color-matching functions** → CIE *XYZ*.
|
|
235
|
+
3. Map *XYZ* → linear sRGB with the D65 matrix.
|
|
236
|
+
4. Clamp out-of-gamut negatives, normalize to constant luminance, gamma-encode.
|
|
237
|
+
|
|
238
|
+
The color-matching functions use the analytic multi-lobe Gaussian fit of
|
|
239
|
+
**Wyman, Sloan & Shirley (2013)**, which reproduces the tabulated CIE curves to
|
|
240
|
+
within ~1% with no embedded data table.
|
|
241
|
+
|
|
242
|
+
### Is it accurate?
|
|
243
|
+
|
|
244
|
+
Yes — it lands on the textbook reference points:
|
|
245
|
+
|
|
246
|
+
| Temperature | `starhue` | meaning |
|
|
247
|
+
|------------:|:----------|:--------|
|
|
248
|
+
| 6500 K | xy ≈ (0.313, 0.324) | essentially the **D65** white point |
|
|
249
|
+
| 5772 K | `#fff1ea` | the Sun — a warm white, *not* yellow |
|
|
250
|
+
| 3500 K | `#ffc88c` | amber (an M-type red giant) |
|
|
251
|
+
| 12000 K | bluish white | hot B-type star |
|
|
252
|
+
|
|
253
|
+
The full Planckian locus matches Mitchell Charity's well-known blackbody-color
|
|
254
|
+
table closely.
|
|
255
|
+
|
|
256
|
+
## Gallery
|
|
257
|
+
|
|
258
|
+
A cool red giant — note the spectral peak sliding into the infrared:
|
|
259
|
+
|
|
260
|
+
<p align="center">
|
|
261
|
+
<img src="starhue/assets/betelgeuse.png" alt="3500 K M-type star" width="560">
|
|
262
|
+
</p>
|
|
263
|
+
|
|
264
|
+
A hot blue star, peak pushed into the ultraviolet:
|
|
265
|
+
|
|
266
|
+
<p align="center">
|
|
267
|
+
<img src="starhue/assets/rigel.png" alt="12100 K B-type star" width="560">
|
|
268
|
+
</p>
|
|
269
|
+
|
|
270
|
+
## References
|
|
271
|
+
|
|
272
|
+
- M. Planck, *On the Law of Distribution of Energy in the Normal Spectrum* (1901).
|
|
273
|
+
- CIE 1931 2° standard colorimetric observer.
|
|
274
|
+
- C. Wyman, P. Sloan & P. Shirley, *Simple Analytic Approximations to the CIE
|
|
275
|
+
XYZ Color Matching Functions*, **JCGT** 2(2), 2013.
|
|
276
|
+
- IEC 61966-2-1:1999 (sRGB).
|
|
277
|
+
- M. Charity, *What color is a blackbody?* — reference color table.
|
|
278
|
+
|
|
279
|
+
## License
|
|
280
|
+
|
|
281
|
+
_To be decided by the project owner._
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools >= 61.0"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "starhue"
|
|
7
|
+
version = "1.0.0"
|
|
8
|
+
requires-python = ">=3.8"
|
|
9
|
+
|
|
10
|
+
# starhue is pure standard library (math, argparse, os, sys, typing).
|
|
11
|
+
# It has no third-party runtime dependencies.
|
|
12
|
+
dependencies = []
|
starhue-1.0.0/setup.cfg
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""starhue — temperature → true star color + Planck spectrum.
|
|
2
|
+
|
|
3
|
+
Give it a temperature in kelvin; get back the actual sRGB color a blackbody of
|
|
4
|
+
that temperature would show your eye, the spectral curve behind it, and a pile
|
|
5
|
+
of derived physics (Wien peak, Stefan–Boltzmann exitance, spectral class).
|
|
6
|
+
|
|
7
|
+
>>> import starhue
|
|
8
|
+
>>> starhue.temperature_to_color(5772) # the Sun
|
|
9
|
+
'#fff1ea'
|
|
10
|
+
>>> star = starhue.Star(3500)
|
|
11
|
+
>>> star.spectral_type.letter
|
|
12
|
+
'M'
|
|
13
|
+
|
|
14
|
+
See :class:`Star` for the high-level object and the ``color``/``physics``
|
|
15
|
+
submodules for the underlying functions.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
from .cct import color_to_temperature
|
|
21
|
+
from .classify import SpectralType, spectral_class
|
|
22
|
+
from .color import (
|
|
23
|
+
hex_to_rgb,
|
|
24
|
+
temperature_to_color,
|
|
25
|
+
wavelength_to_color,
|
|
26
|
+
)
|
|
27
|
+
from .physics import (
|
|
28
|
+
planck,
|
|
29
|
+
planck_nm,
|
|
30
|
+
spectrum,
|
|
31
|
+
stefan_boltzmann,
|
|
32
|
+
wien_peak_frequency,
|
|
33
|
+
wien_peak_wavelength,
|
|
34
|
+
)
|
|
35
|
+
from .star import Star
|
|
36
|
+
|
|
37
|
+
__version__ = "0.1.0"
|
|
38
|
+
|
|
39
|
+
__all__ = [
|
|
40
|
+
"__version__",
|
|
41
|
+
"Star",
|
|
42
|
+
"SpectralType",
|
|
43
|
+
"spectral_class",
|
|
44
|
+
# color
|
|
45
|
+
"temperature_to_color",
|
|
46
|
+
"wavelength_to_color",
|
|
47
|
+
"hex_to_rgb",
|
|
48
|
+
# inverse CCT (color → temperature)
|
|
49
|
+
"color_to_temperature",
|
|
50
|
+
# physics
|
|
51
|
+
"planck",
|
|
52
|
+
"planck_nm",
|
|
53
|
+
"spectrum",
|
|
54
|
+
"stefan_boltzmann",
|
|
55
|
+
"wien_peak_wavelength",
|
|
56
|
+
"wien_peak_frequency",
|
|
57
|
+
]
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
"""Inverse direction: a color → its correlated color temperature (CCT).
|
|
2
|
+
|
|
3
|
+
The CCT of a color is *defined* as the temperature of the blackbody whose
|
|
4
|
+
chromaticity is closest to it in the CIE 1960 UCS ``(u, v)`` plane. We find it by
|
|
5
|
+
searching our own Planckian locus, so ``T → color → T`` round-trips cleanly.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import math
|
|
11
|
+
from typing import Tuple, Union
|
|
12
|
+
|
|
13
|
+
from . import color as _color
|
|
14
|
+
|
|
15
|
+
__all__ = ["color_to_temperature"]
|
|
16
|
+
|
|
17
|
+
#: Temperature range over which CCT is meaningful, in kelvin.
|
|
18
|
+
T_MIN = 1000.0
|
|
19
|
+
T_MAX = 40000.0
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _locus_uv(temperature_k: float) -> Tuple[float, float]:
|
|
23
|
+
return _color.xy_to_uv(*_color.xyz_to_xy(*_color.spectrum_to_xyz(temperature_k)))
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _dist2_at_mired(mired: float, u: float, v: float) -> float:
|
|
27
|
+
lu, lv = _locus_uv(1e6 / mired)
|
|
28
|
+
return (u - lu) ** 2 + (v - lv) ** 2
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _golden_min(f, a: float, b: float, iters: int = 40) -> float:
|
|
32
|
+
"""Golden-section search for the minimizer of a unimodal ``f`` on ``[a, b]``."""
|
|
33
|
+
inv_phi = (math.sqrt(5.0) - 1.0) / 2.0
|
|
34
|
+
c = b - inv_phi * (b - a)
|
|
35
|
+
d = a + inv_phi * (b - a)
|
|
36
|
+
fc, fd = f(c), f(d)
|
|
37
|
+
for _ in range(iters):
|
|
38
|
+
if fc < fd:
|
|
39
|
+
b, d, fd = d, c, fc
|
|
40
|
+
c = b - inv_phi * (b - a)
|
|
41
|
+
fc = f(c)
|
|
42
|
+
else:
|
|
43
|
+
a, c, fc = c, d, fd
|
|
44
|
+
d = a + inv_phi * (b - a)
|
|
45
|
+
fd = f(d)
|
|
46
|
+
return (a + b) / 2.0
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _cct_from_uv(u: float, v: float) -> float:
|
|
50
|
+
"""Nearest blackbody temperature (K) for a CIE 1960 ``(u, v)`` color."""
|
|
51
|
+
# Work in mired (10⁶/T): the Planckian locus is nearly straight there, so a
|
|
52
|
+
# coarse grid reliably brackets the minimum before we refine.
|
|
53
|
+
m_lo, m_hi = 1e6 / T_MAX, 1e6 / T_MIN
|
|
54
|
+
grid = 80
|
|
55
|
+
best_i, best_d2 = 0, float("inf")
|
|
56
|
+
mireds = [m_lo + (m_hi - m_lo) * i / (grid - 1) for i in range(grid)]
|
|
57
|
+
for i, m in enumerate(mireds):
|
|
58
|
+
d2 = _dist2_at_mired(m, u, v)
|
|
59
|
+
if d2 < best_d2:
|
|
60
|
+
best_i, best_d2 = i, d2
|
|
61
|
+
|
|
62
|
+
a = mireds[max(0, best_i - 1)]
|
|
63
|
+
b = mireds[min(grid - 1, best_i + 1)]
|
|
64
|
+
m_best = _golden_min(lambda m: _dist2_at_mired(m, u, v), a, b)
|
|
65
|
+
return 1e6 / m_best
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def color_to_temperature(color: Union[str, Tuple[float, float, float]]) -> float:
|
|
69
|
+
"""Correlated color temperature (K) of a color — the nearest blackbody on the
|
|
70
|
+
Planckian locus.
|
|
71
|
+
|
|
72
|
+
Round-trips with ``starhue.temperature_to_color``.
|
|
73
|
+
|
|
74
|
+
Args:
|
|
75
|
+
color (str | tuple[float, float, float]): A ``#rrggbb`` hex string or an
|
|
76
|
+
sRGB ``(r, g, b)`` triple (0–255).
|
|
77
|
+
|
|
78
|
+
Returns:
|
|
79
|
+
float: The correlated color temperature in kelvin.
|
|
80
|
+
"""
|
|
81
|
+
rgb = _color.hex_to_rgb(color) if isinstance(color, str) else color
|
|
82
|
+
return _cct_from_uv(*_color.xy_to_uv(*_color.rgb_to_xy(rgb)))
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
"""Harvard spectral classification and plain-language color names."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import List, NamedTuple, Tuple
|
|
6
|
+
|
|
7
|
+
__all__ = ["SpectralType", "spectral_class", "appearance_name", "MK_CLASSES"]
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class SpectralType(NamedTuple):
|
|
11
|
+
"""A Harvard spectral classification result."""
|
|
12
|
+
|
|
13
|
+
letter: str #: O, B, A, F, G, K or M
|
|
14
|
+
description: str #: short prose description
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
# (min_temp_K, letter, description). Upper-open on the hot end.
|
|
18
|
+
MK_CLASSES: List[Tuple[float, str, str]] = [
|
|
19
|
+
(30000.0, "O", "blue, blistering and short-lived"),
|
|
20
|
+
(10000.0, "B", "blue-white, massive and luminous"),
|
|
21
|
+
(7500.0, "A", "white with strong hydrogen lines"),
|
|
22
|
+
(6000.0, "F", "yellow-white"),
|
|
23
|
+
(5200.0, "G", "yellow, Sun-like"),
|
|
24
|
+
(3700.0, "K", "orange, cooler than the Sun"),
|
|
25
|
+
(0.0, "M", "red, cool and abundant"),
|
|
26
|
+
]
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def spectral_class(temperature_k: float) -> SpectralType:
|
|
30
|
+
"""Return the Harvard spectral type for an effective temperature.
|
|
31
|
+
|
|
32
|
+
Args:
|
|
33
|
+
temperature_k (float): Effective temperature in kelvin.
|
|
34
|
+
|
|
35
|
+
Returns:
|
|
36
|
+
SpectralType: The matching spectral class and its description.
|
|
37
|
+
"""
|
|
38
|
+
for min_t, letter, desc in MK_CLASSES:
|
|
39
|
+
if temperature_k >= min_t:
|
|
40
|
+
return SpectralType(letter, desc)
|
|
41
|
+
# The last bucket starts at 0 K, so the loop always returns for a normal
|
|
42
|
+
# temperature; this only guards a non-finite value slipping through.
|
|
43
|
+
_, letter, desc = MK_CLASSES[-1]
|
|
44
|
+
return SpectralType(letter, desc)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
# (max_temp_K exclusive, name). The colors people actually perceive.
|
|
48
|
+
_APPEARANCE: List[Tuple[float, str]] = [
|
|
49
|
+
(1200.0, "dim ember red"),
|
|
50
|
+
(2200.0, "deep red"),
|
|
51
|
+
(3200.0, "reddish orange"),
|
|
52
|
+
(4200.0, "warm amber"),
|
|
53
|
+
(5000.0, "pale gold"),
|
|
54
|
+
(5600.0, "soft yellow-white"),
|
|
55
|
+
(6600.0, "neutral white"),
|
|
56
|
+
(8000.0, "cool white"),
|
|
57
|
+
(12000.0, "blue-white"),
|
|
58
|
+
(float("inf"), "icy blue"),
|
|
59
|
+
]
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def appearance_name(temperature_k: float) -> str:
|
|
63
|
+
"""A friendly, perceptual color name for a blackbody temperature.
|
|
64
|
+
|
|
65
|
+
This is the star's color as seen *in a vacuum* — the light leaving its
|
|
66
|
+
surface, before any atmosphere reddens it. So the Sun (5772 K) comes out a
|
|
67
|
+
neutral white here, not the yellow it looks from the ground (Earth's
|
|
68
|
+
atmosphere scatters away the blue and tints the disc yellow).
|
|
69
|
+
|
|
70
|
+
Args:
|
|
71
|
+
temperature_k (float): Absolute temperature in kelvin.
|
|
72
|
+
|
|
73
|
+
Returns:
|
|
74
|
+
str: A perceptual color name, e.g. ``"neutral white"``.
|
|
75
|
+
"""
|
|
76
|
+
for max_t, name in _APPEARANCE:
|
|
77
|
+
if temperature_k < max_t:
|
|
78
|
+
return name
|
|
79
|
+
# The final bucket has max_t == inf, so the loop always returns above.
|
|
80
|
+
return _APPEARANCE[-1][1]
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
"""Command-line interface for starhue.
|
|
2
|
+
|
|
3
|
+
starhue 5772 # one star card
|
|
4
|
+
starhue 3000 5772 9940 # several cards
|
|
5
|
+
starhue --from-color '#ffd1a3' # inverse: a color → its nearest blackbody
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import argparse
|
|
11
|
+
import sys
|
|
12
|
+
from typing import List, Optional, Sequence
|
|
13
|
+
|
|
14
|
+
from . import __version__
|
|
15
|
+
from .cct import color_to_temperature
|
|
16
|
+
from .color import hex_to_rgb
|
|
17
|
+
from .render import star_card, supports_color
|
|
18
|
+
from .star import Star
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _build_parser() -> argparse.ArgumentParser:
|
|
22
|
+
"""Construct the argparse parser for the CLI.
|
|
23
|
+
|
|
24
|
+
Returns:
|
|
25
|
+
argparse.ArgumentParser: The configured parser.
|
|
26
|
+
"""
|
|
27
|
+
p = argparse.ArgumentParser(
|
|
28
|
+
prog="starhue",
|
|
29
|
+
description="Temperature → true star color + Planck spectrum.",
|
|
30
|
+
epilog="Give a temperature in kelvin (e.g. 5772 for the Sun) and see its real color.",
|
|
31
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
32
|
+
)
|
|
33
|
+
p.add_argument("temperatures", nargs="*", type=float, help="one or more temperatures in kelvin")
|
|
34
|
+
p.add_argument(
|
|
35
|
+
"--no-spectrum", dest="spectrum", action="store_false", help="hide the spectrum sparkline"
|
|
36
|
+
)
|
|
37
|
+
p.add_argument(
|
|
38
|
+
"--from-color",
|
|
39
|
+
metavar="COLOR",
|
|
40
|
+
help="inverse mode: a color (#rrggbb or r,g,b) → its nearest blackbody",
|
|
41
|
+
)
|
|
42
|
+
color = p.add_mutually_exclusive_group()
|
|
43
|
+
color.add_argument("--color", dest="color", action="store_true", default=None, help="force ANSI color")
|
|
44
|
+
color.add_argument("--no-color", dest="color", action="store_false", help="disable ANSI color")
|
|
45
|
+
p.add_argument("--version", action="version", version=f"starhue {__version__}")
|
|
46
|
+
return p
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _validate_temps(temps: Sequence[float]) -> None:
|
|
50
|
+
"""Reject any non-positive temperature.
|
|
51
|
+
|
|
52
|
+
Args:
|
|
53
|
+
temps (Sequence[float]): Temperatures in kelvin to validate.
|
|
54
|
+
|
|
55
|
+
Raises:
|
|
56
|
+
SystemExit: If any temperature is not positive.
|
|
57
|
+
"""
|
|
58
|
+
for t in temps:
|
|
59
|
+
if t <= 0:
|
|
60
|
+
raise SystemExit(f"starhue: temperature must be positive, got {t:g}")
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _parse_color(value: str) -> tuple:
|
|
64
|
+
"""Parse a CLI color: ``#rrggbb``/``#rgb`` or ``r,g,b``.
|
|
65
|
+
|
|
66
|
+
Args:
|
|
67
|
+
value (str): The color string from the command line.
|
|
68
|
+
|
|
69
|
+
Returns:
|
|
70
|
+
tuple: The ``(r, g, b)`` channels as 0–255 ints.
|
|
71
|
+
|
|
72
|
+
Raises:
|
|
73
|
+
SystemExit: If the color cannot be parsed.
|
|
74
|
+
"""
|
|
75
|
+
s = value.strip()
|
|
76
|
+
if "," in s:
|
|
77
|
+
parts = s.split(",")
|
|
78
|
+
if len(parts) != 3:
|
|
79
|
+
raise SystemExit(f"starhue: expected 'r,g,b', got {value!r}")
|
|
80
|
+
try:
|
|
81
|
+
return tuple(max(0, min(255, int(p))) for p in parts)
|
|
82
|
+
except ValueError:
|
|
83
|
+
raise SystemExit(f"starhue: invalid r,g,b color {value!r}") from None
|
|
84
|
+
try:
|
|
85
|
+
return hex_to_rgb(s)
|
|
86
|
+
except ValueError as exc:
|
|
87
|
+
raise SystemExit(f"starhue: {exc}") from None
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def main(argv: Optional[List[str]] = None) -> int:
|
|
91
|
+
"""Run the starhue command-line interface.
|
|
92
|
+
|
|
93
|
+
Args:
|
|
94
|
+
argv (list[str] | None): Argument vector; defaults to ``sys.argv`` when
|
|
95
|
+
None.
|
|
96
|
+
|
|
97
|
+
Returns:
|
|
98
|
+
int: Process exit code (0 on success).
|
|
99
|
+
"""
|
|
100
|
+
args = _build_parser().parse_args(argv)
|
|
101
|
+
use_color = supports_color() if args.color is None else args.color
|
|
102
|
+
|
|
103
|
+
# --- inverse mode: a color → its nearest blackbody --------------------
|
|
104
|
+
if args.from_color:
|
|
105
|
+
rgb = _parse_color(args.from_color)
|
|
106
|
+
temperature = color_to_temperature(rgb)
|
|
107
|
+
print(
|
|
108
|
+
f"# {args.from_color.strip()} → nearest blackbody {temperature:.0f} K",
|
|
109
|
+
file=sys.stderr,
|
|
110
|
+
)
|
|
111
|
+
temps: List[float] = [temperature]
|
|
112
|
+
else:
|
|
113
|
+
temps = args.temperatures or [5772.0] # default: show the Sun
|
|
114
|
+
_validate_temps(temps)
|
|
115
|
+
|
|
116
|
+
for i, t in enumerate(temps):
|
|
117
|
+
if i:
|
|
118
|
+
print()
|
|
119
|
+
print(star_card(Star(t), color=use_color, spectrum=args.spectrum))
|
|
120
|
+
|
|
121
|
+
return 0
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
if __name__ == "__main__": # pragma: no cover
|
|
125
|
+
raise SystemExit(main())
|