pingmapper 5.4.8__tar.gz → 5.5.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. {pingmapper-5.4.8 → pingmapper-5.5.0}/PKG-INFO +1 -1
  2. {pingmapper-5.4.8 → pingmapper-5.5.0}/pingmapper/class_mapSubstrateObj.py +7 -4
  3. {pingmapper-5.4.8 → pingmapper-5.5.0}/pingmapper/class_portstarObj.py +27 -4
  4. {pingmapper-5.4.8 → pingmapper-5.5.0}/pingmapper/class_rectObj.py +3 -3
  5. {pingmapper-5.4.8 → pingmapper-5.5.0}/pingmapper/default_params.json +8 -0
  6. {pingmapper-5.4.8 → pingmapper-5.5.0}/pingmapper/gui_main.py +92 -0
  7. {pingmapper-5.4.8 → pingmapper-5.5.0}/pingmapper/main_readFiles.py +616 -0
  8. pingmapper-5.5.0/pingmapper/version.py +1 -0
  9. {pingmapper-5.4.8 → pingmapper-5.5.0}/pingmapper.egg-info/PKG-INFO +1 -1
  10. pingmapper-5.4.8/pingmapper/version.py +0 -1
  11. {pingmapper-5.4.8 → pingmapper-5.5.0}/LICENSE +0 -0
  12. {pingmapper-5.4.8 → pingmapper-5.5.0}/README.md +0 -0
  13. {pingmapper-5.4.8 → pingmapper-5.5.0}/pingmapper/__init__.py +0 -0
  14. {pingmapper-5.4.8 → pingmapper-5.5.0}/pingmapper/__main__.py +0 -0
  15. {pingmapper-5.4.8 → pingmapper-5.5.0}/pingmapper/class_sonObj.py +0 -0
  16. {pingmapper-5.4.8 → pingmapper-5.5.0}/pingmapper/class_sonObj_nadirgaptest.py +0 -0
  17. {pingmapper-5.4.8 → pingmapper-5.5.0}/pingmapper/doWork.py +0 -0
  18. {pingmapper-5.4.8 → pingmapper-5.5.0}/pingmapper/funcs_common.py +0 -0
  19. {pingmapper-5.4.8 → pingmapper-5.5.0}/pingmapper/funcs_model.py +0 -0
  20. {pingmapper-5.4.8 → pingmapper-5.5.0}/pingmapper/funcs_rectify.py +0 -0
  21. {pingmapper-5.4.8 → pingmapper-5.5.0}/pingmapper/main_mapSubstrate.py +0 -0
  22. {pingmapper-5.4.8 → pingmapper-5.5.0}/pingmapper/main_rectify.py +0 -0
  23. {pingmapper-5.4.8 → pingmapper-5.5.0}/pingmapper/nonGUI_batch_main.py +0 -0
  24. {pingmapper-5.4.8 → pingmapper-5.5.0}/pingmapper/nonGui_main.py +0 -0
  25. {pingmapper-5.4.8 → pingmapper-5.5.0}/pingmapper/processing_scripts/main_batchDirectory_2024-01-18_0926.py +0 -0
  26. {pingmapper-5.4.8 → pingmapper-5.5.0}/pingmapper/processing_scripts/main_batchDirectory_2024-01-18_0929.py +0 -0
  27. {pingmapper-5.4.8 → pingmapper-5.5.0}/pingmapper/scratch/funcs_pyhum_correct.py +0 -0
  28. {pingmapper-5.4.8 → pingmapper-5.5.0}/pingmapper/scratch/main.py +0 -0
  29. {pingmapper-5.4.8 → pingmapper-5.5.0}/pingmapper/scratch/main_batchDirectory.py +0 -0
  30. {pingmapper-5.4.8 → pingmapper-5.5.0}/pingmapper/self_check.py +0 -0
  31. {pingmapper-5.4.8 → pingmapper-5.5.0}/pingmapper/test_PINGMapper.py +0 -0
  32. {pingmapper-5.4.8 → pingmapper-5.5.0}/pingmapper/test_cli_self_check.py +0 -0
  33. {pingmapper-5.4.8 → pingmapper-5.5.0}/pingmapper/test_dq_filter.py +0 -0
  34. {pingmapper-5.4.8 → pingmapper-5.5.0}/pingmapper/test_time.py +0 -0
  35. {pingmapper-5.4.8 → pingmapper-5.5.0}/pingmapper/utils/DRAFT_Workflows/avg_predictions_Mussel_WBL.py +0 -0
  36. {pingmapper-5.4.8 → pingmapper-5.5.0}/pingmapper/utils/DRAFT_Workflows/gen_centerline.py +0 -0
  37. {pingmapper-5.4.8 → pingmapper-5.5.0}/pingmapper/utils/DRAFT_Workflows/gen_centerline_from_bankline.py +0 -0
  38. {pingmapper-5.4.8 → pingmapper-5.5.0}/pingmapper/utils/DRAFT_Workflows/gen_centerline_trkpnts_fitspline_DRAFT.py +0 -0
  39. {pingmapper-5.4.8 → pingmapper-5.5.0}/pingmapper/utils/DRAFT_Workflows/testEXAMPLE_mosaic_logit.py +0 -0
  40. {pingmapper-5.4.8 → pingmapper-5.5.0}/pingmapper/utils/RawEGN_avg_predictions.py +0 -0
  41. {pingmapper-5.4.8 → pingmapper-5.5.0}/pingmapper/utils/Substrate_Summaries/00_substrate_logits_mosaic_transects.py +0 -0
  42. {pingmapper-5.4.8 → pingmapper-5.5.0}/pingmapper/utils/Substrate_Summaries/00_substrate_shps_mosaic_transects.py +0 -0
  43. {pingmapper-5.4.8 → pingmapper-5.5.0}/pingmapper/utils/Substrate_Summaries/01_gen_centerline_from_coverage.py +0 -0
  44. {pingmapper-5.4.8 → pingmapper-5.5.0}/pingmapper/utils/Substrate_Summaries/02_gen_summary_stamp_shps.py +0 -0
  45. {pingmapper-5.4.8 → pingmapper-5.5.0}/pingmapper/utils/Substrate_Summaries/03_gen_summary_shp.py +0 -0
  46. {pingmapper-5.4.8 → pingmapper-5.5.0}/pingmapper/utils/Substrate_Summaries/04_combine_summary_shp_csv.py +0 -0
  47. {pingmapper-5.4.8 → pingmapper-5.5.0}/pingmapper/utils/Substrate_Summaries/05_gen_summary_shp_plots.py +0 -0
  48. {pingmapper-5.4.8 → pingmapper-5.5.0}/pingmapper/utils/Substrate_Summaries/06_compare_raw-egn_volume.py +0 -0
  49. {pingmapper-5.4.8 → pingmapper-5.5.0}/pingmapper/utils/Substrate_Summaries/08_raw-egn_hardReacheFreq_hist.py +0 -0
  50. {pingmapper-5.4.8 → pingmapper-5.5.0}/pingmapper/utils/Substrate_Summaries/09_raw-egn_PatchSize_density.py +0 -0
  51. {pingmapper-5.4.8 → pingmapper-5.5.0}/pingmapper/utils/Substrate_Summaries/summarize_project_substrate.py +0 -0
  52. {pingmapper-5.4.8 → pingmapper-5.5.0}/pingmapper/utils/export_coverage.py +0 -0
  53. {pingmapper-5.4.8 → pingmapper-5.5.0}/pingmapper/utils/main_mosaic_transects.py +0 -0
  54. {pingmapper-5.4.8 → pingmapper-5.5.0}/pingmapper.egg-info/SOURCES.txt +0 -0
  55. {pingmapper-5.4.8 → pingmapper-5.5.0}/pingmapper.egg-info/dependency_links.txt +0 -0
  56. {pingmapper-5.4.8 → pingmapper-5.5.0}/pingmapper.egg-info/requires.txt +0 -0
  57. {pingmapper-5.4.8 → pingmapper-5.5.0}/pingmapper.egg-info/top_level.txt +0 -0
  58. {pingmapper-5.4.8 → pingmapper-5.5.0}/pyproject.toml +0 -0
  59. {pingmapper-5.4.8 → pingmapper-5.5.0}/setup.cfg +0 -0
  60. {pingmapper-5.4.8 → pingmapper-5.5.0}/setup.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pingmapper
