File size: 38,921 Bytes
5c1bb37
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
#!/usr/bin/env python3
"""
Blender ERP全景图渲染脚本

此脚本在Blender内部执行,用于渲染ERP(等距圆柱投影)全景图。

使用方法:
    blender --background --python render_erp_blender.py -- \
        --mesh "path/to/mesh.glb" \
        --output "output/panorama_0000.png" \
        --camera-pos "0.0,0.5,0.0" \
        --camera-rot "0.0,0.0,0.0" \
        --resolution "1024,512"
"""

import bpy
import sys
import os
import json
import math
import argparse
from mathutils import Vector, Euler, Quaternion


def parse_args():
    """解析命令行参数(Blender的--之后的参数)"""
    # 找到'--'之后的参数
    argv = sys.argv
    if "--" in argv:
        argv = argv[argv.index("--") + 1:]
    else:
        argv = []
    
    parser = argparse.ArgumentParser(description="Blender ERP渲染脚本")
    parser.add_argument("--mesh", type=str, required=True,
                        help="输入mesh文件路径(GLB/GLTF/OBJ)")
    parser.add_argument("--output", type=str, required=True,
                        help="输出图像路径")
    parser.add_argument("--pose-output", type=str, default=None,
                        help="输出位姿JSON路径(默认与图像同名)")
    parser.add_argument("--camera-pos", type=str, default="0.0,0.0,0.0",
                        help="相机位置 x,y,z")
    parser.add_argument("--camera-rot", type=str, default="0.0,0.0,0.0",
                        help="相机旋转 roll,pitch,yaw(弧度)")
    parser.add_argument("--camera-rot-quat", type=str, default=None,
                        help="(已废弃)相机旋转四元数 w,x,y,z。现在主路径使用 --camera-rot Euler角")
    parser.add_argument("--resolution", type=str, default="1024,512",
                        help="渲染分辨率 width,height")
    parser.add_argument("--samples", type=int, default=16,
                        help="渲染采样数(默认16,Emission材质不需要高采样,可大幅提升渲染速度)")
    parser.add_argument("--engine", type=str, default="CYCLES",
                        choices=["BLENDER_EEVEE", "CYCLES"],
                        help="渲染引擎(全景图必须使用CYCLES)")
    parser.add_argument("--frame-id", type=int, default=0,
                        help="帧序号")
    parser.add_argument("--ref-position", type=str, default=None,
                        help="参考帧位置 x,y,z(Y-up坐标系),None表示第一帧")
    parser.add_argument("--ref-quaternion", type=str, default=None,
                        help="参考帧四元数 w,x,y,z,None表示第一帧")
    parser.add_argument("--render-depth", action="store_true",
                        help="是否渲染深度图(保存为.npy格式)")
    parser.add_argument("--depth-output", type=str, default=None,
                        help="深度图输出路径(默认与图像同目录,后缀_depth.npy)")
    
    return parser.parse_args(argv)


def clear_scene():
    """清空当前场景"""
    # 选择所有对象
    bpy.ops.object.select_all(action='SELECT')
    # 删除选中的对象
    bpy.ops.object.delete(use_global=False)
    
    # 清理孤立数据
    for block in bpy.data.meshes:
        if block.users == 0:
            bpy.data.meshes.remove(block)
    for block in bpy.data.materials:
        if block.users == 0:
            bpy.data.materials.remove(block)
    for block in bpy.data.textures:
        if block.users == 0:
            bpy.data.textures.remove(block)
    for block in bpy.data.images:
        if block.users == 0:
            bpy.data.images.remove(block)


def import_mesh(mesh_path):
    """导入mesh文件"""
    ext = os.path.splitext(mesh_path)[1].lower()
    
    if ext in ['.glb', '.gltf']:
        bpy.ops.import_scene.gltf(filepath=mesh_path)
    elif ext == '.obj':
        bpy.ops.wm.obj_import(filepath=mesh_path)
    elif ext == '.fbx':
        bpy.ops.import_scene.fbx(filepath=mesh_path)
    elif ext == '.ply':
        bpy.ops.wm.ply_import(filepath=mesh_path)
    else:
        raise ValueError(f"不支持的文件格式: {ext}")
    
    print(f"[INFO] 导入mesh: {mesh_path}")
    
    # 获取导入的对象
    imported_objects = [obj for obj in bpy.context.selected_objects]
    print(f"[INFO] 导入了 {len(imported_objects)} 个对象")
    
    # 为房间结构添加程序化纹理
    apply_procedural_textures(imported_objects)
    
    return imported_objects


def is_room_structure(obj_name):
    """
    判断对象是否是房间结构(墙面、地板、天花板)
    
    房间结构的常见命名模式:
    1. None.obj - 标准3D-Front房间结构
    2. geometry_N - 无纹理的通用几何体
    3. 纯数字.obj (如 12670.obj) - 数字ID命名的结构
    """
    name_lower = obj_name.lower()
    
    # 模式1: 包含 "none"
    if 'none' in name_lower:
        return True
    
    # 模式2: 以 "geometry_" 开头
    if name_lower.startswith('geometry_') or name_lower.startswith('geometry.'):
        return True
    
    # 模式3: 纯数字命名 (如 "12670.obj", "7319.obj")
    base_name = obj_name.replace('.obj', '').replace('.OBJ', '')
    if base_name.isdigit():
        return True
    
    return False


