"""Offline asset assembly and Spine 4.2 region export, driven by semantic v2 data.""" import argparse import hashlib import html import json from pathlib import Path import numpy as np from PIL import Image def write_json(file, data): Path(file).write_text(json.dumps(data, ensure_ascii=False, indent=2, allow_nan=False) + '\n', encoding='utf-8') def safe_file(root, name): if not isinstance(name, str) or not name or Path(name).is_absolute(): raise ValueError(f'Expected relative asset path: {name}') root = Path(root).resolve() file = (root / name).resolve() if not file.is_relative_to(root) or not file.is_file(): raise ValueError(f'Missing asset or path outside input directory: {name}') return file def ordered(nodes, edges): """Stable topological order; do not silently break contradictory constraints.""" pending = list(nodes) result = [] while pending: ready = next((n for n in pending if not any(b == n and a in pending for a, b in edges)), None) if ready is None: raise ValueError('Cyclic bone or draw-order constraints') pending.remove(ready) result.append(ready) return result def load_assets(input_dir, completion_dir=None, fallback_policy='sam', draw_order_file=None): root = Path(input_dir).resolve() hashes = {} def tracked(folder, name, label): file = safe_file(folder, name) hashes[f'{label}/{name}'] = hashlib.sha256(file.read_bytes()).hexdigest() return file def read(folder, name, label): return json.loads(tracked(folder, name, label).read_text(encoding='utf-8-sig')) source = Image.open(tracked(root, 'source.png', 'sam')).convert('RGBA') rgba = np.array(source) w, h = source.size plan = read(root, 'semantic-parts.json', 'sam') seg = read(root, 'segmentation.json', 'sam') parts = plan['parts'] by_id = {p['id']: p for p in parts} if len(by_id) != len(parts): raise ValueError('Duplicate semantic ID') def records(items, label): result = {} for item in items: name = item['id'] if name not in by_id or name in result: raise ValueError(f'{label}: unknown or duplicate ID: {name}') result[name] = item return result segmented = records(seg['parts'], 'segmentation') completed, skipped = {}, {} completion = Path(completion_dir).resolve() if completion_dir else None # A completion run snapshots its SAM inputs, so compare content, not directory names. if completion: for name in ['source.png', 'semantic-parts.json', 'segmentation.json']: tracked(completion, name, 'completion') if hashes[f'sam/{name}'] != hashes[f'completion/{name}']: raise ValueError(f'Completion input mismatch: {name}') result = read(completion, 'completion-result.json', 'completion') completed = records(result['parts'], 'completion') skipped = records(result.get('skipped', []), 'completion skipped') if set(completed) & set(skipped): raise ValueError('Completion ID occurs in both parts and skipped') images, original_images, asset_records = {}, {}, [] masks = {} for name, record in segmented.items(): if by_id[name]['visibility'] == 'hidden': raise ValueError(f'Hidden part unexpectedly has a SAM mask: {name}') mask_name = record.get('mask', f'masks/{name}.png') mask = np.array(Image.open(tracked(root, mask_name, 'sam'))) if mask.shape != (h, w) or not np.isin(mask, [0, 255]).all(): raise ValueError(f'Invalid full-canvas binary mask: {name}') mask = mask > 0 if not mask.any() or np.any(mask & (rgba[:, :, 3] == 0)): raise ValueError(f'Empty mask or mask outside source alpha: {name}') if completion: tracked(completion, mask_name, 'completion') if hashes[f'sam/{mask_name}'] != hashes[f'completion/{mask_name}']: raise ValueError(f'Completion mask mismatch: {name}') masks[name] = mask for part in parts: name = part['id'] if part['visibility'] == 'hidden': asset_records.append({'id': name, 'source': 'hidden', 'image': None, 'reason': 'HIDDEN_NO_TEXTURE', 'semantic_verified': False}) continue if name not in masks: raise ValueError(f'Visible part has no SAM mask: {name}') mask = masks[name] ys, xs = np.nonzero(mask) original_box = [int(xs.min()), int(ys.min()), int(xs.max()) + 1, int(ys.max()) + 1] cut = rgba.copy() cut[~mask] = 0 original = Image.fromarray(cut).crop(original_box) original_images[name] = (original, original_box) candidate = completed.get(name, {}) accepted = bool(candidate.get('selected_file')) and candidate.get('qa', {}).get('accepted') is True warnings = list(segmented[name].get('warnings', [])) if accepted: if candidate.get('status') != 'geometry_passed_needs_review': raise ValueError(f'Inconsistent selected completion status: {name}') image = Image.open(tracked(completion, candidate.get('image'), 'completion')).convert('RGBA') box = candidate.get('crop_xyxy') if (not isinstance(box, list) or len(box) != 4 or any(type(x) is not int for x in box) or not (0 <= box[0] < box[2] <= w and 0 <= box[1] < box[3] <= h) or image.size != (box[2] - box[0], box[3] - box[1])): raise ValueError(f'Invalid completion crop/size: {name}') full = Image.new('RGBA', source.size) full.paste(image, (box[0], box[1])) if not np.array_equal(np.array(full)[mask], rgba[mask]): raise ValueError(f'Completion does not preserve original visible RGBA: {name}') origin, reason = 'completion', 'GEOMETRY_PASSED_NEEDS_REVIEW' if candidate.get('qa', {}).get('unfulfilled_regions'): warnings.append('UNFULFILLED_COMPLETION_REGIONS') else: image, box = original, original_box origin = 'sam' if not part['needs_completion']: reason = 'NO_COMPLETION_NEEDED' else: reason = skipped.get(name, {}).get('reason') or ( 'COMPLETION_' + str(candidate.get('status', 'NOT_PROVIDED')).upper()) warnings.append('USING_UNCOMPLETED_SAM') if fallback_policy == 'error': raise ValueError(f'Completion required for {name}: {reason}') images[name] = (image, box) asset_records.append({'id': name, 'source': origin, 'reason': reason, 'image': f'images/{name}.png', 'crop_xyxy': box, 'original_crop_xyxy': original_box, 'semantic_verified': False, 'warnings': warnings, 'completion_status': candidate.get('status'), 'completion_qa': candidate.get('qa')}) if not images: raise ValueError('No drawable assets') edges = [(p['id'], front) for p in parts for front in p['occluded_by']] # Keep hidden nodes while sorting so transitive constraints survive their removal. draw_order = [n for n in ordered(by_id, edges) if n in images] overridden = False if draw_order_file: file = Path(draw_order_file).resolve() hashes['draw_order_override'] = hashlib.sha256(file.read_bytes()).hexdigest() override = json.loads(file.read_text(encoding='utf-8-sig')) draw_order = override['draw_order'] if not isinstance(draw_order, list) or len(draw_order) != len(images) or set(draw_order) != set(images): raise ValueError('draw_order must contain each drawable ID exactly once') overridden = True reach = {n: set() for n in by_id} for n in reversed(ordered(by_id, edges)): for a, b in edges: if a == n: reach[n].add(b) reach[n].update(reach[b]) unresolved = [] for i, a in enumerate(draw_order): for b in draw_order[i + 1:]: if b in reach[a] or a in reach[b]: continue ai, ab = images[a] bi, bb = images[b] x0, y0, x1, y1 = max(ab[0], bb[0]), max(ab[1], bb[1]), min(ab[2], bb[2]), min(ab[3], bb[3]) if x0 < x1 and y0 < y1: am = np.array(ai.getchannel('A'))[y0-ab[1]:y1-ab[1], x0-ab[0]:x1-ab[0]] > 0 bm = np.array(bi.getchannel('A'))[y0-bb[1]:y1-bb[1], x0-bb[0]:x1-bb[0]] > 0 overlap = int((am & bm).sum()) if overlap: unresolved.append({'parts': [a, b], 'overlap_pixels': overlap}) violated = [[a, b] for a in draw_order for b in reach[a] if b in images and draw_order.index(a) > draw_order.index(b)] assembly = {'schema_version': 1, 'quality_status': 'needs_review', 'semantic_verified': False, 'width': w, 'height': h, 'coordinate_space': 'pixel_xy_top_left', 'parts': asset_records, 'draw_order': draw_order, 'draw_order_source': 'override' if overridden else 'occlusion_stable_topological', 'unconstrained_overlaps': unresolved, 'overridden_occlusion_constraints': violated, 'input_hashes': hashes} return source, plan, assembly, images, original_images def build_skeleton(plan, assembly, check_angle=10): w, h = assembly['width'], assembly['height'] bone_parts = {p['id']: p for p in plan['parts'] if p['rig_role'] == 'bone_part'} edges = [(p['parent'], name) for name, p in bone_parts.items() if p['parent']] bone_order = ordered(bone_parts, edges) world = {'root': (0, 0)} bones = [{'name': 'root'}] for name in bone_order: part = bone_parts[name] world[name] = (part['pivot_norm'][0] * w - w / 2, h - part['pivot_norm'][1] * h) parent = part['parent'] or 'root' x, y = world[name] px, py = world[parent] # Setup axes remain aligned with the canvas. Parent rotation still propagates # to children; attachment offsets below preserve the exact original pose. bones.append({'name': name, 'parent': parent, 'x': x - px, 'y': y - py}) semantic = {p['id']: p for p in plan['parts']} assets = {p['id']: p for p in assembly['parts']} slots, attachments = [], {} for name in assembly['draw_order']: part, asset = semantic[name], assets[name] bone = name if part['rig_role'] == 'bone_part' else part['bind_to'] or 'root' bx, by = world[bone] x0, y0, x1, y1 = asset['crop_xyxy'] slots.append({'name': name, 'bone': bone, 'attachment': name}) attachments[name] = {name: {'type': 'region', 'path': name, 'x': (x0 + x1) / 2 - w / 2 - bx, 'y': h - (y0 + y1) / 2 - by, 'width': x1 - x0, 'height': y1 - y0}} animations = {'setup': {}} for name in bone_order: animations[f'check__{name}'] = {'bones': {name: {'rotate': [ {'time': 0, 'value': 0}, {'time': .5, 'value': check_angle}, {'time': 1, 'value': 0}, {'time': 1.5, 'value': -check_angle}, {'time': 2, 'value': 0}]}}} return {'skeleton': {'spine': '4.2.0', 'x': -w / 2, 'y': 0, 'width': w, 'height': h, 'images': './images/'}, 'bones': bones, 'slots': slots, 'skins': [{'name': 'default', 'attachments': attachments}], 'animations': animations} def compose(size, images, order): canvas = Image.new('RGBA', size) coverage = np.zeros((size[1], size[0]), np.uint16) for name in order: image, (x0, y0, x1, y1) = images[name] canvas.alpha_composite(image, (x0, y0)) coverage[y0:y1, x0:x1] += np.array(image.getchannel('A')) > 0 return canvas, coverage def export_assets(data, output, check_angle=10): source, plan, assembly, images, original_images = data out = Path(output) (out / 'images').mkdir(parents=True, exist_ok=True) source.save(out / 'source.png') write_json(out / 'semantic-parts.json', plan) for name, (im, _) in images.items(): im.save(out / 'images' / f'{name}.png') assembled, coverage = compose(source.size, images, assembly['draw_order']) before, _ = compose(source.size, original_images, assembly['draw_order']) assembled.save(out / 'assembled.png') before.save(out / 'assembled-sam.png') foreground = np.array(source.getchannel('A')) > 0 missing = foreground & (coverage == 0) diagnostic = np.zeros((source.height, source.width, 4), np.uint8) diagnostic[coverage > 1] = [255, 180, 0, 220] diagnostic[missing] = [255, 0, 80, 255] Image.fromarray(diagnostic).save(out / 'coverage-diagnostic.png') comparison = Image.new('RGBA', (source.width * 3, source.height), 'white') for i, im in enumerate([source, before, assembled]): comparison.alpha_composite(im, (i * source.width, 0)) comparison.convert('RGB').save(out / 'comparison.png') assembly['diagnostics'] = {'uncovered_source_pixels': int(missing.sum()), 'overlap_pixels': int((coverage > 1).sum()), 'rotation_quality_verified': False} write_json(out / 'assembly.json', assembly) skeleton = build_skeleton(plan, assembly, check_angle) write_json(out / 'skeleton.json', skeleton) rows = [] for part in assembly['parts']: picture = f'' if part['image'] else '' rows.append('' + html.escape(part['id']) + '' + html.escape(part['source']) + '' + html.escape(part['reason']) + '' + picture + '') notes = {key: assembly[key] for key in ['diagnostics', 'draw_order_source', 'unconstrained_overlaps', 'overridden_occlusion_constraints']} (out / 'report.html').write_text(''' 第四阶段:Spine 组装与导出