3
- Version: 5.4.8
3
+ Version: 5.5.0
4
4
  Summary: Open-source interface for processing recreation-grade side scan sonar datasets and reproducibly mapping benthic habitat
5
5
  Author: Cameron Bodine, Daniel Buscombe, Iury T Simoes-Sousa
6
6
  Author-email: bodine.cs@gmail.email
@@ -29,7 +29,7 @@
29
29
  # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
30
30
  # SOFTWARE.
31
31
 
32
- import os, sys
32
+ import os, sys, re
33
33
 
34
34
  # Add 'pingmapper' to the path, may not need after pypi package...
35
35
  SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
@@ -1066,9 +1066,12 @@ class mapSubObj(rectObj):
1066
1066
 
1067
1067
  # Extract chunkID from filename and store in dict
1068
1068
  for n in npzs:
1069
- c = os.path.basename(n)
1070
- c = c.split('.')[0]
1071
- c = int(c.split('_')[-1])
1069
+ c = os.path.splitext(os.path.basename(n))[0]
1070
+ match = re.search(r'_(\d+)$', c)
1071
+ if match is None:
1072
+ continue
1073
+
1074
+ c = int(match.group(1))
1072
1075
  toMap[c] = n
1073
1076
 
1074
1077
  del npzDir, npzs
@@ -714,8 +714,18 @@ class portstarObj(object):
714
714
  except:
715
715
  pass
716
716
 
717
- # First built a vrt
718
- vrt_options = gdal.BuildVRTOptions(resampleAlg=resampleAlg, bandList = bands_to_use)
717
+ # For substrate/prediction mosaics, enforce configured map resolution.
718
+ force_map_res = (not son) and (self.port.pix_res_map != 0)
719
+ if force_map_res:
720
+ vrt_options = gdal.BuildVRTOptions(
721
+ resampleAlg=resampleAlg,
722
+ bandList=bands_to_use,
723
+ xRes=self.port.pix_res_map,
724
+ yRes=self.port.pix_res_map,
725
+ targetAlignedPixels=True,
726
+ )
727
+ else:
728
+ vrt_options = gdal.BuildVRTOptions(resampleAlg=resampleAlg, bandList = bands_to_use)
719
729
  gdal.BuildVRT(outVRT, imgs, options=vrt_options)
720
730
 
721
731
  # Create GeoTiff from vrt
@@ -733,7 +743,10 @@ class portstarObj(object):
733
743
  # gdal.Translate(outTIF, ds, xRes=xRes, yRes=yRes, **kwargs)
734
744
 
735
745
  # Create geotiff
736
- gdal.Translate(outTIF, ds, **kwargs)
746
+ if force_map_res:
747
+ gdal.Translate(outTIF, ds, xRes=self.port.pix_res_map, yRes=self.port.pix_res_map, **kwargs)
748
+ else:
749
+ gdal.Translate(outTIF, ds, **kwargs)
737
750
 
738
751
  # Generate overviews
739
752
  if overview:
@@ -841,7 +854,17 @@ class portstarObj(object):
841
854
 
842
855
  # Build VRT
843
856
  # vrt_options = gdal.BuildVRTOptions(resampleAlg=resampleAlg, bandList = bands, xRes=xRes, yRes=yRes)
