koishi-plugin-steam-info-check 1.0.9 → 1.1.0

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 (37) hide show
  1. package/dist/index.d.ts +1 -0
  2. package/dist/index.js +109 -40
  3. package/dist/locales/zh-CN.d.ts +25 -0
  4. package/dist/locales/zh-CN.js +25 -0
  5. package/nonebot-plugin-steam-info-main/.github/actions/setup-python/action.yml +21 -0
  6. package/nonebot-plugin-steam-info-main/.github/workflows/release.yml +37 -0
  7. package/nonebot-plugin-steam-info-main/LICENSE +21 -0
  8. package/nonebot-plugin-steam-info-main/README.md +117 -0
  9. package/nonebot-plugin-steam-info-main/fonts/MiSans-Bold.ttf +0 -0
  10. package/nonebot-plugin-steam-info-main/fonts/MiSans-Light.ttf +0 -0
  11. package/nonebot-plugin-steam-info-main/fonts/MiSans-Regular.ttf +0 -0
  12. package/nonebot-plugin-steam-info-main/nonebot_plugin_steam_info/__init__.py +487 -0
  13. package/nonebot-plugin-steam-info-main/nonebot_plugin_steam_info/config.py +19 -0
  14. package/nonebot-plugin-steam-info-main/nonebot_plugin_steam_info/data_source.py +264 -0
  15. package/nonebot-plugin-steam-info-main/nonebot_plugin_steam_info/draw.py +921 -0
  16. package/nonebot-plugin-steam-info-main/nonebot_plugin_steam_info/models.py +82 -0
  17. package/nonebot-plugin-steam-info-main/nonebot_plugin_steam_info/res/bg_dots.png +0 -0
  18. package/nonebot-plugin-steam-info-main/nonebot_plugin_steam_info/res/busy.png +0 -0
  19. package/nonebot-plugin-steam-info-main/nonebot_plugin_steam_info/res/default_achievement_image.png +0 -0
  20. package/nonebot-plugin-steam-info-main/nonebot_plugin_steam_info/res/default_header_image.jpg +0 -0
  21. package/nonebot-plugin-steam-info-main/nonebot_plugin_steam_info/res/friends_search.png +0 -0
  22. package/nonebot-plugin-steam-info-main/nonebot_plugin_steam_info/res/gaming.png +0 -0
  23. package/nonebot-plugin-steam-info-main/nonebot_plugin_steam_info/res/parent_status.png +0 -0
  24. package/nonebot-plugin-steam-info-main/nonebot_plugin_steam_info/res/unknown_avatar.jpg +0 -0
  25. package/nonebot-plugin-steam-info-main/nonebot_plugin_steam_info/res/zzz_gaming.png +0 -0
  26. package/nonebot-plugin-steam-info-main/nonebot_plugin_steam_info/res/zzz_online.png +0 -0
  27. package/nonebot-plugin-steam-info-main/nonebot_plugin_steam_info/steam.py +259 -0
  28. package/nonebot-plugin-steam-info-main/nonebot_plugin_steam_info/utils.py +112 -0
  29. package/nonebot-plugin-steam-info-main/pdm.lock +966 -0
  30. package/nonebot-plugin-steam-info-main/preview.png +0 -0
  31. package/nonebot-plugin-steam-info-main/preview_1.png +0 -0
  32. package/nonebot-plugin-steam-info-main/preview_2.png +0 -0
  33. package/nonebot-plugin-steam-info-main/pyproject.toml +29 -0
  34. package/package.json +1 -1
  35. package/src/index.ts +83 -42
  36. package/src/locales/zh-CN.ts +25 -0
  37. package/src/locales/zh-CN.yml +2 -1
