Skip to content

Commit 9945032

Browse files
python camerasolver - Add tests to replicate test.bash
This is essentially replicating the same tests from the `test.bash` file. This is an important first step to getting the camera solver integrated into Maya.
1 parent f599512 commit 9945032

File tree

8 files changed

+1152
-46
lines changed

8 files changed

+1152
-46
lines changed

python/mmSolver/tools/camerasolver/lib.py

Lines changed: 230 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -19,27 +19,32 @@
1919
Library functions for Camera Solver.
2020
"""
2121

22+
import json
2223
import os
2324
import subprocess
25+
import threading
26+
2427

2528
import mmSolver.logger
2629
import mmSolver.api as mmapi
27-
2830
import mmSolver.utils.time as time_utils
2931
import mmSolver.utils.python_compat as pycompat
30-
3132
import mmSolver.tools.savemarkerfile.lib as savemarkerfile_lib
3233
import mmSolver.tools.savelensfile.lib as savelensfile_lib
3334

35+
import maya.cmds
36+
3437
import mmSolver.tools.camerasolver.constant as const
3538

3639

3740
LOG = mmSolver.logger.get_logger()
3841

42+
# Maya stores film aperture in inches camera solver expects mm.
43+
INCHES_TO_MM = 25.4
44+
3945

4046
class AdjustmentSolver(object):
4147
def __init__(self):
42-
# TODO: Should the class have default values?
4348
self.__adjustment_solver_type = None
4449
self.__thread_count = None
4550
self.__evolution_value_range_estimate = None
@@ -91,14 +96,13 @@ def set_evolution_population_count(self, value):
9196

9297
class AdjustmentAttributes(object):
9398
def __init__(self):
94-
# adjustment_attributes
95-
# - name ('camera.focal_length_mm')
96-
# - value_min
97-
# - value_max
98-
# - sample_count
9999
self.__attribute_to_bounds = {}
100100
self.__attribute_to_sample_count = {}
101101

102+
def get_attribute_names(self):
103+
# type: (AdjustmentAttributes) -> list[str]
104+
return list(self.__attribute_to_bounds.keys())
105+
102106
def get_attribute_bounds(self, attr_name):
103107
assert isinstance(attr_name, pycompat.TEXT_TYPE)
104108
return self.__attribute_to_bounds.get(attr_name)
@@ -130,70 +134,184 @@ def construct_output_file_path(output_dir, file_prefix, file_suffix, file_ext):
130134

131135

132136
def save_markers_to_file(mkr_list, frame_range, file_prefix, output_dir):
133-
"""
134-
Save markers to disk.
135-
"""
136137
data = savemarkerfile_lib.generate(mkr_list, frame_range)
137138
file_suffix = ''
138139
file_path = construct_output_file_path(output_dir, file_prefix, file_suffix, '.uv')
139140
savemarkerfile_lib.write_file(file_path, data)
140141
return file_path
141142

142143

143-
def save_camera_to_file(cam, file_prefix, output_dir):
144-
"""
145-
Save camera to disk.
146-
"""
147-
# TODO: Implement this.
144+
def _query_sample_attr_over_frames(node, attr_name, frames):
145+
result = []
146+
for frame in frames:
147+
value = maya.cmds.getAttr(node + '.' + attr_name, time=frame)
148+
result.append([frame, value])
149+
return result
150+
151+
152+
def save_camera_to_file(cam, frame_range, file_prefix, output_dir):
153+
assert isinstance(cam, mmapi.Camera)
154+
assert isinstance(frame_range, time_utils.FrameRange)
148155
file_suffix = ''
149156
file_path = construct_output_file_path(
150-
output_dir, file_prefix, file_suffix, '.mm camera'
157+
output_dir, file_prefix, file_suffix, '.mmcamera'
151158
)
159+
160+
cam_tfm = cam.get_transform_node()
161+
cam_shp = cam.get_shape_node()
162+
assert cam_tfm is not None
163+
assert cam_shp is not None
164+
165+
frames = list(range(frame_range.start, frame_range.end + 1))
166+
167+
def _sample_mm(node, attr):
168+
raw = _query_sample_attr_over_frames(node, attr, frames)
169+
return [[f, v * INCHES_TO_MM] for f, v in raw]
170+
171+
attr_data = {
172+
'translateX': _query_sample_attr_over_frames(cam_tfm, 'translateX', frames),
173+
'translateY': _query_sample_attr_over_frames(cam_tfm, 'translateY', frames),
174+
'translateZ': _query_sample_attr_over_frames(cam_tfm, 'translateZ', frames),
175+
'rotateX': _query_sample_attr_over_frames(cam_tfm, 'rotateX', frames),
176+
'rotateY': _query_sample_attr_over_frames(cam_tfm, 'rotateY', frames),
177+
'rotateZ': _query_sample_attr_over_frames(cam_tfm, 'rotateZ', frames),
178+
'focalLength': _query_sample_attr_over_frames(cam_shp, 'focalLength', frames),
179+
'filmBackWidth': _sample_mm(cam_shp, 'horizontalFilmAperture'),
180+
'filmBackHeight': _sample_mm(cam_shp, 'verticalFilmAperture'),
181+
'filmBackOffsetX': _sample_mm(cam_shp, 'horizontalFilmOffset'),
182+
'filmBackOffsetY': _sample_mm(cam_shp, 'verticalFilmOffset'),
183+
}
184+
185+
image_width, image_height = cam.get_plate_resolution()
186+
pixel_aspect = None
187+
188+
cam_name = cam_tfm.split('|')[-1]
189+
190+
doc = {
191+
'version': 1,
192+
'data': {
193+
'name': cam_name,
194+
'start_frame': frame_range.start,
195+
'end_frame': frame_range.end,
196+
'image': {
197+
'width': image_width,
198+
'height': image_height,
199+
'pixel_aspect_ratio': pixel_aspect,
200+
'file_path': None,
201+
},
202+
'attr': attr_data,
203+
},
204+
}
205+
206+
with open(file_path, 'w') as fh:
207+
json.dump(doc, fh)
152208
return file_path
153209

154210

155211
def save_nuke_lens_to_file(cam, lens, frame_range, file_prefix, output_dir):
156-
"""
157-
Save nuke lens to disk.
158-
"""
159212
file_suffix = ''
160213
data_list = savelensfile_lib.generate(cam, lens, frame_range)
161214
file_path = construct_output_file_path(output_dir, file_prefix, file_suffix, '.nk')
162215
savelensfile_lib.write_nuke_file(file_path, data_list)
163216
return file_path
164217

165218

166-
def save_solver_settings_to_file(cam, file_prefix, output_dir):
167-
"""
168-
Save Solver settings to disk.
169-
"""
170-
# TODO: Implement this.
219+
def save_solver_settings_to_file(
220+
frame_range,
221+
adjustment_solver,
222+
adjustment_attrs,
223+
file_prefix,
224+
output_dir,
225+
):
226+
assert isinstance(frame_range, time_utils.FrameRange)
227+
assert isinstance(adjustment_solver, AdjustmentSolver)
228+
assert isinstance(adjustment_attrs, AdjustmentAttributes)
229+
171230
file_suffix = ''
172231
file_path = construct_output_file_path(
173232
output_dir, file_prefix, file_suffix, '.mmsettings'
174233
)
234+
235+
solver_type_map = {
236+
const.ADJUSTMENT_SOLVER_TYPE_EVOLUTION_REFINE: 'evolution_refine',
237+
const.ADJUSTMENT_SOLVER_TYPE_EVOLUTION_UNKNOWN: 'evolution_unknown',
238+
const.ADJUSTMENT_SOLVER_TYPE_UNIFORM_GRID: 'uniform_grid',
239+
}
240+
adj_type = adjustment_solver.get_adjustment_solver_type()
241+
adj_type_str = None
242+
if adj_type is not None:
243+
adj_type_str = solver_type_map.get(adj_type)
244+
245+
adj_solver_data = None
246+
if adj_type_str is not None:
247+
thread_count = adjustment_solver.get_thread_count()
248+
value_range_estimate = adjustment_solver.get_evolution_value_range_estimate()
249+
if value_range_estimate is None:
250+
value_range_estimate = True
251+
252+
gen_count = adjustment_solver.get_evolution_generation_count()
253+
pop_count = adjustment_solver.get_evolution_population_count()
254+
adj_solver_data = {
255+
'type': adj_type_str,
256+
'thread_count': thread_count,
257+
'evolution_value_range_estimate': value_range_estimate,
258+
'evolution_generation_count': gen_count,
259+
'evolution_population_count': pop_count,
260+
}
261+
262+
attr_list = []
263+
for attr_name in adjustment_attrs.get_attribute_names():
264+
bounds = adjustment_attrs.get_attribute_bounds(attr_name)
265+
sample_count = adjustment_attrs.get_attribute_sample_count(attr_name)
266+
if bounds:
267+
value_min = bounds[0]
268+
value_max = bounds[1]
269+
else:
270+
value_min = 0.0
271+
value_max = 200.0
272+
entry = {
273+
'name': attr_name,
274+
'value_min': value_min,
275+
'value_max': value_max,
276+
'sample_count': sample_count,
277+
}
278+
attr_list.append(entry)
279+
280+
data_section = {
281+
'origin_frame': frame_range.start,
282+
'frames': {
283+
'start_frame': frame_range.start,
284+
'end_frame': frame_range.end,
285+
},
286+
'adjustment_attributes': attr_list,
287+
}
288+
if adj_solver_data is not None:
289+
data_section['adjustment_solver'] = adj_solver_data
290+
291+
doc = {'version': 1, 'data': data_section}
292+
293+
with open(file_path, 'w') as fh:
294+
json.dump(doc, fh, indent=4)
175295
return file_path
176296

177297

178-
def __find_executable_file_path():
179-
"""
180-
Find the EXECUTABLE_FILE_NAME from the module directory.
181-
"""
298+
def find_executable_file_path():
182299
# type: (...) -> str | None
183300
var_name = const.MMSOLVER_LOCATION_ENV_VAR_NAME
184-
module_location = os.environ[var_name] # type: str
185-
assert os.path.isdir(module_location)
301+
module_location = os.environ.get(var_name)
302+
if not module_location or not os.path.isdir(module_location):
303+
return None
186304
executable_file_path = os.path.join(
187305
module_location, 'bin', const.EXECUTABLE_FILE_NAME
188306
)
189307
if not os.path.isfile(executable_file_path):
190-
return
308+
return None
191309
return executable_file_path
192310

193311

194312
def launch_solve(
195313
cam, # type: mmapi.Camera
196-
lens, # type: mmapi.Lens
314+
lens, # type: mmapi.Lens | None
197315
mkr_list, # type: list[mmapi.Marker]
198316
frame_range, # type: time_utils.FrameRange
199317
adjustment_solver, # type: AdjustmentSolver
@@ -202,16 +320,15 @@ def launch_solve(
202320
prefix_name, # type: str
203321
output_dir, # type: str
204322
):
205-
# type: (...) -> None
323+
# type: (...) -> tuple[int, str, str]
206324
"""
207-
Launch solver executable.
325+
Write .uv/.mmcamera/.mmsettings (and optionally .nk) then run the
326+
camera solver executable.
208327
209-
TODO: Read stdout from the process.
210-
211-
TODO: Do not block Maya main thread.
328+
Returns (returncode, stdout, stderr).
212329
"""
213330
assert isinstance(cam, mmapi.Camera)
214-
assert isinstance(lens, mmapi.Lens)
331+
assert lens is None or isinstance(lens, mmapi.Lens)
215332
assert isinstance(mkr_list, list)
216333
assert isinstance(frame_range, time_utils.FrameRange)
217334
assert isinstance(adjustment_solver, AdjustmentSolver)
@@ -220,16 +337,83 @@ def launch_solve(
220337
assert isinstance(prefix_name, pycompat.TEXT_TYPE)
221338
assert output_dir and os.path.isdir(output_dir)
222339

223-
executable_file_path = __find_executable_file_path()
340+
executable_file_path = find_executable_file_path()
224341
if executable_file_path is None:
225342
LOG.error('Could not find %r executable!', const.EXECUTABLE_FILE_NAME)
226-
return
227-
assert os.path.isfile(executable_file_path)
343+
return (-1, '', '')
344+
345+
uv_file_path = save_markers_to_file(mkr_list, frame_range, prefix_name, output_dir)
346+
mmcamera_file_path = save_camera_to_file(cam, frame_range, prefix_name, output_dir)
347+
solver_settings_file_path = save_solver_settings_to_file(
348+
frame_range, adjustment_solver, adjustment_attrs, prefix_name, output_dir
349+
)
350+
351+
nuke_lens_file_path = None
352+
if lens is not None:
353+
nuke_lens_file_path = save_nuke_lens_to_file(
354+
cam, lens, frame_range, prefix_name, output_dir
355+
)
356+
357+
cmd_args = [
358+
executable_file_path,
359+
uv_file_path,
360+
'--mmcamera',
361+
mmcamera_file_path,
362+
'--solver-settings',
363+
solver_settings_file_path,
364+
]
365+
366+
if nuke_lens_file_path is not None:
367+
cmd_args += ['--nuke-lens', nuke_lens_file_path]
368+
369+
cmd_args += [
370+
'--prefix',
371+
prefix_name,
372+
'--output-dir',
373+
output_dir,
374+
'--log-level',
375+
log_level,
376+
]
377+
378+
LOG.debug('Camera solver command: %s', ' '.join(cmd_args))
379+
proc = subprocess.Popen(
380+
cmd_args,
381+
stdout=subprocess.PIPE,
382+
stderr=subprocess.PIPE,
383+
)
228384

229-
# TODO: Convert the arguments into command flags.
230-
cmd_args = [executable_file_path, '--help']
231-
subprocess.call(cmd_args)
232-
return
385+
stdout_lines = []
386+
stderr_lines = []
387+
388+
def _read_stream(stream, line_list, log_fn):
389+
for raw in iter(stream.readline, b''):
390+
line = raw.decode('utf-8', errors='replace').rstrip('\n')
391+
line_list.append(line)
392+
log_fn('%s', line)
393+
stream.close()
394+
395+
stdout_thread = threading.Thread(
396+
target=_read_stream,
397+
args=(proc.stdout, stdout_lines, LOG.info),
398+
daemon=True,
399+
)
400+
stderr_thread = threading.Thread(
401+
target=_read_stream,
402+
args=(proc.stderr, stderr_lines, LOG.warning),
403+
daemon=True,
404+
)
405+
stdout_thread.start()
406+
stderr_thread.start()
407+
stdout_thread.join()
408+
stderr_thread.join()
409+
proc.wait()
410+
411+
stdout = '\n'.join(stdout_lines)
412+
stderr = '\n'.join(stderr_lines)
413+
returncode = proc.returncode
414+
if returncode != 0:
415+
LOG.error('Camera solver failed (exit %d):\n%s', returncode, stderr or stdout)
416+
return (returncode, stdout, stderr)
233417

234418

235419
def load_camera_outputs():
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
# Copyright (C) 2026 David Cattermole.
2+
#
3+
# This file is part of mmSolver.
4+
#
5+
# mmSolver is free software: you can redistribute it and/or modify it
6+
# under the terms of the GNU Lesser General Public License as
7+
# published by the Free Software Foundation, either version 3 of the
8+
# License, or (at your option) any later version.
9+
#
10+
# mmSolver is distributed in the hope that it will be useful,
11+
# but WITHOUT ANY WARRANTY; without even the implied warranty of
12+
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13+
# GNU Lesser General Public License for more details.
14+
#
15+
# You should have received a copy of the GNU Lesser General Public License
16+
# along with mmSolver. If not, see <https://www.gnu.org/licenses/>.
17+
#
18+
"""
19+
Tests for the Camera Solver tool.
20+
"""

0 commit comments

Comments
 (0)