def apply_procedural_textures(objects):
    """为所有对象添加Emission材质(材质预览模式:显示原始颜色,不受光照影响)"""
    applied_count = 0
    for obj in objects:
        if obj.type != 'MESH':
            continue
        
        # 检查对象是否有材质,或者材质是否为空
        has_material = obj.data.materials and len(obj.data.materials) > 0 and obj.data.materials[0] is not None
        
        # 为所有对象应用Emission材质(材质预览模式)
        # 这样所有对象都会显示原始颜色,不受光照影响
        if not has_material or is_room_structure(obj.name):
            # 没有材质或者是房间结构:应用Emission材质
            print(f"[INFO] 为对象添加Emission材质: {obj.name}")
            apply_room_material(obj)
            applied_count += 1
        else:
            # 有材质:也转换为Emission材质(确保所有对象都使用Emission模式)
            print(f"[INFO] 将对象材质转换为Emission: {obj.name}")
            convert_to_emission_material(obj)
            applied_count += 1
    
    if applied_count == 0:
        print("[WARN] 未找到需要添加材质的对象")
    else:
        print(f"[INFO] 共为 {applied_count} 个对象添加了Emission材质(材质预览模式)")


def apply_room_material(obj):
    """为房间结构应用程序化材质(墙面、地板、天花板)"""
    # 创建新材质
    mat = bpy.data.materials.new(name="RoomProceduralMaterial")
    mat.use_nodes = True
    
    nodes = mat.node_tree.nodes
    links = mat.node_tree.links
    
    # 清除默认节点
    nodes.clear()
    
    # 创建输出节点
    output = nodes.new('ShaderNodeOutputMaterial')
    output.location = (800, 0)
    
    # 使用几何节点获取法线
    geometry = nodes.new('ShaderNodeNewGeometry')
    geometry.location = (-600, 0)
    
    # 分离法线的Z分量
    separate_xyz = nodes.new('ShaderNodeSeparateXYZ')
    separate_xyz.location = (-400, 0)
    links.new(geometry.outputs['Normal'], separate_xyz.inputs['Vector'])
    
    # === 判断天花板(法线Z < -0.5,朝下的面) ===
    ceiling_check = nodes.new('ShaderNodeMath')
    ceiling_check.operation = 'LESS_THAN'
    ceiling_check.inputs[1].default_value = -0.5
    ceiling_check.location = (-200, 100)
    links.new(separate_xyz.outputs['Z'], ceiling_check.inputs[0])
    
    # === 判断地板(法线Z > 0.5,朝上的面) ===
    floor_check = nodes.new('ShaderNodeMath')
    floor_check.operation = 'GREATER_THAN'
    floor_check.inputs[1].default_value = 0.5
    floor_check.location = (-200, -100)
    links.new(separate_xyz.outputs['Z'], floor_check.inputs[0])
    
    # === 创建三种材质 ===
    floor_shader = create_wood_floor_material(nodes, links)
    floor_shader.location = (0, 300)
    
    wall_shader = create_brick_wall_material(nodes, links)  # 砖墙材质
    wall_shader.location = (0, 0)
    
    ceiling_shader = create_grid_ceiling_material(nodes, links)  # 网格天花板
    ceiling_shader.location = (0, -300)
    
    # === 混合着色器:先混合地板和墙面 ===
    mix_floor_wall = nodes.new('ShaderNodeMixShader')
    mix_floor_wall.location = (300, 100)
    links.new(floor_check.outputs['Value'], mix_floor_wall.inputs['Fac'])
    links.new(wall_shader.outputs['Emission'], mix_floor_wall.inputs[1])  # Emission材质输出
    links.new(floor_shader.outputs['Emission'], mix_floor_wall.inputs[2])  # Emission材质输出
    
    # === 混合着色器:再混合天花板 ===
    mix_final = nodes.new('ShaderNodeMixShader')
    mix_final.location = (500, 0)
    links.new(ceiling_check.outputs['Value'], mix_final.inputs['Fac'])
    links.new(mix_floor_wall.outputs['Shader'], mix_final.inputs[1])
    links.new(ceiling_shader.outputs['Emission'], mix_final.inputs[2])  # Emission材质输出
    
    # 连接输出
    links.new(mix_final.outputs['Shader'], output.inputs['Surface'])
    
    # 应用材质到对象
    if obj.data.materials:
        obj.data.materials[0] = mat
    else:
        obj.data.materials.append(mat)
    
    print(f"[INFO] Emission材质已应用(地板+砖墙+网格天花板,材质预览模式)")