@@ -0,0 +1,921 @@
1
+ import numpy as np
2
+ from io import BytesIO
3
+ from pathlib import Path
4
+ from typing import List, Dict, Tuple
5
+ from colorsys import rgb_to_hsv, hsv_to_rgb
6
+ from PIL import Image, ImageDraw, ImageFont, ImageFilter, ImageEnhance
7
+
8
+ from .utils import hex_to_rgb
9
+ from .models import DrawPlayerStatusData, Achievements
10
+
11
+
12
+ WIDTH = 400
13
+ PARENT_AVATAR_SIZE = 72
14
+ MEMBER_AVATAR_SIZE = 50
15
+
16
+ unknown_avatar_path = Path(__file__).parent / "res/unknown_avatar.jpg"
17
+ parent_status_path = Path(__file__).parent / "res/parent_status.png"
18
+ friends_search_path = Path(__file__).parent / "res/friends_search.png"
19
+ busy_path = Path(__file__).parent / "res/busy.png"
20
+ zzz_online_path = Path(__file__).parent / "res/zzz_online.png"
21
+ zzz_gaming_path = Path(__file__).parent / "res/zzz_gaming.png"
22
+ gaming_path = Path(__file__).parent / "res/gaming.png"
23
+
24
+ font_regular_path = None
25
+ font_light_path = None
26
+ font_bold_path = None
27
+
28
+
29
+ def set_font_paths(regular_path, light_path, bold_path):
30
+ global font_regular_path, font_light_path, font_bold_path
31
+ base_dir = Path().cwd()
32
+ font_regular_path = str((base_dir / regular_path).resolve())
33
+ font_light_path = str((base_dir / light_path).resolve())
34
+ font_bold_path = str((base_dir / bold_path).resolve())
35
+
36
+
37
+ def check_font():
38
+ if not Path(font_regular_path).exists():
39
+ raise FileNotFoundError(f"Font file {font_regular_path} not found.")
40
+ if not Path(font_light_path).exists():
41
+ raise FileNotFoundError(f"Font file {font_light_path} not found.")
42
+ if not Path(font_bold_path).exists():
43
+ raise FileNotFoundError(f"Font file {font_bold_path} not found.")
44
+
45
+
46
+ personastate_colors = {
47
+ 0: (hex_to_rgb("969697"), hex_to_rgb("656565")),
48
+ 1: (hex_to_rgb("6dcef5"), hex_to_rgb("4c91ac")),
49
+ 2: (hex_to_rgb("6dcef5"), hex_to_rgb("4c91ac")),
50
+ 3: (hex_to_rgb("45778e"), hex_to_rgb("365969")),
51
+ 4: (hex_to_rgb("6dcef5"), hex_to_rgb("4c91ac")),
52
+ 5: (hex_to_rgb("6dcef5"), hex_to_rgb("4c91ac")),
53
+ 6: (hex_to_rgb("6dcef5"), hex_to_rgb("4c91ac")),
54
+ }
55
+
56
+
57
+ def vertically_concatenate_images(images: List[Image.Image]) -> Image.Image:
58
+ widths, heights = zip(*(i.size for i in images))
59
+ total_width = max(widths)
60
+ total_height = sum(heights)
61
+
62
+ new_image = Image.new("RGB", (total_width, total_height))
63
+
64
+ y_offset = 0
65
+ for image in images:
66
+ new_image.paste(image, (0, y_offset))
67
+ y_offset += image.size[1]
68
+
69
+ return new_image
70
+
71
+
72
+ def draw_start_gaming(
73
+ avatar: Image.Image, friend_name: str, game_name: str, nickname: str = None
74
+ ):
75
+ canvas = Image.open(gaming_path)
76
+ canvas.paste(avatar.resize((66, 66), Image.BICUBIC), (15, 20))
77
+
78
+ # 绘制名称
79
+ draw = ImageDraw.Draw(canvas)
80
+ draw.text(
81
+ (104, 14),
82
+ f"{friend_name} ({nickname})" if nickname is not None else friend_name,
83
+ font=ImageFont.truetype(font_regular_path, 19),
84
+ fill=hex_to_rgb("e3ffc2"),
85
+ )
86
+
87
+ # 绘制"正在玩"
88
+ draw.text(
89
+ (103, 42),
90
+ "正在玩",
91
+ font=ImageFont.truetype(font_regular_path, 17),
92
+ fill=hex_to_rgb("969696"),
93
+ )
94
+
95
+ # 绘制游戏名称
96
+ draw.text(
97
+ (104, 66),
98
+ game_name,
99
+ font=ImageFont.truetype(font_bold_path, 14),
100
+ fill=hex_to_rgb("91c257"),
101
+ )
102
+
103
+ return canvas
104
+
105
+
106
+ def draw_parent_status(parent_avatar: Image.Image, parent_name: str) -> Image.Image:
107
+ parent_avatar = parent_avatar.resize(
108
+ (PARENT_AVATAR_SIZE, PARENT_AVATAR_SIZE), Image.BICUBIC
109
+ )
110
+
111
+ canvas = Image.open(parent_status_path).resize((WIDTH, 120), Image.BICUBIC)
112
+
113
+ draw = ImageDraw.Draw(canvas)
114
+
115
+ # 在左下角 (16, 16) 处绘制头像
116
+ avatar_height = 120 - 16 - PARENT_AVATAR_SIZE
117
+ canvas.paste(parent_avatar, (16, avatar_height))
118
+
119
+ # 绘制名称
120
+ draw.text(
121
+ (16 + PARENT_AVATAR_SIZE + 16, avatar_height + 12),
122
+ parent_name,
123
+ font=ImageFont.truetype(font_bold_path, 20),
124
+ fill=hex_to_rgb("6dcff6"),
125
+ )
126
+
127
+ # 绘制状态
128
+ draw.text(
129
+ (16 + PARENT_AVATAR_SIZE + 16, avatar_height + 20 + 16),
130
+ "在线",
131
+ font=ImageFont.truetype(font_light_path, 18),
132
+ fill=hex_to_rgb("4c91ac"),
133
+ )
134
+
135
+ return canvas
136
+
137
+
138
+ def draw_friends_search() -> Image.Image:
139
+ canvas = Image.new("RGB", (WIDTH, 50), hex_to_rgb("434953"))
140
+
141
+ friends_search = Image.open(friends_search_path)
142
+
143
+ canvas.paste(friends_search, (WIDTH - friends_search.width, 0))
144
+
145
+ draw = ImageDraw.Draw(canvas)
146
+
147
+ draw.text(
148
+ (24, 10),
149
+ "好友",
150
+ hex_to_rgb("b7ccd5"),
151
+ font=ImageFont.truetype(font_regular_path, 20),
152
+ )
153
+
154
+ return canvas
155
+
156
+
157
+ def draw_friend_status(
158
+ friend_avatar: Image.Image,
159
+ friend_name: str,
160
+ status: str,
161
+ personastate: int,
162
+ nickname: str = None,
163
+ ) -> Image.Image:
164
+ friend_avatar = friend_avatar.resize(
165
+ (MEMBER_AVATAR_SIZE, MEMBER_AVATAR_SIZE), Image.BICUBIC
166
+ )
167
+
168
+ canvas = Image.new("RGB", (WIDTH, 64), hex_to_rgb("1e2024"))
169
+
170
+ draw = ImageDraw.Draw(canvas)
171
+
172
+ display_name = (
173
+ f"{friend_name} ({nickname})" if nickname is not None else friend_name
174
+ )
175
+
176
+ if personastate == 2:
177
+ # 忙碌 加上一个忙碌图标
178
+ canvas = draw_friend_status(friend_avatar, friend_name, status, 1, nickname)
179
+ draw = ImageDraw.Draw(canvas)
180
+
181
+ busy = Image.open(busy_path)
182
+
183
+ name_width = int(
184
+ draw.textlength(display_name, font=ImageFont.truetype(font_bold_path, 20))
185
+ )
186
+
187
+ canvas.paste(busy, (22 + MEMBER_AVATAR_SIZE + 16 + name_width + 4, 18))
188
+
189
+ return canvas
190
+
191
+ if personastate == 4:
192
+ # 打盹 加上一个 ZZZ
193
+ canvas = draw_friend_status(friend_avatar, friend_name, status, 1, nickname)
194
+ draw = ImageDraw.Draw(canvas)
195
+
196
+ zzz = Image.open(zzz_online_path if status == "在线" else zzz_gaming_path)
197
+
198
+ name_width = int(
199
+ draw.textlength(display_name, font=ImageFont.truetype(font_bold_path, 20))
200
+ )
201
+
202
+ canvas.paste(zzz, (22 + MEMBER_AVATAR_SIZE + 16 + name_width + 8, 18))
203
+
204
+ return canvas
205
+
206
+ # 绘制头像
207
+ canvas.paste(friend_avatar, (22, 8))
208
+
209
+ if status != "在线" and personastate == 1:
210
+ fill = (hex_to_rgb("e3ffc2"), hex_to_rgb("8ebe56"))
211
+ elif status != "离开" and personastate == 3:
212
+ fill = (hex_to_rgb("e3ffc2"), hex_to_rgb("8ebe56"))
213
+ else:
214
+ fill = personastate_colors[personastate]
215
+
216
+ # 绘制名称
217
+ draw.text(
218
+ (22 + MEMBER_AVATAR_SIZE + 18, 12),
219
+ display_name,
220
+ font=ImageFont.truetype(font_bold_path, 20),
221
+ fill=fill[0],
222
+ )
223
+
224
+ # 绘制状态
225
+ draw.text(
226
+ (22 + MEMBER_AVATAR_SIZE + 16, 36),
227
+ status,
228
+ font=ImageFont.truetype(font_regular_path, 18),
229
+ fill=fill[1],
230
+ )
231
+
232
+ return canvas
233
+
234
+
235
+ def draw_gaming_friends_status(data: List[Dict[str, str]]) -> Image.Image:
236
+ # 排序数据,按照游戏名称字母表顺序排序
237
+ data.sort(key=lambda x: x["status"])
238
+
239
+ canvas = Image.new(
240
+ "RGB",
241
+ (WIDTH, 64 + (MEMBER_AVATAR_SIZE + 16) * len(data) + 16),
242
+ hex_to_rgb("1e2024"),
243
+ )
244
+
245
+ draw = ImageDraw.Draw(canvas)
246
+
247
+ # 绘制标题
248
+ draw.text(
249
+ (22, 22),
250
+ "游戏中",
251
+ hex_to_rgb("c5d6d4"),
252
+ font=ImageFont.truetype(font_regular_path, 22),
253
+ )
254
+
255
+ # 绘制好友头像和名称
256
+ friends_status_list = [
257
+ draw_friend_status(
258
+ d["avatar"], d["name"], d["status"], d["personastate"], d["nickname"]
259
+ )
260
+ for d in data
261
+ ]
262
+
263
+ # 拼接好友头像和名称
264
+ for i, friend_status in enumerate(friends_status_list):
265
+ canvas.paste(friend_status, (0, 64 + (MEMBER_AVATAR_SIZE + 16) * i))
266
+
267
+ return canvas
268
+
269
+
270
+ def draw_online_friends_status(data: List[Dict[str, str]]) -> Image.Image:
271
+ canvas = Image.new(
272
+ "RGB",
273
+ (WIDTH, 64 + (MEMBER_AVATAR_SIZE + 16) * len(data) + 16),
274
+ hex_to_rgb("1e2024"),
275
+ )
276
+
277
+ draw = ImageDraw.Draw(canvas)
278
+
279
+ # 绘制标题
280
+ draw.text(
281
+ (22, 22),
282
+ "在线好友",
283
+ hex_to_rgb("c5d6d4"),
284
+ font=ImageFont.truetype(font_regular_path, 22),
285
+ )
286
+
287
+ # 绘制在线人数
288
+ draw.text(
289
+ (115, 25),
290
+ f"({len(data)})",
291
+ hex_to_rgb("67665c"),
292
+ font=ImageFont.truetype(font_regular_path, 18),
293
+ )
294
+
295
+ # 绘制好友头像和名称
296
+ friends_status_list = [
297
+ draw_friend_status(
298
+ d["avatar"], d["name"], d["status"], d["personastate"], d["nickname"]
299
+ )
300
+ for d in data
301
+ ]
302
+
303
+ # 拼接好友头像和名称
304
+ for i, friend_status in enumerate(friends_status_list):
305
+ canvas.paste(friend_status, (0, 64 + (MEMBER_AVATAR_SIZE + 16) * i))
306
+
307
+ return canvas
308
+
309
+
310
+ def draw_offline_friends_status(data: List[Dict[str, str]]) -> Image.Image:
311
+ canvas = Image.new(
312
+ "RGB",
313
+ (WIDTH, 64 + (MEMBER_AVATAR_SIZE + 16) * len(data) + 16),
314
+ hex_to_rgb("1e2024"),
315
+ )
316
+
317
+ draw = ImageDraw.Draw(canvas)
318
+
319
+ # 绘制标题
320
+ draw.text(
321
+ (22, 22),
322
+ "离线",
323
+ hex_to_rgb("c5d6d4"),
324
+ font=ImageFont.truetype(font_regular_path, 22),
325
+ )
326
+
327
+ # 绘制离线人数
328
+ draw.text(
329
+ (72, 25),
330
+ f"({len(data)})",
331
+ hex_to_rgb("67665c"),
332
+ font=ImageFont.truetype(font_regular_path, 18),
333
+ )
334
+
335
+ # 绘制好友头像和名称
336
+ friends_status_list = [
337
+ draw_friend_status(
338
+ d["avatar"], d["name"], d["status"], d["personastate"], d["nickname"]
339
+ )
340
+ for d in data
341
+ ]
342
+
343
+ # 拼接好友头像和名称
344
+ for i, friend_status in enumerate(friends_status_list):
345
+ canvas.paste(friend_status, (0, 64 + (MEMBER_AVATAR_SIZE + 16) * i))
346
+
347
+ return canvas
348
+
349
+
350
+ def draw_friends_status(
351
+ parent_avatar: Image.Image, parent_name: str, data: List[Dict[str, str]]
352
+ ):
353
+ data.sort(key=lambda x: x["personastate"])
354
+
355
+ parent_status = draw_parent_status(parent_avatar, parent_name)
356
+ friends_search = draw_friends_search()
357
+
358
+ status_images: List[Image.Image] = []
359
+ height = parent_status.height + friends_search.height
360
+
361
+ gaming_data = [
362
+ d
363
+ for d in data
364
+ if (d["personastate"] == 1 and d["status"] != "在线")
365
+ or (d["personastate"] == 3 and d["status"] != "离开")
366
+ or (d["personastate"] == 4 and d["status"] != "在线")
367
+ ]
368
+
369
+ if gaming_data:
370
+ status_images.append(draw_gaming_friends_status(gaming_data))
371
+ height += status_images[-1].height
372
+
373
+ online_data = [
374
+ d
375
+ for d in data
376
+ if (d["personastate"] == 1 and d["status"] == "在线")
377
+ or (d["personastate"] == 3 and d["status"] == "离开")
378
+ or (d["personastate"] == 4 and d["status"] == "在线")
379
+ or (d["personastate"] in [2, 5, 6])
380
+ ]
381
+ # 按 1, 2, 4, 5, 6, 3 的顺序排序
382
+ online_data.sort(key=lambda x: (7 if x["personastate"] == 3 else x["personastate"]))
383
+
384
+ if online_data:
385
+ status_images.append(draw_online_friends_status(online_data))
386
+ height += status_images[-1].height
387
+
388
+ offline_data = [d for d in data if d["personastate"] == 0]
389
+ if offline_data:
390
+ status_images.append(draw_offline_friends_status(offline_data))
391
+ height += status_images[-1].height
392
+
393
+ # 拼合图片
394
+ canvas = Image.new("RGB", (WIDTH, height), hex_to_rgb("1e2024"))
395
+ draw = ImageDraw.Draw(canvas)
396
+
397
+ canvas.paste(parent_status, (0, 0))
398
+ canvas.paste(friends_search, (0, parent_status.height))
399
+
400
+ y = parent_status.height + friends_search.height
401
+
402
+ for i, status_image in enumerate(status_images):
403
+ canvas.paste(status_image, (0, y))
404
+ y += status_image.height
405
+
406
+ # 绘制分割线
407
+ if i != len(status_images) - 1:
408
+ draw.rectangle([0, y - 1, WIDTH, y], fill=hex_to_rgb("333439"))
409
+
410
+ return canvas
411
+
412
+
413
+ def get_average_color(image: Image.Image) -> tuple[int, int, int]:
414
+ """获取图片的平均颜色"""
415
+ image_np = np.array(image)
416
+ average_color = image_np.mean(axis=(0, 1)).astype(int)
417
+ return tuple(average_color)
418
+
419
+
420
+ def split_image(
421
+ image: Image.Image, rows: int, cols: int
422
+ ) -> tuple[list[Image.Image], int, int]:
423
+ """将图片分割为rows * cols份"""
424
+ width, height = image.size
425
+ piece_width = width // cols
426
+ piece_height = height // rows
427
+ pieces = []
428
+
429
+ for r in range(rows):
430
+ for c in range(cols):
431
+ box = (
432
+ c * piece_width,
433
+ r * piece_height,
434
+ (c + 1) * piece_width,
435
+ (r + 1) * piece_height,
436
+ )
437
+ piece = image.crop(box)
438
+ pieces.append(piece)
439
+
440
+ return pieces, piece_width, piece_height
441
+
442
+
443
+ def recolor_image(image: Image.Image, rows: int, cols: int) -> Image.Image:
444
+ """分片图片,提取平均颜色后拼接"""
445
+ total_average_color = get_average_color(image) # 获取整体平均颜色
446
+ pieces, piece_width, piece_height = split_image(image, rows, cols)
447
+
448
+ diameter = min(pieces[0].size) # 以最小边为直径
449
+ radius = diameter // 2
450
+ new_image = Image.new("RGB", image.size, total_average_color)
451
+
452
+ for i, piece in enumerate(pieces):
453
+ average_color = get_average_color(piece) # 获取每片的平均颜色
454
+
455
+ # 计算放置的位置
456
+ row, col = divmod(i, cols)
457
+ x = col * piece_width + piece_width // 2
458
+ y = row * piece_height + piece_height // 2
459
+
460
+ # 画圆
461
+ circle = Image.new("RGBA", (piece_width, piece_height), (0, 0, 0, 0))
462
+ draw = ImageDraw.Draw(circle)
463
+ draw.ellipse((0, 0, piece_width, piece_height), fill=average_color)
464
+
465
+ # 将圆形图片粘贴到新图片上
466
+ new_image.paste(circle, (x - radius, y - radius), circle)
467
+
468
+ new_image = new_image.filter(ImageFilter.SMOOTH)
469
+ new_image = new_image.filter(ImageFilter.GaussianBlur(50))
470
+
471
+ return new_image
472
+
473
+
474
+ def create_gradient_image(
475
+ size: Tuple[int, int], color1: Tuple[int, int, int], color2: Tuple[int, int, int]
476
+ ) -> Image.Image:
477
+ """创建渐变图片"""
478
+ # 确保颜色值在 0-255 范围内
479
+ color1 = tuple(max(0, min(255, c)) for c in color1)
480
+ color2 = tuple(max(0, min(255, c)) for c in color2)
481
+ # 创建一个渐变的线性空间
482
+ gradient_array = np.linspace(color1, color2, size[0])
483
+
484
+ # 将渐变数组的形状调整为 (height, width, 3)
485
+ gradient_image = np.tile(gradient_array, (size[1], 1, 1)).astype(np.uint8)
486
+
487
+ return Image.fromarray(gradient_image, "RGBA")
488
+
489
+
490
+ def create_vertical_gradient_rect(width, height, start_color, end_color):
491
+ """
492
+ 创建一个在竖直方向上渐变的矩形图像.
493
+
494
+ Args:
495
+ width (int): 矩形的宽度 (以像素为单位).
496
+ height (int): 矩形的高度 (以像素为单位).
497
+ start_color (tuple): 起始颜色,格式为 (R, G, B),每个值范围为 0-255.
498
+ end_color (tuple): 结束颜色,格式为 (R, G, B),每个值范围为 0-255.
499
+
500
+ Returns:
501
+ Image: PIL Image 对象,表示生成的渐变矩形.
502
+ """
503
+ if width <= 0 or height <= 0:
504
+ return Image.new("RGBA", (1, 1), (0, 0, 0, 0))
505
+ # 确保颜色不超过 0-255 的范围
506
+ start_color = tuple(max(0, min(255, c)) for c in start_color)
507
+ end_color = tuple(max(0, min(255, c)) for c in end_color)
508
+
509
+ # 使用 NumPy 创建一个线性渐变数组
510
+ gradient_array = np.linspace(start_color, end_color, num=height, dtype=np.uint8)
511
+ gradient_array = np.tile(gradient_array[:, np.newaxis, :], (1, width, 1))
512
+
513
+ # 使用 Pillow 创建图像并填充颜色
514
+ image = Image.fromarray(gradient_array)
515
+ return image
516
+
517
+
518
+ def random_color_offset(
519
+ color: Tuple[int, int, int], offset: int
520
+ ) -> Tuple[int, int, int]:
521
+ return tuple(
522
+ min(255, max(0, c + np.random.randint(-offset, offset + 1))) for c in color
523
+ )
524
+
525
+
526
+ def get_brightest_and_darkest_color(
527
+ image: Image.Image,
528
+ saturation_threshold: int = 100,
529
+ hue_difference_threshold: int = 30,
530
+ ) -> Tuple[Tuple[int, int, int], Tuple[int, int, int]]:
531
+ """获取图片最亮和最暗的颜色"""
532
+ # 将RGB图像转换为HSV
533
+ img_hsv = np.array(image.convert("HSV"))
534
+
535
+ # 设定一个阈值来定义“鲜艳的颜色”,例如饱和度大于150
536
+ vivid_mask = img_hsv[..., 1] > saturation_threshold
537
+
538
+ # 获取饱和度较高(鲜艳)的像素索引
539
+ vivid_pixels = img_hsv[vivid_mask]
540
+
541
+ if len(vivid_pixels) < 10:
542
+ return get_brightest_and_darkest_color(image, saturation_threshold - 10)
543
+
544
+ # 在鲜艳的像素中,根据亮度(V通道)找到最亮和最暗的颜色
545
+ brightest_pixel = vivid_pixels[np.argmax(vivid_pixels[..., 2])]
546
+ darkest_pixel = vivid_pixels[np.argmin(vivid_pixels[..., 2])]
547
+
548
+ # 获取最亮和最暗的颜色的色相差异
549
+ hue_difference = abs(int(brightest_pixel[0]) - int(darkest_pixel[0]))
550
+
551
+ # 如果色相差异过小,则尝试寻找新的最暗颜色,直到色相差异大于设定阈值
552
+ if hue_difference < hue_difference_threshold:
553
+ possible_dark_pixels = vivid_pixels[vivid_pixels[..., 0] != brightest_pixel[0]]
554
+ if len(possible_dark_pixels) > 0:
555
+ darkest_pixel = possible_dark_pixels[
556
+ np.argmin(possible_dark_pixels[..., 2])
557
+ ]
558
+
559
+ # 将最亮和最暗的像素从HSV转回RGB
560
+ brightest_color = (
561
+ Image.fromarray(np.uint8([[brightest_pixel]]), "HSV")
562
+ .convert("RGB")
563
+ .getpixel((0, 0))
564
+ )
565
+ darkest_color = (
566
+ Image.fromarray(np.uint8([[darkest_pixel]]), "HSV")
567
+ .convert("RGB")
568
+ .getpixel((0, 0))
569
+ )
570
+
571
+ return brightest_color, darkest_color
572
+
573
+
574
+ def draw_game_info(
575
+ header: Image.Image,
576
+ game_name: str,
577
+ game_time: str,
578
+ last_play_time: str,
579
+ achievements: List[Achievements],
580
+ completed_achievement_number: int,
581
+ total_achievement_number: int,
582
+ achievement_color: Tuple[int, int, int],
583
+ ) -> Image.Image:
584
+ bg = Image.new("RGBA", (880, 110 + 64 + 10), (0, 0, 0, 110))
585
+ header = header.resize((229, 86), Image.BICUBIC)
586
+ bg.paste(header, (10, 110 // 2 - header.height // 2))
587
+
588
+ draw = ImageDraw.Draw(bg)
589
+
590
+ # 画游戏名
591
+ draw.text(
592
+ (260, 10),
593
+ game_name,
594
+ font=ImageFont.truetype(font_regular_path, 26),
595
+ fill=(255, 255, 255),
596
+ )
597
+
598
+ # 画最后游玩时间
599
+ font = ImageFont.truetype(font_light_path, 22)
600
+ display_text = last_play_time
601
+ draw.text(
602
+ (int(bg.width - font.getlength(display_text)) - 10, 75),
603
+ display_text,
604
+ font=font,
605
+ fill=(150, 150, 150),
606
+ )
607
+
608
+ # 画游戏时间
609
+ font = ImageFont.truetype(font_light_path, 22)
610
+ display_text = f"总时数 {game_time}"
611
+ draw.text(
612
+ (int(bg.width - font.getlength(display_text)) - 10, 50),
613
+ display_text,
614
+ font=font,
615
+ fill=(150, 150, 150),
616
+ )
617
+
618
+ if completed_achievement_number is None or total_achievement_number is None:
619
+ return bg.crop((0, 0, bg.width, 110))
620
+
621
+ # 画成就 + 64 + 10
622
+ achievement_bg = Image.new("RGBA", (860, 64), achievement_color)
623
+ draw_achievement = ImageDraw.Draw(achievement_bg)
624
+
625
+ # 画成就进度
626
+ font = ImageFont.truetype(font_light_path, 18)
627
+ x = 14
628
+ draw_achievement.text(
629
+ (x, 20),
630
+ "成就进度",
631
+ font=font,
632
+ fill=(255, 255, 255, 255),
633
+ )
634
+ x += font.getlength("成就进度") + 10
635
+ draw_achievement.text(
636
+ (int(x), 20),
637
+ f"{completed_achievement_number} / {total_achievement_number}",
638
+ font=font,
639
+ fill=(130, 130, 130),
640
+ )
641
+ x += (
642
+ font.getlength(f"{completed_achievement_number} / {total_achievement_number}")
643
+ + 10
644
+ )
645
+ progress_bar = create_progress_bar(
646
+ completed_achievement_number / total_achievement_number, achievement_color
647
+ )
648
+ achievement_bg.paste(progress_bar, (int(x), 24), progress_bar)
649
+
650
+ # 画成就图标
651
+ x = 860 - 48 * 6 - 10 * 6
652
+ for achievement in achievements:
653
+ achievement_image = Image.open(BytesIO(achievement["image"])).resize((48, 48))
654
+ achievement_bg.paste(achievement_image, (x, 8))
655
+ x += 48 + 10
656
+
657
+ if completed_achievement_number > 6:
658
+ font = ImageFont.truetype(font_regular_path, 22)
659
+ display_text = f"+{completed_achievement_number - 5}"
660
+ draw_achievement.rectangle((x, 8, x + 48, 56), fill=(34, 34, 34))
661
+ draw_achievement.text(
662
+ (x + 24 - font.getlength(display_text) // 2, 18),
663
+ display_text,
664
+ font=font,
665
+ fill=(255, 255, 255),
666
+ )
667
+
668
+ bg.paste(achievement_bg, (10, 110), achievement_bg)
669
+ return bg
670
+
671
+
672
+ def draw_player_status(
673
+ player_bg: Image.Image,
674
+ player_avatar: Image.Image,
675
+ player_name: str,
676
+ player_id: str,
677
+ player_description: str,
678
+ player_last_two_weeks_time: str, # e.g. 10.2 小时
679
+ player_games: List[DrawPlayerStatusData],
680
+ ):
681
+ if isinstance(player_bg, bytes):
682
+ player_bg = Image.open(BytesIO(player_bg))
683
+ if isinstance(player_avatar, bytes):
684
+ player_avatar = Image.open(BytesIO(player_avatar))
685
+
686
+ bg = recolor_image(
687
+ player_bg.crop(
688
+ (
689
+ (player_bg.width - 960) // 2,
690
+ 0,
691
+ (player_bg.width + 960) // 2,
692
+ player_bg.height,
693
+ )
694
+ ),
695
+ 10,
696
+ 10,
697
+ )
698
+ # 调暗背景
699
+ enhancer = ImageEnhance.Brightness(bg)
700
+ bg = enhancer.enhance(0.7)
701
+ # bg.size = (960, 1020)
702
+ player_avatar = player_avatar.resize((200, 200))
703
+ bg.paste(player_avatar, (40, 40))
704
+
705
+ draw = ImageDraw.Draw(bg)
706
+
707
+ # 画头像外框
708
+ draw.rectangle((40, 40, 240, 240), outline=(83, 164, 196), width=3)
709
+
710
+ # 画昵称
711
+ draw.text(
712
+ (280, 48),
713
+ player_name,
714
+ font=ImageFont.truetype(font_light_path, 40),
715
+ fill=(255, 255, 255),
716
+ )
717
+
718
+ # 画ID
719
+ draw.text(
720
+ (280, 100),
721
+ f"好友代码: {player_id}",
722
+ font=ImageFont.truetype(font_regular_path, 19),
723
+ fill=(191, 191, 191),
724
+ )
725
+
726
+ # 画简介
727
+ line_width = 0
728
+ offset = 0
729
+ line = ""
730
+ for idx, char in enumerate(player_description):
731
+ line += char
732
+ line_width += ImageFont.truetype(font_light_path, 22).getlength(char)
733
+ if line_width > 640 or idx == len(player_description) - 1 or char == "\n":
734
+ draw.text(
735
+ (280, 132 + offset),
736
+ line,
737
+ font=ImageFont.truetype(font_light_path, 22),
738
+ fill=(255, 255, 255),
739
+ )
740
+ line = ""
741
+ offset += 25
742
+ line_width = 0
743
+ if offset >= 25 * 4:
744
+ break
745
+
746
+ # 画游戏
747
+
748
+ brightest_color, darkest_color = get_brightest_and_darkest_color(player_bg)
749
+ brightest_color = tuple(map(lambda x: x - 30 if x >= 30 else 0, brightest_color))
750
+ darkest_color = tuple(
751
+ map(lambda x: x + 30 if x <= 255 - 30 else 255, darkest_color)
752
+ )
753
+ brightest_color = (brightest_color[0], brightest_color[1], brightest_color[2], 128)
754
+ brightest_color = random_color_offset(brightest_color, 20)
755
+ darkest_color = (darkest_color[0], darkest_color[1], darkest_color[2], 128)
756
+ darkest_color = random_color_offset(darkest_color, 20)
757
+
758
+ # 画游戏信息
759
+ hsv_achievement_color = rgb_to_hsv(*brightest_color[:3])
760
+ achievement_color = tuple(
761
+ map(
762
+ int,
763
+ hsv_to_rgb(
764
+ hsv_achievement_color[0],
765
+ hsv_achievement_color[1] * 0.85,
766
+ hsv_achievement_color[2] * 0.6,
767
+ ),
768
+ )
769
+ )
770
+ game_images: List[Image.Image] = []
771
+ for idx, game in enumerate(player_games):
772
+ game_image = Image.open(BytesIO(game["game_header"]))
773
+ game_info = draw_game_info(
774
+ game_image,
775
+ game["game_name"],
776
+ game["game_time"],
777
+ game["last_play_time"],
778
+ game["achievements"],
779
+ game["completed_achievement_number"],
780
+ game["total_achievement_number"],
781
+ achievement_color,
782
+ )
783
+ game_images.append(game_info)
784
+
785
+ # 画半透明黑色背景
786
+ bg_game = Image.new(
787
+ "RGBA", (920, 106 + sum([game_image.height + 26 for game_image in game_images]))
788
+ )
789
+ draw_game = ImageDraw.Draw(bg_game)
790
+ draw_game.rectangle(
791
+ (
792
+ 0,
793
+ 0,
794
+ 920,
795
+ bg_game.height,
796
+ ),
797
+ fill=(0, 0, 0, 120),
798
+ )
799
+ bg.paste(bg_game, (20, 272), bg_game)
800
+
801
+ # 画渐变条
802
+ gradient = create_gradient_image((920, 50), brightest_color, darkest_color)
803
+ bg.paste(gradient, (20, 272), gradient)
804
+
805
+ # 画渐变条的文字:最新动态,最近游戏
806
+ draw.text(
807
+ (34, 279),
808
+ "最新动态",
809
+ font=ImageFont.truetype(font_light_path, 26),
810
+ fill=(255, 255, 255),
811
+ )
812
+ if player_last_two_weeks_time is not None:
813
+ width = ImageFont.truetype(font_light_path, 26).getlength(
814
+ player_last_two_weeks_time
815
+ )
816
+ draw.text(
817
+ (960 - width - 34, 279),
818
+ player_last_two_weeks_time,
819
+ font=ImageFont.truetype(font_light_path, 26),
820
+ fill=(255, 255, 255),
821
+ )
822
+
823
+ y = 350
824
+ for idx, game_image in enumerate(game_images):
825
+ bg.paste(
826
+ game_image,
827
+ ((920 - game_image.width) // 2 + 20, y),
828
+ game_image.convert("RGBA"),
829
+ )
830
+ y += game_image.height + 26
831
+
832
+ player_bg.paste(bg, ((player_bg.width - 960) // 2, 0), bg.convert("RGBA"))
833
+
834
+ return player_bg
835
+
836
+
837
+ def rounded_rectangle(
838
+ image: Image.Image,
839
+ radius: int,
840
+ border=False,
841
+ border_width=0,
842
+ border_color=(0, 0, 0),
843
+ ):
844
+ """
845
+ 将给定的Image.Image对象切割为圆角矩形。
846
+
847
+ Args:
848
+ image: 一个PIL Image对象。
849
+ radius: 圆角半径,单位为像素。
850
+ border: 是否需要边框,默认为False。
851
+ border_width: 边框宽度,单位为像素,默认为0。
852
+ border_color: 边框颜色,RGB元组,默认为黑色(0, 0, 0)。
853
+
854
+ Returns:
855
+ 一个PIL Image对象,表示切割后的圆角矩形图像。
856
+ """
857
+
858
+ width, height = image.size
859
+
860
+ image_ = Image.new("RGBA", (width + 1, height + 1), (0, 0, 0, 0))
861
+ image_.paste(image, (0, 0), image.convert("RGBA"))
862
+
863
+ # 创建一个圆角矩形的遮罩
864
+ result = Image.new("RGBA", (width + 1, height + 1), (0, 0, 0, 0))
865
+ mask = Image.new("L", (width + 1, height + 1), 0)
866
+ draw = ImageDraw.Draw(mask)
867
+ image_draw = ImageDraw.Draw(result)
868
+
869
+ # 绘制圆角矩形
870
+ draw.rounded_rectangle((0, 0, width, height), radius=radius, fill=255)
871
+
872
+ # 应用遮罩到原始图像
873
+ result.paste(image_, (0, 0), mask)
874
+
875
+ # 添加边框 (如果需要)
876
+ if border:
877
+ image_draw.rounded_rectangle(
878
+ (0, 0, width, height),
879
+ radius=radius,
880
+ outline=border_color,
881
+ width=border_width,
882
+ )
883
+
884
+ return result
885
+
886
+
887
+ def create_progress_bar(
888
+ progress: float, color: Tuple[int, int, int], width=186, height=16
889
+ ):
890
+ color_hsv = rgb_to_hsv(*color)
891
+
892
+ # 外条
893
+ bar_color = tuple(
894
+ map(int, hsv_to_rgb(color_hsv[0], color_hsv[1], color_hsv[2] * 0.8))
895
+ )
896
+ border_color = tuple(map(lambda x: max(x - 20, 0), color))
897
+ border_image = rounded_rectangle(
898
+ Image.new("RGBA", (width, height), bar_color),
899
+ 8,
900
+ border=True,
901
+ border_width=1,
902
+ border_color=border_color,
903
+ )
904
+
905
+ # 内条
906
+ bar_color_top = tuple(
907
+ map(int, hsv_to_rgb(color_hsv[0], color_hsv[1] / 2, color_hsv[2] * 5 / 2))
908
+ )
909
+ bar_color_bottem = tuple(
910
+ map(int, hsv_to_rgb(color_hsv[0], color_hsv[1] / 2, color_hsv[2]))
911
+ )
912
+
913
+ bar_image = create_vertical_gradient_rect(
914
+ int(width * progress) - 6, height - 4, bar_color_top, bar_color_bottem
915
+ )
916
+ bar_image = rounded_rectangle(bar_image, 6)
917
+
918
+ # 合并
919
+ border_image.paste(bar_image, (3, 2), bar_image)
920
+
921
+ return border_image