flexllm 0.3.3__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.
Files changed (39) hide show
  1. flexllm/__init__.py +224 -0
  2. flexllm/__main__.py +1096 -0
  3. flexllm/async_api/__init__.py +9 -0
  4. flexllm/async_api/concurrent_call.py +100 -0
  5. flexllm/async_api/concurrent_executor.py +1036 -0
  6. flexllm/async_api/core.py +373 -0
  7. flexllm/async_api/interface.py +12 -0
  8. flexllm/async_api/progress.py +277 -0
  9. flexllm/base_client.py +988 -0
  10. flexllm/batch_tools/__init__.py +16 -0
  11. flexllm/batch_tools/folder_processor.py +317 -0
  12. flexllm/batch_tools/table_processor.py +363 -0
  13. flexllm/cache/__init__.py +10 -0
  14. flexllm/cache/response_cache.py +293 -0
  15. flexllm/chain_of_thought_client.py +1120 -0
  16. flexllm/claudeclient.py +402 -0
  17. flexllm/client_pool.py +698 -0
  18. flexllm/geminiclient.py +563 -0
  19. flexllm/llm_client.py +523 -0
  20. flexllm/llm_parser.py +60 -0
  21. flexllm/mllm_client.py +559 -0
  22. flexllm/msg_processors/__init__.py +174 -0
  23. flexllm/msg_processors/image_processor.py +729 -0
  24. flexllm/msg_processors/image_processor_helper.py +485 -0
  25. flexllm/msg_processors/messages_processor.py +341 -0
  26. flexllm/msg_processors/unified_processor.py +1404 -0
  27. flexllm/openaiclient.py +256 -0
  28. flexllm/pricing/__init__.py +104 -0
  29. flexllm/pricing/data.json +1201 -0
  30. flexllm/pricing/updater.py +223 -0
  31. flexllm/provider_router.py +213 -0
  32. flexllm/token_counter.py +270 -0
  33. flexllm/utils/__init__.py +1 -0
  34. flexllm/utils/core.py +41 -0
  35. flexllm-0.3.3.dist-info/METADATA +573 -0
  36. flexllm-0.3.3.dist-info/RECORD +39 -0
  37. flexllm-0.3.3.dist-info/WHEEL +4 -0
  38. flexllm-0.3.3.dist-info/entry_points.txt +3 -0
  39. flexllm-0.3.3.dist-info/licenses/LICENSE +201 -0