def convert_to_emission_material(obj):
    """将现有材质转换为Emission材质(材质预览模式)"""
    if not obj.data.materials or len(obj.data.materials) == 0:
        # 如果没有材质,直接应用房间材质
        apply_room_material(obj)
        return
    
    # 获取现有材质
    existing_mat = obj.data.materials[0]
    if existing_mat is None:
        apply_room_material(obj)
        return
    
    # 如果材质已经有节点,尝试提取Base Color并转换为Emission
    if existing_mat.use_nodes:
        nodes = existing_mat.node_tree.nodes
        links = existing_mat.node_tree.links
        
        # 查找Principled BSDF节点
        bsdf_node = None
        for node in nodes:
            if node.type == 'BSDF_PRINCIPLED':
                bsdf_node = node
                break
        
        if bsdf_node and 'Base Color' in bsdf_node.inputs:
            # 找到Base Color输入
            base_color_input = bsdf_node.inputs['Base Color']
            
            # 创建Emission节点
            emission = nodes.new('ShaderNodeEmission')
            emission.name = "Emission"
            emission.location = bsdf_node.location
            
            # 获取Base Color的值或连接
            if base_color_input.is_linked:
                # 如果有连接,连接到Emission
                color_source = base_color_input.links[0].from_node
                color_output = base_color_input.links[0].from_socket
                links.new(color_output, emission.inputs['Color'])
            else:
                # 如果没有连接,使用默认值
                emission.inputs['Color'].default_value = base_color_input.default_value
            
            emission.inputs['Strength'].default_value = 1.0  # Emission强度(材质预览模式,避免过曝)
            
            # 找到输出节点并连接
            output_node = None
            for node in nodes:
                if node.type == 'OUTPUT_MATERIAL':
                    output_node = node
                    break
            
            if output_node:
                # 断开原有连接
                if output_node.inputs['Surface'].is_linked:
                    for link in output_node.inputs['Surface'].links:
                        existing_mat.node_tree.links.remove(link)
                # 连接Emission
                links.new(emission.outputs['Emission'], output_node.inputs['Surface'])
                print(f"[INFO] 已将材质转换为Emission: {obj.name}")
                return
    
    # 如果无法转换,直接应用房间材质
    apply_room_material(obj)


def create_wood_floor_material(nodes, links):
    """创建木地板程序化材质(Emission模式,显示原始颜色)"""
    # 使用Emission材质,直接发光,不受光照影响
    emission = nodes.new('ShaderNodeEmission')
    emission.name = "FloorEmission"
    
    # 木纹噪波纹理
    noise = nodes.new('ShaderNodeTexNoise')
    noise.inputs['Scale'].default_value = 20.0
    noise.inputs['Detail'].default_value = 8.0
    noise.inputs['Roughness'].default_value = 0.6
    noise.location = (-600, 200)
    
    # 波浪纹理(模拟木纹条纹)
    wave = nodes.new('ShaderNodeTexWave')
    wave.wave_type = 'BANDS'
    wave.bands_direction = 'X'
    wave.inputs['Scale'].default_value = 3.0
    wave.inputs['Distortion'].default_value = 5.0
    wave.inputs['Detail'].default_value = 3.0
    wave.location = (-600, 0)
    
    # 颜色渐变(木材颜色)
    color_ramp = nodes.new('ShaderNodeValToRGB')
    color_ramp.color_ramp.elements[0].color = (0.15, 0.08, 0.04, 1.0)  # 深棕色
    color_ramp.color_ramp.elements[1].color = (0.35, 0.20, 0.10, 1.0)  # 浅棕色
    color_ramp.location = (-400, 100)
    
    # 混合噪波和波浪
    mix_rgb = nodes.new('ShaderNodeMix')
    mix_rgb.data_type = 'RGBA'
    mix_rgb.inputs['Factor'].default_value = 0.5
    mix_rgb.location = (-400, 0)
    
    links.new(noise.outputs['Fac'], mix_rgb.inputs['A'])
    links.new(wave.outputs['Fac'], mix_rgb.inputs['B'])
    links.new(mix_rgb.outputs['Result'], color_ramp.inputs['Fac'])
    
    # 连接到Emission材质(直接显示颜色,不受光照影响)
    links.new(color_ramp.outputs['Color'], emission.inputs['Color'])
    emission.inputs['Strength'].default_value = 1.0  # Emission强度(材质预览模式,避免过曝)
    
    return emission


def create_brick_wall_material(nodes, links):
    """创建砖墙程序化材质(Emission模式,显示原始颜色)"""
    # 使用Emission材质,直接发光,不受光照影响
    emission = nodes.new('ShaderNodeEmission')
    emission.name = "BrickWallEmission"
    
    # 使用纹理坐标
    tex_coord = nodes.new('ShaderNodeTexCoord')
    tex_coord.location = (-800, 0)
    
    # 缩放映射(控制砖块大小)
    mapping = nodes.new('ShaderNodeMapping')
    mapping.inputs['Scale'].default_value = (4.0, 8.0, 1.0)  # X方向砖块较宽
    mapping.location = (-600, 0)
    links.new(tex_coord.outputs['Generated'], mapping.inputs['Vector'])
    
    # 砖块纹理
    brick = nodes.new('ShaderNodeTexBrick')
    brick.inputs['Color1'].default_value = (0.6, 0.3, 0.2, 1.0)  # 砖红色
    brick.inputs['Color2'].default_value = (0.5, 0.25, 0.15, 1.0)  # 深砖红色
    brick.inputs['Mortar'].default_value = (0.85, 0.85, 0.8, 1.0)  # 灰白色砂浆
    brick.inputs['Scale'].default_value = 3.0
    brick.inputs['Mortar Size'].default_value = 0.02
    brick.inputs['Mortar Smooth'].default_value = 0.1
    brick.inputs['Bias'].default_value = 0.0
    brick.inputs['Brick Width'].default_value = 0.5
    brick.inputs['Row Height'].default_value = 0.25
    brick.location = (-400, 0)
    links.new(mapping.outputs['Vector'], brick.inputs['Vector'])
    
    # 添加细微噪波增加真实感
    noise = nodes.new('ShaderNodeTexNoise')
    noise.inputs['Scale'].default_value = 50.0
    noise.inputs['Detail'].default_value = 3.0
    noise.location = (-400, -200)
    links.new(mapping.outputs['Vector'], noise.inputs['Vector'])
    
    # 混合砖块颜色和噪波
    mix_color = nodes.new('ShaderNodeMix')
    mix_color.data_type = 'RGBA'
    mix_color.inputs['Factor'].default_value = 0.1
    mix_color.location = (-200, 0)
    links.new(brick.outputs['Color'], mix_color.inputs['A'])
    links.new(noise.outputs['Color'], mix_color.inputs['B'])
    
    # 连接到Emission材质(直接显示颜色,不受光照影响)
    links.new(mix_color.outputs['Result'], emission.inputs['Color'])
    emission.inputs['Strength'].default_value = 2.0  # Emission强度(材质预览模式,避免过曝)
    
    return emission


