Skip to content

Commit cb98331

Browse files
Danielebrahimebrahim
authored andcommitted
Add additional matching modes to run_reconstruction (#333)
This adds support for 'sequential' and 'spatial' matching strategies, in addition to the existing 'exhaustive' and 'sequential_loop' modes. The matching mode must now be explicitly specified via the `matching_mode` argument instead of being inferred from `window_radius`. The 'spatial' mode requires 3D `locations` and uses nearest-neighbor matching.
1 parent 3b76420 commit cb98331

File tree

2 files changed

+190
-18
lines changed

2 files changed

+190
-18
lines changed

src/openlifu/nav/photoscan.py

Lines changed: 117 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -294,7 +294,10 @@ def run_reconstruction(
294294
pipeline_name: str = "default_pipeline",
295295
input_resize_width: int = 3024,
296296
use_masks: bool = True,
297+
matching_mode: str = 'exhaustive',
297298
window_radius: int | None = None,
299+
num_neighbors: int | None = None,
300+
locations: List[Tuple[float, float, float]] | None = None,
298301
return_durations: bool = False,
299302
progress_callback : Callable[[int,str],None] | None = None,
300303
) -> Tuple[Photoscan, Path] | Tuple[Photoscan, Path, Dict[str, float]]:
@@ -305,8 +308,17 @@ def run_reconstruction(
305308
See also `get_meshroom_pipeline_names`.
306309
input_resize_width (int): Width to which input images will be resized, in pixels.
307310
use_masks (bool): Whether to include a background removal step to filter the dense reconstruction.
308-
window_radius (Optional[int]): number of images forward and backward in the sequence to try and
309-
match with, if None match each images to all others.
311+
matching_mode (str): Strategy for generating image pairs. One of:
312+
- 'exhaustive': Match every image with every other image.
313+
- 'sequential': Match each image with the previous and next `window_radius` images (no wrap-around).
314+
- 'sequential_loop': Like 'sequential' but wraps around at the end of the sequence.
315+
- 'spatial': Match each image with its `num_neighbors` nearest neighbors based on 3D location.
316+
window_radius (int | None): Required for 'sequential' and 'sequential_loop' matching_mode. Number of
317+
images forward and backward in the sequence to try and match with.
318+
num_neighbors (int | None): Required for 'spatial' matching_mode. Number of nearest neighbors to match
319+
based on 3D distance in `locations`.
320+
locations (List[Tuple[float, float, float]]] | None): Required for 'spatial'. Must be the same length
321+
as `images`. Provides 3D coordinates for spatial matching.
310322
return_durations (bool): If True, also return a dictionary mapping node names to durations in seconds.
311323
progress_callback: An optional function that will be called to report progress. The function should accept two arguments:
312324
an integer progress value from 0 to 100 followed by a string message describing the step currently being worked on.
@@ -316,6 +328,32 @@ def run_reconstruction(
316328
- If return_durations is False: returns the Photoscan and the data directory path.
317329
- If return_durations is True: also returns a dictionary of node execution times.
318330
"""
331+
image_indices = list(range(len(images)))
332+
valid_modes = {'exhaustive', 'sequential', 'sequential_loop', 'spatial'}
333+
if matching_mode == 'exhaustive':
334+
pairs = _make_pairs_sequential(image_indices, len(image_indices))
335+
336+
elif matching_mode == 'sequential':
337+
if window_radius is None:
338+
raise ValueError(f"A window radius is required for matching mode: '{matching_mode}'.")
339+
pairs = _make_pairs_sequential(image_indices, window_radius)
340+
341+
elif matching_mode == 'sequential_loop':
342+
if window_radius is None:
343+
raise ValueError(f"A window radius is required for matching mode: '{matching_mode}'.")
344+
pairs = _make_pairs_sequential_loop(image_indices, window_radius)
345+
346+
elif matching_mode == 'spatial':
347+
if locations is None:
348+
raise ValueError("Spatial matching requires `locations`, but none were provided.")
349+
if len(locations) != len(images):
350+
raise ValueError("`locations` must be the same length as `images`.")
351+
if num_neighbors is None:
352+
raise ValueError("Spatial matching requires `num_neighbors`, but it was not provided.")
353+
pairs = _make_pairs_spatial(image_indices, num_neighbors, locations)
354+
355+
else:
356+
raise ValueError(f"Invalid matching mode: '{matching_mode}'. Must be one of {valid_modes}.")
319357

320358
if progress_callback is None:
321359
def progress_callback(progress_percent : int, step_description : str): # noqa: ARG001
@@ -404,7 +442,7 @@ def progress_callback(progress_percent : int, step_description : str): # noqa: A
404442
subprocess_stream_output(command_camera_init, logger_meshroom.info, logger_meshroom.warning)
405443

406444
camera_init_path = next(cache_dir.glob("CameraInit/*/cameraInit.sfm"))
407-
write_pair_file(new_paths, camera_init_path, pair_file_path, window_radius=window_radius)
445+
write_pair_file(new_paths, pairs, camera_init_path, pair_file_path)
408446

409447
number_of_nodes = len([node for node in _nodes if node in config_nodes])
410448
pipeline_progress_start = 10.0
@@ -687,7 +725,7 @@ def _read_path_to_view_ids(input_path: Path) -> Dict[Path, str]:
687725
return basename_to_viewid
688726

689727

690-
def _make_pairs(view_ids: List[str], window_radius: int | None = None) -> List[List[str]]:
728+
def _make_pairs_sequential_loop(view_ids: List[Any], window_radius: int | None = None) -> List[List[Any]]:
691729
"""
692730
Generate image match pairs from a list of view IDs. Assumes view_ids are sequential and
693731
wrap around. Each view is matched with the `window_radius` views before and after it.
@@ -715,20 +753,88 @@ def _make_pairs(view_ids: List[str], window_radius: int | None = None) -> List[L
715753
rows[i].append(view_ids[k])
716754
else:
717755
rows[k % num_views].append(view_ids[i])
718-
719756
rows = rows[:-1]
720757
return rows
721758

722-
def write_pair_file(image_paths: List[Path], camera_init_file: Path, output_path: Path, window_radius: int | None=None) -> None:
759+
def _make_pairs_sequential(view_ids: List[Any], window_radius: int) -> List[List[Any]]:
760+
"""
761+
Generate image match pairs from a list of view IDs using a fixed sliding window.
762+
Each view is matched with the `window_radius` subsequent views, without wrapping
763+
around at the end of the list.
764+
765+
Args:
766+
view_ids (List[Any]): Ordered list of view identifiers.
767+
window_radius (int): Number of forward neighbors to match for each view.
768+
769+
Returns:
770+
List[List[Any]]: Each row is of the form [src, tgt1, tgt2, ...], meaning src
771+
is matched to tgt1, tgt2, etc.
723772
"""
724-
Write the imagePairsList file for Meshroom to perform sequential matching with wrap around.
725-
Assumes that `image_paths` are ordered sequentially and wrap around.
773+
num_views = len(view_ids)
774+
rows = []
775+
for i in range(num_views-1):
776+
rows.append([view_ids[i]])
777+
for j in range(i+1, min(i+window_radius+1, num_views)):
778+
rows[i].append(view_ids[j])
779+
return rows
780+
781+
def _make_pairs_spatial(view_ids: List[Any], num_neighbors: int, locations: List[Tuple[float, float, float]]) -> List[List[Any]]:
726782
"""
727-
path_to_viewid = _read_path_to_view_ids(camera_init_file)
728-
view_ids = [path_to_viewid[im_path] for im_path in image_paths]
783+
Generate image match pairs from a list of view IDs based on spatial proximity.
784+
Each view is matched with its `num_neighbors` nearest neighbors using 3D Euclidean
785+
distance.
729786
730-
rows = _make_pairs(view_ids, window_radius)
787+
Args:
788+
view_ids (List[Any]): List of view identifiers.
789+
num_neighbors (int): Number of nearest neighbors to match each view with.
790+
locations (List[Tuple[float, float, float]]): 3D coordinates corresponding to each view.
791+
792+
Returns:
793+
List[List[Any]]: Each row is of the form [src, tgt1, tgt2, ...], where src is matched
794+
to tgt1, tgt2, etc.
795+
"""
796+
assert len(view_ids) == len(locations)
797+
num_views = len(view_ids)
798+
locations = np.array(locations)
799+
neighbors = [set() for _ in range(len(view_ids))]
800+
for i in range(len(view_ids)):
801+
dists = np.linalg.norm(locations - locations[i], axis=1)
802+
dists[i] = np.inf
803+
nearest = np.argsort(dists)[:min(num_neighbors, num_views-1)]
804+
for j in nearest:
805+
if j > i:
806+
neighbors[i].add(j)
807+
else:
808+
neighbors[j].add(i)
809+
810+
#turn sets into list
811+
rows = []
812+
for i, neigh_set in enumerate(neighbors):
813+
if neigh_set:
814+
row = [view_ids[i]]
815+
for j in sorted(neigh_set):
816+
row.append(view_ids[j])
817+
rows.append(row)
818+
return rows
731819

820+
821+
def write_pair_file(images: List[Path],
822+
pairs: List[List[int]],
823+
camera_init_file: Path,
824+
output_path: Path) -> None:
825+
"""
826+
Convert image index-based pairs to Meshroom internal view ID pairs using the provided
827+
camera_init_file. Write the result to a Meshroom compatible file.
828+
829+
Args:
830+
images: A list of image Paths (ordered by index).
831+
pairs: A list of lists of indices into the `images` list.
832+
camera_init_file: Path to Meshroom's cameraInit.sfm or similar file containing view IDs.
833+
output_path: Path where the resulting pair file will be written.
834+
"""
835+
path_to_viewid = _read_path_to_view_ids(camera_init_file)
836+
#convert the image id to Meshrooms view id
837+
rows = [[path_to_viewid[images[image_id]] for image_id in sublist] for sublist in pairs]
732838
with open(output_path, "w") as f:
733839
for row in rows:
734840
f.write(" ".join(map(str, row)) + "\n")

tests/test_photoscans.py

Lines changed: 73 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,9 @@
1111
from openlifu.db.database import Database
1212
from openlifu.nav.photoscan import (
1313
Photoscan,
14-
_make_pairs,
14+
_make_pairs_sequential,
15+
_make_pairs_sequential_loop,
16+
_make_pairs_spatial,
1517
apply_exif_orientation_numpy,
1618
convert_between_ras_and_lps,
1719
convert_numpy_to_vtkimage,
@@ -182,19 +184,83 @@ def test_preprocess_image_modnet():
182184
assert preprocess_image_modnet(np.zeros((600, 700, 3))).shape == (1,3,512,576)
183185
assert preprocess_image_modnet(np.zeros((400, 300, 3))).shape == (1, 3, 672, 512)
184186

185-
def test_make_pairs():
186-
"""Verify sequential matching _make_pairs works correctly"""
187+
def test_make_pairs_sequential_loop():
188+
"""Verify _make_pairs_sequential_loop works correctly"""
187189
expected_all_way_three = [['a', 'b', 'c'], ['b', 'c']]
188190
#no window_radius
189-
assert _make_pairs(['a','b','c'], window_radius=None) == expected_all_way_three
191+
assert _make_pairs_sequential_loop(['a','b','c'], window_radius=None) == expected_all_way_three
190192
#2*window_radius + 1 == list length
191-
assert _make_pairs(['a','b','c'], window_radius=1) == expected_all_way_three
193+
assert _make_pairs_sequential_loop(['a','b','c'], window_radius=1) == expected_all_way_three
192194
#2*window_radius + 1 > list length
193-
assert _make_pairs(['a','b','c'], window_radius=2) == expected_all_way_three
195+
assert _make_pairs_sequential_loop(['a','b','c'], window_radius=2) == expected_all_way_three
194196
#usual case
195197
expected = [['a', 'b', 'c', 'e', 'f'],
196198
['b', 'c', 'd', 'f'],
197199
['c', 'd', 'e'],
198200
['d', 'e', 'f'],
199201
['e', 'f']]
200-
assert _make_pairs(['a','b','c','d','e','f'], window_radius=2) == expected
202+
assert _make_pairs_sequential_loop(['a','b','c','d','e','f'], window_radius=2) == expected
203+
204+
def test_make_pairs_sequential():
205+
"""Verify _make_pairs_sequential works correctly"""
206+
rows = _make_pairs_sequential(['a', 'b', 'c', 'd'],window_radius=1)
207+
expected = [['a', 'b'],
208+
['b', 'c'],
209+
['c', 'd']]
210+
assert rows == expected
211+
rows = _make_pairs_sequential(['a', 'b', 'c', 'd'],window_radius=2)
212+
expected = [['a', 'b', 'c'],
213+
['b', 'c', 'd'],
214+
['c', 'd']]
215+
assert rows == expected
216+
expected_exhaustive = [['a', 'b', 'c', 'd'],
217+
['b', 'c', 'd'],
218+
['c', 'd']]
219+
rows = _make_pairs_sequential(['a', 'b', 'c', 'd'],window_radius=3)
220+
assert rows == expected_exhaustive
221+
rows = _make_pairs_sequential(['a', 'b', 'c', 'd'],window_radius=5)
222+
assert rows == expected_exhaustive
223+
224+
def test_make_pairs_spatial():
225+
"""Verify _make_pairs_spatial works correctly"""
226+
rows = _make_pairs_spatial(['a', 'b', 'c', 'd', 'e'],num_neighbors=1, locations=[(20,0,0),(10,0,0),(5,0,0),(2,0,0), (0,0,0)])
227+
expected = [['a', 'b'],
228+
['b', 'c'],
229+
['c', 'd'],
230+
['d', 'e']]
231+
assert rows == expected
232+
rows = _make_pairs_spatial(['a', 'b', 'c', 'd', 'e'],num_neighbors=2, locations=[(20,0,0),(10,0,0),(5,0,0),(2,0,0), (0,0,0)])
233+
expected = [['a', 'b', 'c'],
234+
['b', 'c', 'd'],
235+
['c', 'd', 'e'],
236+
['d', 'e']]
237+
assert rows == expected
238+
rows = _make_pairs_spatial(['a', 'b', 'c', 'd', 'e'],num_neighbors=3, locations=[(20,0,0),(10,0,0),(5,0,0),(2,0,0), (0,0,0)])
239+
expected = [['a', 'b', 'c', 'd'],
240+
['b', 'c', 'd', 'e'],
241+
['c', 'd', 'e'],
242+
['d', 'e']]
243+
assert rows == expected
244+
rows = _make_pairs_spatial(['a', 'b', 'c', 'd', 'e'],num_neighbors=5, locations=[(20,0,0),(10,0,0),(5,0,0),(2,0,0), (0,0,0)])
245+
expected = [['a', 'b', 'c', 'd', 'e'],
246+
['b', 'c', 'd', 'e'],
247+
['c', 'd', 'e'],
248+
['d', 'e']]
249+
assert rows == expected
250+
251+
#test on circle
252+
N = 8
253+
angles = np.linspace(0, 2 * np.pi, N, endpoint=False)
254+
x = np.cos(angles)
255+
y = np.sin(angles)
256+
z = np.zeros(N)
257+
locations = list(zip(x, y, z))
258+
rows = _make_pairs_spatial(['a', 'b', 'c', 'd', 'e', 'f','g', 'h'],num_neighbors=2, locations=locations)
259+
expected = [['a', 'b', 'h'],
260+
['b', 'c'],
261+
['c', 'd'],
262+
['d', 'e'],
263+
['e', 'f'],
264+
['f', 'g'],
265+
['g', 'h']]
266+
assert rows == expected

0 commit comments

Comments
 (0)