童话边境&迷雾列车少女spine合并的批处理解决

添加了童话边境合并的适配(没有仔细检查)。

不过好像童话边境预乘不一样还要改纹理。

有一些atlas的部件名重复只会显示其中一个,因为有码身体和无码身体冲突了,删除有码身体的atlas部件信息就行。

参考:

ww-rm/SpineViewer: 一个简单好用的 spine 文件查看&导出工具。A simple and easy-to-use spine file viewer and exporter.

AssetsProcessor/jczx at main · enjoy7ech/AssetsProcessor

先用SpineViewer转换skel为json。这边使用0.12.15版本的,因为0.15版本没找到转换工具,只支持3.8.99。

完成后将每个要合并的json和atlas分组放在同一文件夹下的子文件夹

spine_combine_3.6.53_and_3.8.99.exe

-s [folder_path] 顺序读取(无数字文件在前,有数字按数值排序)
-r [folder_path] 逆序读取(反转-s的排序结果)
[file1.json] [file2.json] … [–output=目标文件夹] 合并指定文件
或使用 -o=目标文件夹 指定输出目录

在spine_combine_3.6.53_and_3.8.99.exe目录下打开命令行输入 ./spine_combine_3.6.53_and_3.8.99.exe -s [folder_path] 即可开始处理(合并后会删除合并前的所有json和atlas),不过可能有些情况下会删除合并后的json,目前不清楚原因,建议先备份。

不过仅适用于部分3.8.99及3.6.53版本的spine,包括SpineViewer转换也仅适用3.8.99。

chibi-gif 0.1.32.2-sp38这个网站好像可以将3.3-3.6转成3.8

如果有spine pro或许可以用 spine-cli 转换为3.8.99?不过有点舍近求远了。

3 个赞