第四阶段:Spine 组装与导出

状态:needs_review。几何通过与成功导出不代表语义、接缝或动作质量合格。

Spine 预览(播放器从 CDN 加载,需要网络);选择 setup 查看静态姿态,选择 check__骨骼ID 检查单关节旋转及子骨骼跟随。检查动画不代表成品动作。

骨架 JSON · 来源与诊断

原图 / SAM 组装 / 最终组装

覆盖诊断:红色为原图漏分,黄色为部件重叠

重叠可能是正常遮挡;覆盖率不能判断语义。没有遮挡约束的重叠使用语义计划中的稳定顺序,需复核。

''' + html.escape(json.dumps(notes, ensure_ascii=False, indent=2)) + '''

资产来源

''' + ''.join(rows) + '
ID来源原因部件
', encoding='utf-8') return {'parts': len(images), 'bones': len(skeleton['bones']), 'completion_parts': sum(p['source'] == 'completion' for p in assembly['parts']), 'fallback_parts': sum('USING_UNCOMPLETED_SAM' in p.get('warnings', []) for p in assembly['parts']), 'quality_status': 'needs_review', 'input_hashes': assembly['input_hashes']} def main(): parser = argparse.ArgumentParser(description=__doc__) parser.add_argument('--input-dir', required=True) parser.add_argument('--completion-dir') parser.add_argument('--output') parser.add_argument('--draw-order-file') parser.add_argument('--fallback-policy', choices=['sam', 'error'], default='sam') parser.add_argument('--check-angle', type=float, default=10) args = parser.parse_args() if not 0 < args.check_angle <= 45: parser.error('check-angle must be in (0, 45]') data = load_assets(args.input_dir, args.completion_dir, args.fallback_policy, args.draw_order_file) if args.output: result = export_assets(data, args.output, args.check_angle) else: skeleton = build_skeleton(data[1], data[2], args.check_angle) result = {'input_hashes': data[2]['input_hashes'], 'parts': len(data[3]), 'bones': len(skeleton['bones'])} print(json.dumps(result, ensure_ascii=False)) if __name__ == '__main__': main()