From 43d063881302e58fb82014a5708630b23faebd0b Mon Sep 17 00:00:00 2001 From: Chouffe Date: Thu, 18 Dec 2025 13:08:06 +0100 Subject: [PATCH 1/3] feat: fix aris-convert to use ffmpeg --- src/aris/pyARIS/pyARIS.py | 148 ++++++++---- src/aris/scripts/convert_aris_to_video.py | 276 ++++++++-------------- uv.lock | 2 +- 3 files changed, 201 insertions(+), 225 deletions(-) diff --git a/src/aris/pyARIS/pyARIS.py b/src/aris/pyARIS/pyARIS.py index a5b9fcf..07dd5e8 100644 --- a/src/aris/pyARIS/pyARIS.py +++ b/src/aris/pyARIS/pyARIS.py @@ -14,6 +14,7 @@ import struct import subprocess as sp +import cv2 import numpy as np import pytz import tqdm @@ -687,7 +688,9 @@ def DataImport(filename, startFrame=1, frameBuffer=0): data.close() # Create an empty container for the lookup table - output_data.LUP = None + output_data.LUT = False + output_data.map_y = None + output_data.map_x = None # Load the first frame frame = FrameRead(output_data, startFrame) @@ -1178,16 +1181,12 @@ def FrameRead(ARIS_data, frameIndex, frameBuffer=None) -> ARIS_Frame: ARIS_Frame.BeamCount = 128 data.seek(frameoffset + 1024, 0) - frame = np.empty([samplesperbeam, ARIS_Frame.BeamCount], dtype=float) - for r in range(len(frame)): - for c in range(len(frame[r])): - frame[r][c] = struct.unpack("B", data.read(1))[0] - frame = np.fliplr(frame) - # Remap the data from 0-255 to 0-80 dB - # remap = lambda t: (t * 80)/255 - # vfunc = np.vectorize(remap) - # frame = vfunc(frame) + frame = np.ndarray( + (samplesperbeam, ARIS_Frame.BeamCount), + " ARIS_Frame: # Close the data file data.close() - # Create the lookup table - if ARIS_data.LUP == None: - createLUP(ARIS_data, output) + # Create the lookup table if not present + if ARIS_data.LUT == False: + LUT(ARIS_data, output) + ARIS_data.LUT = True - # Remap the first frame + # Remap the frame remapARIS(ARIS_data, output, frameBuffer) return output @@ -1232,7 +1232,6 @@ def getBeamBin(x, y, frame): def px2Meters(x, y, frame, xdim=None): - # WinStart = frame.samplestartdelay * 0.000001 * frame.soundspeed / 2 pix2Meter = frame.sampleperiod * 0.000001 * frame.soundspeed / 2 if xdim == None: xdim = int(getXY(0, frame.samplesperbeam, frame)[0] * (1 / pix2Meter) * 2) @@ -1241,26 +1240,79 @@ def px2Meters(x, y, frame, xdim=None): return x1, y1 -def createLUP(ARISFile, frame): +def getBeam(x, y, beamcount): + angle = np.rad2deg(np.tan(x / y)) + beamnum = beamLookUp.BeamLookUp(-angle, beamcount) + return beamnum + + +getBeamVec = np.vectorize(getBeam) + + +def getBin(x, y, winstart, sampleperiod, soundspeed): + angle = np.rad2deg(np.tan(x / y)) + hyp = y / np.cos(np.deg2rad(angle)) + binnum2 = int((2 * (hyp - winstart)) / (sampleperiod * 0.000001 * soundspeed)) + return binnum2 + + +getBinVec = np.vectorize(getBin) + + +def LUT(ARISFile, frame): + """The LUT function creates a lookup table which is subsequently used to + remap the sonar data from beams and bins to x, y spatial coordinates. + + Parameters + ----------- + ARISFile : ARIS data structure returned via pyARIS.DataImport() + frame : A ARIS frame class object + + Returns + ------- + output : an x_map and y_map will be added to the ARISFile class object + + Notes + ------- + Basic frame attributes can be found by calling the frame.info() method. + A list of all the frames attributes can be found by using dir(frame), some + of these may or may not be used by the ARIS. + """ # Lookup dimensions SampleLength = frame.sampleperiod * 0.000001 * frame.soundspeed / 2 ARISFile.ydim = int(frame.samplesperbeam) - ARISFile.xdim = int( - getXY(0, frame.samplesperbeam, frame)[0] * (1 / SampleLength) * 2 - ) + ARISFile.xdim = int(getXY(0, frame.samplesperbeam, frame)[0] * (1 / SampleLength) * 2) - LUP = {} + # Convert from the images pixel space to real world coordinates in meters + x = np.arange(ARISFile.xdim) + y = np.arange(ARISFile.ydim) - # Iterate through each point in the frame and lookup data - for x in range(ARISFile.xdim): - for y in range(ARISFile.ydim): - x1, y1 = px2Meters(x, y, frame, xdim=ARISFile.xdim) - Beam, Bin = getBeamBin(x1, y1, frame) - if Beam != 999: - if Bin < frame.samplesperbeam: - LUP[(x, y)] = (Bin, Beam) + pix2Meter = frame.sampleperiod * 0.000001 * frame.soundspeed / 2 + + x1 = (x - ARISFile.xdim / 2) * pix2Meter + y1 = y1 = (y * pix2Meter) + (frame.WinStart) + + # Create a matrix in real-world coordinates with the sonar located at 0,0 + xx, yy = np.meshgrid(x1, y1) + + # Use the getBeamVec function to return the map_x + beamcount = frame.BeamCount + beams = getBeamVec(xx, yy, beamcount) + + # Create a map_y based on the bin locations + winstart = frame.WinStart + sampleperiod = frame.sampleperiod + soundspeed = frame.soundspeed + bins = getBinVec(xx, yy, winstart, sampleperiod, soundspeed) - ARISFile.LUP = LUP + # Clip the map_y mask + bins = bins.astype(np.float32) + mask = np.isnan(beams) + bins[mask] = beams[mask] + + # Embed the maps in the ARISFile object + ARISFile.map_y = bins + ARISFile.map_x = beams.astype(np.float32) def remapARIS(ARISFile, frame, frameBuffer=None): @@ -1278,22 +1330,23 @@ def remapARIS(ARISFile, frame, frameBuffer=None): A remapped frame which is stored in the frames data structure as frame.remap """ # Create an empty frame - Remap = np.zeros([ARISFile.xdim, ARISFile.ydim]) + Remap = np.zeros([ARISFile.ydim, ARISFile.xdim]) - # Populate the empty frame - for key in ARISFile.LUP: - Remap[key[0], key[1]] = frame.frame_data[ - ARISFile.LUP[key][0], ARISFile.LUP[key][1] - ] + # Use OpenCV's remap function to populate the empty frame + Remap = cv2.remap(frame.frame_data, ARISFile.map_x, ARISFile.map_y, cv2.INTER_NEAREST) - Remap = np.rot90(Remap, 1) + Remap = np.flipud(Remap) # Add buffer is requested if frameBuffer != None: buffY = int(ARISFile.ydim * frameBuffer) buffX = int(ARISFile.xdim * frameBuffer) Remap = np.concatenate( - (np.ones([ARISFile.ydim, buffX]), Remap, np.ones([ARISFile.ydim, buffX])), + ( + np.ones([ARISFile.ydim, buffX]), + Remap, + np.ones([ARISFile.ydim, buffX]), + ), axis=1, ) Remap = np.concatenate( @@ -1317,7 +1370,7 @@ def VideoExport( timestamp=False, fontsize=30, ts_pos=(0, 0), - vbr=20, + vbr=10, ): """Output video using the ffmpeg pipeline. The current implementation outputs compresses png files and outputs a mp4. @@ -1339,7 +1392,7 @@ def VideoExport( Notes ------ - Currently this function looks for ffmpeg.exe in the current working directory. + ffmpeg must be available in the system PATH. Must have the '*.mp4' file extension. Uses the tqdm package to display a status bar. @@ -1350,6 +1403,7 @@ def VideoExport( """ # Command to send via the command prompt which specifies the pipe parameters + # Linux compatible: use 'ffmpeg' instead of 'ffmpeg.exe' command = [ "ffmpeg", "-y", # (optional) overwrite output file if it exists @@ -1358,22 +1412,19 @@ def VideoExport( "-vcodec", "mjpeg", "-r", - "1", - # '-s', '793x1327', # size of one frame - "-r", str(fps), # frames per second "-i", "-", # The input comes from a pipe "-an", # Tells FFMPEG not to expect any audio - "-qscale:v", - str(vbr), "-vcodec", "mpeg4", + "-q:v", + str(vbr), # Variable bitrate quality filename, ] # Open the pipe - pipe = sp.Popen(command, stdin=sp.PIPE) + pipe = sp.Popen(command, stdin=sp.PIPE, stderr=sp.DEVNULL) if end_frame is None: end_frame = data.FrameCount @@ -1389,8 +1440,13 @@ def VideoExport( ).strftime("%Y-%m-%d %H:%M:%S") ) draw = ImageDraw.Draw(im) - font = ImageFont.truetype("./arial.ttf", fontsize) + try: + font = ImageFont.truetype("DejaVuSans.ttf", fontsize) + except OSError: + # Fallback to default font if DejaVuSans not available + font = ImageFont.load_default() draw.text(ts_pos, ts, font=font, fill="white") im.save(pipe.stdin, "JPEG") pipe.stdin.close() + pipe.wait() diff --git a/src/aris/scripts/convert_aris_to_video.py b/src/aris/scripts/convert_aris_to_video.py index e6e7588..f3aa965 100644 --- a/src/aris/scripts/convert_aris_to_video.py +++ b/src/aris/scripts/convert_aris_to_video.py @@ -2,35 +2,33 @@ CLI script to convert ARIS files into MP4 video files. This script provides a command-line interface for converting ARIS files, which -contain sonar data, into MP4 video files. Users can specify various parameters, -including the path to the ARIS file, the directory for saving the generated -video, and frame extraction boundaries. +contain sonar data, into MP4 video files using ffmpeg via pyARIS.VideoExport(). Arguments: --filepath-aris: Path to the ARIS file (either --filepath-aris or --dir-aris required). --dir-aris: Directory containing ARIS files to convert (either --filepath-aris or --dir-aris required). --dir-save: Directory to save the generated video (required). ---start-frame: Frame number to start extraction (default: 0). ---end-frame: Frame number to stop extraction (optional). --log, --loglevel: Set the logging level (default: "warning"). +-log, --loglevel: Set the logging level (default: "info"). Example usage: -python convert_aris_to_video.py --filepath-aris data/aris/file.aris --dir-save data/mp4/ -python convert_aris_to_video.py --filepath-aris data/aris/file.aris --dir-save data/mp4/ --start-frame 0 --end-frame 100 +uv run python ./src/aris/scripts/convert_aris_to_video.py --filepath-aris data/aris/file.aris --dir-save data/mp4/ +uv run python ./src/aris/scripts/convert_aris_to_video.py --dir-aris data/aris/ --dir-save data/mp4/ This script facilitates the extraction of frames from ARIS data and encodes -them into a video format, helping users visualize sonar data effectively. +them into a video format using ffmpeg, helping users visualize sonar data effectively. + +Requirements: + - ffmpeg must be installed and available in PATH """ import argparse import logging -import random +import shutil from logging import Logger from pathlib import Path -import aris.frame as aris_frame from aris.pyARIS import pyARIS @@ -38,7 +36,9 @@ def make_cli_parser() -> argparse.ArgumentParser: """ Make the CLI parser. """ - parser = argparse.ArgumentParser() + parser = argparse.ArgumentParser( + description="Convert ARIS sonar files to MP4 videos using ffmpeg." + ) parser.add_argument( "--filepath-aris", help="filepath of the ARIS file to convert", @@ -55,24 +55,11 @@ def make_cli_parser() -> argparse.ArgumentParser: type=Path, required=True, ) - parser.add_argument( - "--start-frame", - type=int, - help="frame start", - default=0, - ) - parser.add_argument( - "--end-frame", - type=int, - nargs="?", - const=None, - help="frame end", - ) parser.add_argument( "-log", "--loglevel", default="info", - help="Provide logging level. Example --loglevel debug, default=warning", + help="Provide logging level. Example --loglevel debug, default=info", ) return parser @@ -83,7 +70,7 @@ def validate_parsed_args(args: dict) -> bool: """ if not args["filepath_aris"] and not args["dir_aris"]: logging.error( - "Specify one parameter among --filepath-aris to or --dir-aris, none specified." + "Specify one parameter among --filepath-aris or --dir-aris, none specified." ) return False if args["filepath_aris"] and ( @@ -99,137 +86,56 @@ def validate_parsed_args(args: dict) -> bool: return True -def sanitize_frame_boundaries( - start_frame: int | None, - end_frame: int | None, - aris_data, -) -> tuple[int, int]: - """ - Sanitize the start_frame and end_frame values to ensure they are within - the valid range of frame indices for the given ARIS data. This helps to - prevent out-of-bounds errors during video generation and frame extraction. - - Parameters: - start_frame (int | None): The frame number to start extraction from. - If None, defaults to 0. - end_frame (int | None): The frame number to stop extraction at. - If None, it will be set to the last frame - index based on the ARIS data. - - aris_data: The ARIS data object containing information about the - total number of frames. - - Returns: - Tuple[int, int]: A tuple containing the sanitized start_frame and - end_frame values. - """ - if start_frame is None: - start_frame = 0 - if end_frame is None: - end_frame = int(aris_data.FrameCount - 1) - return start_frame, end_frame - - -def get_filepath_video_save( - args_cli: dict, - filepath_aris: Path, - aris_data: pyARIS.ARIS_File, - dir_save: Path, -) -> Path: - """ - Construct a filepath for saving the converted video based on the - input ARIS file and specified frame boundaries. - - Parameters: - args_cli (dict): A dictionary containing command line arguments, - including start and end frame information. - aris_data (pyARIS.ARIS_File): The ARIS data object that holds - information about the ARIS file. - dir_save (Path): The directory where the converted video will be saved. - - Returns: - Path: The constructed filepath for the converted video. - """ - start_frame_sanitized, end_frame_sanitized = sanitize_frame_boundaries( - args_cli["start_frame"], - args_cli["end_frame"], - aris_data, - ) - number_frames = aris_data.FrameCount - if start_frame_sanitized == 0 and end_frame_sanitized == (number_frames - 1): - return dir_save / f"{filepath_aris.stem}.mp4" - else: - return ( - dir_save - / f"{filepath_aris.stem}_fromframe_{start_frame_sanitized}_toframe_{end_frame_sanitized}.mp4" - ) - - def process_aris_filepath( - start_frame: int | None, - end_frame: int | None, filepath_aris: Path, dir_save: Path, logger: Logger, force: bool = False, -) -> None: +) -> bool: """ - Process a single ARIS file by extracting frames, generating a video. - The processed video is saved in the specified directory. + Process a single ARIS file by converting it to MP4 video using ffmpeg. Parameters: - start_frame (int | None): The frame number to start extraction from. If None, extraction starts from the first frame. - end_frame (int | None): The frame number to stop extraction at. If None, extraction continues to the last frame. filepath_aris (Path): The path to the ARIS file to be processed. dir_save (Path): The directory where the generated video will be saved. logger (Logger): The logger instance for logging information and errors. force (bool): If True, overwrite existing video files. Defaults to False. + + Returns: + bool: True if conversion successful, False otherwise. """ - logger.info(f"Parsing ARIS file {filepath_aris}") - aris_data, first_frame = pyARIS.DataImport(str(filepath_aris)) - logger.info("ARIS data details:") - aris_data.info() - filepath_video_save = get_filepath_video_save( - args_cli={"start_frame": start_frame, "end_frame": end_frame}, - filepath_aris=filepath_aris, - aris_data=aris_data, - dir_save=dir_save, - ) - logger.info(f"filepath_video_save: {filepath_video_save}") - if not force and filepath_video_save and filepath_video_save.exists(): - logger.info( - f"Skipping because the video is already generated in {filepath_video_save}" - ) - else: - last_frame = pyARIS.FrameRead(aris_data, aris_data.FrameCount - 1) - logger.info("First frame details:") - first_frame.info() - logger.info("Last frame details:") - last_frame.info() - frame_rate_aris = first_frame.framerate - start_frame_sanitized, end_frame_sanitized = sanitize_frame_boundaries( - start_frame, - end_frame, - aris_data, - ) - logger.info( - f"Parsing frames from frame {start_frame_sanitized} until frame {end_frame_sanitized}" - ) - aris_frames = aris_frame.extract_frames_as_numpy_arrays( - aris_data=aris_data, - start_frame=start_frame_sanitized, - end_frame=end_frame_sanitized, - skip_frame=0, - ) - logger.info( - f"Generating a video file from the ARIS file in {filepath_video_save}" - ) - aris_frame.aris_frames_to_mp4v_video( - aris_frames=aris_frames, - filepath_save=filepath_video_save, - fps=int(frame_rate_aris), + filepath_video_save = dir_save / f"{filepath_aris.stem}.mp4" + + if not force and filepath_video_save.exists(): + logger.info(f"Skipping {filepath_aris.name} - output already exists") + return True + + try: + logger.info(f"Loading ARIS file: {filepath_aris.name}") + aris_data, first_frame = pyARIS.DataImport(str(filepath_aris)) + + # Get frame rate from first frame, fallback to 24 FPS + fps = float(first_frame.framerate) if first_frame.framerate > 0 else 24.0 + logger.info(f"Frame rate: {fps} FPS, Total frames: {aris_data.FrameCount}") + + logger.info(f"Exporting video to: {filepath_video_save}") + + # VideoExport uses ffmpeg subprocess with MPEG4 codec + pyARIS.VideoExport( + data=aris_data, + filename=str(filepath_video_save), + fps=fps, + start_frame=1, # pyARIS uses 1-indexed frames + end_frame=None, # Process all frames + timestamp=False, # No timestamp overlay ) - logger.info(f"Done with ARIS filepath {filepath_aris}") + + logger.info(f"Successfully converted {filepath_aris.name}") + return True + + except Exception as e: + logger.error(f"Error converting {filepath_aris.name}: {e}") + return False def main(): @@ -237,43 +143,57 @@ def main(): cli_parser = make_cli_parser() args = vars(cli_parser.parse_args()) logger = logging.getLogger(__name__) - logging.basicConfig(level=args["loglevel"].upper()) + logging.basicConfig( + level=args["loglevel"].upper(), + format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", + ) + + # Validate ffmpeg is available + if not shutil.which("ffmpeg"): + logger.error("ffmpeg not found in PATH. Please install ffmpeg.") + exit(1) + if not validate_parsed_args(args): logging.error(f"Could not validate the parsed args: {args}") exit(1) - else: - logger.info(args) - filepath_aris = args["filepath_aris"] - dir_aris = args["dir_aris"] - filepaths_aris_to_process = ( - [filepath_aris] if filepath_aris else list(dir_aris.rglob("*.aris")) - ) - filepaths_aris_to_process_shuffled = random.Random().sample( - filepaths_aris_to_process, - k=len(filepaths_aris_to_process), - ) - logger.info( - f"Found {len(filepaths_aris_to_process_shuffled)} ARIS files to process" - ) - dir_save = args["dir_save"] - start_frame = args["start_frame"] - end_frame = args["end_frame"] - logger.info(f"Saving results in {dir_save}") - dir_save.mkdir(parents=True, exist_ok=True) - - for fp_aris in filepaths_aris_to_process_shuffled: - try: - process_aris_filepath( - start_frame=start_frame, - end_frame=end_frame, - filepath_aris=fp_aris, - dir_save=dir_save, - logger=logger, - ) - except Exception as e: - logger.error(f"Error processing {fp_aris}: {e}") - - logger.info("Done ✅") + + filepath_aris = args["filepath_aris"] + dir_aris = args["dir_aris"] + filepaths_aris_to_process = ( + [filepath_aris] if filepath_aris else sorted(dir_aris.rglob("*.aris")) + ) + + if not filepaths_aris_to_process: + logger.warning(f"No ARIS files found to process") + exit(0) + + logger.info(f"Found {len(filepaths_aris_to_process)} ARIS file(s) to process") + + dir_save = args["dir_save"] + dir_save.mkdir(parents=True, exist_ok=True) + logger.info(f"Saving results in {dir_save}") + + success_count = 0 + failed_count = 0 + + for fp_aris in filepaths_aris_to_process: + if process_aris_filepath( + filepath_aris=fp_aris, + dir_save=dir_save, + logger=logger, + ): + success_count += 1 + else: + failed_count += 1 + + logger.info("Conversion complete!") + logger.info(f"Successfully converted: {success_count}/{len(filepaths_aris_to_process)} files") + + if failed_count > 0: + logger.warning(f"Failed to convert: {failed_count}/{len(filepaths_aris_to_process)} files") + exit(1) + + logger.info("Done") if __name__ == "__main__": diff --git a/uv.lock b/uv.lock index 38f641e..bfdff95 100644 --- a/uv.lock +++ b/uv.lock @@ -9,7 +9,7 @@ resolution-markers = [ [[package]] name = "aris" -version = "0.1.0" +version = "0.1.1" source = { editable = "." } dependencies = [ { name = "ffmpeg-python" }, From dec28834037c49d3cb79567ae663b9af4a53f2bf Mon Sep 17 00:00:00 2001 From: Chouffe Date: Thu, 18 Dec 2025 13:12:49 +0100 Subject: [PATCH 2/3] fix: remove extraneous f-string prefix --- src/aris/scripts/convert_aris_to_video.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/aris/scripts/convert_aris_to_video.py b/src/aris/scripts/convert_aris_to_video.py index f3aa965..cb66971 100644 --- a/src/aris/scripts/convert_aris_to_video.py +++ b/src/aris/scripts/convert_aris_to_video.py @@ -164,7 +164,7 @@ def main(): ) if not filepaths_aris_to_process: - logger.warning(f"No ARIS files found to process") + logger.warning("No ARIS files found to process") exit(0) logger.info(f"Found {len(filepaths_aris_to_process)} ARIS file(s) to process") From 35a8a7cf9fa1ef4997f848f08160c9b92ad5fe0e Mon Sep 17 00:00:00 2001 From: Chouffe Date: Thu, 18 Dec 2025 13:54:07 +0100 Subject: [PATCH 3/3] style: format convert_aris_to_video.py --- src/aris/scripts/convert_aris_to_video.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/aris/scripts/convert_aris_to_video.py b/src/aris/scripts/convert_aris_to_video.py index cb66971..556dca1 100644 --- a/src/aris/scripts/convert_aris_to_video.py +++ b/src/aris/scripts/convert_aris_to_video.py @@ -187,10 +187,14 @@ def main(): failed_count += 1 logger.info("Conversion complete!") - logger.info(f"Successfully converted: {success_count}/{len(filepaths_aris_to_process)} files") + logger.info( + f"Successfully converted: {success_count}/{len(filepaths_aris_to_process)} files" + ) if failed_count > 0: - logger.warning(f"Failed to convert: {failed_count}/{len(filepaths_aris_to_process)} files") + logger.warning( + f"Failed to convert: {failed_count}/{len(filepaths_aris_to_process)} files" + ) exit(1) logger.info("Done")