diff --git a/contrib/blender/README.md b/contrib/blender/README.md index 791cc5d5..74b93008 100644 --- a/contrib/blender/README.md +++ b/contrib/blender/README.md @@ -2,6 +2,7 @@ # odm_photo Renders photos from ODM generated texture models. Currently can produce 360 panoramic photos and 360 3D panoramic (VR) photos. +NB: the default resolution for 360 photos is 6000x3000 (maximum supported by Facebook). ## Requirements * Blender @@ -21,3 +22,20 @@ To generate a 360 3D panoramic photo: Output is `/odm_photo/odm_photo_vr_L.jpg` and `/odm_photo/odm_photo_vr_R.jpg`. **NB: argument order matters!** + +# odm_video +Renders videos from ODM generated texture models. +Currently can produce 360 panoramic videos. +NB: the default resolution is 4096x2048 (maximum supported by Facebook). + +## Requirements +* Blender +* Python 2.7 (must be on your PATH) +* Spatial Media Metadata Injector (https://github.com/google/spatial-media/tree/master/spatialmedia) (place in `spatialmedia` subdirectory) + +## Usage +To generate a 360 panoramic photo: + + blender -b photo_360.blend --python odm_video.py -- + +Output is `/odm_video/odm_video_360.mp4`. diff --git a/contrib/blender/common.py b/contrib/blender/common.py new file mode 100644 index 00000000..e3774982 --- /dev/null +++ b/contrib/blender/common.py @@ -0,0 +1,45 @@ +import bpy +import materials_utils + +def loadMesh(file): + + bpy.utils.register_module('materials_utils') + + bpy.ops.import_scene.obj(filepath=file, + axis_forward='Y', + axis_up='Z') + + bpy.ops.xps_tools.convert_to_cycles_all() + + model = bpy.data.objects[-1] + minX = float('inf') + maxX = float('-inf') + minY = float('inf') + maxY = float('-inf') + minZ = float('inf') + maxZ = float('-inf') + for coord in model.bound_box: + x = coord[0] + y = coord[1] + z = coord[2] + minX = min(x, minX) + maxX = max(x, maxX) + minY = min(y, minY) + maxY = max(y, maxY) + minZ = min(z, minZ) + maxZ = max(z, maxZ) + + model.location[2] += (maxZ - minZ)/2 + + surfaceShaderType = 'ShaderNodeEmission' + surfaceShaderName = 'Emission' + + for m in bpy.data.materials: + nt = m.node_tree + nt.nodes.remove(nt.nodes['Color Mult']) + nt.nodes.remove(nt.nodes['Diffuse BSDF']) + nt.nodes.new(surfaceShaderType) + nt.links.new(nt.nodes['Material Output'].inputs[0], + nt.nodes[surfaceShaderName].outputs[0]) + nt.links.new(nt.nodes[surfaceShaderName].inputs[0], + nt.nodes['Diffuse Texture'].outputs[0]) diff --git a/contrib/blender/odm_photo.py b/contrib/blender/odm_photo.py index 6f9bb858..b63cdcf3 100644 --- a/contrib/blender/odm_photo.py +++ b/contrib/blender/odm_photo.py @@ -10,11 +10,8 @@ import sys import bpy -import materials_utils import subprocess - -surfaceShaderType = 'ShaderNodeEmission' -surfaceShaderName = 'Emission' +from common import loadMesh def main(): @@ -24,47 +21,12 @@ def main(): projectHome = sys.argv[-1] - bpy.utils.register_module('materials_utils') - - bpy.ops.import_scene.obj(filepath=projectHome + - '/odm_texturing/odm_textured_model_geo.obj', - axis_forward='Y', axis_up='Z') - - bpy.ops.xps_tools.convert_to_cycles_all() - - model = bpy.data.objects[-1] - minX = float('inf') - maxX = float('-inf') - minY = float('inf') - maxY = float('-inf') - minZ = float('inf') - maxZ = float('-inf') - for coord in model.bound_box: - x = coord[0] - y = coord[1] - z = coord[2] - minX = min(x, minX) - maxX = max(x, maxX) - minY = min(y, minY) - maxY = max(y, maxY) - minZ = min(z, minZ) - maxZ = max(z, maxZ) - - model.location[2] += (maxZ - minZ)/2 - - for m in bpy.data.materials: - nt = m.node_tree - nt.nodes.remove(nt.nodes['Color Mult']) - nt.nodes.remove(nt.nodes['Diffuse BSDF']) - nt.nodes.new(surfaceShaderType) - nt.links.new(nt.nodes['Material Output'].inputs[0], - nt.nodes[surfaceShaderName].outputs[0]) - nt.links.new(nt.nodes[surfaceShaderName].inputs[0], - nt.nodes['Diffuse Texture'].outputs[0]) + loadMesh(projectHome + + '/odm_texturing/odm_textured_model_geo.obj') blendName = bpy.path.display_name_from_filepath(bpy.data.filepath) fileName = projectHome + '/odm_photo/odm_' + blendName - render = bpy.data.scenes[0].render + render = bpy.data.scenes['Scene'].render render.filepath = fileName bpy.ops.render.render(write_still=True) diff --git a/contrib/blender/odm_video.py b/contrib/blender/odm_video.py new file mode 100644 index 00000000..6faf8b15 --- /dev/null +++ b/contrib/blender/odm_video.py @@ -0,0 +1,113 @@ +#!/usr/bin/env python + +# Renders a video. +# To generate a 360 panoramic video: +# blender -b photo_360.blend --python odm_video.py -- + +import sys +import subprocess +import os +import bpy +from common import loadMesh + + +def main(): + + if len(sys.argv) < 7 or sys.argv[-4] != '--': + sys.exit('Please provide the ODM project path, camera waypoints (xyz format), and number of frames.') + + projectHome = sys.argv[-3] + waypointFile = sys.argv[-2] + numFrames = int(sys.argv[-1]) + + loadMesh(projectHome + + '/odm_texturing/odm_textured_model_geo.obj') + + waypoints = loadWaypoints(waypointFile) + numWaypoints = len(waypoints) + + scene = bpy.data.scenes['Scene'] + + # create path thru waypoints + curve = bpy.data.curves.new(name='CameraPath', type='CURVE') + curve.dimensions = '3D' + curve.twist_mode = 'Z_UP' + nurbs = curve.splines.new('NURBS') + nurbs.points.add(numWaypoints-1) + weight = 1 + for i in range(numWaypoints): + nurbs.points[i].co[0] = waypoints[i][0] + nurbs.points[i].co[1] = waypoints[i][1] + nurbs.points[i].co[2] = waypoints[i][2] + nurbs.points[i].co[3] = weight + nurbs.use_endpoint_u = True + path = bpy.data.objects.new(name='CameraPath', object_data=curve) + scene.objects.link(path) + + camera = bpy.data.objects['Camera'] + camera.location[0] = 0 + camera.location[1] = 0 + camera.location[2] = 0 + followPath = camera.constraints.new(type='FOLLOW_PATH') + followPath.name = 'CameraFollowPath' + followPath.target = path + followPath.use_curve_follow = True + animateContext = bpy.context.copy() + animateContext['constraint'] = followPath + bpy.ops.constraint.followpath_path_animate(animateContext, + constraint='CameraFollowPath', + frame_start=0, + length=numFrames) + + blendName = bpy.path.display_name_from_filepath(bpy.data.filepath) + fileName = projectHome + '/odm_video/odm_' + blendName.replace('photo', 'video') + scene.frame_start = 0 + scene.frame_end = numFrames + render = scene.render + render.filepath = fileName + '.mp4' + render.image_settings.file_format = 'FFMPEG' + if(render.use_multiview): + render.image_settings.stereo_3d_format.display_mode = 'TOPBOTTOM' + render.image_settings.views_format = 'STEREO_3D' + render.views[0].file_suffix = '' + format3d = 'top-bottom' + else: + width = render.resolution_x + height = render.resolution_y + format3d = 'none' + render.resolution_x = 4096 + render.resolution_y = 2048 + + render.ffmpeg.audio_codec = 'AAC' + render.ffmpeg.codec = 'H264' + render.ffmpeg.format = 'MPEG4' + render.ffmpeg.video_bitrate = 45000 + bpy.ops.render.render(animation=True) + + writeMetadata(fileName+'.mp4', format3d) + + +def loadWaypoints(filename): + waypoints = [] + with open(filename) as f: + for line in f: + xyz = line.split() + waypoints.append((float(xyz[0]), float(xyz[1]), float(xyz[2]))) + return waypoints + + +def writeMetadata(filename, format3d): + subprocess.run(['python', + 'spatialmedia', + '-i', + '--stereo='+format3d, + filename, + filename+'.injected']) + # check metadata injector was succesful + if os.path.exists(filename+'.injected'): + os.remove(filename) + os.rename(filename+'.injected', filename) + + +if __name__ == '__main__': + main() diff --git a/contrib/blender/photo_360.blend b/contrib/blender/photo_360.blend index a55e75e1..6e276947 100644 Binary files a/contrib/blender/photo_360.blend and b/contrib/blender/photo_360.blend differ