def create_grid_ceiling_material(nodes, links):
    """创建网格天花板程序化材质(Emission模式,显示原始颜色)"""
    # 使用Emission材质,直接发光,不受光照影响
    emission = nodes.new('ShaderNodeEmission')
    emission.name = "GridCeilingEmission"
    
    # 使用纹理坐标
    tex_coord = nodes.new('ShaderNodeTexCoord')
    tex_coord.location = (-800, -400)
    
    # 缩放映射
    mapping = nodes.new('ShaderNodeMapping')
    mapping.inputs['Scale'].default_value = (5.0, 5.0, 1.0)
    mapping.location = (-600, -400)
    links.new(tex_coord.outputs['Generated'], mapping.inputs['Vector'])
    
    # 分离XY坐标
    separate = nodes.new('ShaderNodeSeparateXYZ')
    separate.location = (-400, -400)
    links.new(mapping.outputs['Vector'], separate.inputs['Vector'])
    
    # X方向网格线(使用正弦波)
    math_sin_x = nodes.new('ShaderNodeMath')
    math_sin_x.operation = 'SINE'
    math_sin_x.location = (-200, -350)
    
    math_mul_x = nodes.new('ShaderNodeMath')
    math_mul_x.operation = 'MULTIPLY'
    math_mul_x.inputs[1].default_value = 6.28  # 2*PI
    math_mul_x.location = (-300, -350)
    links.new(separate.outputs['X'], math_mul_x.inputs[0])
    links.new(math_mul_x.outputs['Value'], math_sin_x.inputs[0])
    
    # Y方向网格线
    math_sin_y = nodes.new('ShaderNodeMath')
    math_sin_y.operation = 'SINE'
    math_sin_y.location = (-200, -500)
    
    math_mul_y = nodes.new('ShaderNodeMath')
    math_mul_y.operation = 'MULTIPLY'
    math_mul_y.inputs[1].default_value = 6.28
    math_mul_y.location = (-300, -500)
    links.new(separate.outputs['Y'], math_mul_y.inputs[0])
    links.new(math_mul_y.outputs['Value'], math_sin_y.inputs[0])
    
    # 取绝对值使线条清晰
    abs_x = nodes.new('ShaderNodeMath')
    abs_x.operation = 'ABSOLUTE'
    abs_x.location = (-100, -350)
    links.new(math_sin_x.outputs['Value'], abs_x.inputs[0])
    
    abs_y = nodes.new('ShaderNodeMath')
    abs_y.operation = 'ABSOLUTE'
    abs_y.location = (-100, -500)
    links.new(math_sin_y.outputs['Value'], abs_y.inputs[0])
    
    # 合并X和Y网格(取最小值形成网格交叉)
    math_min = nodes.new('ShaderNodeMath')
    math_min.operation = 'MINIMUM'
    math_min.location = (0, -425)
    links.new(abs_x.outputs['Value'], math_min.inputs[0])
    links.new(abs_y.outputs['Value'], math_min.inputs[1])
    
    # 颜色渐变:网格线深色,格子浅色
    color_ramp = nodes.new('ShaderNodeValToRGB')
    color_ramp.color_ramp.elements[0].color = (0.3, 0.3, 0.35, 1.0)  # 深灰色网格线
    color_ramp.color_ramp.elements[0].position = 0.0
    color_ramp.color_ramp.elements[1].color = (0.95, 0.95, 0.95, 1.0)  # 白色格子
    color_ramp.color_ramp.elements[1].position = 0.15
    color_ramp.location = (150, -425)
    links.new(math_min.outputs['Value'], color_ramp.inputs['Fac'])
    
    # 连接到Emission材质(直接显示颜色,不受光照影响)
    links.new(color_ramp.outputs['Color'], emission.inputs['Color'])
    emission.inputs['Strength'].default_value = 2.0  # Emission强度(材质预览模式,避免过曝)
    
    return emission


