返回 Pixelle-Video
image_processor.py
根目录 / pixelle_video / services / api_services / image_processor.py
1 import os
2 import requests
3 import numpy as np
4 from pathlib import Path
5 from datetime import datetime, timedelta
6 from PIL import Image
7 import logging
8
9
10 class ImageProcessor:
11 """
12 图片处理和上传集合类
13 支持:图片处理、分割、拼接,以及上传到阿里云OSS
14 """
15
16 # 阿里云DashScope上传配置
17 UPLOAD_API_URL = "https://dashscope.aliyuncs.com/api/v1/uploads"
18
19 def __init__(self,
20 image_path='',
21 api_key: str = "sk-bcab316d69a7414faa9dc29737019333",
22 model_name: str = "wan2.6-i2v-flash",
23 local_proxy: str | None = None):
24 """
25 初始化图片处理器
26
27 Args:
28 image_path: 图片文件路径(可选,用于处理已有图片)
29 api_key: DashScope API Key(用于上传,可从环境变量 DASHSCOPE_API_KEY 读取)
30 model_name: 模型名称,默认使用 wan2.6-i2v-flash
31 """
32 # 图片处理部分
33 if image_path != '':
34 self.image_path = image_path
35 self.image = Image.open(image_path)
36 self.image_np = np.array(self.image)
37 self.width, self.height = self.image_np.shape[1], self.image_np.shape[0]
38 else:
39 self.image_path = None
40 self.image = None
41 self.image_np = None
42 self.width = None
43 self.height = None
44
45 # 上传功能部分
46 self.api_key = api_key or os.getenv("DASHSCOPE_API_KEY")
47 self.model_name = model_name
48 self.local_proxy = local_proxy
49
50 def _proxies(self):
51 if not self.local_proxy:
52 return None
53 return {"http": self.local_proxy, "https": self.local_proxy}
54
55 @staticmethod
56 def check_column_white(column_pixels):
57 """检查列是否几乎全白"""
58 is_almost_white = np.logical_or(column_pixels == 254, column_pixels == 255)
59 white_pixels_ratio = np.mean(np.all(is_almost_white, axis=-1))
60 return white_pixels_ratio >= 0.98 # 至少98%的像素为白色
61
62 def find_white_section(self, start, end):
63 """查找指定范围内的白色区间"""
64 white_sections = []
65 in_white_section = False
66 start_index = 0
67
68 for col in range(start, end):
69 column_pixels = self.image_np[:, col, :]
70 if self.check_column_white(column_pixels):
71 if not in_white_section:
72 start_index = col
73 in_white_section = True
74 else:
75 if in_white_section:
76 white_sections.append((start_index, col))
77 in_white_section = False
78
79 if in_white_section:
80 white_sections.append((start_index, end))
81
82 return white_sections
83
84 def split_image(self):
85 """将图片从中间分割为左右两部分"""
86 start_col = self.width * 2 // 5
87 end_col = self.width * 3 // 5
88 white_sections = self.find_white_section(start_col, end_col)
89
90 if white_sections:
91 middle_section = white_sections[len(white_sections) // 2]
92 mid_col = (middle_section[0] + middle_section[1]) // 2
93 else:
94 raise ValueError("No suitable white column found within the specified range")
95
96 left_box = (0, 0, mid_col, self.height)
97 right_box = (mid_col, 0, self.width, self.height)
98 left_image = self.image.crop(left_box)
99 right_image = self.image.crop(right_box)
100
101 save_dir, filename = os.path.split(self.image_path)
102 base, extension = os.path.splitext(filename)
103
104 left_image_path = os.path.join(save_dir, base + '_front' + extension)
105 right_image_path = os.path.join(save_dir, base + '_back' + extension)
106 left_image.save(left_image_path)
107 right_image.save(right_image_path)
108
109 return left_image_path, right_image_path
110
111 def stitch_images(self, image_paths, output_path):
112 """拼接多张图片"""
113 if not image_paths:
114 raise ValueError("No image paths provided")
115 sample_image = Image.open(image_paths[0])
116 single_width, single_height = sample_image.size
117 num_images = len(image_paths)
118 total_desired_width = single_width
119 total_current_width = single_width * num_images
120 total_width_to_cut = max(0, total_current_width - total_desired_width)
121 width_to_cut_per_image = total_width_to_cut // num_images
122 stitched_image = Image.new('RGB', (total_desired_width, single_height), "white")
123 current_x = 0
124
125 for path in image_paths:
126 image = Image.open(path)
127 if width_to_cut_per_image > 0:
128 left_margin = width_to_cut_per_image // 2
129 right_margin = image.width - width_to_cut_per_image + left_margin
130 image = image.crop((left_margin, 0, right_margin, image.height))
131 stitched_image.paste(image, (current_x, 0))
132 current_x += image.width
133
134 output_dir = os.path.dirname(output_path)
135 if not os.path.exists(output_dir):
136 os.makedirs(output_dir)
137 stitched_image.save(output_path)
138 return output_path
139
140 def download_image(self, image_url, save_path, max_retries=3):
141 """
142 下载图片,带有重试机制和SSL错误处理
143
144 Args:
145 image_url: 图片URL
146 save_path: 本地保存路径
147 max_retries: 最大重试次数
148 """
149 import time
150 import urllib3
151
152 urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
153
154 for attempt in range(max_retries):
155 try:
156 response = requests.get(
157 image_url,
158 timeout=(10, 30),
159 stream=True,
160 verify=True,
161 proxies=self._proxies(),
162 headers={
163 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36'
164 }
165 )
166
167 if response.status_code == 200:
168 with open(save_path, 'wb') as file:
169 for chunk in response.iter_content(chunk_size=8192):
170 if chunk:
171 file.write(chunk)
172 print(f"✓ 图片下载成功: {save_path}")
173 return True
174 else:
175 print(f"下载失败,状态码: {response.status_code}")
176
177 except requests.exceptions.SSLError as e:
178 print(f"SSL错误 (尝试 {attempt + 1}/{max_retries}): {str(e)[:100]}")
179 if attempt < max_retries - 1:
180 wait_time = (attempt + 1) * 2
181 print(f"等待 {wait_time} 秒后重试...")
182 time.sleep(wait_time)
183 else:
184 print("尝试禁用SSL验证重新下载...")
185 try:
186 response = requests.get(
187 image_url,
188 timeout=(10, 30),
189 stream=True,
190 verify=False,
191 proxies=self._proxies(),
192 headers={
193 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36'
194 }
195 )
196 if response.status_code == 200:
197 with open(save_path, 'wb') as file:
198 for chunk in response.iter_content(chunk_size=8192):
199 if chunk:
200 file.write(chunk)
201 print(f"✓ 图片下载成功(已禁用SSL验证): {save_path}")
202 return True
203 except Exception as fallback_error:
204 print(f"禁用SSL验证后仍然失败: {fallback_error}")
205 raise
206
207 except requests.exceptions.Timeout as e:
208 print(f"超时错误 (尝试 {attempt + 1}/{max_retries}): {e}")
209 if attempt < max_retries - 1:
210 time.sleep((attempt + 1) * 2)
211 else:
212 raise
213
214 except Exception as e:
215 print(f"下载错误 (尝试 {attempt + 1}/{max_retries}): {e}")
216 if attempt < max_retries - 1:
217 time.sleep((attempt + 1) * 2)
218 else:
219 raise
220
221 return False
222
223 def resize_image(self, image_path):
224 """调整图片大小(添加顶部空白)"""
225 original_image = Image.open(image_path)
226 width, height = original_image.size
227 top_blank_height = height // 2
228 final_height = height + top_blank_height
229 final_width = int(final_height * 5 / 3)
230 new_image = Image.new("RGB", (final_width, final_height), color="white")
231 left = (final_width - width) // 2
232 top = top_blank_height
233 new_image.paste(original_image, (left, top))
234 new_image.save(image_path)
235 return image_path
236
237 def has_black_borders(self, image_path, threshold=10, black_limit=20):
238 """检查图片是否有黑色边框"""
239 img = Image.open(image_path)
240 pixels = img.load()
241 width, height = img.size
242
243 def is_black_pixel(pixel):
244 return all(x <= black_limit for x in pixel)
245
246 # 检查顶部和底部边框
247 for y in range(threshold):
248 if all(is_black_pixel(pixels[x, y]) for x in range(width)):
249 return True
250 if all(is_black_pixel(pixels[x, height - 1 - y]) for x in range(width)):
251 return True
252
253 # 检查左右边框
254 for x in range(threshold):
255 if all(is_black_pixel(pixels[x, y]) for y in range(height)):
256 return True
257 if all(is_black_pixel(pixels[width - 1 - x, y]) for y in range(height)):
258 return True
259
260 return False
261
262 # ===== 图片上传功能 =====
263
264 def get_upload_policy(self):
265 """
266 获取文件上传凭证
267
268 Returns:
269 policy_data: 包含上传所需凭证的字典
270
271 Raises:
272 Exception: 获取上传凭证失败时
273 """
274 if not self.api_key:
275 raise RuntimeError("DASHSCOPE_API_KEY 未设置,无法使用图片上传服务")
276
277 headers = {
278 "Authorization": f"Bearer {self.api_key}",
279 "Content-Type": "application/json"
280 }
281 params = {
282 "action": "getPolicy",
283 "model": self.model_name
284 }
285
286 response = requests.get(
287 self.UPLOAD_API_URL,
288 headers=headers,
289 params=params,
290 proxies=self._proxies(),
291 )
292 if response.status_code != 200:
293 raise Exception(f"Failed to get upload policy: {response.text}")
294
295 return response.json()['data']
296
297 def upload_file_to_oss(self, policy_data: dict, file_path: str) -> str:
298 """
299 将文件上传到临时存储OSS
300
301 Args:
302 policy_data: 上传凭证数据
303 file_path: 本地文件路径
304
305 Returns:
306 oss_url: OSS URL (格式: oss://...)
307
308 Raises:
309 Exception: 上传失败时
310 """
311 file_name = Path(file_path).name
312 # Sanitize filename for upload to avoid issues with spaces/characters
313 safe_file_name = "".join([c if c.isalnum() or c in ('-','_','.') else '_' for c in file_name])
314
315 key = f"{policy_data['upload_dir']}/{safe_file_name}"
316
317 with open(file_path, 'rb') as file:
318 files = {
319 'OSSAccessKeyId': (None, policy_data['oss_access_key_id']),
320 'Signature': (None, policy_data['signature']),
321 'policy': (None, policy_data['policy']),
322 'x-oss-object-acl': (None, policy_data['x_oss_object_acl']),
323 'x-oss-forbid-overwrite': (None, policy_data['x_oss_forbid_overwrite']),
324 'key': (None, key),
325 'success_action_status': (None, '200'),
326 'file': (safe_file_name, file)
327 }
328
329 response = requests.post(
330 policy_data['upload_host'],
331 files=files,
332 proxies=self._proxies(),
333 )
334 if response.status_code != 200:
335 raise Exception(f"Failed to upload file: {response.text}")
336
337 # Construct OSS URL correctly: oss://<bucket>/<key>
338 # Extract bucket from upload_host (e.g., https://dashscope-instant.oss-cn-beijing.aliyuncs.com)
339 upload_host = policy_data['upload_host']
340 bucket_name = ""
341 if '://' in upload_host:
342 domain = upload_host.split('://')[1]
343 bucket_name = domain.split('.')[0]
344
345 if bucket_name:
346 return f"oss://{bucket_name}/{key}"
347 else:
348 # Fallback if parsing fails (though unlikely for standard OSS hosts)
349 # If the original code's assumption that key was self-sufficient was somehow valid, logic is here.
350 # But normally, oss://<key> is wrong if key doesn't have bucket.
351 return f"oss://{key}"
352
353 def upload(self, file_path: str) -> str:
354 """
355 上传文件到阿里云OSS并获取URL(统一接口方法)
356
357 Args:
358 file_path: 本地文件路径
359
360 Returns:
361 oss_url: OSS URL,可在48小时内使用
362
363 Raises:
364 FileNotFoundError: 文件不存在时
365 RuntimeError: API Key未设置时
366 Exception: 上传失败时
367 """
368 # 检查文件是否存在
369 if not os.path.exists(file_path):
370 raise FileNotFoundError(f"文件不存在: {file_path}")
371
372 if not self.api_key:
373 raise RuntimeError("DASHSCOPE_API_KEY 未设置,无法使用图片上传服务")
374
375 # 1. 获取上传凭证(注意:上传凭证接口有限流)
376 policy_data = self.get_upload_policy()
377
378 # 2. 上传文件到OSS
379 oss_url = self.upload_file_to_oss(policy_data, file_path)
380
381 # 3. 计算过期时间
382 expire_time = datetime.now() + timedelta(hours=48)
383
384 logging.info(f"文件上传成功: {file_path}")
385 logging.info(f" OSS URL: {oss_url}")
386 logging.info(f" 过期时间: {expire_time.strftime('%Y-%m-%d %H:%M:%S')} (48小时)")
387
388 return oss_url
389
390 def collage_images(self, image_paths, output_path):
391 """
392 拼图功能:将多张图片水平拼接
393 Args:
394 image_paths: 图片路径列表
395 output_path: 输出文件路径
396 """
397 if not image_paths:
398 return None
399
400 images = []
401 for p in image_paths:
402 try:
403 img = Image.open(p)
404 images.append(img)
405 except Exception as e:
406 logging.error(f"Cannot open image {p}: {e}")
407
408 if not images:
409 return None
410
411 # 统一高度,按第一张图片的高度调整其他图片
412 base_height = images[0].height
413 resized_images = []
414 for img in images:
415 if img.height != base_height:
416 ratio = base_height / img.height
417 new_width = int(img.width * ratio)
418 resized_images.append(img.resize((new_width, base_height)))
419 else:
420 resized_images.append(img)
421
422 total_width = sum(img.width for img in resized_images)
423 new_im = Image.new('RGB', (total_width, base_height))
424
425 x_offset = 0
426 for img in resized_images:
427 new_im.paste(img, (x_offset, 0))
428 x_offset += img.width
429
430 new_im.save(output_path)
431 return output_path
432
432 lines PYTHON