@@ -0,0 +1,729 @@
1
+ import numpy as np
2
+ import base64
3
+ import os
4
+ import hashlib
5
+ import json
6
+ import time
7
+ from copy import deepcopy
8
+ from io import BytesIO
9
+ from mimetypes import guess_type
10
+ from pathlib import Path
11
+ from dataclasses import dataclass, field
12
+ from typing import Optional, Tuple, Union
13
+
14
+ import aiohttp
15
+ import requests
16
+ from loguru import logger
17
+ from PIL import Image
18
+ from ..utils.core import async_retry
19
+
20
+ # 兼容不同版本的PIL
21
+ try:
22
+ LANCZOS = Image.LANCZOS
23
+ except AttributeError:
24
+ # 在较旧版本的PIL中,LANCZOS可能被称为ANTIALIAS
25
+ LANCZOS = Image.ANTIALIAS
26
+
27
+ # 默认的缓存目录
28
+ DEFAULT_CACHE_DIR = os.path.expanduser("~/.cache/maque/image_cache")
29
+
30
+
31
+ def safe_repr_source(source: str, max_length: int = 100) -> str:
32
+ """安全地表示图像源,避免输出大量base64字符串"""
33
+ if not source:
34
+ return "空源"
35
+
36
+ # 检查是否是base64数据URI
37
+ if source.startswith("data:image/") and ";base64," in source:
38
+ parts = source.split(";base64,", 1)
39
+ if len(parts) == 2:
40
+ mime_type = parts[0].replace("data:", "")
41
+ base64_data = parts[1]
42
+ return f"[{mime_type} base64数据 长度:{len(base64_data)}]"
43
+
44
+ # 检查是否是纯base64字符串(很长且只包含base64字符)
45
+ if len(source) > 100 and all(c in "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/=" for c in source):
46
+ return f"[base64数据 长度:{len(source)}]"
47
+
48
+ # 普通字符串,截断显示
49
+ if len(source) <= max_length:
50
+ return source
51
+ else:
52
+ return source[:max_length] + "..."
53
+
54
+
55
+ def safe_repr_error(error_msg: str, max_length: int = 200) -> str:
56
+ """安全地表示错误信息,避免输出大量base64字符串"""
57
+ if not error_msg:
58
+ return error_msg
59
+
60
+ # 检查错误信息中是否包含data:image的base64数据
61
+ if "data:image/" in error_msg and ";base64," in error_msg:
62
+ import re
63
+ # 使用正则表达式替换base64数据URI
64
+ pattern = r'data:image/[^;]+;base64,[A-Za-z0-9+/]+=*'
65
+ def replace_base64(match):
66
+ full_uri = match.group(0)
67
+ parts = full_uri.split(";base64,", 1)
68
+ if len(parts) == 2:
69
+ mime_type = parts[0].replace("data:", "")
70
+ base64_data = parts[1]
71
+ return f"[{mime_type} base64数据 长度:{len(base64_data)}]"
72
+ return full_uri
73
+ error_msg = re.sub(pattern, replace_base64, error_msg)
74
+
75
+ # 截断过长的错误信息
76
+ if len(error_msg) <= max_length:
77
+ return error_msg
78
+ else:
79
+ return error_msg[:max_length] + "..."
80
+
81
+
82
+ @dataclass
83
+ class ImageCacheConfig:
84
+ """图像缓存配置类,用于集中管理图像缓存的相关配置"""
85
+
86
+ enabled: bool = False # 是否启用缓存
87
+ cache_dir: str = DEFAULT_CACHE_DIR # 缓存目录路径
88
+ force_refresh: bool = False # 是否强制刷新缓存
89
+ retry_failed: bool = False # 是否重试已知失败的链接
90
+
91
+ def __post_init__(self):
92
+ """初始化后执行的操作,确保缓存目录存在"""
93
+ if self.enabled:
94
+ ensure_cache_dir(self.cache_dir)
95
+
96
+ @classmethod
97
+ def disabled(cls) -> "ImageCacheConfig":
98
+ """快速创建一个禁用缓存的配置"""
99
+ return cls(enabled=False)
100
+
101
+ @classmethod
102
+ def default(cls) -> "ImageCacheConfig":
103
+ """创建默认配置(启用缓存但不强制刷新和重试失败)"""
104
+ return cls(enabled=True)
105
+
106
+ @classmethod
107
+ def with_refresh(cls) -> "ImageCacheConfig":
108
+ """创建启用缓存且强制刷新的配置"""
109
+ return cls(enabled=True, force_refresh=True)
110
+
111
+ @classmethod
112
+ def with_retry(cls) -> "ImageCacheConfig":
113
+ """创建启用缓存且重试失败链接的配置"""
114
+ return cls(enabled=True, retry_failed=True)
115
+
116
+ @classmethod
117
+ def full_refresh(cls) -> "ImageCacheConfig":
118
+ """创建启用缓存且同时强制刷新和重试失败的配置"""
119
+ return cls(enabled=True, force_refresh=True, retry_failed=True)
120
+
121
+
122
+ def get_cache_path(url: str, cache_dir: str = DEFAULT_CACHE_DIR) -> Path:
123
+ """获取图片的缓存路径"""
124
+ # 使用URL的MD5作为文件名
125
+ url_hash = hashlib.md5(url.encode()).hexdigest()
126
+ # 获取URL中的文件扩展名
127
+ ext = os.path.splitext(url)[-1].lower()
128
+ if not ext or ext not in [".jpg", ".jpeg", ".png", ".webp", ".gif"]:
129
+ ext = ".png" # 默认使用.png 而不是 .jpg,因为 PNG 支持透明通道
130
+ return Path(cache_dir) / f"{url_hash}{ext}"
131
+
132
+
133
+ def get_error_cache_path(url: str, cache_dir: str = DEFAULT_CACHE_DIR) -> Path:
134
+ """获取图片请求错误的缓存文件路径"""
135
+ # 使用URL的MD5作为文件名,但添加.error后缀
136
+ url_hash = hashlib.md5(url.encode()).hexdigest()
137
+ return Path(cache_dir) / f"{url_hash}.error"
138
+
139
+
140
+ def ensure_cache_dir(cache_dir: str = DEFAULT_CACHE_DIR):
141
+ """确保缓存目录存在"""
142
+ os.makedirs(cache_dir, exist_ok=True)
143
+
144
+
145
+ def encode_base64_from_local_path(file_path, return_with_mime=True):
146
+ """Encode a local file to a Base64 string, with optional MIME type prefix."""
147
+ mime_type, _ = guess_type(file_path)
148
+ mime_type = mime_type or "application/octet-stream"
149
+ with open(file_path, "rb") as file:
150
+ base64_data = base64.b64encode(file.read()).decode("utf-8")
151
+ if return_with_mime:
152
+ return f"data:{mime_type};base64,{base64_data}"
153
+ return base64_data
154
+
155
+
156
+ async def encode_base64_from_url(
157
+ url, session: aiohttp.ClientSession, return_with_mime=True
158
+ ):
159
+ """Fetch a file from a URL and encode it to a Base64 string, with optional MIME type prefix."""
160
+ async with session.get(url) as response:
161
+ response.raise_for_status()
162
+ content = await response.read()
163
+ mime_type = response.headers.get("Content-Type", "application/octet-stream")
164
+ base64_data = base64.b64encode(content).decode("utf-8")
165
+ if return_with_mime:
166
+ return f"data:{mime_type};base64,{base64_data}"
167
+ return base64_data
168
+
169
+
170
+ def encode_base64_from_pil(image: Image.Image, return_with_mime=True):
171
+ """Encode a PIL image object to a Base64 string, with optional MIME type prefix."""
172
+ buffer = BytesIO()
173
+ image_format = image.format or "PNG" # Default to PNG if format is unknown
174
+ mime_type = f"image/{image_format.lower()}"
175
+ image.save(buffer, format=image_format)
176
+ buffer.seek(0)
177
+ base64_data = base64.b64encode(buffer.read()).decode("utf-8")
178
+ if return_with_mime:
179
+ return f"data:{mime_type};base64,{base64_data}"
180
+ return base64_data
181
+
182
+
183
+ async def encode_to_base64(
184
+ file_source,
185
+ session: aiohttp.ClientSession,
186
+ return_with_mime: bool = True,
187
+ return_pil: bool = False,
188
+ cache_config: Optional[ImageCacheConfig] = None,
189
+ ) -> Union[str, Tuple[str, Image.Image], Image.Image]:
190
+ """A unified function to encode files to Base64 strings or return PIL Image objects.
191
+
192
+ Args:
193
+ file_source: File path, URL, or PIL Image object
194
+ session: aiohttp ClientSession for async URL fetching
195
+ return_with_mime: Whether to include MIME type prefix in base64 string
196
+ return_pil: Whether to return PIL Image object (for image files)
197
+ cache_config: Image cache configuration, if None or disabled, no caching will be used
198
+
199
+ Returns:
200
+ If return_pil is False: base64 string (with optional MIME prefix)
201
+ If return_pil is True and input is image: (base64_string, PIL_Image) or just PIL_Image
202
+ If return_pil is True and input is not image: base64 string
203
+ """
204
+ mime_type = None
205
+ pil_image = None
206
+
207
+ if isinstance(file_source, str):
208
+ if file_source.startswith("file://"):
209
+ file_path = file_source[7:]
210
+ if not os.path.exists(file_path):
211
+ raise ValueError("Local file not found.")
212
+ mime_type, _ = guess_type(file_path)
213
+ if return_pil and mime_type and mime_type.startswith("image"):
214
+ pil_image = Image.open(file_path)
215
+ if return_pil and not return_with_mime:
216
+ return pil_image
217
+ with open(file_path, "rb") as file:
218
+ content = file.read()
219
+
220
+ elif os.path.exists(file_source):
221
+ mime_type, _ = guess_type(file_source)
222
+ if return_pil and mime_type and mime_type.startswith("image"):
223
+ pil_image = Image.open(file_source)
224
+ if return_pil and not return_with_mime:
225
+ return pil_image
226
+ with open(file_source, "rb") as file:
227
+ content = file.read()
228
+
229
+ elif file_source.startswith("http"):
230
+ # 对于URL,使用get_pil_image来获取图像,以利用缓存功能
231
+ if return_pil or mime_type and mime_type.startswith("image"):
232
+ # 获取PIL图像并利用缓存
233
+ pil_image = await get_pil_image(
234
+ file_source, session, cache_config=cache_config
235
+ )
236
+ if return_pil and not return_with_mime:
237
+ return pil_image
238
+
239
+ # 将PIL图像转换为字节内容
240
+ buffer = BytesIO()
241
+ image_format = pil_image.format or "PNG"
242
+ mime_type = f"image/{image_format.lower()}"
243
+ pil_image.save(buffer, format=image_format)
244
+ content = buffer.getvalue()
245
+ else:
246
+ # 对于非图像文件,直接从URL获取内容
247
+ async with session.get(file_source) as response:
248
+ response.raise_for_status()
249
+ content = await response.read()
250
+ mime_type = response.headers.get(
251
+ "Content-Type", "application/octet-stream"
252
+ )
253
+ else:
254
+ raise ValueError("Unsupported file source type.")
255
+
256
+ elif isinstance(file_source, Image.Image):
257
+ pil_image = file_source
258
+ if return_pil and not return_with_mime:
259
+ return pil_image
260
+
261
+ buffer = BytesIO()
262
+ image_format = file_source.format or "PNG"
263
+ mime_type = f"image/{image_format.lower()}"
264
+ file_source.save(buffer, format=image_format)
265
+ content = buffer.getvalue()
266
+
267
+ else:
268
+ raise ValueError("Unsupported file source type.")
269
+
270
+ base64_data = base64.b64encode(content).decode("utf-8")
271
+ result = (
272
+ f"data:{mime_type};base64,{base64_data}" if return_with_mime else base64_data
273
+ )
274
+
275
+ if return_pil and pil_image:
276
+ return result, pil_image
277
+ return result
278
+
279
+
280
+ async def encode_image_to_base64(
281
+ image_source,
282
+ session: aiohttp.ClientSession,
283
+ max_width: Optional[int] = None,
284
+ max_height: Optional[int] = None,
285
+ max_pixels: Optional[int] = None,
286
+ return_with_mime: bool = True,
287
+ cache_config: Optional[ImageCacheConfig] = None,
288
+ ) -> str:
289
+ """Encode an image to base64 string with optional size constraints.
290
+
291
+ Args:
292
+ image_source: Can be a file path (str), URL (str), or PIL Image object
293
+ session: aiohttp ClientSession for async URL fetching
294
+ max_width: Optional maximum width for image resizing
295
+ max_height: Optional maximum height for image resizing
296
+ max_pixels: Optional maximum number of pixels (width * height)
297
+ return_with_mime: Whether to include MIME type prefix in the result
298
+ cache_config: Image cache configuration, if None or disabled, no caching will be used
299
+
300
+ Returns:
301
+ Base64 encoded string (with optional MIME prefix)
302
+ """
303
+ try:
304
+ import cv2
305
+ except ImportError:
306
+ raise ImportError(
307
+ "图像处理功能需要安装 opencv-python。请运行: pip install flexllm[image]"
308
+ )
309
+
310
+ if isinstance(image_source, Image.Image):
311
+ image = image_source
312
+ else:
313
+ # 使用更新后的get_pil_image函数,但不需要返回缓存路径
314
+ image = await get_pil_image(
315
+ image_source, session, cache_config=cache_config, return_cache_path=False
316
+ )
317
+
318
+ # Make a copy of the image to avoid modifying the original
319
+ # image = image.copy()
320
+
321
+ # Store original format
322
+ original_format = image.format or "PNG"
323
+
324
+ # Resize image based on provided constraints
325
+ target_width, target_height = get_target_size(
326
+ image, max_width, max_height, max_pixels
327
+ )
328
+ if target_width < image.width or target_height < image.height:
329
+ image.thumbnail((target_width, target_height), LANCZOS)
330
+
331
+ mime_type = f"image/{original_format.lower()}"
332
+
333
+ # Convert processed image to base64
334
+ cv_img = cv2.cvtColor(np.array(image), cv2.COLOR_RGB2BGR)
335
+ # 直接编码为所需格式(可添加压缩参数)
336
+ _, buffer = cv2.imencode(f".{original_format.lower()}", cv_img)
337
+ base64_data = base64.b64encode(buffer).decode("utf-8")
338
+
339
+ if return_with_mime:
340
+ return f"data:{mime_type};base64,{base64_data}"
341
+ return base64_data
342
+
343
+
344
+ def decode_base64_to_pil(base64_string):
345
+ """将base64字符串解码为PIL Image对象"""
346
+ try:
347
+ # 如果base64字符串包含header (如 'data:image/jpeg;base64,'),去除它
348
+ if "," in base64_string:
349
+ base64_string = base64_string.split(",")[1]
350
+
351
+ # 解码base64为二进制数据
352
+ image_data = base64.b64decode(base64_string)
353
+
354
+ # 转换为PIL Image对象
355
+ image = Image.open(BytesIO(image_data))
356
+ return image
357
+ except Exception as e:
358
+ raise ValueError(f"无法将base64字符串解码为图像: {e!s}")
359
+
360
+
361
+ def decode_base64_to_file(base64_string, output_path, format="JPEG"):
362
+ """将base64字符串解码并保存为图片文件"""
363
+ try:
364
+ # 获取PIL Image对象
365
+ image = decode_base64_to_pil(base64_string)
366
+
367
+ # 确保输出目录存在
368
+ os.makedirs(os.path.dirname(os.path.abspath(output_path)), exist_ok=True)
369
+
370
+ # 保存图像
371
+ image.save(output_path, format=format)
372
+ return True
373
+ except Exception as e:
374
+ raise ValueError(f"无法将base64字符串保存为文件: {e!s}")
375
+
376
+
377
+ def decode_base64_to_bytes(base64_string):
378
+ """将base64字符串解码为字节数据"""
379
+ try:
380
+ # 如果base64字符串包含header,去除它
381
+ if "," in base64_string:
382
+ base64_string = base64_string.split(",")[1]
383
+
384
+ # 解码为字节数据
385
+ return base64.b64decode(base64_string)
386
+ except Exception as e:
387
+ raise ValueError(f"无法将base64字符串解码为字节数据: {e!s}")
388
+
389
+
390
+ @async_retry(retry_times=3, retry_delay=0.3)
391
+ async def _get_image_from_http(session, image_source):
392
+ async with session.get(image_source) as response:
393
+ response.raise_for_status()
394
+ content = await response.read()
395
+ image = Image.open(BytesIO(content))
396
+ return image
397
+
398
+
399
+ async def get_pil_image(
400
+ image_source,
401
+ session: aiohttp.ClientSession = None,
402
+ cache_config: Optional[ImageCacheConfig] = None,
403
+ return_cache_path: bool = False,
404
+ ):
405
+ """从图像链接或本地路径获取PIL格式的图像。
406
+
407
+ Args:
408
+ image_source: 图像来源,可以是本地文件路径、URL或data URI
409
+ session: 用于异步URL请求的aiohttp ClientSession,如果为None且需要时会创建临时会话
410
+ cache_config: 图像缓存配置,如果为None则不使用缓存
411
+ return_cache_path: 是否同时返回图像和缓存路径,如果为True则返回(image, cache_path)元组
412
+
413
+ Returns:
414
+ PIL.Image.Image 或 tuple[PIL.Image.Image, Path]:
415
+ - 如果return_cache_path为False,返回加载的PIL图像对象
416
+ - 如果return_cache_path为True,返回(image, cache_path)元组,对于非URL图像或未使用缓存时,cache_path为None
417
+
418
+ Raises:
419
+ ValueError: 当图像源无效或无法加载图像时
420
+ """
421
+ # 处理缓存配置
422
+ if cache_config is None:
423
+ cache_config = ImageCacheConfig.disabled()
424
+
425
+ # 如果已经是PIL图像对象,直接返回
426
+ if isinstance(image_source, Image.Image):
427
+ return (image_source, None) if return_cache_path else image_source
428
+
429
+ # 处理字符串类型的图像源(文件路径或URL)
430
+ if isinstance(image_source, str):
431
+ # 处理本地文件路径
432
+ if image_source.startswith("file://"):
433
+ file_path = image_source[7:]
434
+ if not os.path.exists(file_path):
435
+ raise ValueError(f"本地文件不存在: {file_path}")
436
+ image = Image.open(file_path)
437
+ return (image, Path(file_path)) if return_cache_path else image
438
+
439
+ # 处理普通本地文件路径
440
+ elif os.path.exists(image_source):
441
+ image = Image.open(image_source)
442
+ return (image, Path(image_source)) if return_cache_path else image
443
+
444
+ # 处理data URI (base64编码的图像数据)
445
+ elif image_source.startswith("data:image/"):
446
+ try:
447
+ # 解析data URI: data:image/jpeg;base64,xxxxx
448
+ if ";base64," in image_source:
449
+ header, data = image_source.split(";base64,", 1)
450
+ import base64
451
+ image_data = base64.b64decode(data)
452
+ image = Image.open(BytesIO(image_data))
453
+ return (image, None) if return_cache_path else image
454
+ else:
455
+ raise ValueError("不支持的data URI格式,仅支持base64编码")
456
+ except Exception as e:
457
+ raise ValueError(f"解析data URI失败: {str(e)}")
458
+
459
+ # 处理URL
460
+ elif image_source.startswith("http"):
461
+ # 检查缓存
462
+ cache_path = None
463
+ error_cache_path = None
464
+
465
+ if cache_config.enabled:
466
+ cache_path = get_cache_path(image_source, cache_config.cache_dir)
467
+ error_cache_path = get_error_cache_path(
468
+ image_source, cache_config.cache_dir
469
+ )
470
+
471
+ # 检查普通缓存
472
+ if not cache_config.force_refresh and cache_path.exists():
473
+ image = Image.open(cache_path)
474
+ return (image, cache_path) if return_cache_path else image
475
+
476
+ # 检查错误缓存
477
+ if (
478
+ not cache_config.force_refresh
479
+ and not cache_config.retry_failed
480
+ and error_cache_path.exists()
481
+ ):
482
+ try:
483
+ with open(error_cache_path, "r") as f:
484
+ error_data = json.load(f)
485
+ # 重新构造相同的错误
486
+ raise ValueError(f"缓存的错误: {error_data['message']}")
487
+ except (json.JSONDecodeError, KeyError):
488
+ # 如果错误缓存文件格式不正确,忽略它
489
+ pass # 静默忽略错误缓存文件格式问题
490
+
491
+ # 创建临时会话(如果未提供)
492
+ close_session = False
493
+ if session is None:
494
+ session = aiohttp.ClientSession()
495
+ close_session = True
496
+
497
+ try:
498
+ image = await _get_image_from_http(session, image_source)
499
+ # 保存到缓存
500
+ if cache_config.enabled:
501
+ try:
502
+ save_image_with_format(image, cache_path)
503
+ # 如果请求成功,删除可能存在的错误缓存
504
+ if error_cache_path and error_cache_path.exists():
505
+ error_cache_path.unlink()
506
+ except Exception as e:
507
+ pass # 静默忽略缓存保存失败
508
+
509
+ return (image, cache_path) if return_cache_path else image
510
+ except Exception as e:
511
+ # 缓存错误信息
512
+ if cache_config.enabled and error_cache_path:
513
+ try:
514
+ with open(error_cache_path, "w") as f:
515
+ error_data = {
516
+ "url": image_source,
517
+ "timestamp": time.time(),
518
+ "message": str(e),
519
+ "type": type(e).__name__,
520
+ }
521
+ json.dump(error_data, f)
522
+ except Exception as cache_err:
523
+ pass # 静默忽略缓存错误信息失败
524
+ # 重新抛出原始异常
525
+ raise
526
+ finally:
527
+ # 如果是临时创建的会话,确保关闭
528
+ if close_session and session:
529
+ await session.close()
530
+ else:
531
+ raise ValueError(f"不支持的图像源类型: {safe_repr_source(str(image_source))}")
532
+ else:
533
+ raise ValueError(f"不支持的图像源类型: {type(image_source)}")
534
+
535
+
536
+ def get_pil_image_sync(
537
+ image_source,
538
+ cache_config: Optional[ImageCacheConfig] = None,
539
+ return_cache_path: bool = False,
540
+ ):
541
+ """从图像链接或本地路径获取PIL格式的图像(同步版本)。
542
+
543
+ Args:
544
+ image_source: 图像来源,可以是本地文件路径、URL或data URI
545
+ cache_config: 图像缓存配置,如果为None则不使用缓存
546
+ return_cache_path: 是否同时返回图像和缓存路径,如果为True则返回(image, cache_path)元组
547
+
548
+ Returns:
549
+ PIL.Image.Image 或 tuple[PIL.Image.Image, Path]:
550
+ - 如果return_cache_path为False,返回加载的PIL图像对象
551
+ - 如果return_cache_path为True,返回(image, cache_path)元组,对于非URL图像或未使用缓存时,cache_path为None
552
+
553
+ Raises:
554
+ ValueError: 当图像源无效或无法加载图像时
555
+ """
556
+ # 处理缓存配置
557
+ if cache_config is None:
558
+ cache_config = ImageCacheConfig.disabled()
559
+
560
+ # 如果已经是PIL图像对象,直接返回
561
+ if isinstance(image_source, Image.Image):
562
+ return (image_source, None) if return_cache_path else image_source
563
+
564
+ # 处理字符串类型的图像源(文件路径或URL)
565
+ if isinstance(image_source, str):
566
+ # 处理本地文件路径
567
+ if image_source.startswith("file://"):
568
+ file_path = image_source[7:]
569
+ if not os.path.exists(file_path):
570
+ raise ValueError(f"本地文件不存在: {file_path}")
571
+ image = Image.open(file_path)
572
+ return (image, Path(file_path)) if return_cache_path else image
573
+
574
+ # 处理普通本地文件路径
575
+ elif os.path.exists(image_source):
576
+ image = Image.open(image_source)
577
+ return (image, Path(image_source)) if return_cache_path else image
578
+
579
+ # 处理data URI (base64编码的图像数据)
580
+ elif image_source.startswith("data:image/"):
581
+ try:
582
+ # 解析data URI: data:image/jpeg;base64,xxxxx
583
+ if ";base64," in image_source:
584
+ header, data = image_source.split(";base64,", 1)
585
+ import base64
586
+ image_data = base64.b64decode(data)
587
+ image = Image.open(BytesIO(image_data))
588
+ return (image, None) if return_cache_path else image
589
+ else:
590
+ raise ValueError("不支持的data URI格式,仅支持base64编码")
591
+ except Exception as e:
592
+ raise ValueError(f"解析data URI失败: {str(e)}")
593
+
594
+ # 处理URL
595
+ elif image_source.startswith("http"):
596
+ # 检查缓存
597
+ cache_path = None
598
+ error_cache_path = None
599
+
600
+ if cache_config.enabled:
601
+ cache_path = get_cache_path(image_source, cache_config.cache_dir)
602
+ error_cache_path = get_error_cache_path(
603
+ image_source, cache_config.cache_dir
604
+ )
605
+
606
+ # 检查普通缓存
607
+ if not cache_config.force_refresh and cache_path.exists():
608
+ image = Image.open(cache_path)
609
+ return (image, cache_path) if return_cache_path else image
610
+
611
+ # 检查错误缓存
612
+ if (
613
+ not cache_config.force_refresh
614
+ and not cache_config.retry_failed
615
+ and error_cache_path.exists()
616
+ ):
617
+ try:
618
+ with open(error_cache_path, "r") as f:
619
+ error_data = json.load(f)
620
+ # 重新构造相同的错误
621
+ raise ValueError(f"缓存的错误: {error_data['message']}")
622
+ except (json.JSONDecodeError, KeyError):
623
+ # 如果错误缓存文件格式不正确,忽略它
624
+ pass # 静默忽略错误缓存文件格式问题
625
+
626
+ try:
627
+ response = requests.get(image_source)
628
+ response.raise_for_status()
629
+ image = Image.open(BytesIO(response.content))
630
+
631
+ # 保存到缓存
632
+ if cache_config.enabled:
633
+ try:
634
+ save_image_with_format(image, cache_path)
635
+ # 如果请求成功,删除可能存在的错误缓存
636
+ if error_cache_path and error_cache_path.exists():
637
+ error_cache_path.unlink()
638
+ except Exception as e:
639
+ pass # 静默忽略缓存保存失败
640
+
641
+ return (image, cache_path) if return_cache_path else image
642
+ except Exception as e:
643
+ # 缓存错误信息
644
+ if cache_config.enabled and error_cache_path:
645
+ try:
646
+ with open(error_cache_path, "w") as f:
647
+ error_data = {
648
+ "url": image_source,
649
+ "timestamp": time.time(),
650
+ "message": str(e),
651
+ "type": type(e).__name__,
652
+ }
653
+ json.dump(error_data, f)
654
+ except Exception as cache_err:
655
+ pass # 静默忽略缓存错误信息失败
656
+ # 重新抛出原始异常
657
+ raise
658
+ else:
659
+ raise ValueError(f"不支持的图像源类型: {safe_repr_source(str(image_source))}")
660
+ else:
661
+ raise ValueError(f"不支持的图像源类型: {type(image_source)}")
662
+
663
+
664
+ def save_image_with_format(image: Image.Image, path: Path):
665
+ """保存图片,自动处理格式转换问题"""
666
+ # 获取目标格式
667
+ target_format = path.suffix[1:].upper() # 去掉点号并转大写
668
+ if target_format == "JPG":
669
+ target_format = "JPEG"
670
+
671
+ # 创建图片副本以避免修改原图
672
+ image = image.copy()
673
+
674
+ # 处理调色板模式(P模式)
675
+ if image.mode == "P":
676
+ if "transparency" in image.info:
677
+ # 如果有透明通道,转换为 RGBA
678
+ image = image.convert("RGBA")
679
+ else:
680
+ # 如果没有透明通道,转换为 RGB
681
+ image = image.convert("RGB")
682
+
683
+ # 如果是 JPEG 格式且图片有 alpha 通道,需要特殊处理
684
+ if target_format == "JPEG" and image.mode in ("RGBA", "LA"):
685
+ # 创建白色背景
686
+ background = Image.new("RGB", image.size, (255, 255, 255))
687
+ if image.mode == "RGBA":
688
+ background.paste(image, mask=image.split()[3]) # 使用alpha通道作为mask
689
+ else:
690
+ background.paste(image, mask=image.split()[1]) # LA模式,使用A通道作为mask
691
+ image = background
692
+
693
+ # 确保图片模式与目标格式兼容
694
+ if target_format == "JPEG" and image.mode not in ("RGB", "CMYK", "L"):
695
+ image = image.convert("RGB")
696
+
697
+ # 保存图片
698
+ try:
699
+ if target_format == "JPEG":
700
+ image.save(path, format=target_format, quality=95)
701
+ else:
702
+ image.save(path, format=target_format)
703
+ except Exception as e:
704
+ pass # 静默忽略图片保存格式问题
705
+ # 如果保存失败,尝试转换为 RGB 后保存
706
+ if image.mode != "RGB":
707
+ image = image.convert("RGB")
708
+ image.save(path, format=target_format)
709
+
710
+
711
+ def get_target_size(image, max_width, max_height, max_pixels):
712
+ width, height = image.width, image.height
713
+
714
+ # 应用最大宽度/高度限制
715
+ if max_width and width > max_width:
716
+ height = int(height * max_width / width)
717
+ width = max_width
718
+
719
+ if max_height and height > max_height:
720
+ width = int(width * max_height / height)
721
+ height = max_height
722
+
723
+ # 应用最大像素限制
724
+ if max_pixels and (width * height > max_pixels):
725
+ ratio = (max_pixels / (width * height)) ** 0.5
726
+ width = int(width * ratio)
727
+ height = int(height * ratio)
728
+
729
+ return width, height