def get_scene_bounds():
    """获取场景中所有物体的边界框"""
    min_coords = [float('inf'), float('inf'), float('inf')]
    max_coords = [float('-inf'), float('-inf'), float('-inf')]
    
    for obj in bpy.context.scene.objects:
        if obj.type == 'MESH':
            # 获取世界坐标下的边界框
            for corner in obj.bound_box:
                world_corner = obj.matrix_world @ Vector(corner)
                for i in range(3):
                    min_coords[i] = min(min_coords[i], world_corner[i])
                    max_coords[i] = max(max_coords[i], world_corner[i])
    
    # 如果没有找到任何mesh,返回默认值
    if min_coords[0] == float('inf'):
        return ([-5, -5, 0], [5, 5, 3])
    
    return (min_coords, max_coords)


def create_erp_camera(name="ERP_Camera"):
    """创建ERP全景相机"""
    # 创建相机数据
    camera_data = bpy.data.cameras.new(name=name)
    
    # 设置为全景相机
    camera_data.type = 'PANO'
    
    # 设置全景类型为等距圆柱投影(EEVEE和Cycles都支持)
    # Blender 5.0 使用 panorama_type
    if hasattr(camera_data, 'panorama_type'):
        camera_data.panorama_type = 'EQUIRECTANGULAR'
    # Cycles相机设置
    if hasattr(camera_data, 'cycles'):
        camera_data.cycles.panorama_type = 'EQUIRECTANGULAR'
    
    # 创建相机对象
    camera_object = bpy.data.objects.new(name, camera_data)
    
    # 链接到场景
    bpy.context.scene.collection.objects.link(camera_object)
    
    print(f"[INFO] 创建ERP相机: {name}")
    
    return camera_object


def setup_camera(camera_object, position, rotation_euler=None, rotation_quat=None):
    """设置相机位置和旋转(Euler 或 Quaternion)"""
    # 设置位置
    camera_object.location = Vector(position)
    
    # 设置旋转
    if rotation_quat is not None:
        # 使用四元数(推荐,避免Euler顺序/分解歧义)
        camera_object.rotation_mode = 'QUATERNION'
        camera_object.rotation_quaternion = Quaternion(rotation_quat)
        print(f"[INFO] 相机位置: {position}")
        print(f"[INFO] 相机旋转(Quaternion wxyz): {list(rotation_quat)}")
    else:
        # 使用欧拉角(Blender使用XYZ顺序的欧拉角)
        camera_object.rotation_mode = 'XYZ'
        camera_object.rotation_euler = Euler(rotation_euler, 'XYZ')
        print(f"[INFO] 相机位置: {position}")
        print(f"[INFO] 相机旋转(Euler XYZ, rad): {rotation_euler}")


def setup_render_settings(resolution, engine, samples):
    """设置渲染参数"""
    scene = bpy.context.scene
    
    # 设置渲染引擎
    scene.render.engine = engine
    print(f"[INFO] 渲染引擎: {engine}")
    
    # 设置分辨率
    scene.render.resolution_x = resolution[0]
    scene.render.resolution_y = resolution[1]
    scene.render.resolution_percentage = 100
    print(f"[INFO] 分辨率: {resolution[0]}x{resolution[1]}")
    
    # 设置输出格式
    scene.render.image_settings.file_format = 'PNG'
    scene.render.image_settings.color_mode = 'RGB'
    scene.render.image_settings.color_depth = '8'
    
    # 引擎特定设置
    if engine == 'BLENDER_EEVEE':
        # EEVEE设置
        if hasattr(scene, 'eevee'):
            # 设置采样数(如果属性存在)
            if hasattr(scene.eevee, 'taa_render_samples'):
                scene.eevee.taa_render_samples = samples
            # 软阴影(Blender 5.0可能不支持)
            if hasattr(scene.eevee, 'use_soft_shadows'):
                scene.eevee.use_soft_shadows = True
    elif engine == 'CYCLES':
        # Cycles设置
        scene.cycles.samples = samples
        scene.cycles.use_denoising = True
        
        # 对于Emission材质,需要确保光线反弹足够
        # 但Emission材质本身会发光,不需要太多反弹
        scene.cycles.max_bounces = 4  # 减少反弹次数(Emission材质不需要太多)
        scene.cycles.diffuse_bounces = 2
        scene.cycles.glossy_bounces = 2
        scene.cycles.transmission_bounces = 2
        
        # 尝试使用GPU
        try:
            bpy.context.preferences.addons['cycles'].preferences.compute_device_type = 'CUDA'
            bpy.context.scene.cycles.device = 'GPU'
            print("[INFO] 使用GPU渲染")
        except:
            print("[INFO] 使用CPU渲染")