其实4.1和4.2的格式转换大概也是能转换的,但是还没经过大范围测试,不知道有没有bug,另外从0.15.x开始的新版本暂时去掉转换工具了,因为没时间补充(

再就是之前有一个fork做了相关的跨版本转换工作,有相关需要也可以参考一下
4.1,4.2到3.8与3.8到4.1,4.2的版本转换。 by steve14608 · Pull Request #22 · ww-rm/SpineViewer

可以发两个json让我看看吗?

发两个合并失败的我尝试研究下

嗯,格式好像确实不对,但是奈何我只是个代码转换的,原工具转换也不行,目前没什么办法。

添加了对童话边境合并的支持。
查看了官方json格式文档,只能说附件太多了。
合并的时候顺手将3.6.53升级到3.8.99,学习版导入正常。
(才不是平行转换3.6.53会出现小问题什么的才这样做)

童话边境有几个会有警告但是可以正常打开,没看出有什么问题。。。

我分析了你提供的json,出错原因是path的骨骼索引判断有问题,现在修复了,你给的错误合并文件可以正常合并了。

py写的很简陋基本上遇到什么才会加或者修改什么。

顺便好奇问下是哪个游戏?

提供一个自动识别非预乘转预乘的py吧。

非预乘转预乘
import cv2
import os
import numpy as np
import sys
import codecs
from tqdm import tqdm

def is_premultiplied(image):
    """判断图片是否为预乘Alpha格式"""
    # 确保图片有Alpha通道
    if image.shape[2] != 4:
        return False, "没有Alpha通道"
    
    # 分离通道
    b, g, r, a = cv2.split(image)
    
    # 归一化到0~1范围
    b = b / 255.0
    g = g / 255.0
    r = r / 255.0
    a = a / 255.0
    
    threshold = 0.01
    
    # 检查完全透明像素(Alpha=0)
    alpha_zero_mask = (a < threshold)
    if np.any(alpha_zero_mask):
        # 完全透明像素的RGB应全为0(预乘特性)
        b_zero = b[alpha_zero_mask]
        g_zero = g[alpha_zero_mask]
        r_zero = r[alpha_zero_mask]
        
        if np.any(b_zero > threshold) or np.any(g_zero > threshold) or np.any(r_zero > threshold):
            return False, "完全透明像素的RGB值不为0"
    
    # 检查半透明像素(0 < Alpha < 1)
    alpha_semi_mask = (a > threshold) & (a < 1 - threshold)
    if np.any(alpha_semi_mask):
        b_semi = b[alpha_semi_mask]
        g_semi = g[alpha_semi_mask]
        r_semi = r[alpha_semi_mask]
        a_semi = a[alpha_semi_mask]
        
        # 预乘的RGB应 <= Alpha
        invalid_pixels = (b_semi > a_semi + threshold) | \
                         (g_semi > a_semi + threshold) | \
                         (r_semi > a_semi + threshold)
        invalid_ratio = np.mean(invalid_pixels)
        
        if invalid_ratio > 0.1:  # 超过10%的半透明像素不符合预乘特性
            return False, f"半透明像素中{invalid_ratio*100:.1f}%的RGB值大于Alpha"
    
    return True, "是预乘格式"

def convert_to_premultiplied(image):
    """将非预乘图片转换为预乘格式"""
    # 分离通道
    b, g, r, a = cv2.split(image)
    
    # 转换为浮点数进行计算
    b_float = b.astype(np.float32) / 255.0
    g_float = g.astype(np.float32) / 255.0
    r_float = r.astype(np.float32) / 255.0
    a_float = a.astype(np.float32) / 255.0
    
    # 应用预乘转换:RGB = RGB × Alpha
    b_premul = (b_float * a_float * 255).astype(np.uint8)
    g_premul = (g_float * a_float * 255).astype(np.uint8)
    r_premul = (r_float * a_float * 255).astype(np.uint8)
    
    # 合并通道
    return cv2.merge([b_premul, g_premul, r_premul, a])

def read_image_with_chinese(path):
    """读取包含中文路径的图片,支持Alpha通道"""
    try:
        # 使用OpenCV的imdecode读取图片,支持中文路径
        # 先读取文件二进制数据
        with open(path, 'rb') as f:
            data = f.read()
        
        # 将二进制数据转换为numpy数组
        np_arr = np.frombuffer(data, np.uint8)
        
        # 解码为图像,包含Alpha通道
        image = cv2.imdecode(np_arr, cv2.IMREAD_UNCHANGED)
        return image
    except Exception as e:
        print(f"读取图片 {path} 失败: {str(e)}")
        return None

def write_image_with_chinese(path, image, params=None):
    """保存图片到包含中文的路径"""
    try:
        # 默认参数
        if params is None:
            params = [cv2.IMWRITE_PNG_COMPRESSION, 3]
        
        # 使用imencode处理中文路径
        ret, buf = cv2.imencode(os.path.splitext(path)[1], image, params)
        if ret:
            with open(path, 'wb') as f:
                f.write(buf)
            return True
        return False
    except Exception as e:
        print(f"保存图片 {path} 失败: {str(e)}")
        return False

def process_image(file_path):
    """处理单张图片:如果是非预乘则转换为预乘并覆盖"""
    try:
        # 读取图片,包含Alpha通道(支持中文路径)
        image = read_image_with_chinese(file_path)
        
        if image is None:
            return False, f"无法读取图片: {file_path}"
        
        # 检查是否为RGBA格式
        if image.shape[2] != 4:
            return True, f"跳过非RGBA图片: {file_path}"
        
        # 判断是否为预乘格式
        is_premul, reason = is_premultiplied(image)
        
        if not is_premul:
            # 转换为预乘格式
            premul_image = convert_to_premultiplied(image)
            
            # 保存图片,覆盖原文件(支持中文路径)
            result = write_image_with_chinese(file_path, premul_image)
            
            if result:
                return True, f"成功转换并覆盖: {file_path}"
            else:
                return False, f"保存图片失败: {file_path}"
        else:
            return True, f"已为预乘格式,跳过: {file_path}"
            
    except Exception as e:
        return False, f"处理图片时出错 {file_path}: {str(e)}"

def get_all_png_files(path):
    """获取路径下所有PNG文件列表"""
    png_files = []
    if os.path.isfile(path):
        if path.lower().endswith('.png'):
            png_files.append(path)
    elif os.path.isdir(path):
        for root, dirs, files in os.walk(path):
            for file in files:
                if file.lower().endswith('.png'):
                    png_files.append(os.path.join(root, file))
    return png_files

def process_directory(path):
    """递归处理目录中的所有PNG图片,带进度条"""
    # 获取所有PNG文件
    png_files = get_all_png_files(path)
    
    if not png_files:
        print(f"没有找到PNG文件: {path}")
        return
    
    if os.path.isdir(path):
        print(f"开始处理目录: {path},共{len(png_files)}个PNG文件")
    else:
        print(f"开始处理文件: {path}")
    
    # 使用tqdm显示进度条
    for file_path in tqdm(png_files, desc="处理进度", unit="个"):
        success, message = process_image(file_path)
        # 可以取消下面这行注释来显示每个文件的处理结果
        # print(message)

def remove_quotes(s):
    """去除字符串两端的单引号或双引号"""
    if len(s) >= 2:
        if (s[0] == '"' and s[-1] == '"') or (s[0] == "'" and s[-1] == "'"):
            return s[1:-1]
    return s

if __name__ == "__main__":
    # 确保标准输出支持中文
    sys.stdout = codecs.getwriter("utf-8")(sys.stdout.detach())
    
    # 检查命令行参数
    if len(sys.argv) > 1:
        input_path = sys.argv[1]
    else:
        # 从控制台获取输入路径
        input_path = input("请输入文件或文件夹路径: ").strip()
    
    # 去除路径两端可能存在的引号
    input_path = remove_quotes(input_path)
    
    # 处理输入路径
    process_directory(input_path)
    input("处理完成,按回车键退出...")
    

水一贴(
除3.6.53及3.8.99,3.8.85外,又测了几个4.X以下的部分:
3.8.72 可合成
3.8.80 可合成
3.8.97 可合成,部分结果异常,比如某部位拉伸,扭曲啥的。

顺便搬个工具,可实现3.6.53 to 3.8.80的转变(拖拽文件夹即可):
SkelConverter.zip (401.5 KB)

另外,ww大佬给的链接中steve14608大佬实现了4.1,4.0版本到3.8与3.8到4.1,4.0版本的转换,但我在编译了其发布的项目后并不能实现转换:pensive:,蹲个大佬接手 :slightly_smiling_face:

水;异常大概率是用的类或键值是我处理时没遇到就没写处理的,如果能提供异常的原文件,我没事的时候可能会修一下。

应该是animations的问题吧,太多了到时候看看吧。有报错还好找,没报错就难找了。

顺带一提py打包最好还是用pyinstaller你这打包程序不知道是库的原因还是什么我下下来立马爆红 vm检测和句柄复制 中标行为异常…

pyinstaller打包出来只能放盘里了。
spine_combine_3.6.53_and_3.8.99.exe

那个转换他实现的时候没接到UI界面上来,可能要用代码去调用,得自己写两行代码才行 :melting_face: