Skip to content

Commit

Permalink
Merge pull request #73 from beersandrew/add-cards-generator-script
Browse files Browse the repository at this point in the history
feat: adding cards generation script
  • Loading branch information
meshula authored Jan 30, 2024
2 parents 7bc8176 + 705a831 commit ce0e9af
Show file tree
Hide file tree
Showing 12 changed files with 463 additions and 1 deletion.
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
# ignore temp files when using usdfixbrokenpixarschemas
*.bck.*
.DS_Store
.DS_Store
*.pyc
42 changes: 42 additions & 0 deletions scripts/cards-generator/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
# USD Cards Generator

## Purpose
Given a USD file, take pictures of each of it's axes and assign them as the cards' images

## Usage

`python generate_cards.py --usd-file <usd_file>`

optional arguments:
- `usd-file` : The USD file you want to generate card images from
- `-h`, `--help` : Show help
- `--create-usdz-result` : Returns the resulting files as a new USDZ file called `<subject_usd_file>_Cards.usdz`
- `--output-extension` : The file extension of the output image you want (exr, png..). If using exr, make sure your usd install includes OpenEXR
- `--verbose` : Prints out the steps as they happen
- `--dome-light` : The path to the dome light HDR image to use, if any
- `--apply-cards` : Saves the images as the cards for the given USD file. If USDZ is input, a new USD file will be created to wrap the existing one called `<subject_usd_file>_Cards.usd`
- `--render-purposes` : A comma separated list of render purposes to include in the thumbnail. Valid values are: default, render, proxy, guide
- `--image-width`: The width of the image to generate. Default is 2048
- `--directory` : A directory to generate thumbnails for all .usd, .usda, .usdc, and .usdz files. When a directory is supplied, usd-file is ignored
- `--recursive` : Will recursively search all directories underneath a given directory, requires a directory to be set

Note: You must have usd installed and available in your path. [Install Steps Here](https://github.com/PixarAnimationStudios/OpenUSD#getting-and-building-the-code)

## How it works when using --apply-cards
Given a USD file to use as the subject of the cards do the following

1. Apply DrawMode defaults (cards attribute, box geometry)
2. Create, position, and orient cameras for each axis (XPos, XNeg, YPos, YNeg, ZPos, ZNeg)
3. Sublayer the subject in the camera
4. Run `usdrecord` to take a snapshot and store it in `/renders/<axis>.0.png`
- macOS
- `usdrecord --frames 0:0 --camera ZCamera --imageWidth 2048 --renderer Metal camera.usda <axis>.#.png`
- windows
- `usdrecord --frames 0:0 --camera ZCamera --imageWidth 2048 --renderer GL camera.usda <axis>.#.png`
- note: this will run with `shell=True` for the subprocess call
- linux
- `usdrecord --frames 0:0 --camera ZCamera --imageWidth 2048 --renderer Metal camera.usda <axis>.#.png`
- ZCamera & camera.usda are generated in step 2
4. If the file is not a USDZ file, assign each image as the usd's axis card image
5. If the file is a USDZ file, create a new `<subject_usd_file>_Cards.usda`, assign each image as the usd's axis card image, and sublayer `<subject_usd_file>.usdz`
6. If `--create-usdz-result` is passed in, combine all of the files into a USDZ, in the case of a USD file it would be the input file and each axis image. In the case of a USDZ file it would be the new USDA, the axis images, and the input USDZ
Empty file.
82 changes: 82 additions & 0 deletions scripts/cards-generator/src/camera/cameraGenerator.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
#!/usr/bin/env python3

from pxr import UsdGeom, Gf
from camera.utils.distanceCalculator import get_distance_to_frame_subject
from camera.globals import SENSOR_SIZE, FOCAL_LENGTH, FOCUS_DISTANCE

def create_camera_for_card(card, camera_stage, center_of_card_face, bounding_box, camera_view_axis_distance):
camera_prim = create_camera_with_defaults(camera_stage, card.name)

distance = get_distance_to_frame_subject(bounding_box, SENSOR_SIZE, FOCAL_LENGTH)

nearClip = distance / 10.0
farClip = (( distance + camera_view_axis_distance ) / 10.0) * 2
clippingPlanes = Gf.Vec2f(nearClip, farClip)
camera_prim.GetClippingRangeAttr().Set(clippingPlanes)

position_camera(camera_prim, card, center_of_card_face, distance)

calculate_apertures(camera_prim, bounding_box, distance, card)

rotate_camera(card, camera_prim)

def create_camera_with_defaults(camera_stage, name):
camera_prim = UsdGeom.Camera.Define(camera_stage, '/CardGenerator/' + name)
camera_prim.CreateFocalLengthAttr(FOCAL_LENGTH)
camera_prim.CreateFocusDistanceAttr(FOCUS_DISTANCE)
camera_prim.CreateFStopAttr(0)
camera_prim.CreateHorizontalApertureOffsetAttr(0)
camera_prim.CreateProjectionAttr("perspective")
camera_prim.CreateVerticalApertureOffsetAttr(0)

return camera_prim

def position_camera(camera_prim, card, center_of_card_face, distance):
camera_translation = create_camera_translation(card, center_of_card_face, distance)
apply_camera_translation(camera_prim, camera_translation)

def create_camera_translation(card, center_of_card_face, distance):
return center_of_card_face + create_translate_vector(distance * card.sign, card.translationIndex)

def calculate_apertures(camera_prim, bounding_box, distance, card):
flip_aperatures = False
for rotation in card.rotations:
if rotation.amount != 180:
flip_aperatures = True
break

actual_horizontal_aperture = FOCAL_LENGTH * bounding_box.width / distance
actual_vertical_aperture = FOCAL_LENGTH * bounding_box.height / distance

if flip_aperatures:
camera_prim.CreateHorizontalApertureAttr(actual_vertical_aperture)
camera_prim.CreateVerticalApertureAttr(actual_horizontal_aperture)
else:
camera_prim.CreateHorizontalApertureAttr(actual_horizontal_aperture)
camera_prim.CreateVerticalApertureAttr(actual_vertical_aperture)

def rotate_camera(card, camera_prim):
for rotation in card.rotations:
apply_camera_rotation(camera_prim, rotation.index, rotation.amount)

def create_translate_vector(distance, translationIndex):
vector = Gf.Vec3d(0, 0, 0)
vector[translationIndex] = distance / 10.0 # convert units from mm to cm
return vector

def apply_camera_translation(camera_prim, camera_translation):
xformRoot = UsdGeom.Xformable(camera_prim.GetPrim())
translateOp = xformRoot.AddTranslateOp(UsdGeom.XformOp.PrecisionDouble)
translateOp.Set(camera_translation)

def apply_camera_rotation(camera_prim, rotationDirection, rotationAmount):
xformRoot = UsdGeom.Xformable(camera_prim.GetPrim())
if rotationDirection == 0:
rotateOp = xformRoot.AddRotateXOp()
rotateOp.Set(rotationAmount)
elif rotationDirection == 1:
rotateOp = xformRoot.AddRotateYOp()
rotateOp.Set(rotationAmount)
else:
rotateOp = xformRoot.AddRotateZOp()
rotateOp.Set(rotationAmount)
67 changes: 67 additions & 0 deletions scripts/cards-generator/src/camera/cameraManager.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
#!/usr/bin/env python3

from pxr import Usd, UsdGeom, UsdLux, Gf
from collections import namedtuple
from camera.cameraGenerator import create_camera_for_card

BoundingBox = namedtuple('BoundingBox', ['width', 'height'])
RENDER_PURPOSE_MAP = {
"default": UsdGeom.Tokens.default_,
"render": UsdGeom.Tokens.render,
"proxy": UsdGeom.Tokens.proxy,
"guide": UsdGeom.Tokens.guide
}

def setup_cameras(subject_stage, usd_file, cards, dome_light, render_purposes):
camera_stage = create_camera_stage()
create_cameras(camera_stage, subject_stage, cards, render_purposes)
sublayer_subject(camera_stage, usd_file)

if dome_light:
add_domelight(camera_stage, dome_light)

camera_stage.Save()

def create_camera_stage():
stage = Usd.Stage.CreateNew('cameras.usda')
stage.SetMetadata('metersPerUnit', 0.01)

return stage

def create_cameras(camera_stage, subject_stage, cards, render_purposes):
render_purpose_tokens = convert_render_purposes_to_tokens(render_purposes)
stage_bounding_box = get_bounding_box(subject_stage, render_purpose_tokens)
min_bound = stage_bounding_box.GetMin()
max_bound = stage_bounding_box.GetMax()
subject_center = (min_bound + max_bound) / 2.0

for card in cards:
card_width = (max_bound[card.horizontalIndex] - min_bound[card.horizontalIndex]) * 10
card_height = (max_bound[card.verticalIndex] - min_bound[card.verticalIndex]) * 10
card_bounding_box = BoundingBox(card_width, card_height)

faceTranslationValue = max_bound[card.translationIndex] if card.sign > 0 else min_bound[card.translationIndex]
center_of_card_face = Gf.Vec3d(subject_center[0], subject_center[1], subject_center[2])
center_of_card_face[card.translationIndex] = faceTranslationValue

camera_view_axis_distance = card_height = (max_bound[card.translationIndex] - min_bound[card.translationIndex])

create_camera_for_card(card, camera_stage, center_of_card_face, card_bounding_box, camera_view_axis_distance)

def convert_render_purposes_to_tokens(render_purposes):
return [RENDER_PURPOSE_MAP[key] for key in render_purposes.split(',')]

def get_bounding_box(subject_stage, render_purpose_tokens):
bboxCache = UsdGeom.BBoxCache(Usd.TimeCode(0.0), render_purpose_tokens)
root = subject_stage.GetPseudoRoot()
return bboxCache.ComputeWorldBound(root).GetBox()


def sublayer_subject(camera_stage, input_file):
camera_stage.GetRootLayer().subLayerPaths = [input_file]

def add_domelight(camera_stage, dome_light):
UsdLux.DomeLight.Define(camera_stage, '/CardGenerator/DomeLight')
domeLight = UsdLux.DomeLight(camera_stage.GetPrimAtPath('/CardGenerator/DomeLight'))
domeLight.CreateTextureFileAttr().Set(dome_light)
domeLight.CreateTextureFormatAttr().Set("latlong")
5 changes: 5 additions & 0 deletions scripts/cards-generator/src/camera/globals.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
#!/usr/bin/env python3

FOCAL_LENGTH = 50
SENSOR_SIZE = 24
FOCUS_DISTANCE = 168.60936
57 changes: 57 additions & 0 deletions scripts/cards-generator/src/camera/renderManger.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
#!/usr/bin/env python3

import subprocess
import os
import sys
from pathlib import Path
from concurrent.futures import ThreadPoolExecutor

CARDS_FOLDER_NAME = "cards"


def take_snapshots(cards, output_extension, image_width):
images = []
renderer = get_renderer()
IMAGE_WIDTH = image_width

def task(card):
cardName = card.usdFileName + "_" + card.name
image_name = create_image_filename(cardName, output_extension, card.parentPath)
cmd = ['usdrecord','--camera', card.name, '--imageWidth', str(IMAGE_WIDTH), '--renderer', renderer, 'cameras.usda', image_name]
run_os_specific_command(cmd)
return image_name

with ThreadPoolExecutor() as executor:
images = list(executor.map(task, cards))

os.remove("cameras.usda")
return images

def create_image_filename(card_name, extension, parent_path):
if CARDS_FOLDER_NAME:
cards_folder_dir = Path().joinpath(parent_path, CARDS_FOLDER_NAME)
else:
cards_folder_dir = parent_path
cards_folder_dir.mkdir(parents=True, exist_ok=True)
return str(Path().joinpath(cards_folder_dir, card_name).with_suffix("." + extension)).replace("\\", "/")

def get_renderer():
if os.name == 'nt':
print("windows default renderer GL being used...")
return "GL"
else:
if sys.platform == 'darwin':
print("macOS default renderer Metal being used...")
return 'Metal'
else:
print("linux default renderer GL being used...")
return 'GL'

def run_os_specific_command(cmd):
if os.name == 'nt':
subprocess.run(cmd, check=True, shell=True, stdout=subprocess.DEVNULL, stderr=subprocess.STDOUT)
else:
if sys.platform == 'darwin':
subprocess.run(cmd, check=True, stdout=subprocess.DEVNULL, stderr=subprocess.STDOUT)
else:
subprocess.run(cmd, check=True, stdout=subprocess.DEVNULL, stderr=subprocess.STDOUT)
Empty file.
20 changes: 20 additions & 0 deletions scripts/cards-generator/src/camera/utils/distanceCalculator.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import math

def get_distance_to_frame_subject(bounding_box, sensor_size, focal_length):
distance_to_capture_horizontal = calculate_field_of_view_distance(sensor_size, bounding_box.width, focal_length)
distance_to_capture_vertical = calculate_field_of_view_distance(sensor_size, bounding_box.height, focal_length)

return max(distance_to_capture_horizontal, distance_to_capture_vertical)

def calculate_field_of_view_distance(sensor_size, object_size, focal_length):
return calculate_camera_distance(object_size, calculate_field_of_view(sensor_size, focal_length))

def calculate_field_of_view(sensor_size, focal_length):
# Focal length and sensor size should be in the same units (e.g., mm)
field_of_view = 2 * math.atan(sensor_size / (2 * focal_length))
return field_of_view

def calculate_camera_distance(subject_size, field_of_view):
# Subject size and field of view should be in the same units (e.g., mm and degrees)
distance = (subject_size / 2) / math.tan(field_of_view / 2)
return distance
Loading

0 comments on commit ce0e9af

Please sign in to comment.