def setup_lighting(camera_position=None, scene_bounds=None):
    """
    设置照明(材质预览模式:仅使用强环境光,无其他光源,显示材质原始颜色和亮度)
    
    Args:
        camera_position: 相机位置 (x, y, z)(未使用)
        scene_bounds: 场景边界 (min, max)(未使用)
    """
    scene = bpy.context.scene
    
    # 添加环境光(材质预览模式)
    world = bpy.data.worlds.new("World")
    scene.world = world
    world.use_nodes = True
    
    # 获取节点树
    nodes = world.node_tree.nodes
    links = world.node_tree.links
    
    # 清除默认节点
    nodes.clear()
    
    # 创建背景节点 - 使用非常强的环境光(类似材质预览模式)
    # 只使用环境光,无其他光源,确保材质显示原始颜色和亮度,无明暗变化
    background = nodes.new('ShaderNodeBackground')
    background.inputs['Color'].default_value = (1.0, 1.0, 1.0, 1.0)  # 白色背景
    background.inputs['Strength'].default_value = 1.0  # 环境光强度(材质预览模式,避免过曝)
    
    # 创建输出节点
    output = nodes.new('ShaderNodeOutputWorld')
    
    # 连接节点
    links.new(background.outputs['Background'], output.inputs['Surface'])
    
    # === 不添加任何其他光源 ===
    # 只使用环境光,确保整个场景光照完全均匀,无距离衰减,无明暗变化
    # 这样材质会显示其原始颜色和亮度,就像Blender材质预览模式一样
    
    print("[INFO] 设置照明完成(材质预览模式:仅强环境光,无其他光源,显示材质原始颜色和亮度)")


def setup_depth_pass():
    """
    设置深度渲染 pass(Blender 5.0+ API)
    
    在 Blender 中启用 Z pass,用于获取深度信息。
    使用 Blender 5.0 新的 compositing_node_group API。
    """
    scene = bpy.context.scene
    
    # 启用 View Layer 的 Z pass
    view_layer = bpy.context.view_layer
    view_layer.use_pass_z = True
    
    # Blender 5.0: scene.node_tree 已移除,改用 compositing_node_group
    # 创建新的 CompositorNodeTree 并赋给场景
    tree = bpy.data.node_groups.new("DepthCompositor", "CompositorNodeTree")
    scene.compositing_node_group = tree
    nodes = tree.nodes
    links = tree.links
    
    # 创建 Render Layers 节点
    render_layers = nodes.new('CompositorNodeRLayers')
    render_layers.location = (0, 300)
    
    # Blender 5.0: 用 NodeGroupOutput 替代 CompositorNodeComposite
    output = nodes.new('NodeGroupOutput')
    output.location = (400, 300)
    tree.interface.new_socket(name="Image", in_out="OUTPUT", socket_type="NodeSocketColor")
    
    # 连接 RGB 输出
    links.new(render_layers.outputs['Image'], output.inputs['Image'])
    
    # 创建 File Output 节点(用于深度 EXR)
    file_output = nodes.new('CompositorNodeOutputFile')
    file_output.location = (400, 0)
    file_output.directory = ""  # 稍后在渲染时设置(Blender 5.0: 替代 base_path)
    file_output.format.media_type = 'IMAGE'  # Blender 5.0: 必须先设 media_type
    file_output.format.file_format = 'OPEN_EXR'
    file_output.format.color_depth = '32'
    file_output.format.exr_codec = 'ZIP'
    
    # Blender 5.0: file_output_items 替代 file_slots
    file_output.file_output_items.clear()
    file_output.file_output_items.new('FLOAT', "depth")
    
    # 连接深度输出
    links.new(render_layers.outputs['Depth'], file_output.inputs['depth'])
    
    print("[INFO] 深度 pass 已启用(Blender 5.0 API)")
    
    return file_output


def _convert_depth_exr_via_blender_api(exr_path, npy_path):
    """使用 Blender 图像 API 将 EXR 转为 NPY(备用路径,不依赖 OpenEXR)。"""
    import numpy as np
    img = bpy.data.images.load(exr_path)
    width = img.size[0]
    height = img.size[1]
    pixels = np.array(img.pixels[:])
    pixels = pixels.reshape(height, width, -1)
    depth = pixels[:, :, 0]
    depth = np.flipud(depth)
    unit_scale = bpy.context.scene.unit_settings.scale_length
    depth_meters = depth * unit_scale
    max_valid_depth = 1000.0
    depth_meters[depth_meters > max_valid_depth] = np.nan
    depth_meters[depth_meters <= 0] = np.nan
    np.save(npy_path, depth_meters.astype(np.float32))
    bpy.data.images.remove(img)
    os.remove(exr_path)
    print(f"[OK] 深度图保存(备用方法): {npy_path}")


