spotifywebapiPython 1.0.243__py3-none-any.whl → 1.0.251__py3-none-any.whl
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.
- spotifywebapipython/const.py +1 -1
- spotifywebapipython/docs/__init__.html +56 -2
- spotifywebapipython/docs/index.html +5 -2
- spotifywebapipython/docs/search.js +1 -1
- spotifywebapipython/docs/spotifywebapipython/const.html +3 -3
- spotifywebapipython/docs/spotifywebapipython/saappmessages.html +2 -2
- spotifywebapipython/docs/spotifywebapipython/sautils.html +2 -2
- spotifywebapipython/docs/spotifywebapipython/spotifyapierror.html +2 -2
- spotifywebapipython/docs/spotifywebapipython/spotifyapimessage.html +2 -2
- spotifywebapipython/docs/spotifywebapipython/spotifyauthtoken.html +2 -2
- spotifywebapipython/docs/spotifywebapipython/spotifyclient.html +235 -4
- spotifywebapipython/docs/spotifywebapipython/spotifydiscovery.html +2 -2
- spotifywebapipython/docs/spotifywebapipython/spotifywebapiauthenticationerror.html +2 -2
- spotifywebapipython/docs/spotifywebapipython/spotifywebapierror.html +2 -2
- spotifywebapipython/docs/spotifywebapipython/spotifywebplayertoken.html +2 -2
- spotifywebapipython/models/__init__.py +2 -0
- spotifywebapipython/models/episodesimplified.py +41 -0
- spotifywebapipython/models/imagepalettecolors.py +111 -0
- spotifywebapipython/models/imagevibrantcolors.py +2 -0
- spotifywebapipython/models/pageobject.py +1 -0
- spotifywebapipython/spotifyclient.py +219 -6
- spotifywebapipython/spotifyconnect/spotifyconnectdirectorytask.py +227 -76
- spotifywebapipython/spotifyconnect/spotifyconnectzeroconfcastcontroller.py +2 -2
- spotifywebapipython/spotifyconnect/spotifyconnectzeroconfcastmultizonecontrollerlistener.py +247 -0
- spotifywebapipython/spotifyconnect/spotifyconnectzeroconfcastmultizonemanagerlistener.py +384 -0
- spotifywebapipython/vibrant/__init__.py +2 -0
- spotifywebapipython/vibrant/colorthieffast.py +626 -0
- {spotifywebapipython-1.0.243.dist-info → spotifywebapipython-1.0.251.dist-info}/METADATA +3 -2
- {spotifywebapipython-1.0.243.dist-info → spotifywebapipython-1.0.251.dist-info}/RECORD +32 -29
- spotifywebapipython/spotifyconnect/spotifyconnectzeroconfcastmultizonelistener.py +0 -212
- {spotifywebapipython-1.0.243.dist-info → spotifywebapipython-1.0.251.dist-info}/LICENSE +0 -0
- {spotifywebapipython-1.0.243.dist-info → spotifywebapipython-1.0.251.dist-info}/WHEEL +0 -0
- {spotifywebapipython-1.0.243.dist-info → spotifywebapipython-1.0.251.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,626 @@
|
|
|
1
|
+
# Copyright 2025-2025 JiangLong Jia
|
|
2
|
+
#
|
|
3
|
+
# Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
4
|
+
# of this software and associated documentation files (the "Software"), to deal
|
|
5
|
+
# in the Software without restriction, including without limitation the rights
|
|
6
|
+
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
7
|
+
# copies of the Software, and to permit persons to whom the Software is
|
|
8
|
+
# furnished to do so, subject to the following conditions:
|
|
9
|
+
#
|
|
10
|
+
# The above copyright notice and this permission notice shall be included in all
|
|
11
|
+
# copies or substantial portions of the Software.
|
|
12
|
+
|
|
13
|
+
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
14
|
+
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
15
|
+
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
16
|
+
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
17
|
+
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
18
|
+
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
|
19
|
+
__version__ = '1.1.0'
|
|
20
|
+
|
|
21
|
+
import functools
|
|
22
|
+
from typing import Dict, Callable, List, Tuple, Optional, Union
|
|
23
|
+
|
|
24
|
+
import colorsys
|
|
25
|
+
import io
|
|
26
|
+
import numpy as np
|
|
27
|
+
from PIL import Image
|
|
28
|
+
import requests
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class ColorThiefFast:
|
|
32
|
+
"""
|
|
33
|
+
Color thief main class.
|
|
34
|
+
"""
|
|
35
|
+
RGB = Tuple[int, int, int]
|
|
36
|
+
|
|
37
|
+
def __init__(self, image: Union[str, Image.Image, np.ndarray], return_percent: bool = False):
|
|
38
|
+
self.image: np.ndarray = self.process_image(image)
|
|
39
|
+
self.return_percent = return_percent
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def process_image(
|
|
43
|
+
self,
|
|
44
|
+
image
|
|
45
|
+
) -> np.ndarray:
|
|
46
|
+
|
|
47
|
+
if isinstance(image, str):
|
|
48
|
+
try:
|
|
49
|
+
if (image.startswith("http:")) or (image.startswith("https:")):
|
|
50
|
+
image = requests.get(image).content
|
|
51
|
+
image = io.BytesIO(image)
|
|
52
|
+
image = Image.open(image)
|
|
53
|
+
else:
|
|
54
|
+
image = Image.open(image)
|
|
55
|
+
image = image.convert('RGBA')
|
|
56
|
+
image = np.array(image)
|
|
57
|
+
except FileNotFoundError:
|
|
58
|
+
raise ValueError(f"File {image} not found.")
|
|
59
|
+
|
|
60
|
+
elif isinstance(image, Image.Image):
|
|
61
|
+
image = image.convert('RGBA')
|
|
62
|
+
image = np.array(image)
|
|
63
|
+
|
|
64
|
+
elif not isinstance(image, np.ndarray):
|
|
65
|
+
raise TypeError("Input must be a http/https url, file path, a PIL Image, or a numpy array.")
|
|
66
|
+
|
|
67
|
+
if image.ndim == 2:
|
|
68
|
+
alpha = np.full_like(image, 255, dtype=np.uint8)
|
|
69
|
+
image = np.stack((image, image, image, alpha), axis=-1)
|
|
70
|
+
elif image.ndim == 3:
|
|
71
|
+
if image.shape[2] == 1:
|
|
72
|
+
alpha = np.full_like(image[:, :, 0], 255, dtype=np.uint8)
|
|
73
|
+
image = np.repeat(image, 3, axis=2)
|
|
74
|
+
image = np.dstack((image, alpha))
|
|
75
|
+
elif image.shape[2] == 3:
|
|
76
|
+
alpha = np.full((image.shape[0], image.shape[1]), 255, dtype=np.uint8)
|
|
77
|
+
image = np.dstack((image, alpha))
|
|
78
|
+
elif image.shape[2] != 4:
|
|
79
|
+
raise ValueError("Unsupported number of channels in the numpy array.")
|
|
80
|
+
else:
|
|
81
|
+
raise ValueError("The input image is incorrect")
|
|
82
|
+
|
|
83
|
+
return image
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def get_color(
|
|
87
|
+
self,
|
|
88
|
+
quality: int = 10
|
|
89
|
+
) -> Union[Tuple[int, int, int], Tuple[int, int, int, int]]:
|
|
90
|
+
"""
|
|
91
|
+
Get the dominant color.
|
|
92
|
+
|
|
93
|
+
Args:
|
|
94
|
+
quality:
|
|
95
|
+
quality settings, 1 is the highest quality, the bigger
|
|
96
|
+
the number, the faster a color will be returned but
|
|
97
|
+
the greater the likelihood that it will not be the
|
|
98
|
+
visually most dominant color.
|
|
99
|
+
|
|
100
|
+
Returns:
|
|
101
|
+
tuple: (r, g, b) or (r, g, b, percent)
|
|
102
|
+
"""
|
|
103
|
+
palette = self.get_palette(5, quality)
|
|
104
|
+
return palette[0]
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def get_palette(
|
|
108
|
+
self,
|
|
109
|
+
color_count: int = 10,
|
|
110
|
+
quality: int = 10,
|
|
111
|
+
brightness_filter_low: int = None,
|
|
112
|
+
brightness_filter_high: int = None,
|
|
113
|
+
hue_distance_filter: int = None,
|
|
114
|
+
custom_processing: Callable[[np.ndarray], np.ndarray] = None
|
|
115
|
+
) -> Union[List[Tuple[int, int, int]], List[Tuple[int, int, int, int]]]:
|
|
116
|
+
"""
|
|
117
|
+
Build a color palette. We are using the median cut algorithm to
|
|
118
|
+
cluster similar colors.
|
|
119
|
+
|
|
120
|
+
Args:
|
|
121
|
+
color_count:
|
|
122
|
+
The size of the palette, max number of colors.
|
|
123
|
+
Default is 10.
|
|
124
|
+
quality:
|
|
125
|
+
Quality settings, 1 is the highest quality, the bigger
|
|
126
|
+
the number, the faster the palette generation, but the
|
|
127
|
+
greater the likelihood that colors will be missed.
|
|
128
|
+
Default is 10.
|
|
129
|
+
brightness_filter_low (int):
|
|
130
|
+
Remove colors that are too dark based on their brightness value.
|
|
131
|
+
Default is None.
|
|
132
|
+
brightness_filter_high (int):
|
|
133
|
+
Remove colors that are too light based on their brightness value.
|
|
134
|
+
Default is None.
|
|
135
|
+
hue_distance_filter (int):
|
|
136
|
+
Remove colors that are too close to each other for the specified hue.
|
|
137
|
+
This keeps the colors looking fairly distinct.
|
|
138
|
+
Default is None.
|
|
139
|
+
custom_processing:
|
|
140
|
+
Customize pixel processing functions.
|
|
141
|
+
Returns:
|
|
142
|
+
list: a list of tuple in the form (r, g, b) or (r, g, b, percent)
|
|
143
|
+
|
|
144
|
+
The returned list of RGB values will always have a length value that
|
|
145
|
+
matches the `color_count` argument. If colors are filtered, then the
|
|
146
|
+
list is padded with black (e.g. 0,0,0) RGB entries. This ensures that
|
|
147
|
+
the list length always matches the requested color count.
|
|
148
|
+
|
|
149
|
+
If the `brightness_filter_x` values are specified, each color in the
|
|
150
|
+
returned array is converted to a brightness value (R+G+B=brightness, 0-765)
|
|
151
|
+
and will be filtered out based on the high / low filter arguments.
|
|
152
|
+
|
|
153
|
+
If the `hue_distance_filter` is specified, each color in the returned array
|
|
154
|
+
is converted to a hue value and will be filtered out based on the distance
|
|
155
|
+
between in and the next color.
|
|
156
|
+
"""
|
|
157
|
+
# validations.
|
|
158
|
+
if custom_processing is None:
|
|
159
|
+
custom_processing = PixelsProcessor.get_valid_pixels
|
|
160
|
+
|
|
161
|
+
if (not isinstance(color_count, int)) \
|
|
162
|
+
or (color_count < 0):
|
|
163
|
+
color_count = 10
|
|
164
|
+
|
|
165
|
+
image_array = np.reshape(self.image, (-1, 4))
|
|
166
|
+
image_array = image_array[::quality, :]
|
|
167
|
+
|
|
168
|
+
valid_pixels = custom_processing(image_array)
|
|
169
|
+
|
|
170
|
+
# Send array to quantize function which clusters values using median cut algorithm
|
|
171
|
+
cmap = MMCQ.quantize(valid_pixels, color_count + 1)
|
|
172
|
+
cmap.return_percent = self.return_percent
|
|
173
|
+
result = cmap.palette
|
|
174
|
+
|
|
175
|
+
# if brightness filters specified, then apply them.
|
|
176
|
+
if (brightness_filter_low is not None) or (brightness_filter_high is not None):
|
|
177
|
+
result = self.filter_colors_by_brightness(result, low=brightness_filter_low, high=brightness_filter_high)
|
|
178
|
+
|
|
179
|
+
if (hue_distance_filter is not None):
|
|
180
|
+
result = self.filter_colors_by_hue(result, min_distance=hue_distance_filter)
|
|
181
|
+
|
|
182
|
+
# ensure number of palette entries returned matches requested color count.
|
|
183
|
+
palette_len:int = len(result or [])
|
|
184
|
+
if palette_len < color_count:
|
|
185
|
+
for i in range(palette_len, color_count):
|
|
186
|
+
result.append([0,0,0])
|
|
187
|
+
|
|
188
|
+
# return result to caller.
|
|
189
|
+
return result
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def rgb_brightness(
|
|
193
|
+
self,
|
|
194
|
+
rgb: List[RGB]
|
|
195
|
+
) -> int:
|
|
196
|
+
"""
|
|
197
|
+
Return brightness as sum of R+G+B (0-765).
|
|
198
|
+
"""
|
|
199
|
+
r, g, b = rgb
|
|
200
|
+
return r + g + b
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def filter_colors_by_brightness(
|
|
204
|
+
self,
|
|
205
|
+
colors: List[RGB],
|
|
206
|
+
low: int = 200,
|
|
207
|
+
high: int = 600
|
|
208
|
+
) -> List[RGB]:
|
|
209
|
+
"""
|
|
210
|
+
Keep only colors with brightness between [low, high].
|
|
211
|
+
Brightness is R+G+B, range 0-765.
|
|
212
|
+
"""
|
|
213
|
+
# validations.
|
|
214
|
+
if low is None:
|
|
215
|
+
low = 0
|
|
216
|
+
if high is None:
|
|
217
|
+
high = 765
|
|
218
|
+
|
|
219
|
+
# validations - brightness filter can only be between 0-765 (R+G+B=brightness).
|
|
220
|
+
if isinstance(low, int):
|
|
221
|
+
if low < 0:
|
|
222
|
+
low = 0
|
|
223
|
+
if isinstance(high, int):
|
|
224
|
+
if high > 765:
|
|
225
|
+
high = 765
|
|
226
|
+
|
|
227
|
+
# process brightness filter.
|
|
228
|
+
return [c for c in colors if low <= self.rgb_brightness(c) <= high]
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def rgb_to_hue(
|
|
232
|
+
self,
|
|
233
|
+
rgb: RGB
|
|
234
|
+
) -> float:
|
|
235
|
+
"""
|
|
236
|
+
Convert RGB (0-255 each) to hue in range 0-360 degrees.
|
|
237
|
+
"""
|
|
238
|
+
r, g, b = [x / 255.0 for x in rgb]
|
|
239
|
+
h, _, _ = colorsys.rgb_to_hsv(r, g, b) # h in [0.0, 1.0)
|
|
240
|
+
return h * 360
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def circular_distance(
|
|
244
|
+
self,
|
|
245
|
+
h1: float,
|
|
246
|
+
h2: float
|
|
247
|
+
) -> float:
|
|
248
|
+
"""
|
|
249
|
+
Return the circular distance between two hue angles (0-360).
|
|
250
|
+
"""
|
|
251
|
+
diff = abs(h1 - h2)
|
|
252
|
+
return min(diff, 360 - diff)
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def filter_colors_by_hue(
|
|
256
|
+
self,
|
|
257
|
+
colors: List[RGB],
|
|
258
|
+
min_distance: float = 20.0
|
|
259
|
+
) -> List[RGB]:
|
|
260
|
+
"""
|
|
261
|
+
Keep only colors whose hue is at least `min_distance` apart.
|
|
262
|
+
Uses circular distance around the color wheel.
|
|
263
|
+
"""
|
|
264
|
+
# validations.
|
|
265
|
+
if min_distance is None:
|
|
266
|
+
min_distance = 20
|
|
267
|
+
|
|
268
|
+
# validations - hue min distance filter can only be between 0-255.
|
|
269
|
+
if isinstance(min_distance, int):
|
|
270
|
+
if min_distance < 0:
|
|
271
|
+
min_distance = 0
|
|
272
|
+
if min_distance > 255:
|
|
273
|
+
min_distance = 255
|
|
274
|
+
|
|
275
|
+
kept: List[ColorThiefFast.RGB] = []
|
|
276
|
+
kept_hues: List[float] = []
|
|
277
|
+
|
|
278
|
+
for c in colors:
|
|
279
|
+
hue = self.rgb_to_hue(c)
|
|
280
|
+
if all(self.circular_distance(hue, kh) >= min_distance for kh in kept_hues):
|
|
281
|
+
kept.append(c)
|
|
282
|
+
kept_hues.append(hue)
|
|
283
|
+
return kept
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
class PixelsProcessor:
|
|
287
|
+
def get_valid_pixels(
|
|
288
|
+
image_array: np.ndarray
|
|
289
|
+
) -> np.ndarray:
|
|
290
|
+
"""
|
|
291
|
+
Default method to obtain valid pixels.
|
|
292
|
+
"""
|
|
293
|
+
condition = ((image_array[..., 3] >= 125) &
|
|
294
|
+
~((image_array[..., 0] > 250) & (image_array[..., 1] > 250) & (image_array[..., 2] > 250)))
|
|
295
|
+
|
|
296
|
+
valid_pixels = image_array[condition][:, :3]
|
|
297
|
+
return valid_pixels
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
class MMCQ:
|
|
301
|
+
"""
|
|
302
|
+
Basic Python port of the MMCQ (modified median cut quantization)
|
|
303
|
+
algorithm from the Leptonica library (http://www.leptonica.com/).
|
|
304
|
+
"""
|
|
305
|
+
SIGBITS = 5
|
|
306
|
+
RSHIFT = 8 - SIGBITS
|
|
307
|
+
MAX_ITERATION = 1000
|
|
308
|
+
FRACT_BY_POPULATIONS = 0.75
|
|
309
|
+
|
|
310
|
+
@staticmethod
|
|
311
|
+
def get_color_index(r, g, b):
|
|
312
|
+
"""
|
|
313
|
+
Each quantified (r,g,b) will calculate a unique index value.
|
|
314
|
+
"""
|
|
315
|
+
return (r << (2 * MMCQ.SIGBITS)) + (g << MMCQ.SIGBITS) + b
|
|
316
|
+
|
|
317
|
+
@staticmethod
|
|
318
|
+
def get_histo(
|
|
319
|
+
pixels: np.ndarray
|
|
320
|
+
) -> Dict[int, int]:
|
|
321
|
+
"""
|
|
322
|
+
Calculate the number of pixels in each quantized region of the color space,
|
|
323
|
+
and save it in histo dict.
|
|
324
|
+
"""
|
|
325
|
+
pixels = pixels.astype(np.uint32)
|
|
326
|
+
|
|
327
|
+
r = np.right_shift(pixels[:, 0], MMCQ.RSHIFT)
|
|
328
|
+
g = np.right_shift(pixels[:, 1], MMCQ.RSHIFT)
|
|
329
|
+
b = np.right_shift(pixels[:, 2], MMCQ.RSHIFT)
|
|
330
|
+
color_index_array = MMCQ.get_color_index(r, g, b)
|
|
331
|
+
|
|
332
|
+
unique_indices, counts = np.unique(color_index_array, return_counts=True)
|
|
333
|
+
histo = {i: int(count) for i, count in zip(unique_indices, counts) if count != 0}
|
|
334
|
+
return histo
|
|
335
|
+
|
|
336
|
+
@staticmethod
|
|
337
|
+
def vbox_from_pixels(pixels: np.ndarray, histo: Dict[int, int]) -> "VBox":
|
|
338
|
+
rval = np.right_shift(pixels[:, 0], MMCQ.RSHIFT)
|
|
339
|
+
gval = np.right_shift(pixels[:, 1], MMCQ.RSHIFT)
|
|
340
|
+
bval = np.right_shift(pixels[:, 2], MMCQ.RSHIFT)
|
|
341
|
+
rmin = rval.min()
|
|
342
|
+
rmax = rval.max()
|
|
343
|
+
gmin = gval.min()
|
|
344
|
+
gmax = gval.max()
|
|
345
|
+
bmin = bval.min()
|
|
346
|
+
bmax = bval.max()
|
|
347
|
+
return VBox(rmin, rmax, gmin, gmax, bmin, bmax, histo, pixels.shape[0])
|
|
348
|
+
|
|
349
|
+
@staticmethod
|
|
350
|
+
def median_cut_apply(histo, vbox: "VBox"):
|
|
351
|
+
if not vbox.count:
|
|
352
|
+
return (None, None)
|
|
353
|
+
|
|
354
|
+
rw = vbox.r2 - vbox.r1 + 1
|
|
355
|
+
gw = vbox.g2 - vbox.g1 + 1
|
|
356
|
+
bw = vbox.b2 - vbox.b1 + 1
|
|
357
|
+
maxw = max([rw, gw, bw])
|
|
358
|
+
|
|
359
|
+
# only one pixel, no split
|
|
360
|
+
if vbox.count == 1:
|
|
361
|
+
return (vbox.copy, None)
|
|
362
|
+
|
|
363
|
+
# Find the partial sum arrays along the selected axis.
|
|
364
|
+
partial_sum = {}
|
|
365
|
+
lookahead_sum = {}
|
|
366
|
+
|
|
367
|
+
if maxw == rw:
|
|
368
|
+
do_cut_color = 'r'
|
|
369
|
+
column_num = 0
|
|
370
|
+
elif maxw == gw:
|
|
371
|
+
do_cut_color = 'g'
|
|
372
|
+
column_num = 1
|
|
373
|
+
else:
|
|
374
|
+
do_cut_color = 'b'
|
|
375
|
+
column_num = 2
|
|
376
|
+
|
|
377
|
+
rgb_array, color_index_array = vbox.get_rgb_array_and_color_index_array()
|
|
378
|
+
color_num_list = [histo.get(index, 0) for index in color_index_array]
|
|
379
|
+
unique_values, indices = np.unique(rgb_array[:, column_num], return_inverse=True)
|
|
380
|
+
color_sum_array = np.bincount(indices, weights=color_num_list)
|
|
381
|
+
|
|
382
|
+
total_array = np.cumsum(color_sum_array, dtype=np.int32)
|
|
383
|
+
for i, t in zip(unique_values, total_array):
|
|
384
|
+
partial_sum[i] = t
|
|
385
|
+
|
|
386
|
+
total = total_array[-1]
|
|
387
|
+
for i, d in partial_sum.items():
|
|
388
|
+
lookahead_sum[i] = total - d
|
|
389
|
+
|
|
390
|
+
# determine the cut planes
|
|
391
|
+
dim1 = do_cut_color + '1'
|
|
392
|
+
dim2 = do_cut_color + '2'
|
|
393
|
+
dim1_val = getattr(vbox, dim1)
|
|
394
|
+
dim2_val = getattr(vbox, dim2)
|
|
395
|
+
for i in range(dim1_val, dim2_val + 1):
|
|
396
|
+
if partial_sum[i] > (total / 2):
|
|
397
|
+
vbox1 = vbox.copy
|
|
398
|
+
vbox2 = vbox.copy
|
|
399
|
+
left = i - dim1_val
|
|
400
|
+
right = dim2_val - i
|
|
401
|
+
if left <= right:
|
|
402
|
+
d2 = min([dim2_val - 1, int(i + right / 2)])
|
|
403
|
+
else:
|
|
404
|
+
d2 = max([dim1_val, int(i - 1 - left / 2)])
|
|
405
|
+
|
|
406
|
+
# avoid 0-count boxes
|
|
407
|
+
while not partial_sum.get(d2, False):
|
|
408
|
+
d2 += 1
|
|
409
|
+
count2 = lookahead_sum.get(d2)
|
|
410
|
+
while not count2 and partial_sum.get(d2 - 1, False):
|
|
411
|
+
d2 -= 1
|
|
412
|
+
count2 = lookahead_sum.get(d2)
|
|
413
|
+
|
|
414
|
+
# set dimensions
|
|
415
|
+
setattr(vbox1, dim2, d2)
|
|
416
|
+
setattr(vbox2, dim1, getattr(vbox1, dim2) + 1)
|
|
417
|
+
return (vbox1, vbox2)
|
|
418
|
+
return (None, None)
|
|
419
|
+
|
|
420
|
+
@staticmethod
|
|
421
|
+
def quantize(pixels: np.ndarray, max_color: int):
|
|
422
|
+
"""
|
|
423
|
+
Pixels quantize.
|
|
424
|
+
|
|
425
|
+
Args:
|
|
426
|
+
pixels: a array of pixel in the form (r, g, b)
|
|
427
|
+
max_color: max number of colors
|
|
428
|
+
|
|
429
|
+
Returns:
|
|
430
|
+
|
|
431
|
+
"""
|
|
432
|
+
if pixels.shape[0] == 0:
|
|
433
|
+
raise Exception('Empty pixels when quantize.')
|
|
434
|
+
if max_color < 2 or max_color > 256:
|
|
435
|
+
raise Exception('Wrong number of max colors when quantize.')
|
|
436
|
+
|
|
437
|
+
histo = MMCQ.get_histo(pixels)
|
|
438
|
+
|
|
439
|
+
# get the beginning vbox from the colors
|
|
440
|
+
vbox = MMCQ.vbox_from_pixels(pixels, histo)
|
|
441
|
+
pq = PQueue(lambda x: x.count)
|
|
442
|
+
pq.push(vbox)
|
|
443
|
+
|
|
444
|
+
# inner function to do the iteration
|
|
445
|
+
def iter_(lh: "PQueue", target: Union[int, float]):
|
|
446
|
+
n_color = 1
|
|
447
|
+
n_iter = 0
|
|
448
|
+
while n_iter < MMCQ.MAX_ITERATION:
|
|
449
|
+
vbox = lh.pop()
|
|
450
|
+
if not vbox.count: # just put it back
|
|
451
|
+
lh.push(vbox)
|
|
452
|
+
n_iter += 1
|
|
453
|
+
continue
|
|
454
|
+
|
|
455
|
+
# do the cut
|
|
456
|
+
vbox1, vbox2 = MMCQ.median_cut_apply(histo, vbox)
|
|
457
|
+
if not vbox1:
|
|
458
|
+
raise Exception("vbox1 not defined; shouldn't happen!")
|
|
459
|
+
lh.push(vbox1)
|
|
460
|
+
|
|
461
|
+
if vbox2: # vbox2 can be null
|
|
462
|
+
lh.push(vbox2)
|
|
463
|
+
n_color += 1
|
|
464
|
+
if n_color >= target:
|
|
465
|
+
return
|
|
466
|
+
if n_iter > MMCQ.MAX_ITERATION:
|
|
467
|
+
return
|
|
468
|
+
n_iter += 1
|
|
469
|
+
|
|
470
|
+
# first set of colors, sorted by population
|
|
471
|
+
iter_(pq, MMCQ.FRACT_BY_POPULATIONS * max_color)
|
|
472
|
+
|
|
473
|
+
# Re-sort by the product of pixel occupancy times the size in color space.
|
|
474
|
+
pq2 = PQueue(lambda x: x.count * x.volume)
|
|
475
|
+
while pq.size():
|
|
476
|
+
pq2.push(pq.pop())
|
|
477
|
+
|
|
478
|
+
# next set - generate the median cuts using the (npix * vol) sorting.
|
|
479
|
+
iter_(pq2, max_color - pq2.size())
|
|
480
|
+
|
|
481
|
+
# calculate the actual colors
|
|
482
|
+
cmap = CMap()
|
|
483
|
+
while pq2.size():
|
|
484
|
+
cmap.push(pq2.pop())
|
|
485
|
+
return cmap
|
|
486
|
+
|
|
487
|
+
|
|
488
|
+
class VBox:
|
|
489
|
+
"""
|
|
490
|
+
3D color space box.
|
|
491
|
+
"""
|
|
492
|
+
def __init__(self, r1, r2, g1, g2, b1, b2, histo: Dict[int, int], total_pixel_count: int):
|
|
493
|
+
self.r1 = int(r1)
|
|
494
|
+
self.r2 = int(r2)
|
|
495
|
+
self.g1 = int(g1)
|
|
496
|
+
self.g2 = int(g2)
|
|
497
|
+
self.b1 = int(b1)
|
|
498
|
+
self.b2 = int(b2)
|
|
499
|
+
self.histo = histo
|
|
500
|
+
self.total_pixel_count = total_pixel_count
|
|
501
|
+
|
|
502
|
+
self._rgb_array: Optional[np.ndarray] = None
|
|
503
|
+
self._color_index: Optional[np.ndarray] = None
|
|
504
|
+
|
|
505
|
+
@functools.cached_property
|
|
506
|
+
def volume(self) -> int:
|
|
507
|
+
"""
|
|
508
|
+
The volume of pixels in VBox.
|
|
509
|
+
"""
|
|
510
|
+
sub_r = self.r2 - self.r1
|
|
511
|
+
sub_g = self.g2 - self.g1
|
|
512
|
+
sub_b = self.b2 - self.b1
|
|
513
|
+
return (sub_r + 1) * (sub_g + 1) * (sub_b + 1)
|
|
514
|
+
|
|
515
|
+
@property
|
|
516
|
+
def copy(self) -> "VBox":
|
|
517
|
+
return VBox(self.r1, self.r2, self.g1, self.g2, self.b1, self.b2, self.histo, self.total_pixel_count)
|
|
518
|
+
|
|
519
|
+
@functools.cached_property
|
|
520
|
+
def avg(self) -> Tuple[int, int, int]:
|
|
521
|
+
"""
|
|
522
|
+
Calculate the color avg of VBox.
|
|
523
|
+
"""
|
|
524
|
+
mult = 1 << (8 - MMCQ.SIGBITS)
|
|
525
|
+
rgb_array, color_index_array = self.get_rgb_array_and_color_index_array()
|
|
526
|
+
histo_num = np.array([self.histo.get(c, 0) for c in color_index_array], dtype=np.int32)
|
|
527
|
+
r_sum = np.sum(histo_num * (rgb_array[:, 0] + 0.5) * mult)
|
|
528
|
+
g_sum = np.sum(histo_num * (rgb_array[:, 1] + 0.5) * mult)
|
|
529
|
+
b_sum = np.sum(histo_num * (rgb_array[:, 2] + 0.5) * mult)
|
|
530
|
+
|
|
531
|
+
total = histo_num.sum()
|
|
532
|
+
if total:
|
|
533
|
+
r_avg = r_sum / total
|
|
534
|
+
g_avg = g_sum / total
|
|
535
|
+
b_avg = b_sum / total
|
|
536
|
+
else:
|
|
537
|
+
r_avg = mult * (self.r1 + self.r2 + 1) / 2
|
|
538
|
+
g_avg = mult * (self.g1 + self.g2 + 1) / 2
|
|
539
|
+
b_avg = mult * (self.b1 + self.b2 + 1) / 2
|
|
540
|
+
|
|
541
|
+
r_avg = int(np.clip(r_avg, 0, 255))
|
|
542
|
+
g_avg = int(np.clip(g_avg, 0, 255))
|
|
543
|
+
b_avg = int(np.clip(b_avg, 0, 255))
|
|
544
|
+
return r_avg, g_avg, b_avg
|
|
545
|
+
|
|
546
|
+
@functools.cached_property
|
|
547
|
+
def count(self) -> int:
|
|
548
|
+
"""
|
|
549
|
+
The number of pixels in VBox.
|
|
550
|
+
"""
|
|
551
|
+
rgb_array, color_index_array = self.get_rgb_array_and_color_index_array()
|
|
552
|
+
npix_list = [self.histo.get(i, 0) for i in color_index_array]
|
|
553
|
+
npix = sum(npix_list)
|
|
554
|
+
return npix
|
|
555
|
+
|
|
556
|
+
@functools.cached_property
|
|
557
|
+
def percent(self) -> int:
|
|
558
|
+
"""
|
|
559
|
+
The percentage of VBox pixels to all pixels.
|
|
560
|
+
"""
|
|
561
|
+
return int(self.count / self.total_pixel_count * 100)
|
|
562
|
+
|
|
563
|
+
def get_rgb_array(self):
|
|
564
|
+
return np.mgrid[self.r1: self.r2 + 1, self.g1: self.g2 + 1, self.b1: self.b2 + 1].T.reshape(-1, 3)
|
|
565
|
+
|
|
566
|
+
def get_rgb_array_and_color_index_array(self):
|
|
567
|
+
if self._rgb_array is None:
|
|
568
|
+
rgb_array = np.mgrid[self.r1: self.r2 + 1, self.g1: self.g2 + 1, self.b1: self.b2 + 1].T.reshape(-1, 3)
|
|
569
|
+
self._rgb_array = rgb_array
|
|
570
|
+
self._color_index_array = MMCQ.get_color_index(rgb_array[:, 0], rgb_array[:, 1], rgb_array[:, 2])
|
|
571
|
+
return self._rgb_array, self._color_index_array
|
|
572
|
+
|
|
573
|
+
|
|
574
|
+
class CMap:
|
|
575
|
+
"""
|
|
576
|
+
Color map.
|
|
577
|
+
"""
|
|
578
|
+
def __init__(self, return_percent: bool = False):
|
|
579
|
+
self.pqueue: "PQueue" = PQueue(lambda x: x['vbox'].count * x['vbox'].volume)
|
|
580
|
+
self.return_percent = return_percent
|
|
581
|
+
|
|
582
|
+
@property
|
|
583
|
+
def palette(self) -> List:
|
|
584
|
+
if self.return_percent:
|
|
585
|
+
return self.pqueue.map(lambda x: x['color'] + (x['percent'],))
|
|
586
|
+
else:
|
|
587
|
+
return self.pqueue.map(lambda x: x['color'])
|
|
588
|
+
|
|
589
|
+
def push(self, vbox: "VBox"):
|
|
590
|
+
self.pqueue.push({
|
|
591
|
+
'vbox': vbox,
|
|
592
|
+
'color': vbox.avg,
|
|
593
|
+
'percent': vbox.percent,
|
|
594
|
+
})
|
|
595
|
+
|
|
596
|
+
def size(self):
|
|
597
|
+
return self.pqueue.size()
|
|
598
|
+
|
|
599
|
+
|
|
600
|
+
class PQueue:
|
|
601
|
+
"""
|
|
602
|
+
Simple priority queue.
|
|
603
|
+
"""
|
|
604
|
+
def __init__(self, sort_key: Callable):
|
|
605
|
+
self.sort_key = sort_key
|
|
606
|
+
self.contents: List = []
|
|
607
|
+
self._sorted = False
|
|
608
|
+
|
|
609
|
+
def sort(self):
|
|
610
|
+
self.contents.sort(key=self.sort_key)
|
|
611
|
+
self._sorted = True
|
|
612
|
+
|
|
613
|
+
def push(self, o):
|
|
614
|
+
self.contents.append(o)
|
|
615
|
+
self._sorted = False
|
|
616
|
+
|
|
617
|
+
def pop(self):
|
|
618
|
+
if not self._sorted:
|
|
619
|
+
self.sort()
|
|
620
|
+
return self.contents.pop()
|
|
621
|
+
|
|
622
|
+
def size(self) -> int:
|
|
623
|
+
return len(self.contents)
|
|
624
|
+
|
|
625
|
+
def map(self, f: Callable) -> List:
|
|
626
|
+
return list(map(f, self.contents))
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.1
|
|
2
2
|
Name: spotifywebapipython
|
|
3
|
-
Version: 1.0.
|
|
3
|
+
Version: 1.0.251
|
|
4
4
|
Summary: Spotify Web API Python3 Library
|
|
5
5
|
Author: Todd Lucas
|
|
6
6
|
Author-email: <thlucas@yahoo.com>
|
|
@@ -22,8 +22,9 @@ Requires-Python: >3.11.0
|
|
|
22
22
|
Description-Content-Type: text/markdown
|
|
23
23
|
License-File: LICENSE
|
|
24
24
|
Requires-Dist: lxml >=5.2.0
|
|
25
|
+
Requires-Dist: numpy >=2.3.2
|
|
25
26
|
Requires-Dist: oauthlib >=3.2.2
|
|
26
|
-
Requires-Dist: pillow >=
|
|
27
|
+
Requires-Dist: pillow >=11.3.0
|
|
27
28
|
Requires-Dist: platformdirs >=4.1.0
|
|
28
29
|
Requires-Dist: PyChromecast >=14.0.7
|
|
29
30
|
Requires-Dist: pyotp >=2.9.0
|