844
- vrt_options = gdal.BuildVRTOptions(resampleAlg=resampleAlg, bandList = bands_to_use)
857
+ force_map_res = (not son) and (self.port.pix_res_map != 0)
858
+ if force_map_res:
859
+ vrt_options = gdal.BuildVRTOptions(
860
+ resampleAlg=resampleAlg,
861
+ bandList=bands_to_use,
862
+ xRes=self.port.pix_res_map,
863
+ yRes=self.port.pix_res_map,
864
+ targetAlignedPixels=True,
865
+ )
866
+ else:
867
+ vrt_options = gdal.BuildVRTOptions(resampleAlg=resampleAlg, bandList = bands_to_use)
845
868
  gdal.BuildVRT(outVRT, imgs, options=vrt_options)
846
869
 
847
870
  # Generate overviews
@@ -2409,7 +2409,7 @@ class rectObj(sonObj):
2409
2409
  NA
2410
2410
  '''
2411
2411
  filterIntensity = False
2412
- pix_res = self.pix_res_son
2412
+ pix_res = self.pix_res_son if son else self.pix_res_map
2413
2413
  do_resize = True
2414
2414
 
2415
2415
  # # Set pixel resolution
@@ -2786,7 +2786,7 @@ class rectObj(sonObj):
2786
2786
  del out, img
2787
2787
 
2788
2788
  if do_resize:
2789
- self._pixresResize(gtiff)
2789
+ self._pixresResize(gtiff, son=son)
2790
2790
 
2791
2791
  if self.rect_wcr:
2792
2792
  if son:
@@ -2932,7 +2932,7 @@ class rectObj(sonObj):
2932
2932
  del out
2933
2933
 
2934
2934
  if do_resize:
2935
- self._pixresResize(gtiff)
2935
+ self._pixresResize(gtiff, son=son)
2936
2936
 
2937
2937
  gc.collect()
2938
2938
 
@@ -29,6 +29,14 @@
29
29
  "wcm":true,
30
30
  "wcr":false,
31
31
  "wco":false,
32
+ "waterfall_ss_image":false,
33
+ "waterfall_ss_video":false,
34
+ "waterfall_di_image":false,
35
+ "waterfall_di_video":false,
36
+ "waterfall_video_fps":10,
37
+ "waterfall_video_resolution":"1080p",
38
+ "waterfall_mode_selection":"Auto (enabled exports)",
39
+ "waterfall_window_stride":64,
32
40
  "export_16bit":false,
33
41
  "export_colormap_uint8":true,
34
42
  "tileFile":".jpg",
@@ -382,6 +382,84 @@ def gui(batch: bool):
382
382
  layout.append([text_tile])
383
383
  layout.append([col_tile_1, sg.VerticalSeparator(), col_tile_2, sg.VerticalSeparator(), col_tile_3])
384
384
 
385
+ ############################
386
+ # Waterfall Image/Video Exports
387
+
388
+ text_waterfall = sg.Text('Waterfall Image/Video Exports\n', font=("Helvetica", 14, "underline"))
389
+
390
+ tip_wf_ss_img = ml_tip('Export combined side-scan waterfall image (port+star), arranged for top-to-bottom scrolling.')
391
+ tip_wf_ss_vid = ml_tip('Export side-scan scrolling video (top to bottom) from combined port+star waterfall.')
392
+ tip_wf_di_img = ml_tip('Export down-imaging waterfall image for each down-looking beam.')
393
+ tip_wf_di_vid = ml_tip('Export down-imaging scrolling video (right to left) for each down-looking beam.')
394
+ tip_wf_fps = ml_tip('Waterfall video frame rate (frames per second).')
395
+ tip_wf_res = ml_tip('Video output resolution preset for waterfall videos.')
396
+ tip_wf_mode = ml_tip('Tile mode(s) used for waterfall export. Auto uses currently enabled tile exports.')
397
+ tip_wf_stride = ml_tip('Window step in pixels per frame (larger = faster scrolling).')
398
+
399
+ check_wf_ss_img = sg.Checkbox(
400
+ 'Side-scan Waterfall Image (Port+Star Combined)',
401
+ key='waterfall_ss_image',
402
+ default=default_params.get('waterfall_ss_image', False),
403
+ tooltip=tip_wf_ss_img,
404
+ )
405
+ check_wf_ss_vid = sg.Checkbox(
406
+ 'Side-scan Waterfall Video (Top -> Bottom)',
407
+ key='waterfall_ss_video',
408
+ default=default_params.get('waterfall_ss_video', False),
409
+ tooltip=tip_wf_ss_vid,
410
+ )
411
+ check_wf_di_img = sg.Checkbox(
412
+ 'Down-imaging Waterfall Image',
413
+ key='waterfall_di_image',
414
+ default=default_params.get('waterfall_di_image', False),
415
+ tooltip=tip_wf_di_img,
416
+ )
417
+ check_wf_di_vid = sg.Checkbox(
418
+ 'Down-imaging Waterfall Video (Right -> Left)',
419
+ key='waterfall_di_video',
420
+ default=default_params.get('waterfall_di_video', False),
421
+ tooltip=tip_wf_di_vid,
422
+ )
423
+
424
+ text_wf_fps = sg.Text('Video FPS', size=(20,1))
425
+ in_wf_fps = sg.Input(
426
+ key='waterfall_video_fps',
427
+ default_text=default_params.get('waterfall_video_fps', 10),
428
+ size=(10,1),
429
+ tooltip=tip_wf_fps,
430
+ )
431
+
432
+ text_wf_res = sg.Text('Video Resolution', size=(20,1))
433
+ combo_wf_res = sg.Combo(
434
+ ['4K', '1080p', '720p', '4xxp'],
435
+ key='waterfall_video_resolution',
436
+ default_value=default_params.get('waterfall_video_resolution', '1080p'),
437
+ tooltip=tip_wf_res,
438
+ )
439
+
440
+ text_wf_mode = sg.Text('Waterfall Mode(s)', size=(20,1))
441
+ combo_wf_mode = sg.Combo(
442
+ ['Auto (enabled exports)', 'WCP', 'SRC', 'WCP+SRC'],
443
+ key='waterfall_mode_selection',
444
+ default_value=default_params.get('waterfall_mode_selection', 'Auto (enabled exports)'),
445
+ tooltip=tip_wf_mode,
446
+ )
447
+
448
+ text_wf_stride = sg.Text('Window Stride [px]', size=(20,1))
449
+ in_wf_stride = sg.Input(
450
+ key='waterfall_window_stride',
451
+ default_text=default_params.get('waterfall_window_stride', 64),
452
+ size=(10,1),
453
+ tooltip=tip_wf_stride,
454
+ )
455
+
456
+ col_wf_1 = sg.Column([[check_wf_ss_img], [check_wf_ss_vid], [check_wf_di_img], [check_wf_di_vid]], pad=0)
457
+ col_wf_2 = sg.Column([[text_wf_fps, in_wf_fps], [text_wf_res, combo_wf_res], [text_wf_mode, combo_wf_mode], [text_wf_stride, in_wf_stride]], pad=0)
458
+
459
+ layout.append([sg.HorizontalSeparator()])
460
+ layout.append([text_waterfall])
461
+ layout.append([col_wf_1, sg.VerticalSeparator(), col_wf_2])
462
+
385
463
  ########################
386
464
  # Depth & Shadow Removal
387
465
 
@@ -695,6 +773,12 @@ def gui(batch: bool):
695
773
  map_mosaic = int(map_mosaic)
696
774
 
697
775
 
776
+ wf_mode = values['waterfall_mode_selection']
777
+ if wf_mode == 'Auto (enabled exports)':
778
+ wf_mode = 'auto'
779
+ else:
780
+ wf_mode = str(wf_mode).strip().lower()
781
+
698
782
  params = {
699
783
  # 'humFile':values[0],
700
784
  # 'projDir':os.path.join(values[1], values[2]),
@@ -753,6 +837,14 @@ def gui(batch: bool):
753
837
  'map_mosaic':map_mosaic,
754
838
  'banklines':values['banklines'],
755
839
  'coverage':values['coverage']
840
+ , 'waterfall_ss_image':values['waterfall_ss_image']
841
+ , 'waterfall_ss_video':values['waterfall_ss_video']
842
+ , 'waterfall_di_image':values['waterfall_di_image']
843
+ , 'waterfall_di_video':values['waterfall_di_video']
844
+ , 'waterfall_video_fps':int(values['waterfall_video_fps'])
845
+ , 'waterfall_video_resolution':values['waterfall_video_resolution']
846
+ , 'waterfall_mode_selection':wf_mode
847
+ , 'waterfall_window_stride':int(values['waterfall_window_stride'])
756
848
  }
757
849
 
758
850
  globals().update(params)
@@ -30,6 +30,7 @@
30
30
  # SOFTWARE.
31
31
 
32
32
  import os, sys
33
+ import re
33
34
 
34
35
  # Add 'pingmapper' to the path, may not need after pypi package...
35
36
  SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
@@ -105,6 +106,576 @@ def _sidescan_group_key(beam_name):
105
106
  return beam_name
106
107
 
107
108
 
109
+ def _extract_chunk_id_from_path(path):
110
+ name = os.path.splitext(os.path.basename(path))[0]
111
+ match = re.search(r'_(\d+)$', name)
112
+ if match:
113
+ return int(match.group(1))
114
+ return None
115
+
116
+
117
+ def _sort_tile_paths(paths):
118
+ def _key(path):
119
+ chunk_id = _extract_chunk_id_from_path(path)
120
+ if chunk_id is None:
121
+ return (1, os.path.basename(path))
122
+ return (0, chunk_id)
123
+
124
+ return sorted(paths, key=_key)
125
+
126
+
127
+ def _to_bgr8(img):
128
+ if img is None:
129
+ return None
130
+
131
+ if img.dtype == np.uint16:
132
+ img = (img / 257.0).astype(np.uint8)
133
+ elif img.dtype != np.uint8:
134
+ arr = np.asarray(img, dtype=np.float32)
135
+ finite = np.isfinite(arr)
136
+ if finite.any():
137
+ lo = arr[finite].min()
138
+ hi = arr[finite].max()
139
+ if hi > lo:
140
+ arr = (arr - lo) / (hi - lo)
141
+ else:
142
+ arr = np.zeros_like(arr)
143
+ else:
144
+ arr = np.zeros_like(arr)
145
+ img = (arr * 255.0).clip(0, 255).astype(np.uint8)
146
+
147
+ if img.ndim == 2:
148
+ return cv2.cvtColor(img, cv2.COLOR_GRAY2BGR)
149
+
150
+ if img.ndim == 3 and img.shape[2] == 4:
151
+ return cv2.cvtColor(img, cv2.COLOR_BGRA2BGR)
152
+
153
+ return img
154
+
155
+
156
+ def _resize_height(img, target_h):
157
+ if img.shape[0] == target_h:
158
+ return img
159
+
160
+ h, w = img.shape[:2]
161
+ if h <= 0 or w <= 0 or target_h <= 0:
162
+ return img
163
+
164
+ new_w = max(1, int(round(w * (target_h / float(h)))))
165
+ return cv2.resize(img, (new_w, target_h), interpolation=cv2.INTER_AREA)
166
+
167
+
168
+ def _resize_width(img, target_w):
169
+ if img.shape[1] == target_w:
170
+ return img
171
+
172
+ h, w = img.shape[:2]
173
+ if h <= 0 or w <= 0 or target_w <= 0:
174
+ return img
175
+
176
+ new_h = max(1, int(round(h * (target_w / float(w)))))
177
+ return cv2.resize(img, (target_w, new_h), interpolation=cv2.INTER_AREA)
178
+
179
+
180
+ def _downscale_if_needed(img, max_dim=20000):
181
+ h, w = img.shape[:2]
182
+ max_hw = max(h, w)
183
+ if max_hw <= max_dim:
184
+ return img
185
+
186
+ scale = float(max_dim) / float(max_hw)
187
+ new_w = max(1, int(round(w * scale)))
188
+ new_h = max(1, int(round(h * scale)))
189
+ print(f"\n\tDownscaling waterfall from {w}x{h} to {new_w}x{new_h} to control memory usage.")
190
+ return cv2.resize(img, (new_w, new_h), interpolation=cv2.INTER_AREA)
191
+
192
+
193
+ def _fit_width(img, max_w):
194
+ h, w = img.shape[:2]
195
+ if w <= max_w:
196
+ return img
197
+ scale = float(max_w) / float(w)
198
+ new_h = max(1, int(round(h * scale)))
199
+ return cv2.resize(img, (max_w, new_h), interpolation=cv2.INTER_AREA)
200
+
201
+
202
+ def _collect_mode_tile_paths(son, mode):
203
+ mode_dir = os.path.join(son.projDir, son.beamName, mode)
204
+ if not os.path.exists(mode_dir):
205
+ return []
206
+
207
+ exts = ['*.png', '*.jpg', '*.jpeg', '*.tif', '*.tiff']
208
+ paths = []
209
+ for ext in exts:
210
+ paths.extend(glob(os.path.join(mode_dir, ext)))
211
+
212
+ return _sort_tile_paths(paths)
213
+
214
+
215
+ def _get_chunk_range_map(son):
216
+ chunk_range = {}
217
+ try:
218
+ meta_path = getattr(son, 'sonMetaFile', None)
219
+ if not meta_path or not os.path.exists(meta_path):
220
+ return chunk_range
221
+
222
+ df = pd.read_csv(meta_path, usecols=['chunk_id', 'ping_cnt', 'pixM'])
223
+ if len(df) == 0:
224
+ return chunk_range
225
+
226
+ df['chunk_id'] = pd.to_numeric(df['chunk_id'], errors='coerce')
227
+ df['ping_cnt'] = pd.to_numeric(df['ping_cnt'], errors='coerce')
228
+ df['pixM'] = pd.to_numeric(df['pixM'], errors='coerce')
229
+ df = df.dropna(subset=['chunk_id', 'ping_cnt', 'pixM'])
230
+ if len(df) == 0:
231
+ return chunk_range
232
+
233
+ # Approximate chunk range in meters from ping count and pixel size.
234
+ df['range_m'] = df['ping_cnt'] * df['pixM']
235
+ grouped = df.groupby('chunk_id', as_index=False)['range_m'].median()
236
+ for _, row in grouped.iterrows():
237
+ cid = int(row['chunk_id'])
238
+ rng = float(row['range_m'])
239
+ if np.isfinite(rng) and rng > 0:
240
+ chunk_range[cid] = rng
241
+ except Exception:
242
+ return {}
243
+
244
+ return chunk_range
245
+
246
+
247
+ def _range_reference_m(chunk_range_map):
248
+ if not chunk_range_map:
249
+ return None
250
+ vals = [float(v) for v in chunk_range_map.values() if np.isfinite(v) and float(v) > 0]
251
+ if len(vals) == 0:
252
+ return None
253
+ return float(np.median(vals))
254
+
255
+
256
+ def _pad_to_height(img, target_h):
257
+ h, w = img.shape[:2]
258
+ if h >= target_h:
259
+ return img
260
+ pad_h = target_h - h
261
+ return cv2.copyMakeBorder(img, 0, pad_h, 0, 0, cv2.BORDER_CONSTANT, value=0)
262
+
263
+
264
+ def _pad_to_height_center(img, target_h):
265
+ h, w = img.shape[:2]
266
+ if h >= target_h:
267
+ return img
268
+ pad_h = target_h - h
269
+ top = pad_h // 2
270
+ bottom = pad_h - top
271
+ return cv2.copyMakeBorder(img, top, bottom, 0, 0, cv2.BORDER_CONSTANT, value=0)
272
+
273
+
274
+ def _pad_to_width(img, target_w):
275
+ h, w = img.shape[:2]
276
+ if w >= target_w:
277
+ return img
278
+ pad_w = target_w - w
279
+ return cv2.copyMakeBorder(img, 0, 0, 0, pad_w, cv2.BORDER_CONSTANT, value=0)
280
+
281
+
282
+ def _scale_tile_by_range(img, tile_path, chunk_range_map=None, ref_range_m=None, base_h=None):
283
+ if img is None:
284
+ return None
285
+ if not (chunk_range_map and ref_range_m and base_h):
286
+ return img
287
+
288
+ chunk_id = _extract_chunk_id_from_path(tile_path)
289
+ if chunk_id is None:
290
+ return img
291
+
292
+ rng = chunk_range_map.get(chunk_id, None)
293
+ if rng is None or (not np.isfinite(rng)) or rng <= 0:
294
+ return img
295
+
296
+ scale = float(rng) / float(ref_range_m)
297
+ scale = max(0.25, min(4.0, scale))
298
+ target_h = max(1, int(round(base_h * scale)))
299
+ return _resize_height(img, target_h)
300
+
301
+
302
+ def _build_strip_from_tiles(tile_paths, chunk_range_map=None, ref_range_m=None):
303
+ if len(tile_paths) == 0:
304
+ return None
305
+
306
+ imgs = []
307
+ base_h = None
308
+ if ref_range_m is None:
309
+ ref_range_m = _range_reference_m(chunk_range_map)
310
+
311
+ for path in tile_paths:
312
+ img = cv2.imread(path, cv2.IMREAD_UNCHANGED)
313
+ if img is None:
314
+ continue
315
+
316
+ if base_h is None:
317
+ base_h = img.shape[0]
318
+
319
+ if chunk_range_map and ref_range_m and base_h:
320
+ chunk_id = _extract_chunk_id_from_path(path)
321
+ rng = chunk_range_map.get(chunk_id, None)
322
+ if rng is not None and np.isfinite(rng) and rng > 0:
323
+ scale = float(rng) / float(ref_range_m)
324
+ scale = max(0.25, min(4.0, scale))
325
+ target_h = max(1, int(round(base_h * scale)))
326
+ img = _resize_height(img, target_h)
327
+
328
+ imgs.append(img)
329
+
330
+ if len(imgs) == 0:
331
+ return None
332
+
333
+ # Preserve range-driven vertical scaling by padding each tile to common max height.
334
+ max_h = max(img.shape[0] for img in imgs)
335
+ imgs = [_pad_to_height(img, max_h) for img in imgs]
336
+ strip = cv2.hconcat(imgs)
337
+ return strip
338
+
339
+
340
+ def _build_sidescan_combined(port_tiles, star_tiles, port_range_map=None, star_range_map=None, ref_range_m=None):
341
+ if len(port_tiles) == 0 or len(star_tiles) == 0:
342
+ return None
343
+
344
+ def _map_by_chunk(paths):
345
+ mapped = {}
346
+ for p in paths:
347
+ cid = _extract_chunk_id_from_path(p)
348
+ if cid is not None:
349
+ mapped[int(cid)] = p
350
+ return mapped
351
+
352
+ port_by_chunk = _map_by_chunk(port_tiles)
353
+ star_by_chunk = _map_by_chunk(star_tiles)
354
+
355
+ # Newest chunk first so newer data sits at the top of the stitched waterfall,
356
+ # while the oldest chunk remains at the bottom for the initial video frame.
357
+ common = sorted(set(port_by_chunk.keys()) & set(star_by_chunk.keys()), reverse=True)
358
+ if len(common) == 0:
359
+ pair_paths = list(zip(port_tiles, star_tiles))
360
+ pair_paths = list(reversed(pair_paths))
361
+ else:
362
+ pair_paths = [(port_by_chunk[c], star_by_chunk[c]) for c in common]
363
+
364
+ # Baseline heights for range-based scaling.
365
+ base_h_port = None
366
+ base_h_star = None
367
+ for p_path, s_path in pair_paths:
368
+ if base_h_port is None:
369
+ p_img = cv2.imread(p_path, cv2.IMREAD_UNCHANGED)
370
+ if p_img is not None:
371
+ base_h_port = p_img.shape[0]
372
+ if base_h_star is None:
373
+ s_img = cv2.imread(s_path, cv2.IMREAD_UNCHANGED)
374
+ if s_img is not None:
375
+ base_h_star = s_img.shape[0]
376
+ if (base_h_port is not None) and (base_h_star is not None):
377
+ break
378
+
379
+ rows = []
380
+ for p_path, s_path in pair_paths:
381
+ p_img = cv2.imread(p_path, cv2.IMREAD_UNCHANGED)
382
+ s_img = cv2.imread(s_path, cv2.IMREAD_UNCHANGED)
383
+ if p_img is None or s_img is None:
384
+ continue
385
+
386
+ p_img = _scale_tile_by_range(p_img, p_path, port_range_map, ref_range_m, base_h_port)
387
+ s_img = _scale_tile_by_range(s_img, s_path, star_range_map, ref_range_m, base_h_star)
388
+
389
+ # Apply side-scan waterfall geometry: rotate both CCW, then flip port to place nadir at center.
390
+ p_img = cv2.rotate(p_img, cv2.ROTATE_90_COUNTERCLOCKWISE)
391
+ s_img = cv2.rotate(s_img, cv2.ROTATE_90_COUNTERCLOCKWISE)
392
+ p_img = cv2.flip(p_img, 1)
393
+
394
+ p_img = _to_bgr8(p_img)
395
+ s_img = _to_bgr8(s_img)
396
+
397
+ row_h = max(p_img.shape[0], s_img.shape[0])
398
+ # Align each chunk pair on the vertical centerline.
399
+ p_img = _pad_to_height_center(p_img, row_h)
400
+ s_img = _pad_to_height_center(s_img, row_h)
401
+
402
+ # Keep chunk row widths comparable before vertical stacking.
403
+ row = cv2.hconcat([p_img, s_img])
404
+ rows.append(row)
405
+
406
+ if len(rows) == 0:
407
+ return None
408
+
409
+ max_w = max(r.shape[1] for r in rows)
410
+ rows = [_pad_to_width(r, max_w) for r in rows]
411
+ return cv2.vconcat(rows)
412
+
413
+
414
+ def _window_positions(length, window_size, stride, reverse=False):
415
+ if length <= window_size:
416
+ positions = [0]
417
+ else:
418
+ positions = list(range(0, length - window_size + 1, stride))
419
+ if positions[-1] != (length - window_size):
420
+ positions.append(length - window_size)
421
+
422
+ if reverse:
423
+ positions = list(reversed(positions))
424
+
425
+ return positions
426
+
427
+
428
+ def _export_scrolling_video(
429
+ img,
430
+ out_video,
431
+ axis='x',
432
+ reverse=False,
433
+ fps=10,
434
+ target_size=(1920, 1080),
435
+ stride=64,
436
+ ):
437
+ os.makedirs(os.path.dirname(out_video), exist_ok=True)
438
+
439
+ h, w = img.shape[:2]
440
+ target_w, target_h = int(target_size[0]), int(target_size[1])
441
+ target_w = max(64, target_w)
442
+ target_h = max(64, target_h)
443
+
444
+ # Derive crop window from target resolution while preserving aspect ratio.
445
+ target_aspect = target_w / float(target_h)
446
+ win_w = min(w, target_w)
447
+ win_h = min(h, target_h)
448
+ if win_w <= 0 or win_h <= 0:
449
+ return
450
+
451
+ cur_aspect = win_w / float(win_h)
452
+ if cur_aspect > target_aspect:
453
+ win_w = max(1, int(round(win_h * target_aspect)))
454
+ else:
455
+ win_h = max(1, int(round(win_w / target_aspect)))
456
+
457
+ # If image is larger than target, prefer at least target-sized crop on scroll axis.
458
+ if axis == 'y' and h >= target_h:
459
+ win_h = target_h
460
+ win_w = min(w, max(1, int(round(win_h * target_aspect))))
461
+ elif axis != 'y' and w >= target_w:
462
+ win_w = target_w
463
+ win_h = min(h, max(1, int(round(win_w / target_aspect))))
464
+
465
+ # Ensure stride is valid and not too tiny for large windows.
466
+ stride = int(stride)
467
+ if stride <= 0:
468
+ stride = max(1, (win_h if axis == 'y' else win_w) // 10)
469
+ if axis == 'y':
470
+ win = min(win_h, h)
471
+ positions = _window_positions(h, win, stride, reverse=reverse)
472
+ first_frame = img[positions[0]:positions[0] + win, :win_w]
473
+ else:
474
+ win = min(win_w, w)
475
+ positions = _window_positions(w, win, stride, reverse=reverse)
476
+ first_frame = img[:win_h, positions[0]:positions[0] + win]
477
+
478
+ first_frame_bgr = _to_bgr8(first_frame)
479
+ frame_h, frame_w = target_h, target_w
480
+ writer = cv2.VideoWriter(
481
+ out_video,
482
+ cv2.VideoWriter_fourcc(*'mp4v'),
483
+ max(1, int(fps)),
484
+ (frame_w, frame_h),
485
+ )
486
+
487
+ if not writer.isOpened():
488
+ print(f"\n\tWARNING: Could not open video writer for {out_video}")
489
+ return
490
+
491
+ for pos in positions:
492
+ if axis == 'y':
493
+ frame = img[pos:pos + win, :win_w]
494
+ else:
495
+ frame = img[:win_h, pos:pos + win]
496
+
497
+ frame_bgr = _to_bgr8(frame)
498
+ if frame_bgr.shape[0] != frame_h or frame_bgr.shape[1] != frame_w:
499
+ frame_bgr = cv2.resize(frame_bgr, (frame_w, frame_h), interpolation=cv2.INTER_AREA)
500
+ writer.write(frame_bgr)
501
+
502
+ writer.release()
503
+
504
+
505
+ def _export_waterfall_products(
506
+ sonObjs,
507
+ wcp,
508
+ wcm,
509
+ wcr,
510
+ wco,
511
+ mode_selection='auto',
512
+ ss_image=False,
513
+ ss_video=False,
514
+ di_image=False,
515
+ di_video=False,
516
+ fps=10,
517
+ video_resolution='1080p',
518
+ stride=64,
519
+ ):
520
+ if not (ss_image or ss_video or di_image or di_video):
521
+ return
522
+
523
+ mode_flags = {
524
+ 'wcp': bool(wcp),
525
+ 'wcm': bool(wcm),
526
+ 'src': bool(wcr),
527
+ 'wco': bool(wco),
528
+ }
529
+
530
+ mode_selection = str(mode_selection).strip().lower()
531
+ if mode_selection in {'wcp', 'src', 'wcp+src'}:
532
+ if mode_selection == 'wcp':
533
+ modes = ['wcp']
534
+ elif mode_selection == 'src':
535
+ modes = ['src']
536
+ else:
537
+ modes = ['wcp', 'src']
538
+ else:
539
+ modes = [m for m, enabled in mode_flags.items() if enabled]
540
+
541
+ if len(modes) == 0:
542
+ print('\n\tWaterfall export requested, but no sonogram tile modes are enabled (wcp/wcm/wcr/wco). Skipping.')
543
+ return
544
+
545
+ res_key = str(video_resolution).strip().lower()
546
+ if res_key in {'4k', '2160p'}:
547
+ target_size = (3840, 2160)
548
+ elif res_key in {'1080p', 'fhd'}:
549
+ target_size = (1920, 1080)
550
+ elif res_key in {'720p', 'hd'}:
551
+ target_size = (1280, 720)
552
+ elif res_key in {'4xxp', '480p'}:
553
+ target_size = (854, 480)
554
+ else:
555
+ target_size = (1920, 1080)
556
+
557
+ print('\nGenerating waterfall products...')
558
+
559
+ # Group side-scan beams into port/star pairs by suffix key.
560
+ ss_groups = {}
561
+ down_beams = []
562
+ for son in sonObjs:
563
+ beam = str(getattr(son, 'beamName', ''))
564
+ if _is_sidescan_beam(beam):
565
+ key = _sidescan_group_key(beam)
566
+ group = ss_groups.setdefault(key, {})
567
+ if beam.startswith('ss_port'):
568
+ group['port'] = son
569
+ elif beam.startswith('ss_star'):
570
+ group['star'] = son
571
+ else:
572
+ down_beams.append(son)
573
+
574
+ # Side-scan: combine port/star into one waterfall and scroll top -> bottom.
575
+ if ss_image or ss_video:
576
+ ss_any = False
577
+ for group_key, group in sorted(ss_groups.items()):
578
+ if 'port' not in group or 'star' not in group:
579
+ continue
580
+
581
+ proj_dir = group['port'].projDir
582
+ port_range_map = _get_chunk_range_map(group['port'])
583
+ star_range_map = _get_chunk_range_map(group['star'])
584
+ shared_ref = _range_reference_m(port_range_map)
585
+ if shared_ref is None:
586
+ shared_ref = _range_reference_m(star_range_map)
587
+
588
+ for mode in modes:
589
+ port_tiles = _collect_mode_tile_paths(group['port'], mode)
590
+ star_tiles = _collect_mode_tile_paths(group['star'], mode)
591
+ if len(port_tiles) == 0 or len(star_tiles) == 0:
592
+ continue
593
+
594
+ ss_any = True
595
+
596
+ combined = _build_sidescan_combined(
597
+ port_tiles,
598
+ star_tiles,
599
+ port_range_map=port_range_map,
600
+ star_range_map=star_range_map,
601
+ ref_range_m=shared_ref,
602
+ )
603
+ if combined is None:
604
+ continue
605
+
606
+ # Ensure both port and star fully fit inside output frame width.
607
+ combined = _fit_width(combined, target_size[0])
608
+ combined = _downscale_if_needed(combined)
609
+
610
+ out_dir = os.path.join(proj_dir, 'waterfall_exports', 'sidescan', mode)
611
+ os.makedirs(out_dir, exist_ok=True)
612
+
613
+ if ss_image:
614
+ cv2.imwrite(os.path.join(out_dir, 'waterfall.png'), combined)
615
+
616
+ if ss_video:
617
+ _export_scrolling_video(
618
+ combined,
619
+ os.path.join(out_dir, 'waterfall_scroll_t2b.mp4'),
620
+ axis='y',
621
+ reverse=True,
622
+ fps=fps,
623
+ target_size=target_size,
624
+ stride=stride,
625
+ )
626
+
627
+ if not ss_any:
628
+ print('\n\tNo side-scan tiles found for requested waterfall mode(s).')
629
+ print('\tEnable or select matching mode(s), e.g., WCP and/or SRC.')
630
+
631
+ # Down-imaging: generate per-beam waterfall and scroll right -> left.
632
+ if di_image or di_video:
633
+ di_any = False
634
+ for son in down_beams:
635
+ beam = str(getattr(son, 'beamName', 'unknown_beam'))
636
+ proj_dir = son.projDir
637
+ down_range_map = _get_chunk_range_map(son)
638
+ down_ref = _range_reference_m(down_range_map)
639
+
640
+ for mode in modes:
641
+ tiles = _collect_mode_tile_paths(son, mode)
642
+ if len(tiles) == 0:
643
+ continue
644
+
645
+ di_any = True
646
+
647
+ strip = _build_strip_from_tiles(
648
+ tiles,
649
+ chunk_range_map=down_range_map,
650
+ ref_range_m=down_ref,
651
+ )
652
+ if strip is None:
653
+ continue
654
+
655
+ strip = _downscale_if_needed(strip)
656
+
657
+ out_dir = os.path.join(proj_dir, 'waterfall_exports', 'down_imaging', mode)
658
+ os.makedirs(out_dir, exist_ok=True)
659
+
660
+ if di_image:
661
+ cv2.imwrite(os.path.join(out_dir, f'{beam}_waterfall.png'), strip)
662
+
663
+ if di_video:
664
+ _export_scrolling_video(
665
+ strip,
666
+ os.path.join(out_dir, f'{beam}_waterfall_scroll.mp4'),
667
+ axis='x',
668
+ reverse=False,
669
+ fps=fps,
670
+ target_size=target_size,
671
+ stride=stride,
672
+ )
673
+
674
+ if not di_any:
675
+ print('\n\tNo down-imaging tiles found for requested waterfall mode(s).')
676
+ print('\tEnable or select matching mode(s), e.g., WCP and/or SRC.')
677
+
678
+
108
679
  #===========================================
109
680
  def read_master_func(logfilename='',
110
681
  project_mode=0,
@@ -179,6 +750,14 @@ def read_master_func(logfilename='',
179
750
  map_mosaic=0,
180
751
  banklines=False,
181
752
  side_scan_only=False,
753
+ waterfall_ss_image=False,
754
+ waterfall_ss_video=False,
755
+ waterfall_di_image=False,
756
+ waterfall_di_video=False,
757
+ waterfall_video_fps=10,
758
+ waterfall_video_resolution='1080p',
759
+ waterfall_mode_selection='auto',
760
+ waterfall_window_stride=64,
182
761
  return_context=False,
183
762
  **kwargs):
184
763
 
@@ -590,6 +1169,17 @@ def read_master_func(logfilename='',
590
1169
  # Decode DAT file (varies by model) #
591
1170
  ############################################################################
592
1171
 
1172
+ wf_mode = str(waterfall_mode_selection).strip().lower()
1173
+ wf_requested = bool(waterfall_ss_image) or bool(waterfall_ss_video) or bool(waterfall_di_image) or bool(waterfall_di_video)
1174
+ if wf_requested:
1175
+ if wf_mode == 'wcp':
1176
+ wcp = True
1177
+ elif wf_mode == 'src':
1178
+ wcr = True
1179
+ elif wf_mode == 'wcp+src':
1180
+ wcp = True
1181
+ wcr = True
1182
+
593
1183
  if (project_mode != 2):
594
1184
 
595
1185
  #####
@@ -1708,6 +2298,32 @@ def read_master_func(logfilename='',
1708
2298
  print("Time (s):", round(time.time() - start_time, ndigits=1))
1709
2299
  printUsage()
1710
2300
 
2301
+ if bool(waterfall_ss_image) or bool(waterfall_ss_video) or bool(waterfall_di_image) or bool(waterfall_di_video):
2302
+ start_time = time.time()
2303
+ print("\nGenerating waterfall image/video exports...")
2304
+ try:
2305
+ _export_waterfall_products(
2306
+ sonObjs,
2307
+ wcp=wcp,
2308
+ wcm=wcm,
2309
+ wcr=wcr,
2310
+ wco=wco,
2311
+ mode_selection=waterfall_mode_selection,
2312
+ ss_image=bool(waterfall_ss_image),
2313
+ ss_video=bool(waterfall_ss_video),
2314
+ di_image=bool(waterfall_di_image),
2315
+ di_video=bool(waterfall_di_video),
2316
+ fps=int(waterfall_video_fps),
2317
+ video_resolution=str(waterfall_video_resolution),
2318
+ stride=int(waterfall_window_stride),
2319
+ )
2320
+ except Exception as e:
2321
+ print(f"\nWaterfall export failed: {e}")
2322
+
2323
+ print("Done!")
2324
+ print("Time (s):", round(time.time() - start_time, ndigits=1))
2325
+ printUsage()
2326
+
1711
2327
  ##############################################
1712
2328
  # Let's pickle sonObj so we can reload later #
1713
2329
  ##############################################
@@ -0,0 +1 @@
1
+ __version__ = '5.5.0'
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pingmapper
3
- Version: 5.4.8
3
+ Version: 5.5.0
4
4
  Summary: Open-source interface for processing recreation-grade side scan sonar datasets and reproducibly mapping benthic habitat
5
5
  Author: Cameron Bodine, Daniel Buscombe, Iury T Simoes-Sousa
6
6
  Author-email: bodine.cs@gmail.email
@@ -1 +0,0 @@
1
- __version__ = '5.4.8'
File without changes
File without changes
File without changes
File without changes
File without changes