def convert_depth_exr_to_npy(exr_path, npy_path):
    """
    将 Blender 渲染的深度 EXR 转换为 NPY 格式
    
    Blender ERP 相机的深度是 range depth(射线距离),单位为 Blender 单位(通常是米)
    
    Args:
        exr_path: EXR 文件路径
        npy_path: NPY 输出路径
    """
    import numpy as np
    try:
        import OpenEXR
        import Imath
        
        # 打开 EXR 文件
        exr_file = OpenEXR.InputFile(exr_path)
        
        # 获取图像尺寸
        header = exr_file.header()
        dw = header['dataWindow']
        width = dw.max.x - dw.min.x + 1
        height = dw.max.y - dw.min.y + 1
        
        # 读取深度通道
        # Blender 深度 pass 保存在 'R'、'G'、'B' 或 'V' 通道
        pt = Imath.PixelType(Imath.PixelType.FLOAT)
        
        # 尝试不同的通道名称
        channel_names = ['depth.R', 'R', 'V', 'Z', 'depth.V']
        depth_str = None
        for ch in channel_names:
            if ch in header['channels']:
                depth_str = exr_file.channel(ch, pt)
                print(f"[INFO] 使用深度通道: {ch}")
                break
        
        if depth_str is None:
            # 列出所有可用通道
            available_channels = list(header['channels'].keys())
            print(f"[WARN] 可用通道: {available_channels}")
            # 尝试使用第一个通道
            if available_channels:
                depth_str = exr_file.channel(available_channels[0], pt)
                print(f"[INFO] 使用通道: {available_channels[0]}")
            else:
                raise ValueError("无法找到深度通道")
        
        # 转换为 numpy 数组
        depth = np.frombuffer(depth_str, dtype=np.float32)
        depth = depth.reshape(height, width)
        
        # 获取场景单位比例(转换为米)
        unit_scale = bpy.context.scene.unit_settings.scale_length
        
        # 将深度转换为米
        # Blender 的深度值是场景单位,需要乘以 unit_scale 转换为米
        depth_meters = depth * unit_scale
        
        # 处理无效深度(Blender 用非常大的值表示无穷远)
        # 通常 > 1e9 的值表示背景/无穷远
        max_valid_depth = 1000.0  # 1000 米以上视为无效
        depth_meters[depth_meters > max_valid_depth] = np.nan
        depth_meters[depth_meters <= 0] = np.nan
        
        # 保存为 NPY
        np.save(npy_path, depth_meters.astype(np.float32))
        
        # 删除临时 EXR 文件
        os.remove(exr_path)
        
        # 统计信息
        valid_mask = np.isfinite(depth_meters)
        if np.any(valid_mask):
            print(f"[OK] 深度图保存: {npy_path}")
            print(f"     形状: {depth_meters.shape}")
            print(f"     深度范围: {np.nanmin(depth_meters):.3f} - {np.nanmax(depth_meters):.3f} 米")
            print(f"     有效像素: {np.sum(valid_mask)} / {depth_meters.size} ({100*np.sum(valid_mask)/depth_meters.size:.1f}%)")
        else:
            print(f"[WARN] 深度图全部无效!")
            
    except ImportError:
        print("[ERROR] 需要安装 OpenEXR 库: pip install OpenEXR")
        print("[INFO] 尝试使用 Blender 内置方法...")
        try:
            _convert_depth_exr_via_blender_api(exr_path, npy_path)
        except Exception as e:
            print(f"[ERROR] 备用方法也失败: {e}")
            print(f"[INFO] EXR 文件保留在: {exr_path}")
    except Exception as e:
        print(f"[WARN] OpenEXR 读取失败,尝试 Blender 内置方法: {e}")
        try:
            _convert_depth_exr_via_blender_api(exr_path, npy_path)
        except Exception as e2:
            print(f"[ERROR] 备用方法也失败: {e2}")
            print(f"[INFO] EXR 文件保留在: {exr_path}")


def render_and_save(output_path, render_depth=False, depth_output=None):
    """
    执行渲染并保存
    
    Args:
        output_path: RGB 图像输出路径
        render_depth: 是否渲染深度
        depth_output: 深度图输出路径(.npy 格式)
    """
    # 确保输出目录存在
    output_dir = os.path.dirname(output_path)
    if output_dir and not os.path.exists(output_dir):
        os.makedirs(output_dir)
    
    # 设置输出路径
    bpy.context.scene.render.filepath = output_path
    
    # 执行渲染
    print(f"[INFO] 开始渲染...")
    bpy.ops.render.render(write_still=True)

    # 强校验:必须有实际输出图像,避免上游出现“returncode=0但无文件”
    if (not os.path.exists(output_path)) or os.path.getsize(output_path) <= 0:
        raise RuntimeError(f"渲染完成但输出图像不存在或为空: {output_path}")

    print(f"[OK] 渲染完成: {output_path}")


