目录迁移

This commit is contained in:
dongchangxi committed 2025-12-09 16:34:35 +08:00
1 parent 2ea782ed93
commit cc15c76e62
26 files changed
+295 -86

No files matched your search

@@ -0,0 +1,100 @@
import os,time
import re
from .logs import log
def changeMtlFile(localMtlPath,newName):
beginTime = time.time()
try:
# 使用更高效的文件读取方式 [6,8](@ref)
with open(localMtlPath, 'r', encoding='utf-8') as f:
content = f.read()
# 使用字符串方法直接查找和替换,避免不必要的循环 [9](@ref)
lines = content.split('\n')
for i, line in enumerate(lines):
stripped_line = line.strip()
if stripped_line.startswith('map_Kd '):
lines[i] = f"map_Kd {newName}"
break
# 批量写入,减少I/O操作 [6](@ref)
with open(localMtlPath, 'w', encoding='utf-8') as f:
f.write('\n'.join(lines))
except IOError as e:
log(f"处理文件 {localMtlPath} 时出错: {e}")
return False
except UnicodeDecodeError as e:
log(f"文件编码错误 {localMtlPath}: {e}")
return False
endTime = time.time()
log(f"修改文件文件内容 {localMtlPath} : 耗时{endTime - beginTime}秒")
return True
def changeObjFile(localObjPath,newName):
beginTime = time.time()
try:
# 优化策略:mtllib通常在文件前几行,先只读取前100行
# 对于大文件(如155万行),这样可以大幅减少内存使用和处理时间
with open(localObjPath, 'r', encoding='utf-8') as f:
first_100_lines = []
mtllib_line_idx = -1
# 读取前100行查找mtllib
for i in range(100):
line = f.readline()
if not line:
break
first_100_lines.append(line)
if mtllib_line_idx == -1 and line.strip().startswith('mtllib '):
mtllib_line_idx = i
# 如果在前100行找到了mtllib,直接修改
if mtllib_line_idx != -1:
parts = first_100_lines[mtllib_line_idx].split(' ', 1)
if len(parts) > 1:
#old_mtl_name = parts[1].strip()
new_mtl_name = f"{newName}"
first_100_lines[mtllib_line_idx] = f"mtllib {new_mtl_name}\n"
# 读取剩余内容并合并
remaining_content = f.read()
# 写回文件
with open(localObjPath, 'w', encoding='utf-8') as f_out:
f_out.writelines(first_100_lines)
if remaining_content:
f_out.write(remaining_content)
endTime = time.time()
log(f"修改文件文件内容 {localObjPath} : 耗时{endTime - beginTime}秒")
return True
log(f"浪费时间读取完整的文件,进行替换mtllib路径, localObjPath={localObjPath}, newName={newName}")
# 如果前100行没找到,读取整个文件(很少见的情况)
f.seek(0) # 重置文件指针
content = f.read()
# 使用正则表达式一次性替换(更高效)
pattern = r'^mtllib\s+(.+)$'
def replace_mtllib(match):
old_mtl_name = match.group(1).strip()
new_mtl_name = f"{newName}_{old_mtl_name}"
return f"mtllib {new_mtl_name}"
new_content = re.sub(pattern, replace_mtllib, content, flags=re.MULTILINE)
if new_content != content:
with open(localObjPath, 'w', encoding='utf-8') as f_out:
f_out.write(new_content)
endTime = time.time()
log(f"完整读取修改文件文件内容 {localObjPath} : 耗时{endTime - beginTime}秒")
return True
except IOError as e:
print(f"处理文件 {localObjPath} 时出错: {e}")
return False
except UnicodeDecodeError as e:
print(f"文件编码错误 {localObjPath}: {e}")
return False
return False
@@ -0,0 +1,688 @@
import requests
import time
import os
import platform
import shutil
import json
import shlex
import subprocess
import sys
import signal
from concurrent.futures import ThreadPoolExecutor, as_completed
from .oss_redis import ossClient
from .logs import log
from .oss_func import download_file_with_check, checkFileExists
from .changeFiles import changeObjFile, changeMtlFile
from .small_machine_transform import transform_save
ENV = 'prod'
url = 'https://mp.api.suwa3d.com'
if ENV == 'dev':
url = 'http://mp.api.dev.com'
elif ENV == 'prod':
url = 'https://mp.api.suwa3d.com'
def get_resource_path(relative_path):
"""
获取资源文件的绝对路径,兼容 PyInstaller 打包后的环境
参数:
relative_path: 相对于脚本目录的相对路径
返回:
资源文件的绝对路径
"""
try:
# PyInstaller 创建临时文件夹,并将路径存储在 _MEIPASS 中
base_path = sys._MEIPASS
except AttributeError:
# 如果不是打包环境,使用脚本所在目录
base_path = os.path.dirname(os.path.abspath(__file__))
return os.path.join(base_path, relative_path)
def get_transform_script_path():
"""
获取 small_machine_transform.py 脚本的路径
在打包成exe后,如果脚本不在可访问位置,则将其提取到临时目录
返回:
transform_script_path: 脚本的绝对路径
"""
# 首先尝试从资源路径获取
if getattr(sys, 'frozen', False):
# 打包后的环境
# 尝试从资源路径获取
resource_path = get_resource_path('utils/small_machine_transform.py')
# 如果资源路径存在,直接使用
if os.path.exists(resource_path):
return resource_path
# 如果不存在,尝试提取到临时目录
import tempfile
temp_dir = tempfile.gettempdir()
extract_path = os.path.join(temp_dir, 'factory_sliceing_utils', 'small_machine_transform.py')
# 确保目录存在
os.makedirs(os.path.dirname(extract_path), exist_ok=True)
# 如果提取路径不存在,尝试从资源中复制
if not os.path.exists(extract_path):
# 尝试从多个可能的资源路径查找
possible_paths = [
get_resource_path('small_machine_transform.py'),
get_resource_path('utils/small_machine_transform.py'),
]
for src_path in possible_paths:
if os.path.exists(src_path):
shutil.copy2(src_path, extract_path)
log(f"已将 small_machine_transform.py 提取到: {extract_path}")
return extract_path
# 如果提取路径存在,直接使用
if os.path.exists(extract_path):
return extract_path
# 如果都找不到,返回资源路径(即使不存在,让调用者处理错误)
return resource_path
else:
# 开发环境,直接使用相对路径
script_dir = os.path.dirname(os.path.abspath(__file__))
return os.path.join(script_dir, 'small_machine_transform.py')
# 根据打印ID 获取下载目录
def getDownloadDirByPrintId(printIds):
# 调用接口获取下载的路径
api_url = f"{url}/api/printOrder/getInfoByPrintIds"
res = requests.post(api_url, json={"print_ids": printIds})
res = res.json()
print(f"根据打印ID 获取下载目录, res={res}")
if res["code"] == 1000:
return res["data"]
else:
return False
# 根据批次id,进行切片中的状态变更
def requestApiToUpdateSliceStatus(versionId, downloadCounts):
api_url = f"{url}/api/printTypeSettingOrder/updateBatchSliceing?batch_id={versionId}&download_counts=" + str(downloadCounts)
log(f'发起状态变更请求url={api_url}, versionId={versionId}')
try:
# 添加超时参数,防止请求时间过长
res = requests.post(api_url, timeout=60) # 60秒超时
if res.status_code != 200:
log(f'状态变更请求失败, res={res.text}')
return False
log(f'状态变更请求成功, res={res.text}')
return True
except requests.exceptions.Timeout:
log(f'状态变更请求超时, url={api_url}')
return False
except requests.exceptions.RequestException as e:
log(f'状态变更请求异常, error={str(e)}')
return False
# 判断是否上传了 JSON 文件
def checkJsonFileExists(versionId):
log(f"检测文件和图片是否存在, versionId={versionId}")
jsonFilePath = f'batchPrint/{versionId}/{versionId}.json'
# 判断oss 上是否存在
jpgFilePath = f'batchPrint/{versionId}/{versionId}.jpg'
if not ossClient().object_exists(jsonFilePath):
log(f'JSON文件不存在: {jsonFilePath}')
return False, False
if not ossClient().object_exists(jpgFilePath):
log(f'JPG文件不存在: {jpgFilePath}')
return False, False
log(f"文件和图片检测成功,存在, versionId={versionId}")
return jsonFilePath, jpgFilePath
# 检测本地文件是否存在
def checkLocalFileExists(localFilePath):
if not os.path.exists(localFilePath):
return False
return True
# 读取JSON文件内容
def readJsonFile(localFilePath):
with open(localFilePath, 'r', encoding='utf-8') as f:
jsonData = json.load(f)
return jsonData
# 根据批次ID,获取批次信息
def getBatchInfo(versionId):
url1 = f"{url}/api/printTypeSettingOrder/getBatchInfoAndPrintMachineInfoByBatchId?batch_id={versionId}"
res = requests.get(url1)
res = res.json()
log(f"获取批次信息和打印机信息, url={url1}, res={res}")
if res["code"] == 1000:
return res["data"]
else:
return False
# 下载文件,读取文件内容,并且文件迁移至正确的目录里,不再放在临时目录里
def downloadJsonAndJpgFileAndMoveToCorrectDir(versionId, currentDir):
jsonFilePath, jpgFilePath = checkJsonFileExists(versionId)
if jsonFilePath == False or jpgFilePath == False:
return False, False
# 将文件下载到临时目录,待会要判断是否什么类型机型
tempDir = os.path.join(currentDir, 'batchPrint', 'temp', versionId)
if not os.path.exists(tempDir):
os.makedirs(tempDir)
localFilePath = os.path.join(tempDir, f'{versionId}.json')
# 使用带完整性校验的下载方法下载JSON文件
ok = download_file_with_check(jsonFilePath, localFilePath)
if not ok:
log(f"JSON 文件下载失败或不完整, versionId={versionId}")
return False, False
# 下载JPG文件
localJpgFilePath = os.path.join(tempDir, f'{versionId}.jpg')
ok = download_file_with_check(jpgFilePath, localJpgFilePath)
if not ok:
log(f"JPG 文件下载失败或不完整, versionId={versionId}")
return False, False
# 根据批次ID,获取批次信息和打印机信息
batchMachineInfo = getBatchInfo(versionId)
if not batchMachineInfo:
log(f"获取批次信息和打印机信息失败, versionId={versionId}")
return False, False
# "batch_info": batchInfo,
# "print_machine_info": printMachineInfo,
machineInfo = batchMachineInfo["print_machine_info"]
print(f"machineInfo={machineInfo['id']}")
dirNewName = ""
if str(machineInfo["machine_type"]) == '1':
dirNewName = os.path.join(currentDir, 'batchPrint', versionId + '_small_No' + str(machineInfo['id']))
else:
dirNewName = os.path.join(currentDir, 'batchPrint', versionId + '_big_No' + str(machineInfo['id']))
# 判断目录是否存在,存在就删除
if os.path.exists(dirNewName):
shutil.rmtree(dirNewName)
# 创建目录
os.makedirs(dirNewName)
# 创建json子目录
jsonSubDir = os.path.join(dirNewName, 'json')
if not os.path.exists(jsonSubDir):
os.makedirs(jsonSubDir)
dataSubDir = os.path.join(dirNewName, 'data')
if not os.path.exists(dataSubDir):
os.makedirs(dataSubDir)
# 将数据移动过来
shutil.move(localFilePath, os.path.join(jsonSubDir, '3DPrintLayout.json'))
shutil.move(localJpgFilePath, os.path.join(jsonSubDir, f'{versionId}.jpg'))
#如果是大机台,将 json 文件复制到 data 目录下
if str(machineInfo["machine_type"]) != '1':
shutil.copy(os.path.join(jsonSubDir, '3DPrintLayout.json'), os.path.join(dataSubDir, '3DPrintLayout.json'))
if not os.path.exists(os.path.join(dataSubDir, '3DPrintLayout.json')):
log(f"JSON文件不存在, dataSubDir={dataSubDir}")
return False, False
# 检测文件是否移动成功
if not os.path.exists(os.path.join(jsonSubDir, '3DPrintLayout.json')):
log(f"JSON文件不存在, versionId={versionId}")
return False, False
if not os.path.exists(os.path.join(jsonSubDir, f'{versionId}.jpg')):
log(f"JPG文件不存在, versionId={versionId}")
return False, False
log(f"文件移动成功, versionId={versionId}")
# 返回目录路径
return dirNewName, machineInfo
# 整合json文件,读取对应的数据,返回数据结构
def getJsonData(dirNewName):
jsonFilePath = os.path.join(dirNewName, 'json', '3DPrintLayout.json')
if not os.path.exists(jsonFilePath):
log(f"JSON文件不存在, dirNewName={dirNewName}")
return False
# 读取JSON文件内容
jsonData = readJsonFile(jsonFilePath)
# 读取models
models = jsonData.get('models', [])
if not models:
log(f"models不存在, dirNewName={dirNewName}")
return False
listData = []
# 遍历models
for model in models:
file_name = model.get('file_name', '')
# 分割数据
arrFileName = file_name.split('_')
orderId = arrFileName[0]
pid = arrFileName[1]
printId = arrFileName[2].replace("P", "")
size = arrFileName[3]
counts = arrFileName[4].replace("x", "").replace(".obj", "")
# 检测这些数据
if not orderId or not pid or not printId or not size or not counts:
log(f"数据不完整, orderId={orderId}, pid={pid}, printId={printId}, size={size}, counts={counts}")
return False
# 创建数据结构
modelInfo = {
"orderId": orderId,
"printId": printId,
"pid": pid,
"size": size,
"counts": counts,
"file_name": file_name,
}
listData.append(modelInfo)
# 检测数据长度
if len(listData) == 0:
log(f"数据长度为0, dirNewName={dirNewName}")
return False
# 返回数据结构
return listData
# 读取 json 文件,获取 homo_matrix 数据,根据 file_name 获取 homo_matrix 数据
def getHomoMatrixByFileName(dirNewName, fileName):
jsonFilePath = os.path.join(dirNewName, 'json', '3DPrintLayout.json')
if not os.path.exists(jsonFilePath):
log(f"JSON文件不存在, dirNewName={dirNewName}")
return False
# 读取JSON文件内容
jsonData = readJsonFile(jsonFilePath)
if not jsonData:
log(f"读取JSON文件内容失败, dirNewName={dirNewName}")
return False
for model in jsonData["models"]:
#log(f"jsonData={model['file_name']} == {fileName}")
if model["file_name"] == fileName:
log(f"model={model['transform']}")
return model["transform"]["homo_matrix"]
return False
# 处理单个数据项:下载文件、修改关联路径、转换数据(如果是小机台)
def _process_single_item(v, arrDownloadPath, dirNewName, dirPath, isSmallMachine):
"""
处理单个数据项的函数,用于多线程处理
参数:
v: 单个数据项
arrDownloadPath: 下载路径数组
dirNewName: 目录名称
dirPath: 数据目录路径
isSmallMachine: 是否是小机台
返回:
(success: bool, error_msg: str)
"""
try:
# 查找匹配的下载路径信息
info = {}
for tempv in arrDownloadPath:
if str(tempv["print_order_id"]) == str(v["printId"]):
info = tempv
break
if not info:
return False, f"未找到匹配的下载路径信息, printId={v['printId']}"
filePath = info["path"]
pid = info["pid"]
orderId = info["order_id"]
printId = info["print_order_id"]
size = info["real_size"]
counts = info["quantity"]
fileName = v["file_name"]
# 判断文件是否存在
ossJpgFilePath = f"{filePath}/printId_{printId}Tex1.jpg"
if not checkFileExists(ossJpgFilePath):
ossJpgFilePath = f"{filePath}/{pid}Tex1.jpg"
localJpgName = os.path.join(f"{orderId}_{pid}Tex1.jpg")
loaclMtlName = os.path.join(f"{orderId}_{pid}.mtl")
localObjName = fileName
arrDownloadFiles = [
{"ossPath": ossJpgFilePath, "localPath": os.path.join(dirPath, localJpgName)},
{"ossPath": f"{filePath}/{pid}.obj", "localPath": os.path.join(dirPath, localObjName)},
{"ossPath": f"{filePath}/{pid}.mtl", "localPath": os.path.join(dirPath, loaclMtlName)},
]
# 遍历下载文件
beginTime = time.time()
objsLocal = ""
for objFiles in arrDownloadFiles:
downloadBeginTime = time.time()
# 判断 mtl 和 jpg 文件是否存在,存在就不在下载了
if "mtl" in objFiles["localPath"] or "jpg" in objFiles["localPath"]:
if os.path.exists(objFiles["localPath"]):
continue
downloadOk = download_file_with_check(objFiles["ossPath"], objFiles["localPath"])
if not downloadOk:
error_msg = f"下载文件失败, ossPath={objFiles['ossPath']}, localPath={objFiles['localPath']}"
log(error_msg)
return False, error_msg
log(f"下载文件耗时: {time.time() - downloadBeginTime}秒 - {objFiles['localPath']}")
# 下载成功之后要修改文件之间的关联路径
if objFiles["localPath"].endswith(".obj"):
beginChangeTime = time.time()
objsLocal = objFiles["localPath"]
changeObjFile(objFiles["localPath"], f"{orderId}_{pid}.mtl")
log(f"修改obj关联耗时: {time.time() - beginChangeTime}秒 - {objFiles['localPath']}")
if objFiles["localPath"].endswith(".mtl"):
changeMtlFile(objFiles["localPath"], f"{orderId}_{pid}Tex1.jpg")
endTime = time.time()
log(f"下载文件和修改文件之间的关联路径 : 耗时{endTime - beginTime}秒 - {fileName}")
# 如果是小机台,则要转换数据
if isSmallMachine:
timeBegin = time.time()
homo_matrix = getHomoMatrixByFileName(dirNewName, localObjName)
if not homo_matrix:
error_msg = f"获取homo_matrix失败, dirNewName={dirNewName}, objsLocal={objsLocal}"
log(error_msg)
return False, error_msg
# 通过blender 调用执行 python 文件
blender_bin_path = findBpyModule()
# 获取 small_machine_transform.py 的绝对路径(兼容打包环境)
transform_script_path = get_transform_script_path()
# 将 homo_matrix 转换为 JSON 字符串
homo_matrix_json = json.dumps(homo_matrix)
# 检查必要文件是否存在
if not blender_bin_path or not os.path.exists(blender_bin_path):
error_msg = f"Blender 可执行文件不存在: {blender_bin_path}"
log(error_msg)
return False, error_msg
if not os.path.exists(transform_script_path):
error_msg = f"转换脚本文件不存在: {transform_script_path}"
log(error_msg)
return False, error_msg
if not os.path.exists(objsLocal):
error_msg = f"OBJ 文件不存在: {objsLocal}"
log(error_msg)
return False, error_msg
# 构建命令参数列表(使用列表形式,避免 shell 转义问题)
cmd = [
blender_bin_path,
'--background',
'--python',
transform_script_path,
'--',
f'--objPathName={objsLocal}',
f'--trans={homo_matrix_json}'
]
log(f"准备执行 Blender 命令")
log(f" Blender路径: {blender_bin_path}")
log(f" 脚本路径: {transform_script_path}")
log(f" OBJ文件: {objsLocal}")
log(f" 完整命令: {' '.join(cmd[:4])} ... (参数已隐藏)")
try:
log(f"开始执行 Blender 转换命令...")
# 使用 subprocess.Popen 以便更好地控制进程和信号处理
# 在 Windows 上,Blender 输出可能是 UTF-8,需要指定编码并处理错误
# 准备进程启动参数
startupinfo = None
preexec_fn = None
if platform.system() == 'Windows':
# Windows 上,不设置 CREATE_NEW_PROCESS_GROUP,以便 Ctrl+C 可以传播
import subprocess as sp
startupinfo = sp.STARTUPINFO()
startupinfo.dwFlags |= sp.STARTF_USESHOWWINDOW
startupinfo.wShowWindow = sp.SW_HIDE
else:
# Unix 系统,设置 preexec_fn 以确保信号处理正确
preexec_fn = os.setsid
process = subprocess.Popen(
cmd,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
encoding='utf-8', # 指定 UTF-8 编码
errors='replace', # 遇到无法解码的字符时替换为占位符,而不是抛出异常
startupinfo=startupinfo,
preexec_fn=preexec_fn
)
try:
# 等待进程完成,捕获输出
stdout, stderr = process.communicate(timeout=300) # 5分钟超时
returncode = process.returncode
except subprocess.TimeoutExpired:
# 超时,终止进程
log(f"Blender 命令执行超时,正在终止进程...")
if platform.system() == 'Windows':
process.terminate()
try:
process.wait(timeout=5)
except subprocess.TimeoutExpired:
process.kill()
else:
# Unix 系统,发送 SIGTERM 到进程组
try:
os.killpg(os.getpgid(process.pid), signal.SIGTERM)
process.wait(timeout=5)
except (subprocess.TimeoutExpired, ProcessLookupError, OSError):
try:
os.killpg(os.getpgid(process.pid), signal.SIGKILL)
except (ProcessLookupError, OSError):
pass
raise
except KeyboardInterrupt:
# 收到中断信号,终止进程
log(f"收到中断信号,正在终止 Blender 进程...")
if platform.system() == 'Windows':
process.terminate()
try:
process.wait(timeout=5)
except subprocess.TimeoutExpired:
process.kill()
else:
# Unix 系统,发送 SIGTERM 到进程组
try:
os.killpg(os.getpgid(process.pid), signal.SIGTERM)
process.wait(timeout=5)
except (subprocess.TimeoutExpired, ProcessLookupError, OSError):
try:
os.killpg(os.getpgid(process.pid), signal.SIGKILL)
except (ProcessLookupError, OSError):
pass
raise # 重新抛出,让调用者处理
# 创建结果对象(模拟 subprocess.run 的返回值)
class Result:
def __init__(self, returncode, stdout, stderr):
self.returncode = returncode
self.stdout = stdout
self.stderr = stderr
result = Result(returncode, stdout, stderr)
log(f"Blender 命令执行完成, 返回码: {result.returncode}")
# 无论成功失败都输出详细信息
if result.stdout:
log(f"Blender stdout 输出: {result.stdout}")
if result.stderr:
log(f"Blender stderr 输出: {result.stderr}")
if result.returncode != 0:
error_output = result.stderr if result.stderr else result.stdout
error_msg = f"调用blender 执行 python 文件失败, 返回码={result.returncode}, fileName={fileName}"
if error_output:
error_msg += f", 错误信息: {error_output[:10000000]}" # 增加错误信息长度限制
log(error_msg)
return False, error_msg
log(f"调用blender 执行 python 文件成功, fileName={fileName}")
except subprocess.TimeoutExpired:
error_msg = f"调用blender 执行超时(超过5分钟), fileName={fileName}"
log(error_msg)
return False, error_msg
except Exception as e:
error_msg = f"调用blender 执行时发生异常: {str(e)}, fileName={fileName}"
log(error_msg)
return False, error_msg
timeEnd = time.time()
log(f"转换数据时间: 耗时{timeEnd - timeBegin}秒 - {objsLocal}")
return True, ""
except Exception as e:
error_msg = f"处理数据项时发生异常, fileName={v.get('file_name', 'unknown')}, error={str(e)}"
log(error_msg)
return False, error_msg
# json文件进行下载对应的数据 和转换数据,传递目录路径
def downloadDataByOssAndTransformSave(dirNewName, isSmallMachine=False, max_workers=1):
listData = getJsonData(dirNewName)
if not listData:
log(f"获取数据失败, dirNewName={dirNewName}")
return False
# 遍历数据
arrPrintId = []
arrPrintDataInfo = []
for modelInfo in listData:
arrPrintId.append(modelInfo.get('printId'))
arrPrintDataInfo.append({
"printId": modelInfo.get('printId'),
"file_name": modelInfo.get('file_name'),
})
# 调用接口获取下载的路径
arrDownloadPath = getDownloadDirByPrintId(arrPrintId)
if not arrDownloadPath:
log(f"获取下载路径失败, arrPrintId={arrPrintId}")
return False
dirPath = os.path.join(dirNewName, 'data')
if not os.path.exists(dirPath):
os.makedirs(dirPath)
# 使用多线程并发处理数据
log(f"开始多线程处理数据, 共{len(listData)}个项目, 线程数={max_workers}")
beginTime = time.time()
# 使用线程池并发处理
try:
with ThreadPoolExecutor(max_workers=max_workers) as executor:
# 提交所有任务
future_to_item = {
executor.submit(_process_single_item, v, arrDownloadPath, dirNewName, dirPath, isSmallMachine): v
for v in listData
}
# 收集结果
success_count = 0
fail_count = 0
try:
for future in as_completed(future_to_item):
v = future_to_item[future]
try:
success, error_msg = future.result()
if success:
success_count += 1
log(f"处理成功: {v.get('file_name', 'unknown')} ({success_count}/{len(listData)})")
else:
fail_count += 1
log(f"处理失败: {v.get('file_name', 'unknown')}, 错误: {error_msg} ({fail_count}/{len(listData)})")
# 如果任何一个任务失败,记录错误但继续处理其他任务
except KeyboardInterrupt:
# 收到中断信号,取消所有未完成的任务
log(f"收到中断信号,正在取消未完成的任务...")
for f in future_to_item:
f.cancel()
raise # 重新抛出,让外层捕获
except Exception as e:
fail_count += 1
error_msg = f"处理数据项时发生异常: {str(e)}"
log(f"处理异常: {v.get('file_name', 'unknown')}, 错误: {error_msg} ({fail_count}/{len(listData)})")
except KeyboardInterrupt:
# 收到中断信号,取消所有未完成的任务
log(f"收到中断信号,正在取消未完成的任务...")
for f in future_to_item:
f.cancel()
# 尝试等待正在执行的任务完成(非阻塞方式)
import concurrent.futures
for f in list(future_to_item.keys()):
if not f.done():
try:
# 尝试获取结果,如果任务还在执行则立即返回
f.result(timeout=0.1)
except (concurrent.futures.TimeoutError, concurrent.futures.CancelledError):
pass
except:
pass
raise # 重新抛出,让外层捕获
endTime = time.time()
log(f"多线程处理完成, 总耗时{endTime - beginTime}秒, 成功:{success_count}, 失败:{fail_count}, 总计:{len(listData)}")
# 如果有任何失败,返回 False
if fail_count > 0:
log(f"部分任务处理失败,共{fail_count}个失败")
return False
return True
except KeyboardInterrupt:
log(f"多线程处理被中断")
raise # 重新抛出,让调用者处理
def findBpyModule():
# 返回 Blender 可执行文件路径
blender_bin_path = '/Applications/Blender.app/Contents/MacOS/Blender'
# 判断当前是 windows 还是 macOS
if platform.system() == 'Windows':
# Windows 上常见的 Blender 安装路径
possible_paths = [
'C:\\Program Files\\Blender Foundation\\Blender 5.0\\blender.exe',
'C:\\Program Files\\Blender Foundation\\Blender 4.4\\blender.exe',
'C:\\Program Files\\Blender Foundation\\Blender 4.3\\blender.exe',
'C:\\Program Files\\Blender Foundation\\Blender 4.2\\blender.exe',
'C:\\Program Files\\Blender Foundation\\Blender 4.1\\blender.exe',
'C:\\Program Files\\Blender Foundation\\Blender 4.0\\blender.exe',
]
# 查找存在的路径
for path in possible_paths:
if os.path.exists(path):
blender_bin_path = path
break
else:
# 如果都没找到,使用默认路径
blender_bin_path = 'C:\\Program Files\\Blender Foundation\\Blender 5.0\\blender.exe'
log(f"警告: 未找到 Blender 可执行文件,使用默认路径: {blender_bin_path}")
else:
blender_bin_path = '/Applications/Blender.app/Contents/MacOS/Blender'
# 检查路径是否存在
if not os.path.exists(blender_bin_path):
error_msg = f"Blender 可执行文件不存在: {blender_bin_path}"
log(error_msg)
raise FileNotFoundError(error_msg)
return blender_bin_path
@@ -0,0 +1,4 @@
import time
def log(message):
print(f'{time.strftime("%Y-%m-%d %H:%M:%S", time.localtime())} {message}')
@@ -0,0 +1,45 @@
import os
from .oss_redis import ossClient
from .logs import log
# 从 OSS 下载文件到本地,并做完整性校验(状态码 + 文件大小)
def download_file_with_check(ossFilePath, localFilePath):
try:
log(f"开始从 OSS 下载文件: {ossFilePath} -> {localFilePath}")
if not checkFileExists(ossFilePath):
log(f"文件不存在: {ossFilePath}")
return False
# 同步阻塞下载,下载完成后才会返回
result = ossClient().get_object_to_file(ossFilePath, localFilePath)
# 1. 检查 HTTP 状态码
status = getattr(result, "status", None)
if status != 200:
log(f"下载失败,HTTP 状态码异常: status={status}")
return False
# 2. 远端 / 本地文件大小对比,作为二次校验
remote_meta = ossClient().head_object(ossFilePath)
remote_size = getattr(remote_meta, "content_length", None)
if remote_size is None:
log("无法获取远端文件大小,放弃本次下载结果")
return False
if not os.path.exists(localFilePath):
log("本地文件不存在,下载可能失败")
return False
local_size = os.path.getsize(localFilePath)
if remote_size != local_size:
log(f"文件大小不一致,下载可能不完整: remote={remote_size}, local={local_size}")
return False
log("文件下载成功且完整性校验通过")
return True
except Exception as e:
log(f"下载文件出现异常: {str(e)}")
return False
def checkFileExists(ossFilePath):
return ossClient().object_exists(ossFilePath)
@@ -0,0 +1,125 @@
import oss2,redis
import time
import logging
# 配置日志
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
# 连接oss - 单例模式
class OSSClientSingleton:
_instance = None
_client = None
def __new__(cls):
if cls._instance is None:
cls._instance = super(OSSClientSingleton, cls).__new__(cls)
return cls._instance
def get_client(self):
if self._client is None:
AccessKeyId = 'LTAI5tSReWm8hz7dSYxxth8f'
AccessKeySecret = '8ywTDF9upPAtvgXtLKALY2iMYHIxdS'
Endpoint = 'oss-cn-shanghai.aliyuncs.com'
Bucket = 'suwa3d-securedata'
self._client = oss2.Bucket(oss2.Auth(AccessKeyId, AccessKeySecret), Endpoint, Bucket)
return self._client
def ossClient():
"""获取OSS客户端单例"""
return OSSClientSingleton().get_client()
#连接redis,单例模式
class RedisClientSingleton:
_instance = None
_client = None
# Redis连接配置
REDIS_CONFIG = {
'host': 'mp.api.suwa3d.com',
'password': 'kcV2000',
'port': 6379,
'db': 6,
'socket_timeout': 30, # 操作超时30秒
'socket_connect_timeout': 10, # 连接超时10秒
'socket_keepalive': True, # 启用 TCP keepalive
'socket_keepalive_options': {}, # keepalive 选项
'health_check_interval': 30 # 健康检查间隔30秒
}
# 重试配置
RETRY_INTERVAL = 5 # 重试间隔(秒)
MAX_RETRY_INTERVAL = 60 # 最大重试间隔(秒),用于指数退避
def __new__(cls):
if cls._instance is None:
cls._instance = super(RedisClientSingleton, cls).__new__(cls)
return cls._instance
def _create_redis_client(self):
"""创建Redis客户端"""
return redis.Redis(**self.REDIS_CONFIG)
def _connect_with_retry(self, max_retries=None, retry_interval=None):
"""
带重试机制的Redis连接方法
:param max_retries: 最大重试次数,None表示无限重试
:param retry_interval: 重试间隔(秒),None表示使用默认值,支持指数退避
:return: Redis客户端实例
"""
if retry_interval is None:
retry_interval = self.RETRY_INTERVAL
retry_count = 0
current_interval = retry_interval
while True:
try:
logger.info(f"尝试连接Redis (第 {retry_count + 1} 次)...")
client = self._create_redis_client()
# 测试连接
client.ping()
logger.info("Redis连接成功!")
return client
except (redis.ConnectionError, redis.TimeoutError, AttributeError, Exception) as e:
retry_count += 1
error_msg = str(e)
logger.warning(f"Redis连接失败 (第 {retry_count} 次): {error_msg}")
# 如果设置了最大重试次数且已达到,则抛出异常
if max_retries is not None and retry_count >= max_retries:
logger.error(f"达到最大重试次数 {max_retries},停止重试")
raise
# 指数退避:每次重试间隔逐渐增加,但不超过最大值
logger.info(f"等待 {current_interval} 秒后重试...")
time.sleep(current_interval)
current_interval = min(current_interval * 1.5, self.MAX_RETRY_INTERVAL)
def get_client(self):
"""
获取Redis客户端,如果连接断开则自动重连(带重试机制)
:return: Redis客户端实例
"""
if self._client is None:
# 首次连接,使用无限重试直到成功
logger.info("初始化Redis连接...")
self._client = self._connect_with_retry(max_retries=None)
else:
# 检查连接是否有效,如果断开则重新连接(带重试)
try:
self._client.ping()
except (redis.ConnectionError, redis.TimeoutError, AttributeError) as e:
# 连接断开,重新创建连接(使用无限重试直到成功)
logger.warning(f"Redis连接已断开: {str(e)},开始重新连接...")
self._client = None
self._client = self._connect_with_retry(max_retries=None)
return self._client
def redisClient():
"""获取Redis客户端单例"""
return RedisClientSingleton().get_client()
@@ -0,0 +1,193 @@
import os,time,sys,argparse
import numpy as np
import json
import warnings
def findBpyModule():
# /Applications/Blender.app/Contents/Resources/4.4/python/bin
blender_bin_path = '/Applications/Blender.app/Contents/Resources/4.4/python/bin/blender'
return blender_bin_path
def custom_mesh_transform(vertices, transform_matrix):
"""
手动实现网格变换:对每个顶点应用齐次变换矩阵
参数:
vertices: 网格顶点数组 (N, 3)
transform_matrix: 4x4 齐次变换矩阵
返回:
变换后的顶点数组 (N, 3)
"""
# 验证变换矩阵
transform_matrix = np.array(transform_matrix, dtype=np.float64)
# 检查矩阵形状
if transform_matrix.shape != (4, 4):
raise ValueError(f"变换矩阵必须是 4x4 矩阵,当前形状: {transform_matrix.shape}")
# 检查矩阵中是否包含无效值(NaN 或 Inf)
if np.any(np.isnan(transform_matrix)) or np.any(np.isinf(transform_matrix)):
invalid_count = np.sum(np.isnan(transform_matrix)) + np.sum(np.isinf(transform_matrix))
raise ValueError(f"变换矩阵包含无效值 (NaN/Inf): {invalid_count} 个无效元素\n矩阵:\n{transform_matrix}")
# 检查矩阵行列式(对于齐次变换矩阵,最后一行应该是 [0, 0, 0, 1])
# 如果矩阵接近奇异,行列式会接近0
det = np.linalg.det(transform_matrix[:3, :3]) # 只检查3x3旋转/缩放部分
if abs(det) < 1e-10:
print(f"警告: 变换矩阵的3x3部分行列式接近0 (det={det:.2e}),可能导致数值不稳定")
# 检查最后一行是否为 [0, 0, 0, 1](齐次变换矩阵的标准形式)
last_row = transform_matrix[3, :]
if not np.allclose(last_row, [0, 0, 0, 1], atol=1e-6):
print(f"警告: 变换矩阵最后一行不是标准形式 [0,0,0,1],而是: {last_row}")
# 1. 顶点转齐次坐标 (N, 3) → (N, 4)
homogeneous_vertices = np.hstack((vertices, np.ones((vertices.shape[0], 1))))
# 2. 应用变换矩阵:矩阵乘法 (4x4) * (4xN) → (4xN)
# 捕获警告并检查结果
with warnings.catch_warnings(record=True) as w:
warnings.simplefilter("always") # 捕获所有警告
transformed_homogeneous = transform_matrix @ homogeneous_vertices.T
# 如果有警告,记录详细信息
if w:
warning_msgs = [str(warning.message) for warning in w]
print(f"警告: 矩阵乘法过程中出现 {len(w)} 个警告:")
for msg in warning_msgs:
print(f" - {msg}")
print(f"变换矩阵:\n{transform_matrix}")
print(f"矩阵包含 NaN: {np.any(np.isnan(transform_matrix))}")
print(f"矩阵包含 Inf: {np.any(np.isinf(transform_matrix))}")
if not np.any(np.isnan(transform_matrix)) and not np.any(np.isinf(transform_matrix)):
print(f"矩阵最大值: {np.nanmax(transform_matrix):.6e}")
print(f"矩阵最小值: {np.nanmin(transform_matrix):.6e}")
# 检查结果是否包含无效值
if np.any(np.isnan(transformed_homogeneous)) or np.any(np.isinf(transformed_homogeneous)):
invalid_count = np.sum(np.isnan(transformed_homogeneous)) + np.sum(np.isinf(transformed_homogeneous))
raise ValueError(f"变换后的顶点包含无效值 (NaN/Inf): {invalid_count} 个无效元素")
# 3. 转回非齐次坐标 (3xN) → (N, 3)
transformed_vertices = transformed_homogeneous[:3, :].T
return transformed_vertices
# {
# "file_name": "888687_324912_P117183_13.8cm_x1.obj",
# "transform": {
# "homo_matrix": [
# [-0.09437,0.995195,0.026116,77.975499],
# [-0.58016,-0.033658,-0.813807,322.60588],
# [-0.809017,-0.09195,0.580549,24.503686],
# [0.0,0.0,0.0,1.0]
# ]
# }
# },
def transform_save(obj_path,homo_matrix):
import bpy
# 延迟导入 bpy,只在 Blender 环境中可用
# try:
# blender_bin_path = findBpyModule()
# subprocess.run([blender_bin_path, "--background", "--python-expr", "import bpy"])
# except ImportError:
# log("错误: bpy 模块不可用。此函数只能在 Blender 环境中运行。")
# log("请使用 Blender 的 Python 解释器运行此脚本,或通过 Blender 命令行运行。")
# raise ImportError("bpy 模块不可用。此函数只能在 Blender 环境中运行。")
print("开始转换数据")
obj_name = obj_path.split("/")[-1]
print(f"obj_name={obj_name}")
# 清除场景
bpy.ops.object.select_all(action='SELECT')
bpy.ops.object.delete(use_global=False)
## 尺寸调整,环境设置
bpy.ops.object.delete(use_global=False, confirm=False)
bpy.context.scene.unit_settings.length_unit = 'CENTIMETERS'
bpy.context.scene.unit_settings.scale_length = 0.001
bpy.context.scene.unit_settings.mass_unit = 'GRAMS'
reconstructed_matrix = np.array(homo_matrix, dtype=np.float64)
bpy.ops.wm.obj_import(filepath=obj_path)
imported_object = bpy.context.object
if imported_object is None or imported_object.type != 'MESH':
print(f"警告: 未能成功导入网格对象 {obj_name}。跳过。")
return
mesh_data = imported_object.data
mesh_data.calc_loop_triangles()
original_vertices = np.empty(len(mesh_data.vertices) * 3, dtype=np.float64)
mesh_data.vertices.foreach_get('co', original_vertices)
original_vertices = original_vertices.reshape(-1, 3)
try:
transformed_vertices = custom_mesh_transform(original_vertices, reconstructed_matrix)
# 验证转换后的顶点是否有效
if np.any(np.isnan(transformed_vertices)) or np.any(np.isinf(transformed_vertices)):
invalid_count = np.sum(np.isnan(transformed_vertices)) + np.sum(np.isinf(transformed_vertices))
error_msg = f"错误: 转换后的顶点包含无效值 (NaN/Inf): {invalid_count} 个无效元素, obj_name={obj_name}"
print(error_msg)
print(f"变换矩阵:\n{reconstructed_matrix}")
raise ValueError(error_msg)
print(f"转换成功: {len(transformed_vertices)} 个顶点")
print(f"顶点坐标范围: X[{transformed_vertices[:, 0].min():.2f}, {transformed_vertices[:, 0].max():.2f}], "
f"Y[{transformed_vertices[:, 1].min():.2f}, {transformed_vertices[:, 1].max():.2f}], "
f"Z[{transformed_vertices[:, 2].min():.2f}, {transformed_vertices[:, 2].max():.2f}]")
except Exception as e:
error_msg = f"转换顶点时发生错误, obj_name={obj_name}, error={str(e)}"
print(error_msg)
print(f"变换矩阵:\n{reconstructed_matrix}")
raise
offset = np.array([-380, -345, 0])
transformed_vertices += offset
print(f"已对模型 {obj_name} 应用偏移: {offset}")
flattened_verts = transformed_vertices.reshape(-1)
mesh_data.vertices.foreach_set('co', flattened_verts)
mesh_data.update()
# meshes.append(imported_object)
# 导出前确保选中当前对象
bpy.ops.object.select_all(action='DESELECT')
imported_object.select_set(True)
bpy.context.view_layer.objects.active = imported_object
bpy.ops.wm.obj_export(
filepath=obj_path,
export_selected_objects=True,
export_materials=True,
path_mode='AUTO'
)
#os.remove(old_mtl_path)
if __name__ == "__main__":
script_args = []
try:
separator_index = sys.argv.index("--")
script_args = sys.argv[separator_index + 1:]
except ValueError:
pass
parser = argparse.ArgumentParser()
parser.add_argument("--objPathName", type=str, required=True, help="obj文件路径")
parser.add_argument("--trans", type=str, required=True, help="旋转参数(JSON格式)")
args = parser.parse_args(script_args)
print(f"objPathName={args.objPathName}, trans={args.trans}")
# 解析 JSON 字符串为列表
try:
homo_matrix = json.loads(args.trans)
except json.JSONDecodeError as e:
print(f"错误: 无法解析 trans 参数为 JSON: {e}")
sys.exit(1)
print("进入转换方法")
transform_save(args.objPathName, homo_matrix)