def save_pose(camera_object, output_path, frame_id=0, ref_position=None, ref_quaternion=None):
    """
    保存相机位姿(绝对位姿,兼容 ERPT 格式)
    
    输出格式:
    - position: 相机中心在世界坐标系的绝对位置(米),[X右, Y上, Z前]
    - rotation_quaternion: [w, x, y, z],camera->world 旋转 (R_cw)
    
    核心公式:R_cw_erpt = T @ R_blender_obj @ M
    - T: Blender世界(Y前Z上) -> 统一世界(Y上Z前) 坐标轴交换
    - R_blender_obj: Blender相机的旋转矩阵(object local -> world)
    - M: Blender相机本地(-Z前) -> ERPT相机(+Z前) Z轴翻转
    
    Args:
        camera_object: Blender相机对象
        output_path: 输出路径
        frame_id: 帧序号
        ref_position: (保留参数,当前未使用)
        ref_quaternion: (保留参数,当前未使用)
    """
    from mathutils import Matrix
    
    # 获取当前相机的绝对位置和旋转(Blender坐标系:X右, Y前, Z上)
    abs_position_blender = list(camera_object.location)
    abs_quat_blender = camera_object.rotation_euler.to_quaternion()
    
    # === 位置转换 ===
    # Blender世界(X右,Y前,Z上) -> 统一标准(X右,Y上,Z前)
    abs_position_unified = [
        abs_position_blender[0],   # X_unified = X_blender
        abs_position_blender[2],   # Y_unified = Z_blender (上)
        abs_position_blender[1]    # Z_unified = Y_blender (前)
    ]
    
    # === 旋转转换 ===
    # Blender object rotation matrix (local -> world in Blender coords)
    R_obj_blender = abs_quat_blender.to_matrix()
    
    # T: Blender世界坐标 -> 统一世界坐标(交换Y和Z轴)
    T_blender_to_unified = Matrix([
        [1, 0, 0],  # X不变
        [0, 0, 1],  # Y_unified = Z_blender
        [0, 1, 0]   # Z_unified = Y_blender
    ])
    
    # M: Blender相机本地坐标 -> ERPT相机坐标(翻转Z轴)
    # Blender相机沿 -Z_local 看,ERPT相机沿 +Z_camera 看
    # 因此 ERPT_Z = -Blender_Z_local,即 Z 轴翻转
    M_cam = Matrix([
        [1, 0,  0],
        [0, 1,  0],
        [0, 0, -1]
    ])
    
    # 核心公式:R_cw_erpt = T @ R_obj_blender @ M
    # 含义:ERPT相机坐标 -> (M) -> Blender本地 -> (R_obj) -> Blender世界 -> (T) -> 统一世界
    R_cw_erpt = T_blender_to_unified @ R_obj_blender @ M_cam
    
    # 转换为四元数(cam_to_world,ERPT期望的格式)
    quat_cw = R_cw_erpt.to_quaternion()
    abs_quaternion_cw = [quat_cw.w, quat_cw.x, quat_cw.y, quat_cw.z]
    
    # === 输出 ===
    # 绝对位姿,cam_to_world格式,兼容ERPT
    pose_data = {
        "frame_id": frame_id,
        "position": abs_position_unified,
        "rotation_quaternion": abs_quaternion_cw,
        "camera_type": "erp_ray",
        "coordinate_system": "right-handed, Y-up, Z-forward (cam_to_world)",
        "render_method": "blender_cycles"
    }
    
    # 保存JSON
    with open(output_path, 'w') as f:
        json.dump(pose_data, f, indent=2)

    if (not os.path.exists(output_path)) or os.path.getsize(output_path) <= 0:
        raise RuntimeError(f"位姿文件写入失败或为空: {output_path}")
    
    print(f"[OK] 位姿保存: {output_path}")
    print(f"  Position (absolute, meters): {abs_position_unified}")
    print(f"  Rotation (cam_to_world): {abs_quaternion_cw}")
    
    # 返回绝对位姿(统一标准坐标系,cam_to_world)
    return abs_position_unified, abs_quaternion_cw


def main():
    # 解析参数
    args = parse_args()
    
    # 解析相机位置
    camera_pos = [float(x) for x in args.camera_pos.split(',')]
    camera_rot = [float(x) for x in args.camera_rot.split(',')]
    camera_rot_quat = None
    if args.camera_rot_quat:
        camera_rot_quat = [float(x) for x in args.camera_rot_quat.split(',')]
    resolution = [int(x) for x in args.resolution.split(',')]
    
    # 解析参考帧位姿
    ref_position = None
    ref_quaternion = None
    if args.ref_position:
        ref_position = [float(x) for x in args.ref_position.split(',')]
    if args.ref_quaternion:
        ref_quaternion = [float(x) for x in args.ref_quaternion.split(',')]
    
    # 确定位姿输出路径
    if args.pose_output:
        pose_output = args.pose_output
    else:
        pose_output = os.path.splitext(args.output)[0] + '_pose.json'
    
    print("=" * 60)
    print("Blender ERP渲染")
    print("=" * 60)
    
    # 1. 清空场景
    print("\n[1/6] 清空场景...")
    clear_scene()
    
    # 2. 导入mesh
    print("\n[2/6] 导入mesh...")
    import_mesh(args.mesh)
    
    # 3. 创建ERP相机
    print("\n[3/6] 创建ERP相机...")
    camera = create_erp_camera()
    setup_camera(camera, camera_pos, rotation_euler=camera_rot, rotation_quat=camera_rot_quat)
    bpy.context.scene.camera = camera
    
    # 获取场景边界(用于灯光设置)
    scene_bounds = get_scene_bounds()
    print(f"[INFO] 场景边界: min={scene_bounds[0]}, max={scene_bounds[1]}")
    
    # 4. 设置渲染参数
    print("\n[4/6] 设置渲染参数...")
    setup_render_settings(resolution, args.engine, args.samples)
    setup_lighting(camera_position=camera_pos, scene_bounds=scene_bounds)
    
    # 5. 渲染
    print("\n[5/6] 渲染中...")
    render_and_save(args.output)
    
    # 6. 保存位姿(相对于第一帧)
    print("\n[6/6] 保存位姿...")
    abs_pos, abs_quat = save_pose(
        camera, 
        pose_output, 
        frame_id=args.frame_id,
        ref_position=ref_position,
        ref_quaternion=ref_quaternion
    )
    
    # 输出绝对位姿供批量脚本使用
    print(f"[ABS_POSE] {abs_pos[0]},{abs_pos[1]},{abs_pos[2]}|{abs_quat[0]},{abs_quat[1]},{abs_quat[2]},{abs_quat[3]}")
    
    print("\n" + "=" * 60)
    print("渲染完成!")
    print("=" * 60)


if __name__ == "__main__":
    main()